first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context'
import getMeta from '../../../utils/meta'
import classNames from 'classnames'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useDsNavStyle } from '@/features/project-list/components/use-is-ds-nav'
export function useAddAffiliation() {
const { totalProjectsCount } = useProjectListContext()
const { isOverleaf } = getMeta('ol-ExposedSettings')
const userAffiliations = getMeta('ol-userAffiliations') || []
return {
show: isOverleaf && totalProjectsCount > 0 && !userAffiliations.length,
}
}
type AddAffiliationProps = {
className?: string
}
function AddAffiliation({ className }: AddAffiliationProps) {
const { t } = useTranslation()
const { show } = useAddAffiliation()
const dsNavStyle = useDsNavStyle()
if (!show) {
return null
}
const classes = classNames('text-center', 'add-affiliation', className)
return (
<div className={classes}>
<p className={dsNavStyle ? 'text-muted' : undefined}>
{t('are_you_affiliated_with_an_institution')}
</p>
<OLButton variant="secondary" href="/user/settings">
{t('add_affiliation')}
</OLButton>
</div>
)
}
export default AddAffiliation

View File

@@ -0,0 +1,174 @@
import useSelectColor from '../../hooks/use-select-color'
import { SketchPicker } from 'react-color'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
const PRESET_COLORS: ReadonlyArray<{ color: string; name: string }> = [
{ color: '#A7B1C2', name: 'Grey' },
{ color: '#F04343', name: 'Red' },
{ color: '#DD8A3E', name: 'Orange' },
{ color: '#E4CA3E', name: 'Yellow' },
{ color: '#33CF67', name: 'Green' },
{ color: '#43A7F0', name: 'Light blue' },
{ color: '#434AF0', name: 'Dark blue' },
{ color: '#B943F0', name: 'Purple' },
{ color: '#FF4BCD', name: 'Pink' },
]
type ColorPickerItemProps = {
color: string
name: string
}
function ColorPickerItem({ color, name }: ColorPickerItemProps) {
const { selectColor, selectedColor, pickingCustomColor } = useSelectColor()
const { t } = useTranslation()
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
selectColor(color)
}
}
return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */
<div
aria-label={`${name}, ${t('set_color')}`}
className="color-picker-item"
onClick={() => selectColor(color)}
role="button"
style={{ backgroundColor: color }}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<span id={name} className="sr-only">
{t('select_color', { name })}
</span>
{!pickingCustomColor && color === selectedColor && (
<MaterialIcon type="check" className="color-picker-item-icon" />
)}
</div>
)
}
function MoreButton() {
const {
selectedColor,
selectColor,
showCustomPicker,
openCustomPicker,
closeCustomPicker,
setPickingCustomColor,
} = useSelectColor()
const [localColor, setLocalColor] = useState<string>()
const { t } = useTranslation()
useEffect(() => {
setLocalColor(selectedColor)
}, [selectedColor])
const isCustomColorSelected =
localColor && !PRESET_COLORS.some(colorObj => colorObj.color === localColor)
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
if (showCustomPicker) {
closeCustomPicker()
} else {
openCustomPicker()
}
}
}
return (
<div className="color-picker-more-wrapper" data-content="My Content">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */}
<div
className="color-picker-item more-button"
role="button"
onClick={showCustomPicker ? closeCustomPicker : openCustomPicker}
style={{
backgroundColor: isCustomColorSelected
? localColor || selectedColor
: 'white',
}}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<OLTooltip
key="tooltip-color-picker-plus"
id="tooltip-color-picker-plus"
description={t('choose_a_custom_color')}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<div>
{isCustomColorSelected ? (
<MaterialIcon type="check" className="color-picker-item-icon" />
) : showCustomPicker ? (
<MaterialIcon
type="expand_more"
className="color-picker-more-open"
/>
) : (
<MaterialIcon type="add" className="color-picker-more" />
)}
</div>
</OLTooltip>
</div>
{showCustomPicker && (
<>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,
jsx-a11y/no-static-element-interactions, jsx-a11y/interactive-supports-focus */}
<div
className="popover-backdrop"
role="button"
onClick={() => closeCustomPicker()}
/>
<SketchPicker
disableAlpha
presetColors={[]}
onChange={color => {
setPickingCustomColor(true)
setLocalColor(color.hex)
}}
onChangeComplete={color => {
selectColor(color.hex)
setPickingCustomColor(false)
}}
color={localColor}
className="custom-picker"
/>
</>
)}
</div>
)
}
export function ColorPicker({
disableCustomColor,
}: {
disableCustomColor?: boolean
}) {
const { selectColor, selectedColor } = useSelectColor()
useEffect(() => {
if (!selectedColor) {
selectColor(
PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)].color
)
}
}, [selectColor, selectedColor])
return (
<>
{PRESET_COLORS.map(({ color, name }) => (
<ColorPickerItem color={color} name={name} key={color} />
))}
{!disableCustomColor && <MoreButton />}
</>
)
}

View File

@@ -0,0 +1,44 @@
import { useTranslation, Trans } from 'react-i18next'
import { CommonsPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type CommonsPlanProps = Pick<
CommonsPlanSubscription,
'subscription' | 'plan' | 'featuresPageURL'
>
function CommonsPlan({
featuresPageURL,
subscription,
plan,
}: CommonsPlanProps) {
const { t } = useTranslation()
const currentPlanLabel = (
<Trans i18nKey="premium_plan_label" components={{ b: <strong /> }} />
)
return (
<>
<span className="current-plan-label d-md-none">{currentPlanLabel}</span>
<OLTooltip
description={t('commons_plan_tooltip', {
plan: plan.name,
institution: subscription.name,
})}
id="commons-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href={featuresPageURL}
className="current-plan-label d-none d-md-inline-block"
>
{currentPlanLabel}&nbsp;
<MaterialIcon type="info" className="current-plan-label-icon" />
</a>
</OLTooltip>
</>
)
}
export default CommonsPlan

View File

@@ -0,0 +1,71 @@
import FreePlan from './free-plan'
import IndividualPlan from './individual-plan'
import GroupPlan from './group-plan'
import CommonsPlan from './commons-plan'
import PausedPlan from './paused-plan'
import getMeta from '../../../../utils/meta'
function CurrentPlanWidget() {
const usersBestSubscription = getMeta('ol-usersBestSubscription')
if (!usersBestSubscription) {
return null
}
const { type } = usersBestSubscription
const isFreePlan = type === 'free' || type === 'standalone-ai-add-on'
const isIndividualPlan = type === 'individual'
const isGroupPlan = type === 'group'
const isCommonsPlan = type === 'commons'
const isPaused =
isIndividualPlan &&
usersBestSubscription.subscription?.recurlyStatus?.state === 'paused'
const featuresPageURL = '/learn/how-to/Overleaf_premium_features'
const subscriptionPageUrl = '/user/subscription'
let currentPlan
if (isFreePlan) {
currentPlan = <FreePlan featuresPageURL={featuresPageURL} />
}
if (isIndividualPlan) {
currentPlan = (
<IndividualPlan
remainingTrialDays={usersBestSubscription.remainingTrialDays}
plan={usersBestSubscription.plan}
featuresPageURL={featuresPageURL}
/>
)
}
if (isPaused) {
currentPlan = <PausedPlan subscriptionPageUrl={subscriptionPageUrl} />
}
if (isGroupPlan) {
currentPlan = (
<GroupPlan
subscription={usersBestSubscription.subscription}
remainingTrialDays={usersBestSubscription.remainingTrialDays}
plan={usersBestSubscription.plan}
featuresPageURL={featuresPageURL}
/>
)
}
if (isCommonsPlan) {
currentPlan = (
<CommonsPlan
subscription={usersBestSubscription.subscription}
plan={usersBestSubscription.plan}
featuresPageURL={featuresPageURL}
/>
)
}
return <div className="current-plan">{currentPlan}</div>
}
export default CurrentPlanWidget

View File

@@ -0,0 +1,54 @@
import { useTranslation, Trans } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { FreePlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import * as eventTracking from '../../../../infrastructure/event-tracking'
type FreePlanProps = Pick<FreePlanSubscription, 'featuresPageURL'>
function FreePlan({ featuresPageURL }: FreePlanProps) {
const { t } = useTranslation()
const currentPlanLabel = (
<Trans i18nKey="free_plan_label" components={{ b: <strong /> }} />
)
const handleClick = () => {
eventTracking.sendMB('upgrade-button-click', {
source: 'dashboard-top',
'project-dashboard-react': 'enabled',
'is-dashboard-sidebar-hidden': false,
'is-screen-width-less-than-768px': false,
})
}
return (
<>
<span className="current-plan-label d-md-none">{currentPlanLabel}</span>
<OLTooltip
description={t('free_plan_tooltip')}
id="free-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href={featuresPageURL}
className="current-plan-label d-none d-md-inline-block"
>
{currentPlanLabel}&nbsp;
<MaterialIcon type="info" className="current-plan-label-icon" />
</a>
</OLTooltip>{' '}
<span className="d-none d-md-inline-block">
<OLButton
variant="primary"
href="/user/subscription/plans"
onClick={handleClick}
>
{t('upgrade')}
</OLButton>
</span>
</>
)
}
export default FreePlan

View File

@@ -0,0 +1,62 @@
import { useTranslation, Trans } from 'react-i18next'
import { GroupPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type GroupPlanProps = Pick<
GroupPlanSubscription,
'subscription' | 'plan' | 'remainingTrialDays' | 'featuresPageURL'
>
function GroupPlan({
featuresPageURL,
subscription,
plan,
remainingTrialDays,
}: GroupPlanProps) {
const { t } = useTranslation()
const currentPlanLabel =
remainingTrialDays >= 0 ? (
remainingTrialDays === 1 ? (
<Trans i18nKey="trial_last_day" components={{ b: <strong /> }} />
) : (
<Trans
i18nKey="trial_remaining_days"
components={{ b: <strong /> }}
values={{ days: remainingTrialDays }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
) : (
<Trans i18nKey="premium_plan_label" components={{ b: <strong /> }} />
)
return (
<>
<span className="current-plan-label d-md-none">{currentPlanLabel}</span>
<OLTooltip
description={
subscription.teamName != null
? t('group_plan_with_name_tooltip', {
plan: plan.name,
groupName: subscription.teamName,
})
: t('group_plan_tooltip', { plan: plan.name })
}
id="group-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href={featuresPageURL}
className="current-plan-label d-none d-md-inline-block"
>
{currentPlanLabel}&nbsp;
<MaterialIcon type="info" className="current-plan-label-icon" />
</a>
</OLTooltip>
</>
)
}
export default GroupPlan

View File

@@ -0,0 +1,54 @@
import { useTranslation, Trans } from 'react-i18next'
import { IndividualPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type IndividualPlanProps = Pick<
IndividualPlanSubscription,
'plan' | 'remainingTrialDays' | 'featuresPageURL'
>
function IndividualPlan({
featuresPageURL,
plan,
remainingTrialDays,
}: IndividualPlanProps) {
const { t } = useTranslation()
const currentPlanLabel =
remainingTrialDays >= 0 ? (
remainingTrialDays === 1 ? (
<Trans i18nKey="trial_last_day" components={{ b: <strong /> }} />
) : (
<Trans
i18nKey="trial_remaining_days"
components={{ b: <strong /> }}
values={{ days: remainingTrialDays }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
) : (
<Trans i18nKey="premium_plan_label" components={{ b: <strong /> }} />
)
return (
<>
<span className="current-plan-label d-md-none">{currentPlanLabel}</span>
<OLTooltip
description={t('plan_tooltip', { plan: plan.name })}
id="individual-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href={featuresPageURL}
className="current-plan-label d-none d-md-inline-block"
>
{currentPlanLabel}&nbsp;
<MaterialIcon type="info" className="current-plan-label-icon" />
</a>
</OLTooltip>
</>
)
}
export default IndividualPlan

View File

@@ -0,0 +1,41 @@
import { Trans, useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type PausedPlanProps = {
subscriptionPageUrl: string
}
function PausedPlan({ subscriptionPageUrl }: PausedPlanProps) {
const { t } = useTranslation()
const currentPlanLabel = (
<Trans
i18nKey="your_premium_plan_is_paused"
components={[
// eslint-disable-next-line react/jsx-key
<strong />,
]}
/>
)
return (
<>
<span className="current-plan-label d-md-none">{currentPlanLabel}</span>
<OLTooltip
description={t('click_to_unpause')}
id="individual-plan"
overlayProps={{ placement: 'bottom' }}
>
<a
href={subscriptionPageUrl}
className="current-plan-label d-none d-md-inline-block"
>
{currentPlanLabel}&nbsp;
<MaterialIcon type="info" className="current-plan-label-icon" />
</a>
</OLTooltip>
</>
)
}
export default PausedPlan

View File

@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import Notification from '@/shared/components/notification'
export default function DashApiError() {
const { t } = useTranslation()
return (
<OLRow className="row-spaced">
<OLCol xs={{ span: 8, offset: 2 }} aria-live="polite">
<div className="notification-list">
<Notification
content={t('generic_something_went_wrong')}
type="error"
/>
</div>
</OLCol>
</OLRow>
)
}

View File

@@ -0,0 +1,189 @@
import { useTranslation } from 'react-i18next'
import { Spinner } from 'react-bootstrap-5'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import CopyProjectButton from '../table/cells/action-buttons/copy-project-button'
import DownloadProjectButton from '../table/cells/action-buttons/download-project-button'
import ArchiveProjectButton from '../table/cells/action-buttons/archive-project-button'
import TrashProjectButton from '../table/cells/action-buttons/trash-project-button'
import UnarchiveProjectButton from '../table/cells/action-buttons/unarchive-project-button'
import UntrashProjectButton from '../table/cells/action-buttons/untrash-project-button'
import LeaveProjectButton from '../table/cells/action-buttons/leave-project-button'
import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button'
import { Project } from '../../../../../../types/project/dashboard/api'
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
import MaterialIcon from '@/shared/components/material-icon'
type ActionDropdownProps = {
project: Project
}
function ActionsDropdown({ project }: ActionDropdownProps) {
const { t } = useTranslation()
return (
<Dropdown align="end">
<DropdownToggle
id={`project-actions-dropdown-toggle-btn-${project.id}`}
bsPrefix="dropdown-table-button-toggle"
>
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
</DropdownToggle>
<DropdownMenu flip={false}>
<RenameProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="edit"
>
{text}
</DropdownItem>
</li>
)}
</RenameProjectButton>
<CopyProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="file_copy"
>
{text}
</DropdownItem>
</li>
)}
</CopyProjectButton>
<DownloadProjectButton project={project}>
{(text, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={downloadProject}
leadingIcon="download"
>
{text}
</DropdownItem>
</li>
)}
</DownloadProjectButton>
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<Spinner
animation="border"
aria-hidden="true"
as="span"
className="dropdown-item-leading-icon spinner"
size="sm"
role="status"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
<ArchiveProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="inbox"
>
{text}
</DropdownItem>
</li>
)}
</ArchiveProjectButton>
<TrashProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="delete"
>
{text}
</DropdownItem>
</li>
)}
</TrashProjectButton>
<UnarchiveProjectButton project={project}>
{(text, unarchiveProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={unarchiveProject}
leadingIcon="restore_page"
>
{text}
</DropdownItem>
</li>
)}
</UnarchiveProjectButton>
<UntrashProjectButton project={project}>
{(text, untrashProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={untrashProject}
leadingIcon="restore_page"
>
{text}
</DropdownItem>
</li>
)}
</UntrashProjectButton>
<LeaveProjectButton project={project}>
{text => (
<li role="none">
<DropdownItem as="button" tabIndex={-1} leadingIcon="logout">
{text}
</DropdownItem>
</li>
)}
</LeaveProjectButton>
<DeleteProjectButton project={project}>
{text => (
<li role="none">
<DropdownItem as="button" tabIndex={-1} leadingIcon="block">
{text}
</DropdownItem>
</li>
)}
</DeleteProjectButton>
</DropdownMenu>
</Dropdown>
)
}
export default ActionsDropdown

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react'
type MenuItemButtonProps = {
children: ReactNode
onClick?: (e?: React.MouseEvent) => void
className?: string
afterNode?: React.ReactNode
}
export default function MenuItemButton({
children,
onClick,
className,
afterNode,
...buttonProps
}: MenuItemButtonProps) {
return (
<li role="presentation" className={className}>
<button
className="menu-item-button"
role="menuitem"
onClick={onClick}
{...buttonProps}
>
{children}
</button>
{afterNode}
</li>
)
}

View File

@@ -0,0 +1,111 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import {
Filter,
UNCATEGORIZED_KEY,
useProjectListContext,
} from '../../context/project-list-context'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import ProjectsFilterMenu from '../projects-filter-menu'
import TagsList from '../tags-list'
type ItemProps = {
filter: Filter
text: string
onClick?: () => void
}
export function Item({ filter, text, onClick }: ItemProps) {
const { selectFilter } = useProjectListContext()
const handleClick = () => {
selectFilter(filter)
onClick?.()
}
return (
<ProjectsFilterMenu filter={filter}>
{isActive => (
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleClick}
trailingIcon={isActive ? 'check' : undefined}
active={isActive}
>
{text}
</DropdownItem>
)}
</ProjectsFilterMenu>
)
}
function ProjectsDropdown() {
const { t } = useTranslation()
const [title, setTitle] = useState(() => t('all_projects'))
const { filter, selectedTagId, tags } = useProjectListContext()
const filterTranslations = useRef<Record<Filter, string>>({
all: t('all_projects'),
owned: t('your_projects'),
shared: t('shared_with_you'),
archived: t('archived_projects'),
trashed: t('trashed_projects'),
})
useEffect(() => {
if (selectedTagId === undefined) {
setTitle(filterTranslations.current[filter])
}
if (selectedTagId === UNCATEGORIZED_KEY) {
setTitle(t('uncategorized_projects'))
} else {
const tag = tags.find(({ _id: id }) => id === selectedTagId)
if (tag) {
setTitle(tag.name ?? '')
}
}
}, [filter, tags, selectedTagId, t])
return (
<Dropdown>
<DropdownToggle
id="projects-types-dropdown-toggle-btn"
className="ps-0 mb-0 btn-transparent h3"
size="lg"
aria-label={t('filter_projects')}
>
<span className="text-truncate" aria-hidden>
{title}
</span>
</DropdownToggle>
<DropdownMenu flip={false}>
<li role="none">
<Item filter="all" text={t('all_projects')} />
</li>
<li role="none">
<Item filter="owned" text={t('your_projects')} />
</li>
<li role="none">
<Item filter="shared" text={t('shared_with_you')} />
</li>
<li role="none">
<Item filter="archived" text={t('archived_projects')} />
</li>
<li role="none">
<Item filter="trashed" text={t('trashed_projects')} />
</li>
<DropdownHeader className="text-uppercase">{t('tags')}:</DropdownHeader>
<TagsList />
</DropdownMenu>
</Dropdown>
)
}
export default ProjectsDropdown

View File

@@ -0,0 +1,89 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useSort from '../../hooks/use-sort'
import withContent, { SortBtnProps } from '../sort/with-content'
import { useProjectListContext } from '../../context/project-list-context'
import { Sort } from '../../../../../../types/project/dashboard/api'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return (
<DropdownItem
as="button"
tabIndex={-1}
onClick={onClick}
trailingIcon={iconType}
>
{text}
</DropdownItem>
)
}
const ItemWithContent = withContent(Item)
function SortByDropdown() {
const { t } = useTranslation()
const [title, setTitle] = useState(() => t('last_modified'))
const { sort } = useProjectListContext()
const { handleSort } = useSort()
const sortByTranslations = useRef<Record<Sort['by'], string>>({
title: t('title'),
owner: t('owner'),
lastUpdated: t('last_modified'),
})
const handleClick = (by: Sort['by']) => {
setTitle(sortByTranslations.current[by])
handleSort(by)
}
useEffect(() => {
setTitle(sortByTranslations.current[sort.by])
}, [sort.by])
return (
<Dropdown className="projects-sort-dropdown" align="end">
<DropdownToggle
id="projects-sort-dropdown"
className="pe-0 mb-0 btn-transparent"
size="sm"
aria-label={t('sort_projects')}
>
<span className="text-truncate" aria-hidden>
{title}
</span>
</DropdownToggle>
<DropdownMenu flip={false}>
<DropdownHeader className="text-uppercase">
{t('sort_by')}:
</DropdownHeader>
<ItemWithContent
column="title"
text={t('title')}
sort={sort}
onClick={() => handleClick('title')}
/>
<ItemWithContent
column="owner"
text={t('owner')}
sort={sort}
onClick={() => handleClick('owner')}
/>
<ItemWithContent
column="lastUpdated"
text={t('last_modified')}
sort={sort}
onClick={() => handleClick('lastUpdated')}
/>
</DropdownMenu>
</Dropdown>
)
}
export default SortByDropdown

View File

@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function LoadMore() {
const {
visibleProjects,
hiddenProjectsCount,
loadMoreCount,
showAllProjects,
loadMoreProjects,
} = useProjectListContext()
const { t } = useTranslation()
return (
<div className="text-center">
{hiddenProjectsCount > 0 ? (
<>
<OLButton
variant="secondary"
className="project-list-load-more-button"
onClick={() => loadMoreProjects()}
>
{t('show_x_more_projects', { x: loadMoreCount })}
</OLButton>
</>
) : null}
<p>
{hiddenProjectsCount > 0 ? (
<>
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllProjects()}
className="btn-inline-link"
>
{t('show_all_projects')}
</OLButton>
</>
) : (
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length,
})}
</span>
)}
</p>
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import ProjectsList from './projects-list'
type ArchiveProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function ArchiveProjectModal({
projects,
actionHandler,
showModal,
handleCloseModal,
}: ArchiveProjectModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="archive"
actionHandler={actionHandler}
title={t('archive_projects')}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
>
<p>{t('about_to_archive_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<p>
{t('archiving_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/learn/how-to/How_do_I_archive_and_unarchive_projects%3F"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
</ProjectsActionModal>
)
}
export default ArchiveProjectModal

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import useAsync from '../../../../shared/hooks/use-async'
import { useProjectListContext } from '../../context/project-list-context'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import useSelectColor from '../../hooks/use-select-color'
import { createTag } from '../../util/api'
import { MAX_TAG_LENGTH } from '../../util/tag'
import { ColorPicker } from '../color-picker/color-picker'
import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLForm from '@/features/ui/components/ol/ol-form'
import Notification from '@/shared/components/notification'
type CreateTagModalProps = {
id: string
show: boolean
onCreate: (tag: Tag) => void
onClose: () => void
disableCustomColor?: boolean
}
export default function CreateTagModal({
id,
show,
onCreate,
onClose,
disableCustomColor,
}: CreateTagModalProps) {
const { tags } = useProjectListContext()
const { selectedColor } = useSelectColor()
const { t } = useTranslation()
const { isLoading, isError, runAsync, status } = useAsync<Tag>()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const [tagName, setTagName] = useState<string>()
const [validationError, setValidationError] = useState<string>()
const runCreateTag = useCallback(() => {
if (tagName) {
runAsync(createTag(tagName, selectedColor))
.then(tag => onCreate(tag))
.catch(debugConsole.error)
}
}, [runAsync, tagName, selectedColor, onCreate])
const handleSubmit = useCallback(
e => {
e.preventDefault()
runCreateTag()
},
[runCreateTag]
)
useEffect(() => {
if (tagName && tagName.length > MAX_TAG_LENGTH) {
setValidationError(
t('tag_name_cannot_exceed_characters', { maxLength: MAX_TAG_LENGTH })
)
} else if (tagName && tags.find(tag => tag.name === tagName)) {
setValidationError(t('tag_name_is_already_used', { tagName }))
} else if (validationError) {
setValidationError(undefined)
}
}, [tagName, tags, t, validationError])
if (!show) {
return null
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('create_new_tag')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<OLForm id="create-tag-modal-form" onSubmit={handleSubmit}>
<OLFormGroup controlId="create-tag-modal-form">
<OLFormLabel>{t('new_tag_name')}</OLFormLabel>
<OLFormControl
name="new-tag-form-name"
onChange={e => setTagName(e.target.value)}
ref={autoFocusedRef}
required
type="text"
/>
</OLFormGroup>
<OLFormGroup aria-hidden="true">
<OLFormLabel>{t('tag_color')}</OLFormLabel>:{' '}
<div>
<ColorPicker disableCustomColor={disableCustomColor} />
</div>
</OLFormGroup>
</OLForm>
{validationError && (
<Notification type="error" content={validationError} />
)}
{isError && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={onClose}
disabled={status === 'pending'}
>
{t('cancel')}
</OLButton>
<OLButton
onClick={() => runCreateTag()}
variant="primary"
disabled={
status === 'pending' || !tagName?.length || !!validationError
}
isLoading={isLoading}
>
{t('create')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import ProjectsList from './projects-list'
import { isLeavableProject, isDeletableProject } from '../../util/project'
import Notification from '@/shared/components/notification'
type DeleteLeaveProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function DeleteLeaveProjectModal({
projects,
actionHandler,
showModal,
handleCloseModal,
}: DeleteLeaveProjectModalProps) {
const { t } = useTranslation()
const [projectsToDeleteDisplay, setProjectsToDeleteDisplay] = useState<
typeof projects
>([])
const [projectsToLeaveDisplay, setProjectsToLeaveDisplay] = useState<
typeof projects
>([])
const projectsToDelete = useMemo(() => {
return projects.filter(isDeletableProject)
}, [projects])
const projectsToLeave = useMemo(() => {
return projects.filter(isLeavableProject)
}, [projects])
useEffect(() => {
if (showModal) {
setProjectsToDeleteDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projectsToDelete
})
} else {
setProjectsToDeleteDisplay([])
}
}, [showModal, projectsToDelete])
useEffect(() => {
if (showModal) {
setProjectsToLeaveDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projectsToLeave
})
} else {
setProjectsToLeaveDisplay([])
}
}, [showModal, projectsToLeave])
return (
<ProjectsActionModal
action="leaveOrDelete"
actionHandler={actionHandler}
title={t('delete_and_leave_projects')}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={[...projectsToDelete, ...projectsToLeave]}
>
<p>{t('about_to_delete_projects')}</p>
<ProjectsList
projects={projectsToDelete}
projectsToDisplay={projectsToDeleteDisplay}
/>
<p>{t('about_to_leave_projects')}</p>
<ProjectsList
projects={projectsToLeave}
projectsToDisplay={projectsToLeaveDisplay}
/>
<Notification
content={t('this_action_cannot_be_undone')}
type="warning"
/>
</ProjectsActionModal>
)
}
export default DeleteLeaveProjectModal

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import ProjectsList from './projects-list'
import Notification from '@/shared/components/notification'
type DeleteProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function DeleteProjectModal({
projects,
actionHandler,
showModal,
handleCloseModal,
}: DeleteProjectModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="delete"
actionHandler={actionHandler}
title={t('delete_projects')}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
>
<p>{t('about_to_delete_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<Notification
content={t('this_action_cannot_be_undone')}
type="warning"
/>
</ProjectsActionModal>
)
}
export default DeleteProjectModal

View File

@@ -0,0 +1,81 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import useAsync from '../../../../shared/hooks/use-async'
import { deleteTag } from '../../util/api'
import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import Notification from '@/shared/components/notification'
type DeleteTagModalProps = {
id: string
tag?: Tag
onDelete: (tagId: string) => void
onClose: () => void
}
export default function DeleteTagModal({
id,
tag,
onDelete,
onClose,
}: DeleteTagModalProps) {
const { t } = useTranslation()
const { isLoading, isError, runAsync } = useAsync()
const runDeleteTag = useCallback(
(tagId: string) => {
runAsync(deleteTag(tagId))
.then(() => {
onDelete(tagId)
})
.catch(debugConsole.error)
},
[runAsync, onDelete]
)
if (!tag) {
return null
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('delete_tag')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{t('about_to_delete_tag')}
<ul>
<li>{tag.name}</li>
</ul>
{isError && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onClose} disabled={isLoading}>
{t('cancel')}
</OLButton>
<OLButton
onClick={() => runDeleteTag(tag._id)}
variant="danger"
disabled={isLoading}
isLoading={isLoading}
>
{t('delete')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,147 @@
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import useAsync from '../../../../shared/hooks/use-async'
import { useProjectListContext } from '../../context/project-list-context'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import useSelectColor from '../../hooks/use-select-color'
import { editTag } from '../../util/api'
import { getTagColor, MAX_TAG_LENGTH } from '../../util/tag'
import { ColorPicker } from '../color-picker/color-picker'
import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
import Notification from '@/shared/components/notification'
type EditTagModalProps = {
id: string
tag?: Tag
onEdit: (tagId: string, newTagName: string, newTagColor?: string) => void
onClose: () => void
}
export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
const { tags } = useProjectListContext()
const { t } = useTranslation()
const { isLoading, isError, runAsync, status } = useAsync()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const [newTagName, setNewTagName] = useState<string | undefined>()
const [validationError, setValidationError] = useState<string>()
const { selectedColor } = useSelectColor(getTagColor(tag))
useEffect(() => {
setNewTagName(tag?.name)
}, [tag])
const runEditTag = useCallback(
(tagId: string) => {
if (newTagName) {
const color = selectedColor
runAsync(editTag(tagId, newTagName, color))
.then(() => onEdit(tagId, newTagName, color))
.catch(debugConsole.error)
}
},
[runAsync, newTagName, selectedColor, onEdit]
)
const handleSubmit = useCallback(
e => {
e.preventDefault()
if (tag) {
runEditTag(tag._id)
}
},
[tag, runEditTag]
)
useEffect(() => {
if (newTagName && newTagName.length > MAX_TAG_LENGTH) {
setValidationError(
t('tag_name_cannot_exceed_characters', { maxLength: MAX_TAG_LENGTH })
)
} else if (
newTagName &&
newTagName !== tag?.name &&
tags.find(tag => tag.name === newTagName)
) {
setValidationError(t('tag_name_is_already_used', { tagName: newTagName }))
} else if (validationError) {
setValidationError(undefined)
}
}, [newTagName, tags, tag?.name, t, validationError])
if (!tag) {
return null
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('edit_tag')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<OLForm onSubmit={handleSubmit}>
<OLFormGroup>
<input
ref={autoFocusedRef}
className="form-control"
type="text"
placeholder="Tag Name"
name="new-tag-name"
value={newTagName === undefined ? (tag.name ?? '') : newTagName}
required
onChange={e => setNewTagName(e.target.value)}
/>
</OLFormGroup>
<OLFormGroup aria-hidden="true">
<OLFormLabel>{t('tag_color')}</OLFormLabel>:{' '}
<div>
<ColorPicker />
</div>
</OLFormGroup>
</OLForm>
{validationError && (
<Notification content={validationError} type="error" />
)}
{isError && (
<Notification
content={t('generic_something_went_wrong')}
type="error"
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onClose} disabled={isLoading}>
{t('cancel')}
</OLButton>
<OLButton
onClick={() => runEditTag(tag._id)}
variant="primary"
disabled={
isLoading ||
status === 'pending' ||
!newTagName?.length ||
(newTagName === tag?.name && selectedColor === getTagColor(tag)) ||
!!validationError
}
isLoading={isLoading}
>
{t('save')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import ProjectsList from './projects-list'
import Notification from '@/shared/components/notification'
type LeaveProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function LeaveProjectModal({
projects,
actionHandler,
showModal,
handleCloseModal,
}: LeaveProjectModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="leave"
actionHandler={actionHandler}
title={t('leave_projects')}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
>
<p>{t('about_to_leave_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<Notification
content={t('this_action_cannot_be_undone')}
type="warning"
/>
</ProjectsActionModal>
)
}
export default LeaveProjectModal

View File

@@ -0,0 +1,155 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '../../../../shared/hooks/use-async'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import useSelectColor from '../../hooks/use-select-color'
import { deleteTag, editTag } from '../../util/api'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import { getTagColor } from '../../util/tag'
import { ColorPicker } from '../color-picker/color-picker'
import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
import Notification from '@/shared/components/notification'
type ManageTagModalProps = {
id: string
tag?: Tag
onEdit: (tagId: string, newTagName: string, newTagColor?: string) => void
onDelete: (tagId: string) => void
onClose: () => void
}
export function ManageTagModal({
id,
tag,
onEdit,
onDelete,
onClose,
}: ManageTagModalProps) {
const { t } = useTranslation()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const {
isLoading: isDeleteLoading,
isError: isDeleteError,
runAsync: runDeleteAsync,
} = useAsync()
const {
isLoading: isUpdateLoading,
isError: isRenameError,
runAsync: runEditAsync,
} = useAsync()
const [newTagName, setNewTagName] = useState<string | undefined>(tag?.name)
const { selectedColor } = useSelectColor(tag?.color)
const runDeleteTag = useCallback(
(tagId: string) => {
runDeleteAsync(deleteTag(tagId))
.then(() => {
onDelete(tagId)
})
.catch(debugConsole.error)
},
[runDeleteAsync, onDelete]
)
const runUpdateTag = useCallback(
(tagId: string) => {
if (newTagName) {
runEditAsync(editTag(tagId, newTagName, selectedColor))
.then(() => onEdit(tagId, newTagName, selectedColor))
.catch(debugConsole.error)
}
},
[runEditAsync, newTagName, selectedColor, onEdit]
)
const handleSubmit = useCallback(
e => {
e.preventDefault()
if (tag) {
runUpdateTag(tag._id)
}
},
[tag, runUpdateTag]
)
if (!tag) {
return null
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('edit_tag')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<OLForm onSubmit={handleSubmit}>
<OLFormGroup>
<input
ref={autoFocusedRef}
className="form-control"
type="text"
placeholder="Tag Name"
name="new-tag-name"
value={newTagName === undefined ? (tag.name ?? '') : newTagName}
required
onChange={e => setNewTagName(e.target.value)}
/>
</OLFormGroup>
<OLFormGroup aria-hidden="true">
<OLFormLabel>{t('tag_color')}</OLFormLabel>:<br />
<ColorPicker disableCustomColor />
</OLFormGroup>
</OLForm>
{(isDeleteError || isRenameError) && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</OLModalBody>
<OLModalFooter>
<OLButton
variant="danger"
onClick={() => runDeleteTag(tag._id)}
className="me-auto"
disabled={isDeleteLoading || isUpdateLoading}
isLoading={isDeleteLoading}
>
{t('delete_tag')}
</OLButton>
<OLButton
variant="secondary"
onClick={onClose}
disabled={isDeleteLoading || isUpdateLoading}
>
{t('save_or_cancel-cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={() => runUpdateTag(tag._id)}
disabled={Boolean(
isUpdateLoading ||
isDeleteLoading ||
!newTagName?.length ||
(newTagName === tag?.name && selectedColor === getTagColor(tag))
)}
isLoading={isUpdateLoading}
>
{t('save_or_cancel-save')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,115 @@
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../types/project/dashboard/api'
import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
import useIsMounted from '../../../../shared/hooks/use-is-mounted'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { isSmallDevice } from '../../../../infrastructure/event-tracking'
import Notification from '@/shared/components/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
type ProjectsActionModalProps = {
title?: string
action: 'archive' | 'trash' | 'delete' | 'leave' | 'leaveOrDelete'
actionHandler: (project: Project) => Promise<void>
handleCloseModal: () => void
projects: Array<Project>
showModal: boolean
children?: React.ReactNode
}
function ProjectsActionModal({
title,
action,
actionHandler,
handleCloseModal,
showModal,
projects,
children,
}: ProjectsActionModalProps) {
const { t } = useTranslation()
const [errors, setErrors] = useState<Array<any>>([])
const [isProcessing, setIsProcessing] = useState(false)
const isMounted = useIsMounted()
async function handleActionForProjects(projects: Array<Project>) {
const errored = []
setIsProcessing(true)
setErrors([])
for (const project of projects) {
try {
await actionHandler(project)
} catch (e) {
errored.push({ projectName: project.name, error: e })
}
}
if (isMounted.current) {
setIsProcessing(false)
}
if (errored.length === 0) {
handleCloseModal()
} else {
setErrors(errored)
}
}
useEffect(() => {
if (showModal) {
eventTracking.sendMB('project-list-page-interaction', {
action,
isSmallDevice,
})
}
}, [action, showModal])
return (
<OLModal
animation
show={showModal}
onHide={handleCloseModal}
id="action-project-modal"
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle>{title}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{children}
{!isProcessing &&
errors.length > 0 &&
errors.map((error, i) => (
<div className="notification-list" key={i}>
<Notification
type="error"
title={error.projectName}
content={getUserFacingMessage(error.error) as string}
/>
</div>
))}
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleCloseModal}>
{t('cancel')}
</OLButton>
<OLButton
variant="danger"
onClick={() => handleActionForProjects(projects)}
disabled={isProcessing}
>
{t('confirm')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(ProjectsActionModal)

View File

@@ -0,0 +1,28 @@
import classnames from 'classnames'
import { Project } from '../../../../../../types/project/dashboard/api'
type ProjectsToDisplayProps = {
projects: Project[]
projectsToDisplay: Project[]
}
function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) {
return (
<ul>
{projectsToDisplay.map(project => (
<li
key={`projects-action-list-${project.id}`}
className={classnames({
'list-style-check-green': !projects.some(
({ id }) => id === project.id
),
})}
>
<b>{project.name}</b>
</li>
))}
</ul>
)
}
export default ProjectsList

View File

@@ -0,0 +1,142 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { Project } from '../../../../../../types/project/dashboard/api'
import { renameProject } from '../../util/api'
import useAsync from '../../../../shared/hooks/use-async'
import { useProjectListContext } from '../../context/project-list-context'
import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { isSmallDevice } from '../../../../infrastructure/event-tracking'
import Notification from '@/shared/components/notification'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
type RenameProjectModalProps = {
handleCloseModal: () => void
project: Project
showModal: boolean
}
function RenameProjectModal({
handleCloseModal,
showModal,
project,
}: RenameProjectModalProps) {
const { t } = useTranslation()
const [newProjectName, setNewProjectName] = useState(project.name)
const { error, isError, isLoading, runAsync } = useAsync()
const { toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
useEffect(() => {
if (showModal) {
eventTracking.sendMB('project-list-page-interaction', {
action: 'rename',
projectId: project.id,
isSmallDevice,
})
}
}, [showModal, project.id])
const isValid = useMemo(
() => newProjectName !== project.name && newProjectName.trim().length > 0,
[newProjectName, project]
)
useEffect(() => {
setNewProjectName(project.name)
}, [project.name])
const handleSubmit = useCallback(
event => {
event.preventDefault()
if (!isValid) return
runAsync(renameProject(project.id, newProjectName))
.then(() => {
toggleSelectedProject(project.id, false)
updateProjectViewData({
...project,
name: newProjectName,
})
handleCloseModal()
})
.catch(debugConsole.error)
},
[
handleCloseModal,
isValid,
newProjectName,
project,
runAsync,
toggleSelectedProject,
updateProjectViewData,
]
)
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setNewProjectName(event.target.value)
}
return (
<OLModal
animation
show={showModal}
onHide={handleCloseModal}
id="rename-project-modal"
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle>{t('rename_project')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isError && (
<div className="notification-list">
<Notification
type="error"
content={getUserFacingMessage(error) as string}
/>
</div>
)}
<OLForm id="rename-project-form" onSubmit={handleSubmit}>
<OLFormGroup controlId="rename-project-form-name">
<OLFormLabel>{t('new_name')}</OLFormLabel>
<OLFormControl
type="text"
placeholder={t('project_name')}
required
value={newProjectName}
onChange={handleOnChange}
/>
</OLFormGroup>
</OLForm>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleCloseModal}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
type="submit"
form="rename-project-form"
disabled={isLoading || !isValid}
>
{t('rename')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default memo(RenameProjectModal)

View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectsActionModal from './projects-action-modal'
import ProjectsList from './projects-list'
type TrashProjectPropsModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
>
function TrashProjectModal({
projects,
actionHandler,
showModal,
handleCloseModal,
}: TrashProjectPropsModalProps) {
const { t } = useTranslation()
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
[]
)
useEffect(() => {
if (showModal) {
setProjectsToDisplay(displayProjects => {
return displayProjects.length ? displayProjects : projects
})
} else {
setProjectsToDisplay([])
}
}, [showModal, projects])
return (
<ProjectsActionModal
action="trash"
actionHandler={actionHandler}
title={t('trash_projects')}
showModal={showModal}
handleCloseModal={handleCloseModal}
projects={projects}
>
<p>{t('about_to_trash_projects')}</p>
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
<p>
{t('trashing_projects_wont_affect_collaborators')}{' '}
<a
href="https://www.overleaf.com/learn/how-to/How_do_I_remove_or_delete_a_project%3F"
target="_blank"
rel="noreferrer"
>
{t('find_out_more_nt')}
</a>
</p>
</ProjectsActionModal>
)
}
export default TrashProjectModal

View File

@@ -0,0 +1,280 @@
import { type JSXElementConstructor, useCallback, useState } from 'react'
import classnames from 'classnames'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import NewProjectButtonModal, {
NewProjectButtonModalVariant,
} from './new-project-button/new-project-button-modal'
import AddAffiliation, { useAddAffiliation } from './add-affiliation'
import { Nullable } from '../../../../../types/utils'
import { sendMB } from '../../../infrastructure/event-tracking'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import {
Dropdown,
DropdownDivider,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
import type { PortalTemplate } from '../../../../../types/portal-template'
type SendTrackingEvent = {
dropdownMenu: string
dropdownOpen: boolean
institutionTemplateName?: string
}
type Segmentation = SendTrackingEvent & {
'welcome-page-redesign': 'default'
}
type ModalMenuClickOptions = {
modalVariant: NewProjectButtonModalVariant
dropdownMenuEvent: string
}
type NewProjectButtonProps = {
id: string
buttonText?: string
className?: string
trackingKey?: string
showAddAffiliationWidget?: boolean
}
function NewProjectButton({
id,
buttonText,
className,
trackingKey,
showAddAffiliationWidget,
}: NewProjectButtonProps) {
const { t } = useTranslation()
const { templateLinks } = getMeta('ol-ExposedSettings')
const [modal, setModal] =
useState<Nullable<NewProjectButtonModalVariant>>(null)
const portalTemplates = getMeta('ol-portalTemplates') || []
const { show: enableAddAffiliationWidget } = useAddAffiliation()
const sendProjectListMB = useSendProjectListMB()
const sendTrackingEvent = useCallback(
({
dropdownMenu,
dropdownOpen,
institutionTemplateName,
}: SendTrackingEvent) => {
if (trackingKey) {
let segmentation: Segmentation = {
'welcome-page-redesign': 'default',
dropdownMenu,
dropdownOpen,
}
if (institutionTemplateName) {
segmentation = {
...segmentation,
institutionTemplateName,
}
}
sendMB(trackingKey, segmentation)
}
},
[trackingKey]
)
const handleMainButtonClick = useCallback(
(dropdownOpen: boolean) => {
sendTrackingEvent({
dropdownMenu: 'main-button',
dropdownOpen,
})
},
[sendTrackingEvent]
)
const handleModalMenuClick = useCallback(
(
e: React.MouseEvent,
{ modalVariant, dropdownMenuEvent }: ModalMenuClickOptions
) => {
// avoid invoking the "onClick" callback on the main dropdown button
e.stopPropagation()
sendTrackingEvent({
dropdownMenu: dropdownMenuEvent,
dropdownOpen: true,
})
sendProjectListMB('new-project-click', { item: dropdownMenuEvent })
setModal(modalVariant)
},
[sendProjectListMB, sendTrackingEvent]
)
const handlePortalTemplateClick = useCallback(
(e: React.MouseEvent, template: PortalTemplate) => {
// avoid invoking the "onClick" callback on the main dropdown button
e.stopPropagation()
sendTrackingEvent({
dropdownMenu: 'institution-template',
dropdownOpen: true,
institutionTemplateName: template.name,
})
sendProjectListMB('new-project-click', {
item: template.name,
destinationURL: template.url,
})
},
[sendProjectListMB, sendTrackingEvent]
)
const handleStaticTemplateClick = useCallback(
(e: React.MouseEvent, template: { trackingKey: string; url: string }) => {
// avoid invoking the "onClick" callback on the main dropdown button
e.stopPropagation()
sendTrackingEvent({
dropdownMenu: template.trackingKey,
dropdownOpen: true,
})
sendProjectListMB('new-project-click', {
item: template.trackingKey,
destinationURL: template.url,
})
},
[sendProjectListMB, sendTrackingEvent]
)
const [importProjectFromGithubMenu] = importOverleafModules(
'importProjectFromGithubMenu'
)
const ImportProjectFromGithubMenu: JSXElementConstructor<{
onClick: (e: React.MouseEvent) => void
}> = importProjectFromGithubMenu?.import.default
return (
<>
<Dropdown
className={classnames('new-project-dropdown', className)}
onSelect={handleMainButtonClick}
onToggle={nextShow => {
if (nextShow) sendProjectListMB('new-project-expand', undefined)
}}
>
<DropdownToggle
id={id}
className="new-project-button"
variant="primary"
>
{buttonText || t('new_project')}
</DropdownToggle>
<DropdownMenu>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'blank_project',
dropdownMenuEvent: 'blank-project',
})
}
>
{t('blank_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'example_project',
dropdownMenuEvent: 'example-project',
})
}
>
{t('example_project')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'upload_project',
dropdownMenuEvent: 'upload-project',
})
}
>
{t('upload_project')}
</DropdownItem>
</li>
<li role="none">
{ImportProjectFromGithubMenu && (
<ImportProjectFromGithubMenu
onClick={e =>
handleModalMenuClick(e, {
modalVariant: 'import_from_github',
dropdownMenuEvent: 'import-from-github',
})
}
/>
)}
</li>
{portalTemplates.length > 0 ? (
<>
<DropdownDivider />
<DropdownHeader aria-hidden="true">
{`${t('institution')} ${t('templates')}`}
</DropdownHeader>
{portalTemplates.map((portalTemplate, index) => (
<li role="none" key={`portal-template-${index}`}>
<DropdownItem
key={`portal-template-${index}`}
href={`${portalTemplate.url}#templates`}
onClick={e => handlePortalTemplateClick(e, portalTemplate)}
aria-label={`${portalTemplate.name} ${t('template')}`}
>
{portalTemplate.name}
</DropdownItem>
</li>
))}
</>
) : null}
{templateLinks && templateLinks.length > 0 && (
<>
<DropdownDivider />
<DropdownHeader aria-hidden="true">
{t('templates')}
</DropdownHeader>
</>
)}
{templateLinks?.map((templateLink, index) => (
<li role="none" key={`new-project-button-template-${index}`}>
<DropdownItem
href={templateLink.url}
onClick={e => handleStaticTemplateClick(e, templateLink)}
aria-label={`${templateLink.name} ${t('template')}`}
>
{templateLink.name === 'view_all'
? t('view_all')
: templateLink.name}
</DropdownItem>
</li>
))}
{showAddAffiliationWidget && enableAddAffiliationWidget ? (
<>
<DropdownDivider />
<li className="add-affiliation-mobile-wrapper">
<AddAffiliation className="is-mobile" />
</li>
</>
) : null}
</DropdownMenu>
</Dropdown>
<NewProjectButtonModal modal={modal} onHide={() => setModal(null)} />
</>
)
}
export default NewProjectButton

View File

@@ -0,0 +1,22 @@
import ModalContentNewProjectForm from './modal-content-new-project-form'
import OLModal from '@/features/ui/components/ol/ol-modal'
type BlankProjectModalProps = {
onHide: () => void
}
function BlankProjectModal({ onHide }: BlankProjectModalProps) {
return (
<OLModal
show
animation
onHide={onHide}
id="blank-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} />
</OLModal>
)
}
export default BlankProjectModal

View File

@@ -0,0 +1,22 @@
import OLModal from '@/features/ui/components/ol/ol-modal'
import ModalContentNewProjectForm from './modal-content-new-project-form'
type ExampleProjectModalProps = {
onHide: () => void
}
function ExampleProjectModal({ onHide }: ExampleProjectModalProps) {
return (
<OLModal
show
animation
onHide={onHide}
id="example-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} template="example" />
</OLModal>
)
}
export default ExampleProjectModal

View File

@@ -0,0 +1,113 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '../../../../shared/hooks/use-async'
import {
getUserFacingMessage,
postJSON,
} from '../../../../infrastructure/fetch-json'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import { useLocation } from '../../../../shared/hooks/use-location'
import Notification from '@/shared/components/notification'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLForm from '@/features/ui/components/ol/ol-form'
type NewProjectData = {
project_id: string
owner_ref: string
owner: {
first_name: string
last_name: string
email: string
id: string
}
}
type Props = {
onCancel: () => void
template?: string
}
function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
const { t } = useTranslation()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const [projectName, setProjectName] = useState('')
const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
const location = useLocation()
const createNewProject = () => {
runAsync(
postJSON('/project/new', {
body: {
projectName,
template,
},
})
)
.then(data => {
if (data.project_id) {
location.assign(`/project/${data.project_id}`)
}
})
.catch(() => {})
}
const handleChangeName = (e: React.ChangeEvent<HTMLInputElement>) => {
setProjectName(e.currentTarget.value)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
createNewProject()
}
return (
<>
<OLModalHeader closeButton>
<OLModalTitle>{t('new_project')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
{isError && (
<div className="notification-list">
<Notification
type="error"
content={getUserFacingMessage(error) as string}
/>
</div>
)}
<OLForm onSubmit={handleSubmit}>
<OLFormControl
type="text"
ref={autoFocusedRef}
placeholder={t('project_name')}
onChange={handleChangeName}
value={projectName}
/>
</OLForm>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={createNewProject}
disabled={projectName === '' || isLoading}
isLoading={isLoading}
>
{t('create')}
</OLButton>
</OLModalFooter>
</>
)
}
export default ModalContentNewProjectForm

View File

@@ -0,0 +1,57 @@
import BlankProjectModal from './blank-project-modal'
import ExampleProjectModal from './example-project-modal'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { JSXElementConstructor, lazy, Suspense, useCallback } from 'react'
import { Nullable } from '../../../../../../types/utils'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import { useLocation } from '@/shared/hooks/use-location'
const UploadProjectModal = lazy(() => import('./upload-project-modal'))
export type NewProjectButtonModalVariant =
| 'blank_project'
| 'example_project'
| 'upload_project'
| 'import_from_github'
type NewProjectButtonModalProps = {
modal: Nullable<NewProjectButtonModalVariant>
onHide: () => void
}
function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
const [importProjectFromGithubModalWrapper] = importOverleafModules(
'importProjectFromGithubModalWrapper'
)
const ImportProjectFromGithubModalWrapper: JSXElementConstructor<{
onHide: () => void
}> = importProjectFromGithubModalWrapper?.import.default
const location = useLocation()
const openProject = useCallback(
(projectId: string) => {
location.assign(`/project/${projectId}`)
},
[location]
)
switch (modal) {
case 'blank_project':
return <BlankProjectModal onHide={onHide} />
case 'example_project':
return <ExampleProjectModal onHide={onHide} />
case 'upload_project':
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<UploadProjectModal onHide={onHide} openProject={openProject} />
</Suspense>
)
case 'import_from_github':
return <ImportProjectFromGithubModalWrapper onHide={onHide} />
default:
return null
}
}
export default NewProjectButtonModal

View File

@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import XHRUpload from '@uppy/xhr-upload'
import getMeta from '../../../../utils/meta'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
type UploadResponse = {
project_id: string
}
type UploadProjectModalProps = {
onHide: () => void
openProject: (projectId: string) => void
}
function UploadProjectModal({ onHide, openProject }: UploadProjectModalProps) {
const { t } = useTranslation()
const { maxUploadSize, projectUploadTimeout } = getMeta('ol-ExposedSettings')
const [ableToUpload, setAbleToUpload] = useState(false)
const [uppy] = useState(() => {
return new Uppy({
allowMultipleUploadBatches: false,
restrictions: {
maxNumberOfFiles: 1,
maxFileSize: maxUploadSize,
allowedFileTypes: ['.zip'],
},
})
.use(XHRUpload, {
endpoint: '/project/new/upload',
headers: {
'X-CSRF-TOKEN': getMeta('ol-csrfToken'),
},
limit: 1,
fieldName: 'qqfile', // "qqfile" is needed for our express multer middleware
timeout: projectUploadTimeout,
})
.on('file-added', () => {
// this function can be invoked multiple times depending on maxNumberOfFiles
// in this case, since have maxNumberOfFiles = 1, this function will be invoked
// once if the correct file were added
// if user dragged more files than the maxNumberOfFiles allow,
// the rest of the files will appear on the 'restriction-failed' event callback
setAbleToUpload(true)
})
.on('upload-error', () => {
// refresh state so they can try uploading a new zip
setAbleToUpload(false)
})
.on('upload-success', async (file, response) => {
const { project_id: projectId }: UploadResponse = response.body
if (projectId) {
openProject(projectId)
}
})
.on('restriction-failed', () => {
// 'restriction-failed event will be invoked when one of the "restrictions" above
// is not complied:
// 1. maxNumberOfFiles: if the uploaded files is more than 1, the rest of the files will appear here
// for example, user drop 5 files to the uploader, this function will be invoked 4 times and the `file-added` event
// will be invoked once
// 2. maxFileSize: if the uploaded file has size > maxFileSize, it will appear here
// 3. allowedFileTypes: if the type is not .zip, it will also appear here
// reset state so they can try uploading a different file, etc
setAbleToUpload(false)
})
})
useEffect(() => {
if (ableToUpload) {
uppy.upload()
}
}, [ableToUpload, uppy])
return (
<OLModal
show
animation
onHide={onHide}
id="upload-project-modal"
backdrop="static"
>
<OLModalHeader closeButton>
<OLModalTitle as="h3">{t('upload_zipped_project')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<Dashboard
uppy={uppy}
proudlyDisplayPoweredByUppy={false}
showLinkToFileUploadResult={false}
hideUploadButton
showSelectedFiles={false}
height={300}
locale={{
strings: {
browseFiles: 'Select a .zip file',
dropPasteFiles: '%{browseFiles} or \n\n drag a .zip file',
},
}}
className="project-list-upload-project-modal-uppy-dashboard"
/>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onHide}>
{t('cancel')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default UploadProjectModal

View File

@@ -0,0 +1,48 @@
import { memo, useEffect, useState } from 'react'
import Notification from './notification'
import customLocalStorage from '@/infrastructure/local-storage'
import { useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
function AccessibilitySurveyBanner() {
const { t } = useTranslation()
const [show, setShow] = useState(false)
useEffect(() => {
const isDismissed = customLocalStorage.getItem(
'has_dismissed_accessibility_survey_banner'
)
if (!isDismissed) setShow(true)
}, [])
const handleClose = () => {
customLocalStorage.setItem(
'has_dismissed_accessibility_survey_banner',
'true'
)
setShow(false)
}
if (!show) return null
return (
<Notification
className="sr-only"
type="info"
onDismiss={handleClose}
content={<p>{t('help_improve_screen_reader_fill_out_this_survey')}</p>}
action={
<OLButton
variant="secondary"
href="https://docs.google.com/forms/d/e/1FAIpQLSdxKP_biRXvrkmJzlBjMwI_qPSuv4NbBvYUzSOc3OOTIOTmnQ/viewform"
target="_blank"
rel="noreferrer"
>
{t('take_survey')}
</OLButton>
}
/>
)
}
export default memo(AccessibilitySurveyBanner)

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import bannerImage from '../../../images/brl-banner.png'
import usePersistedState from '../../../../../shared/hooks/use-persisted-state'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
export default function BRLBanner() {
const { t } = useTranslation()
const [dismissedUntil, setDismissedUntil] = usePersistedState<
Date | undefined
>(`has_dismissed_brl_banner_until`)
const viewEventSent = useRef<boolean>(false)
const [showModal, setShowModal] = useState(true)
useEffect(() => {
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return
}
if (!viewEventSent.current) {
eventTracking.sendMB('promo-prompt', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: 'BR',
})
viewEventSent.current = true
}
}, [dismissedUntil])
const handleClick = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: 'BR',
type: 'click',
})
setShowModal(false)
window.open('/user/subscription/plans')
}, [])
const bannerDismissed = useCallback(() => {
eventTracking.sendMB('promo-dismiss', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: 'BR',
})
const until = new Date()
until.setDate(until.getDate() + 30) // 30 days
setDismissedUntil(until)
}, [setDismissedUntil])
const handleHide = useCallback(() => {
setShowModal(false)
bannerDismissed()
}, [bannerDismissed])
const handleMaybeLater = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: 'BR',
type: 'pause',
})
setShowModal(false)
const until = new Date()
until.setDate(until.getDate() + 1) // 1 day
setDismissedUntil(until)
}, [setDismissedUntil])
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return null
}
return (
<OLModal show={showModal} onHide={handleHide}>
<OLModalHeader closeButton>
<OLModalTitle>{t('latam_discount_modal_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
<img
alt={t('latam_discount_modal_title')}
src={bannerImage}
style={{
width: '100%',
}}
/>
</p>
<p>
{t('latam_discount_modal_info', {
discount: '50',
currencyName: 'Brazilian Reais',
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleMaybeLater}>
{t('maybe_later')}
</OLButton>
<OLButton variant="primary" onClick={handleClick}>
{t('get_discounted_plan')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import bannerImage from '../../../images/inr-banner.png'
import usePersistedState from '../../../../../shared/hooks/use-persisted-state'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function INRBanner() {
const { t } = useTranslation()
const [dismissedUntil, setDismissedUntil] = usePersistedState<
Date | undefined
>(`has_dismissed_inr_banner_until`)
const viewEventSent = useRef<boolean>(false)
// Only used by 'modal' variant
const [showModal, setShowModal] = useState(true)
useEffect(() => {
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return
}
if (!viewEventSent.current) {
eventTracking.sendMB('promo-prompt', {
location: 'dashboard-modal',
name: 'geo-pricing',
content: 'modal',
country: 'IN',
})
viewEventSent.current = true
}
}, [dismissedUntil])
const handleClick = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-modal',
name: 'geo-pricing',
content: 'modal',
type: 'click',
country: 'IN',
})
setShowModal(false)
window.open('/user/subscription/plans')
}, [])
const bannerDismissed = useCallback(() => {
eventTracking.sendMB('promo-dismiss', {
location: 'dashboard-modal',
name: 'geo-pricing',
content: 'modal',
type: 'click',
country: 'IN',
})
const until = new Date()
until.setDate(until.getDate() + 30) // 30 days
setDismissedUntil(until)
}, [setDismissedUntil])
const handleHide = useCallback(() => {
setShowModal(false)
bannerDismissed()
}, [bannerDismissed])
const handleMaybeLater = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-modal',
name: 'geo-pricing',
content: 'modal',
type: 'pause',
country: 'IN',
})
setShowModal(false)
const until = new Date()
until.setDate(until.getDate() + 1) // 1 day
setDismissedUntil(until)
}, [setDismissedUntil])
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return null
}
return (
<OLModal show={showModal} onHide={handleHide} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('inr_discount_modal_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
<img
alt={t('inr_discount_modal_title')}
src={bannerImage}
style={{
width: '100%',
}}
/>
</p>
<p>{t('inr_discount_modal_info')}</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleMaybeLater}>
{t('maybe_later')}
</OLButton>
<OLButton variant="primary" onClick={handleClick}>
{t('get_discounted_plan')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import mxnBannerImage from '../../../images/mxn-banner.png'
import copBannerImage from '../../../images/cop-banner.png'
import clpBannerImage from '../../../images/clp-banner.png'
import penBannerImage from '../../../images/pen-banner.png'
import usePersistedState from '../../../../../shared/hooks/use-persisted-state'
import * as eventTracking from '../../../../../infrastructure/event-tracking'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
const LATAM_CURRENCIES = {
MXN: {
name: 'Mexican Pesos',
countryCode: 'MX',
discountCode: '25',
imageSource: mxnBannerImage,
},
COP: {
name: 'Colombian Pesos',
countryCode: 'CO',
discountCode: '60',
imageSource: copBannerImage,
},
CLP: {
name: 'Chilean Pesos',
countryCode: 'CL',
discountCode: '30',
imageSource: clpBannerImage,
},
PEN: {
name: 'Peruvian Soles',
countryCode: 'PE',
discountCode: '40',
imageSource: penBannerImage,
},
}
export default function LATAMBanner() {
const { t } = useTranslation()
const [dismissedUntil, setDismissedUntil] = usePersistedState<
Date | undefined
>(`has_dismissed_latam_banner_until`)
const viewEventSent = useRef<boolean>(false)
const [showModal, setShowModal] = useState(true)
const currency = getMeta('ol-recommendedCurrency')
const {
imageSource,
name: currencyName,
discountCode,
countryCode,
} = LATAM_CURRENCIES[currency as keyof typeof LATAM_CURRENCIES]
useEffect(() => {
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return
}
if (!viewEventSent.current) {
eventTracking.sendMB('promo-prompt', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: countryCode,
})
viewEventSent.current = true
}
}, [dismissedUntil, countryCode])
const handleClick = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: countryCode,
type: 'click',
})
setShowModal(false)
window.open('/user/subscription/plans')
}, [countryCode])
const bannerDismissed = useCallback(() => {
eventTracking.sendMB('promo-dismiss', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: countryCode,
})
const until = new Date()
until.setDate(until.getDate() + 30) // 30 days
setDismissedUntil(until)
}, [setDismissedUntil, countryCode])
const handleHide = useCallback(() => {
setShowModal(false)
bannerDismissed()
}, [bannerDismissed])
const handleMaybeLater = useCallback(() => {
eventTracking.sendMB('promo-click', {
location: 'dashboard-modal',
name: 'geo-pricing',
page: '/project',
content: 'modal',
country: countryCode,
type: 'pause',
})
setShowModal(false)
const until = new Date()
until.setDate(until.getDate() + 1) // 1 day
setDismissedUntil(until)
}, [setDismissedUntil, countryCode])
if (dismissedUntil && new Date(dismissedUntil) > new Date()) {
return null
}
// Safety, but should always be a valid LATAM currency if ol-showLATAMBanner is true
if (!(currency in LATAM_CURRENCIES)) {
return null
}
return (
<OLModal show={showModal} onHide={handleHide} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('latam_discount_modal_title')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
<img
alt={t('latam_discount_modal_title')}
src={imageSource}
style={{
width: '100%',
}}
/>
</p>
<p>
{t('latam_discount_modal_info', {
discount: discountCode,
currencyName,
})}
</p>
</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={handleMaybeLater}>
{t('maybe_later')}
</OLButton>
<OLButton variant="primary" onClick={handleClick}>
{t('get_discounted_plan')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}

View File

@@ -0,0 +1,19 @@
import getMeta from '@/utils/meta'
import BRLBanner from './ads/brl-banner'
import INRBanner from './ads/inr-banner'
import LATAMBanner from './ads/latam-banner'
function GeoBanners() {
const showInrGeoBanner = getMeta('ol-showInrGeoBanner')
const showBrlGeoBanner = getMeta('ol-showBrlGeoBanner')
const showLATAMBanner = getMeta('ol-showLATAMBanner')
return (
<>
{showBrlGeoBanner && <BRLBanner />}
{showLATAMBanner && <LATAMBanner />}
{showInrGeoBanner && <INRBanner />}
</>
)
}
export default GeoBanners

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect } from 'react'
import Notification from './notification'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import getMeta from '../../../../utils/meta'
import customLocalStorage from '../../../../infrastructure/local-storage'
import { useProjectListContext } from '../../context/project-list-context'
import { useTranslation } from 'react-i18next'
import {
GroupsAndEnterpriseBannerVariant,
GroupsAndEnterpriseBannerVariants,
} from '../../../../../../types/project/dashboard/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
type urlForVariantsType = {
[key in GroupsAndEnterpriseBannerVariant]: string // eslint-disable-line no-unused-vars
}
const urlForVariants: urlForVariantsType = {
'on-premise': '/for/contact-sales-2',
FOMO: '/for/contact-sales-4',
}
let viewEventSent = false
export default function GroupsAndEnterpriseBanner() {
const { t } = useTranslation()
const { totalProjectsCount } = useProjectListContext()
const showGroupsAndEnterpriseBanner = getMeta(
'ol-showGroupsAndEnterpriseBanner'
)
const groupsAndEnterpriseBannerVariant = getMeta(
'ol-groupsAndEnterpriseBannerVariant'
)
const hasDismissedGroupsAndEnterpriseBanner = hasRecentlyDismissedBanner()
const contactSalesUrl = urlForVariants[groupsAndEnterpriseBannerVariant]
const shouldRenderBanner =
showGroupsAndEnterpriseBanner &&
totalProjectsCount !== 0 &&
!hasDismissedGroupsAndEnterpriseBanner &&
isVariantValid(groupsAndEnterpriseBannerVariant)
const handleClose = useCallback(() => {
customLocalStorage.setItem(
'has_dismissed_groups_and_enterprise_banner',
new Date()
)
}, [])
const handleClickContact = useCallback(() => {
eventTracking.sendMB('groups-and-enterprise-banner-click', {
location: 'dashboard-banner-react',
variant: groupsAndEnterpriseBannerVariant,
})
}, [groupsAndEnterpriseBannerVariant])
useEffect(() => {
if (!viewEventSent && shouldRenderBanner) {
eventTracking.sendMB('groups-and-enterprise-banner-prompt', {
location: 'dashboard-banner-react',
variant: groupsAndEnterpriseBannerVariant,
})
viewEventSent = true
}
}, [shouldRenderBanner, groupsAndEnterpriseBannerVariant])
if (!shouldRenderBanner) {
return null
}
return (
<Notification
type="info"
onDismiss={handleClose}
content={<BannerContent variant={groupsAndEnterpriseBannerVariant} />}
action={
<OLButton
variant="secondary"
href={contactSalesUrl}
target="_blank"
rel="noreferrer"
onClick={handleClickContact}
>
{t('contact_sales')}
</OLButton>
}
/>
)
}
function isVariantValid(variant: GroupsAndEnterpriseBannerVariant) {
return GroupsAndEnterpriseBannerVariants.includes(variant)
}
function BannerContent({
variant,
}: {
variant: GroupsAndEnterpriseBannerVariant
}) {
switch (variant) {
case 'on-premise':
return (
<span>
Overleaf On-Premises: Does your company want to keep its data within
its firewall? Overleaf offers Server Pro, an on-premises solution for
companies. Get in touch to learn more.
</span>
)
case 'FOMO':
return (
<span>
Why do Fortune 500 companies and top research institutions trust
Overleaf to streamline their collaboration? Get in touch to learn
more.
</span>
)
}
}
function hasRecentlyDismissedBanner() {
const dismissed = customLocalStorage.getItem(
'has_dismissed_groups_and_enterprise_banner'
)
// previous banner set this to 'true', which shouldn't hide the new banner
if (!dismissed || dismissed === 'true') {
return false
}
const dismissedDate = new Date(dismissed)
const recentlyDismissedCutoff = new Date()
recentlyDismissedCutoff.setDate(recentlyDismissedCutoff.getDate() - 30) // 30 days
// once the dismissedDate passes the cut off mark, banner will be shown again
return dismissedDate > recentlyDismissedCutoff
}

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import getMeta from '../../../../../../utils/meta'
import useAsync from '../../../../../../shared/hooks/use-async'
import {
FetchError,
postJSON,
} from '../../../../../../infrastructure/fetch-json'
import { UserEmailData } from '../../../../../../../../types/user-email'
import { Institution } from '../../../../../../../../types/institution'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
import Notification from '@/features/project-list/components/notifications/notification'
type ReconfirmAffiliationProps = {
email: UserEmailData['email']
institution: Institution
}
function ReconfirmAffiliation({
email,
institution,
}: ReconfirmAffiliationProps) {
const { t } = useTranslation()
const { samlInitPath } = getMeta('ol-ExposedSettings')
const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
const [hasSent, setHasSent] = useState(false)
const [isPending, setIsPending] = useState(false)
const ssoEnabled = institution.ssoEnabled
const location = useLocation()
useEffect(() => {
if (isSuccess) {
setHasSent(true)
}
}, [isSuccess])
const handleRequestReconfirmation = () => {
if (ssoEnabled) {
setIsPending(true)
location.assign(
`${samlInitPath}?university_id=${institution.id}&reconfirm=/project`
)
} else {
runAsync(
postJSON('/user/emails/send-reconfirmation', {
body: { email },
})
).catch(debugConsole.error)
}
}
const rateLimited =
error && error instanceof FetchError && error.response?.status === 429
if (hasSent) {
return (
<Notification
type="info"
content={
<>
<Trans
i18nKey="please_check_your_inbox_to_confirm"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: institution.name }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
&nbsp;
{isError && (
<>
<br />
<div>
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
</>
)}
</>
}
action={
<OLButton
variant="link"
onClick={handleRequestReconfirmation}
className="btn-inline-link"
disabled={isLoading}
isLoading={isLoading}
loadingLabel={t('sending') + '…'}
>
{t('resend_confirmation_email')}
</OLButton>
}
/>
)
}
return (
<Notification
type="info"
content={
<>
<Trans
i18nKey="are_you_still_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ institutionName: institution.name }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
&nbsp;
<Trans
i18nKey="please_reconfirm_institutional_email"
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<a href={`/user/settings?remove=${email}`} />]}
/>
&nbsp;
<a
href="/learn/how-to/Institutional_Email_Reconfirmation"
target="_blank"
>
{t('learn_more')}
</a>
{isError && (
<>
<br />
<div>
{rateLimited
? t('too_many_requests')
: t('generic_something_went_wrong')}
</div>
</>
)}
</>
}
action={
<OLButton
variant="secondary"
isLoading={isLoading || isPending}
disabled={isLoading || isPending}
onClick={handleRequestReconfirmation}
>
{t('confirm_affiliation')}
</OLButton>
}
/>
)
}
export default ReconfirmAffiliation

View File

@@ -0,0 +1,41 @@
import Notification from '../../notification'
import ReconfirmAffiliation from './reconfirm-affiliation'
import getMeta from '../../../../../../utils/meta'
import ReconfirmationInfoSuccess from '../../../../../settings/components/emails/reconfirmation-info/reconfirmation-info-success'
function ReconfirmationInfo() {
const allInReconfirmNotificationPeriods =
getMeta('ol-allInReconfirmNotificationPeriods') || []
const userEmails = getMeta('ol-userEmails') || []
const reconfirmedViaSAML = getMeta('ol-reconfirmedViaSAML')
return (
<>
{allInReconfirmNotificationPeriods.map(userEmail =>
userEmail.affiliation?.institution ? (
<ReconfirmAffiliation
email={userEmail.email}
institution={userEmail.affiliation.institution}
key={`reconfirmation-period-email-${userEmail.email}`}
/>
) : null
)}
{userEmails.map(userEmail =>
userEmail.samlProviderId === reconfirmedViaSAML &&
userEmail.affiliation?.institution ? (
<Notification
key={`samlIdentifier-email-${userEmail.email}`}
type="info"
onDismiss={() => {}}
content={
<ReconfirmationInfoSuccess
institution={userEmail.affiliation?.institution}
/>
}
/>
) : null
)}
</>
)
}
export default ReconfirmationInfo

View File

@@ -0,0 +1,301 @@
import { useTranslation, Trans } from 'react-i18next'
import Notification from '../notification'
import getMeta from '../../../../../utils/meta'
import useAsyncDismiss from '../hooks/useAsyncDismiss'
import useAsync from '../../../../../shared/hooks/use-async'
import { FetchError, postJSON } from '../../../../../infrastructure/fetch-json'
import {
NotificationProjectInvite,
Notification as NotificationType,
} from '../../../../../../../types/project/dashboard/notification'
import GroupInvitationNotification from './group-invitation/group-invitation'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
function Common() {
const notifications = getMeta('ol-notifications') || []
if (!notifications.length) {
return null
}
return (
<>
{notifications.map((notification, index) => (
<CommonNotification notification={notification} key={index} />
))}
</>
)
}
type CommonNotificationProps = {
notification: NotificationType
}
function CommonNotification({ notification }: CommonNotificationProps) {
const { t } = useTranslation()
const { samlInitPath } = getMeta('ol-ExposedSettings')
const user = getMeta('ol-user')
const { isLoading, isSuccess, error, runAsync } = useAsync<
never,
FetchError
>()
const { handleDismiss } = useAsyncDismiss()
// 404 probably means the invite has already been accepted and deleted. Treat as success
const accepted = isSuccess || error?.response?.status === 404
function handleAcceptInvite(notification: NotificationProjectInvite) {
const {
messageOpts: { projectId, token },
} = notification
runAsync(
postJSON(`/project/${projectId}/invite/token/${token}/accept`)
).catch(debugConsole.error)
}
const { _id: id, templateKey, html } = notification
return (
<>
{templateKey === 'notification_project_invite' ? (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
accepted ? (
<Trans
i18nKey="notification_project_invite_accepted_message"
components={{ b: <b /> }}
values={{ projectName: notification.messageOpts.projectName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
) : (
<Trans
i18nKey="notification_project_invite_message"
components={{ b: <b /> }}
values={{
userName: notification.messageOpts.userName,
projectName: notification.messageOpts.projectName,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
)
}
action={
accepted ? (
<OLButton
variant="secondary"
href={`/project/${notification.messageOpts.projectId}`}
>
{t('open_project')}
</OLButton>
) : (
<OLButton
variant="secondary"
isLoading={isLoading}
loadingLabel={t('joining')}
disabled={isLoading}
onClick={() => handleAcceptInvite(notification)}
>
{t('join_project')}
</OLButton>
)
}
/>
) : templateKey === 'wfh_2020_upgrade_offer' ? (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
<>
Important notice: Your free WFH2020 upgrade came to an end on June
30th 2020. We're still providing a number of special initiatives
to help you continue collaborating throughout 2020.
</>
}
action={
<OLButton
variant="secondary"
href="https://www.overleaf.com/events/wfh2020"
>
View
</OLButton>
}
/>
) : templateKey === 'notification_ip_matched_affiliation' ? (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
<>
<Trans
i18nKey="looks_like_youre_at"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: notification.messageOpts.university_name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
<br />
{notification.messageOpts.ssoEnabled ? (
<>
<Trans
i18nKey="you_can_now_log_in_sso"
components={[<b />]} // eslint-disable-line react/jsx-key
/>
<br />
{t('link_institutional_email_get_started')}{' '}
<a
href={
notification.messageOpts.portalPath ||
'https://www.overleaf.com/learn/how-to/Institutional_Login'
}
>
{t('find_out_more_nt')}
</a>
</>
) : (
<>
<Trans
i18nKey="did_you_know_institution_providing_professional"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{
institutionName: notification.messageOpts.university_name,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
<br />
{t('add_email_to_claim_features')}
</>
)}
</>
}
action={
<OLButton
variant="secondary"
href={
notification.messageOpts.ssoEnabled
? `${samlInitPath}?university_id=${notification.messageOpts.institutionId}&auto=/project`
: '/user/settings'
}
>
{notification.messageOpts.ssoEnabled
? t('link_account')
: t('add_affiliation')}
</OLButton>
}
/>
) : templateKey === 'notification_tpds_file_limit' ? (
<Notification
type="error"
onDismiss={() => id && handleDismiss(id)}
title={`${notification?.messageOpts?.projectName || 'A project'} exceeds the 2000 file limit`}
content={
<>
You can't add more files to the project or sync it with any
integrations until you reduce the number of files.
</>
}
action={
notification.messageOpts.projectId ? (
<OLButton
variant="secondary"
onClick={() => id && handleDismiss(id)}
href={`/project/${notification.messageOpts.projectId}`}
>
Open project
</OLButton>
) : undefined
}
/>
) : templateKey === 'notification_dropbox_duplicate_project_names' ? (
<Notification
type="warning"
onDismiss={() => id && handleDismiss(id)}
content={
<>
<p>
<Trans
i18nKey="dropbox_duplicate_project_names"
components={[<b />]} // eslint-disable-line react/jsx-key
values={{ projectName: notification.messageOpts.projectName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<p>
<Trans
i18nKey="dropbox_duplicate_project_names_suggestion"
components={[<b />]} // eslint-disable-line react/jsx-key
/>{' '}
<a
href="/learn/how-to/Dropbox_Synchronization#Troubleshooting"
target="_blank"
>
{t('learn_more')}
</a>
.
</p>
</>
}
/>
) : templateKey ===
'notification_dropbox_unlinked_due_to_lapsed_reconfirmation' ? (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
<>
<Trans
i18nKey="dropbox_unlinked_premium_feature"
components={[<b />]} // eslint-disable-line react/jsx-key
/>{' '}
{user.features?.dropbox ? (
<Trans
i18nKey="can_now_relink_dropbox"
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<a href="/user/settings#project-sync" />]}
/>
) : (
t('confirm_affiliation_to_relink_dropbox')
)}{' '}
<a
href="/learn/how-to/Institutional_Email_Reconfirmation"
target="_blank"
>
{t('learn_more')}
</a>
</>
}
/>
) : templateKey === 'notification_group_invitation' ? (
<GroupInvitationNotification notification={notification} />
) : templateKey === 'notification_personal_and_group_subscriptions' ? (
<Notification
type="warning"
onDismiss={() => id && handleDismiss(id)}
content={
<Trans
i18nKey="notification_personal_and_group_subscriptions"
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
components={[<strong />, <a href="/user/subscription" />]}
/>
}
/>
) : (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={html}
/>
)}
</>
)
}
export default Common

View File

@@ -0,0 +1,326 @@
import { Trans, useTranslation } from 'react-i18next'
import Notification from '../notification'
import getMeta from '../../../../../utils/meta'
import useAsync from '../../../../../shared/hooks/use-async'
import { useProjectListContext } from '../../../context/project-list-context'
import {
postJSON,
getUserFacingMessage,
} from '../../../../../infrastructure/fetch-json'
import { UserEmailData } from '../../../../../../../types/user-email'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/features/ui/components/ol/ol-button'
import LoadingSpinner from '@/shared/components/loading-spinner'
const ssoAvailable = ({ samlProviderId, affiliation }: UserEmailData) => {
const { hasSamlFeature, hasSamlBeta } = getMeta('ol-ExposedSettings')
if (!hasSamlFeature) {
return false
}
if (samlProviderId) {
return true
}
if (!affiliation?.institution) {
return false
}
if (affiliation.institution.ssoEnabled) {
return true
}
if (hasSamlBeta && affiliation.institution.ssoBeta) {
return true
}
return false
}
function emailHasLicenceAfterConfirming(emailData: UserEmailData) {
if (emailData.confirmedAt) {
return false
}
if (!emailData.affiliation) {
return false
}
const affiliation = emailData.affiliation
const institution = affiliation.institution
if (!institution) {
return false
}
if (!institution.confirmed) {
return false
}
if (affiliation.pastReconfirmDate) {
return false
}
return affiliation.institution.commonsAccount
}
function isOnFreeOrIndividualPlan() {
const subscription = getMeta('ol-usersBestSubscription')
if (!subscription) {
return false
}
const { type } = subscription
return (
type === 'free' || type === 'individual' || type === 'standalone-ai-add-on'
)
}
const showConfirmEmail = (userEmail: UserEmailData) => {
const { emailConfirmationDisabled } = getMeta('ol-ExposedSettings')
return !emailConfirmationDisabled && !ssoAvailable(userEmail)
}
const EMAIL_DELETION_SCHEDULE = {
'2025-06-03': '2017-12-31',
'2025-07-03': '2019-12-31',
'2025-08-03': '2021-12-31',
'2025-09-03': '2025-03-03',
}
// Emails that remain unconfirmed after 90 days will be removed from the account
function getEmailDeletionDate(emailData: UserEmailData, signUpDate: string) {
if (emailData.default) return false
if (emailData.confirmedAt) return false
if (!signUpDate) return false
const currentDate = new Date()
for (const [deletionDate, cutoffDate] of Object.entries(
EMAIL_DELETION_SCHEDULE
)) {
const emailSignupDate = new Date(signUpDate)
const emailCutoffDate = new Date(cutoffDate)
const emailDeletionDate = new Date(deletionDate)
if (emailSignupDate < emailCutoffDate) {
const notificationStartDate = new Date(
emailDeletionDate.getTime() - 90 * 24 * 60 * 60 * 1000
)
if (currentDate >= notificationStartDate) {
if (currentDate > emailDeletionDate) {
return new Date().toLocaleDateString()
}
return emailDeletionDate.toLocaleDateString()
}
}
}
return false
}
function ConfirmEmailNotification({
userEmail,
signUpDate,
}: {
userEmail: UserEmailData
signUpDate: string
}) {
const { t } = useTranslation()
const { isLoading, isSuccess, isError, error, runAsync } = useAsync()
// We consider secondary emails added on or after 22.03.2024 to be trusted for account recovery
// https://github.com/overleaf/internal/pull/17572
const emailTrustCutoffDate = new Date('2024-03-22')
const emailDeletionDate = getEmailDeletionDate(userEmail, signUpDate)
const isPrimary = userEmail.default
const isEmailTrusted =
userEmail.lastConfirmedAt &&
new Date(userEmail.lastConfirmedAt) >= emailTrustCutoffDate
const shouldShowCommonsNotification =
emailHasLicenceAfterConfirming(userEmail) && isOnFreeOrIndividualPlan()
const handleResendConfirmationEmail = ({ email }: UserEmailData) => {
runAsync(
postJSON('/user/emails/resend_confirmation', {
body: { email },
})
).catch(debugConsole.error)
}
if (isSuccess) {
return null
}
if (!userEmail.lastConfirmedAt && !shouldShowCommonsNotification) {
return (
<Notification
type="warning"
content={
<div data-testid="pro-notification-body">
{isLoading ? (
<div data-testid="loading-resending-confirmation-email">
<LoadingSpinner
loadingText={t('resending_confirmation_email')}
/>
</div>
) : isError ? (
<div aria-live="polite">{getUserFacingMessage(error)}</div>
) : (
<>
<p>
{isPrimary
? t('please_confirm_primary_email', {
emailAddress: userEmail.email,
})
: t('please_confirm_secondary_email', {
emailAddress: userEmail.email,
})}
</p>
{emailDeletionDate && (
<p>
{t('email_remove_by_date', { date: emailDeletionDate })}
</p>
)}
</>
)}
</div>
}
action={
<>
<OLButton
variant="secondary"
onClick={() => handleResendConfirmationEmail(userEmail)}
>
{t('resend_confirmation_email')}
</OLButton>
<OLButton variant="link" href="/user/settings">
{isPrimary
? t('change_primary_email')
: t('remove_email_address')}
</OLButton>
</>
}
/>
)
}
if (!isEmailTrusted && !isPrimary && !shouldShowCommonsNotification) {
return (
<Notification
type="warning"
content={
<div data-testid="not-trusted-notification-body">
{isLoading ? (
<div data-testid="error-id">
<LoadingSpinner
loadingText={t('resending_confirmation_email')}
/>
</div>
) : isError ? (
<div aria-live="polite">{getUserFacingMessage(error)}</div>
) : (
<>
<p>
<b>{t('confirm_secondary_email')}</b>
</p>
<p>
{t('reconfirm_secondary_email', {
emailAddress: userEmail.email,
})}
</p>
<p>{t('ensure_recover_account')}</p>
</>
)}
</div>
}
action={
<>
<OLButton
variant="secondary"
onClick={() => handleResendConfirmationEmail(userEmail)}
>
{t('resend_confirmation_email')}
</OLButton>
<OLButton
variant="link"
href="/user/settings"
style={{ textDecoration: 'underline' }}
>
{t('remove_email_address')}
</OLButton>
</>
}
/>
)
}
// Only show the notification if a) a commons license is available and b) the
// user is on a free or individual plan. Users on a group or Commons plan
// already have premium features.
if (shouldShowCommonsNotification) {
return (
<Notification
type="info"
content={
<div data-testid="notification-body">
{isLoading ? (
<LoadingSpinner loadingText={t('resending_confirmation_email')} />
) : isError ? (
<div aria-live="polite">{getUserFacingMessage(error)}</div>
) : (
<>
<Trans
i18nKey="one_step_away_from_professional_features"
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
<br />
<Trans
i18nKey="institution_has_overleaf_subscription"
values={{
institutionName: userEmail.affiliation?.institution.name,
emailAddress: userEmail.email,
}}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={[<strong />]} // eslint-disable-line react/jsx-key
/>
</>
)}
</div>
}
action={
<OLButton
variant="secondary"
onClick={() => handleResendConfirmationEmail(userEmail)}
>
{t('resend_email')}
</OLButton>
}
/>
)
}
return null
}
function ConfirmEmail() {
const { totalProjectsCount } = useProjectListContext()
const userEmails = getMeta('ol-userEmails') || []
const signUpDate = getMeta('ol-user')?.signUpDate
if (!totalProjectsCount || !userEmails.length || !signUpDate) {
return null
}
return (
<>
{userEmails.map(userEmail => {
return showConfirmEmail(userEmail) ? (
<ConfirmEmailNotification
key={`confirm-email-${userEmail.email}`}
userEmail={userEmail}
signUpDate={signUpDate}
/>
) : null
})}
</>
)
}
export default ConfirmEmail
export { getEmailDeletionDate }

View File

@@ -0,0 +1,51 @@
import { useTranslation } from 'react-i18next'
import type { Dispatch, SetStateAction } from 'react'
import Notification from '../../notification'
import { GroupInvitationStatus } from './hooks/use-group-invitation-notification'
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
type GroupInvitationCancelIndividualSubscriptionNotificationProps = {
setGroupInvitationStatus: Dispatch<SetStateAction<GroupInvitationStatus>>
cancelPersonalSubscription: () => void
dismissGroupInviteNotification: () => void
notification: NotificationGroupInvitation
}
export default function GroupInvitationCancelIndividualSubscriptionNotification({
setGroupInvitationStatus,
cancelPersonalSubscription,
dismissGroupInviteNotification,
notification,
}: GroupInvitationCancelIndividualSubscriptionNotificationProps) {
const { t } = useTranslation()
const {
messageOpts: { inviterName },
} = notification
return (
<Notification
type="info"
onDismiss={dismissGroupInviteNotification}
content={t('invited_to_group_have_individual_subcription', {
inviterName,
})}
action={
<>
<OLButton
variant="secondary"
onClick={() =>
setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
}
className="me-1"
>
{t('not_now')}
</OLButton>
<OLButton variant="secondary" onClick={cancelPersonalSubscription}>
{t('cancel_my_subscription')}
</OLButton>
</>
}
/>
)
}

View File

@@ -0,0 +1,51 @@
import { useTranslation, Trans } from 'react-i18next'
import Notification from '../../notification'
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
import OLButton from '@/features/ui/components/ol/ol-button'
type GroupInvitationNotificationProps = {
acceptGroupInvite: () => void
notification: NotificationGroupInvitation
isAcceptingInvitation: boolean
dismissGroupInviteNotification: () => void
}
export default function GroupInvitationNotificationJoin({
acceptGroupInvite,
notification,
isAcceptingInvitation,
dismissGroupInviteNotification,
}: GroupInvitationNotificationProps) {
const { t } = useTranslation()
const {
messageOpts: { inviterName },
} = notification
return (
<Notification
type="info"
onDismiss={dismissGroupInviteNotification}
content={
<Trans
i18nKey="invited_to_group"
values={{ inviterName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
components={
/* eslint-disable-next-line react/jsx-key */
[<span className="team-invite-name" />]
}
/>
}
action={
<OLButton
variant="secondary"
onClick={acceptGroupInvite}
disabled={isAcceptingInvitation}
>
{t('join_now')}
</OLButton>
}
/>
)
}

View File

@@ -0,0 +1,20 @@
import Notification from '../../notification'
import { useTranslation } from 'react-i18next'
type GroupInvitationSuccessfulNotificationProps = {
hideNotification: () => void
}
export default function GroupInvitationSuccessfulNotification({
hideNotification,
}: GroupInvitationSuccessfulNotificationProps) {
const { t } = useTranslation()
return (
<Notification
type="success"
onDismiss={hideNotification}
content={t('congratulations_youve_successfully_join_group')}
/>
)
}

View File

@@ -0,0 +1,55 @@
import type { NotificationGroupInvitation } from '../../../../../../../../types/project/dashboard/notification'
import GroupInvitationCancelIndividualSubscriptionNotification from './group-invitation-cancel-subscription'
import GroupInvitationNotificationJoin from './group-invitation-join'
import GroupInvitationSuccessfulNotification from './group-invitation-successful'
import {
GroupInvitationStatus,
useGroupInvitationNotification,
} from './hooks/use-group-invitation-notification'
type GroupInvitationNotificationProps = {
notification: NotificationGroupInvitation
}
export default function GroupInvitationNotification({
notification,
}: GroupInvitationNotificationProps) {
const {
isAcceptingInvitation,
groupInvitationStatus,
setGroupInvitationStatus,
acceptGroupInvite,
cancelPersonalSubscription,
dismissGroupInviteNotification,
hideNotification,
} = useGroupInvitationNotification(notification)
switch (groupInvitationStatus) {
case GroupInvitationStatus.CancelIndividualSubscription:
return (
<GroupInvitationCancelIndividualSubscriptionNotification
setGroupInvitationStatus={setGroupInvitationStatus}
cancelPersonalSubscription={cancelPersonalSubscription}
dismissGroupInviteNotification={dismissGroupInviteNotification}
notification={notification}
/>
)
case GroupInvitationStatus.AskToJoin:
return (
<GroupInvitationNotificationJoin
isAcceptingInvitation={isAcceptingInvitation}
notification={notification}
acceptGroupInvite={acceptGroupInvite}
dismissGroupInviteNotification={dismissGroupInviteNotification}
/>
)
case GroupInvitationStatus.SuccessfullyJoined:
return (
<GroupInvitationSuccessfulNotification
hideNotification={hideNotification}
/>
)
default:
return null
}
}

View File

@@ -0,0 +1,126 @@
import {
type Dispatch,
type SetStateAction,
useState,
useCallback,
useEffect,
} from 'react'
import type { NotificationGroupInvitation } from '../../../../../../../../../types/project/dashboard/notification'
import useAsync from '../../../../../../../shared/hooks/use-async'
import {
FetchError,
postJSON,
putJSON,
} from '../../../../../../../infrastructure/fetch-json'
import { useLocation } from '../../../../../../../shared/hooks/use-location'
import getMeta from '../../../../../../../utils/meta'
import useAsyncDismiss from '../../../hooks/useAsyncDismiss'
import { debugConsole } from '@/utils/debugging'
const SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN = 10 * 1000
/* eslint-disable no-unused-vars */
export enum GroupInvitationStatus {
Idle = 'Idle',
CancelIndividualSubscription = 'CancelIndividualSubscription',
AskToJoin = 'AskToJoin',
SuccessfullyJoined = 'SuccessfullyJoined',
NotificationIsHidden = 'NotificationIsHidden',
Error = 'Error',
}
/* eslint-enable no-unused-vars */
type UseGroupInvitationNotificationReturnType = {
isAcceptingInvitation: boolean
groupInvitationStatus: GroupInvitationStatus
setGroupInvitationStatus: Dispatch<SetStateAction<GroupInvitationStatus>>
acceptGroupInvite: () => void
cancelPersonalSubscription: () => void
dismissGroupInviteNotification: () => void
hideNotification: () => void
}
export function useGroupInvitationNotification(
notification: NotificationGroupInvitation
): UseGroupInvitationNotificationReturnType {
const {
_id: notificationId,
messageOpts: { token, managedUsersEnabled },
} = notification
const [groupInvitationStatus, setGroupInvitationStatus] =
useState<GroupInvitationStatus>(GroupInvitationStatus.Idle)
const { runAsync, isLoading: isAcceptingInvitation } = useAsync<
never,
FetchError
>()
const location = useLocation()
const { handleDismiss } = useAsyncDismiss()
const hasIndividualRecurlySubscription = getMeta(
'ol-hasIndividualRecurlySubscription'
)
useEffect(() => {
if (hasIndividualRecurlySubscription) {
setGroupInvitationStatus(
GroupInvitationStatus.CancelIndividualSubscription
)
} else {
setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
}
}, [hasIndividualRecurlySubscription])
const acceptGroupInvite = useCallback(() => {
if (managedUsersEnabled) {
location.assign(`/subscription/invites/${token}/`)
} else {
runAsync(
putJSON(`/subscription/invites/${token}/`, {
body: {
_csrf: getMeta('ol-csrfToken'),
},
})
)
.then(() => {
setGroupInvitationStatus(GroupInvitationStatus.SuccessfullyJoined)
})
.catch(err => {
debugConsole.error(err)
setGroupInvitationStatus(GroupInvitationStatus.Error)
})
.finally(() => {
// remove notification automatically in the browser
window.setTimeout(() => {
setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden)
}, SUCCESSFUL_NOTIF_TIME_BEFORE_HIDDEN)
})
}
}, [runAsync, token, location, managedUsersEnabled])
const cancelPersonalSubscription = useCallback(() => {
setGroupInvitationStatus(GroupInvitationStatus.AskToJoin)
runAsync(postJSON('/user/subscription/cancel')).catch(debugConsole.error)
}, [runAsync])
const dismissGroupInviteNotification = useCallback(() => {
if (notificationId) {
handleDismiss(notificationId)
}
}, [handleDismiss, notificationId])
const hideNotification = useCallback(() => {
setGroupInvitationStatus(GroupInvitationStatus.NotificationIsHidden)
}, [])
return {
isAcceptingInvitation,
groupInvitationStatus,
setGroupInvitationStatus,
acceptGroupInvite,
cancelPersonalSubscription,
dismissGroupInviteNotification,
hideNotification,
}
}

View File

@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
import Notification from '../../../../../shared/components/notification'
import getMeta from '../../../../../utils/meta'
function GroupSsoSetupSuccess() {
const { t } = useTranslation()
const wasSuccess = getMeta('ol-groupSsoSetupSuccess')
if (!wasSuccess) {
return null
}
return (
<li className="notification-entry">
<Notification
type="success"
content={t('success_sso_set_up')}
isDismissible
/>
</li>
)
}
export default GroupSsoSetupSuccess

View File

@@ -0,0 +1,167 @@
import { Fragment } from 'react'
import { useTranslation, Trans } from 'react-i18next'
import Notification from '../notification'
import getMeta from '../../../../../utils/meta'
import useAsyncDismiss from '../hooks/useAsyncDismiss'
import OLButton from '@/features/ui/components/ol/ol-button'
function Institution() {
const { t } = useTranslation()
const { samlInitPath, appName } = getMeta('ol-ExposedSettings')
const notificationsInstitution = getMeta('ol-notificationsInstitution') || []
const { handleDismiss } = useAsyncDismiss()
if (!notificationsInstitution.length) {
return null
}
return (
<>
{notificationsInstitution.map(
(
{
_id: id,
email,
institutionEmail,
institutionId,
institutionName,
templateKey,
requestedEmail,
error,
},
index
) => (
<Fragment key={index}>
{templateKey === 'notification_institution_sso_available' && (
<Notification
type="info"
content={
<>
<p>
<Trans
i18nKey="can_link_institution_email_acct_to_institution_acct"
components={{ b: <b /> }}
values={{ appName, email, institutionName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<div>
<Trans
i18nKey="doing_this_allow_log_in_through_institution"
components={{ b: <b /> }}
values={{ appName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<a
href="/learn/how-to/Institutional_Login"
target="_blank"
>
{t('learn_more')}
</a>
</div>
</>
}
action={
<OLButton
variant="secondary"
href={`${samlInitPath}?university_id=${institutionId}&auto=/project&email=${email}`}
>
{t('link_account')}
</OLButton>
}
/>
)}
{templateKey === 'notification_institution_sso_linked' && (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
<Trans
i18nKey="account_has_been_link_to_institution_account"
components={{ b: <b /> }}
values={{ appName, email, institutionName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
}
/>
)}
{templateKey === 'notification_institution_sso_non_canonical' && (
<Notification
type="warning"
onDismiss={() => id && handleDismiss(id)}
content={
<>
<Trans
i18nKey="tried_to_log_in_with_email"
components={{ b: <b /> }}
values={{ appName, email: requestedEmail }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
<Trans
i18nKey="in_order_to_match_institutional_metadata_associated"
components={{ b: <b /> }}
values={{ email: institutionEmail }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</>
}
/>
)}
{templateKey ===
'notification_institution_sso_already_registered' && (
<Notification
type="info"
onDismiss={() => id && handleDismiss(id)}
content={
<>
<Trans
i18nKey="tried_to_register_with_email"
components={{ b: <b /> }}
values={{ appName, email }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
{t('we_logged_you_in')}
</>
}
action={
<OLButton
variant="secondary"
href="/learn/how-to/Institutional_Login"
target="_blank"
>
{t('find_out_more')}
</OLButton>
}
/>
)}
{templateKey === 'notification_institution_sso_error' && (
<Notification
type="error"
onDismiss={() => id && handleDismiss(id)}
content={
<>
{t('generic_something_went_wrong')}.
<div>
{error?.translatedMessage
? error?.translatedMessage
: error?.message}
</div>
{error?.tryAgain ? `${t('try_again')}.` : null}
</>
}
/>
)}
</Fragment>
)
)}
</>
)
}
export default Institution

View File

@@ -0,0 +1,16 @@
import { useFeatureFlag } from '@/shared/context/split-test-context'
import getMeta from '@/utils/meta'
export const usePapersNotification = () => {
const user = getMeta('ol-user')
const inRollout = useFeatureFlag('papers-notification-banner')
const shouldShow =
inRollout &&
user &&
(user.features?.references || user.features?.papers) &&
!user.refProviders?.mendeley &&
!user.refProviders?.zotero &&
!user.refProviders?.papers
return { shouldShow }
}

View File

@@ -0,0 +1,15 @@
import useAsync from '../../../../../shared/hooks/use-async'
import { deleteJSON } from '../../../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
function useAsyncDismiss() {
const { runAsync, ...rest } = useAsync()
const handleDismiss = (id: number | string) => {
runAsync(deleteJSON(`/notifications/${id}`)).catch(debugConsole.error)
}
return { handleDismiss, ...rest }
}
export default useAsyncDismiss

View File

@@ -0,0 +1,21 @@
import classnames from 'classnames'
import NewNotification from '@/shared/components/notification'
type NotificationProps = Pick<
React.ComponentProps<typeof NewNotification>,
'type' | 'action' | 'content' | 'onDismiss' | 'className' | 'title'
>
function Notification({ className, ...props }: NotificationProps) {
const notificationComponent = (
<NewNotification isDismissible={props.onDismiss != null} {...props} />
)
return notificationComponent ? (
<li className={classnames('notification-entry', className)}>
{notificationComponent}
</li>
) : null
}
export default Notification

View File

@@ -0,0 +1,86 @@
import { memo, useCallback, useEffect } from 'react'
import Notification from './notification'
import { Trans, useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
import usePersistedState from '@/shared/hooks/use-persisted-state'
import { sendMB } from '@/infrastructure/event-tracking'
function PapersNotificationBanner() {
const { t } = useTranslation()
const [dismissed, setDismissed] = usePersistedState(
'papers-notification-banner-dismissed',
false
)
useEffect(() => {
if (!dismissed) {
sendMB('promo-prompt', {
location: 'dashboard-banner',
name: 'papers-integration',
})
}
}, [dismissed])
const handleClose = useCallback(() => {
sendMB('promo-dismiss', {
location: 'dashboard-banner',
name: 'papers-integration',
})
setDismissed(true)
}, [setDismissed])
const handlePapersButtonClick = useCallback(() => {
sendMB('promo-click', {
location: 'dashboard-banner',
name: 'papers-integration',
type: 'click-try-for-free',
})
}, [])
const handleSettingsLinkClick = useCallback(() => {
sendMB('promo-click', {
location: 'dashboard-banner',
name: 'papers-integration',
type: 'click-link-account',
})
}, [])
if (dismissed) return null
return (
<Notification
type="info"
title={t(
'you_can_now_sync_your_papers_library_directly_with_your_overleaf_projects'
)}
onDismiss={handleClose}
content={
<p>
<Trans
i18nKey="already_have_a_papers_account"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
onClick={handleSettingsLinkClick}
href="/user/settings#references"
/>,
]}
/>
</p>
}
action={
<OLButton
variant="secondary"
onClick={handlePapersButtonClick}
href="https://www.papersapp.com/30-day-trial/?utm_source=overleaf_inproduct&utm_medium=referral&utm_campaign=overleaf_integration"
target="_blank"
rel="noreferrer"
>
{t('try_papers_for_free')}
</OLButton>
}
/>
)
}
export default memo(PapersNotificationBanner)

View File

@@ -0,0 +1,68 @@
import { JSXElementConstructor } from 'react'
import Common from './groups/common'
import Institution from './groups/institution'
import ConfirmEmail from './groups/confirm-email'
import ReconfirmationInfo from './groups/affiliation/reconfirmation-info'
import GroupsAndEnterpriseBanner from './groups-and-enterprise-banner'
import GroupSsoSetupSuccess from './groups/group-sso-setup-success'
import getMeta from '../../../../utils/meta'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import GeoBanners from './geo-banners'
import AccessibilitySurveyBanner from './accessibility-survey-banner'
import {
DeprecatedBrowser,
isDeprecatedBrowser,
} from '@/shared/components/deprecated-browser'
import PapersNotificationBanner from './papers-notification-banner'
import { usePapersNotification } from './hooks/use-papers-notification'
const [enrollmentNotificationModule] = importOverleafModules(
'managedGroupSubscriptionEnrollmentNotification'
)
const [usGovBannerModule] = importOverleafModules('usGovBanner')
const EnrollmentNotification: JSXElementConstructor<{
groupId: string
groupName: string
}> = enrollmentNotificationModule?.import.default
const USGovBanner: JSXElementConstructor<Record<string, never>> =
usGovBannerModule?.import.default
function UserNotifications() {
const groupSubscriptionsPendingEnrollment =
getMeta('ol-groupSubscriptionsPendingEnrollment') || []
const { shouldShow: showPapersNotificationBanner } = usePapersNotification()
return (
<div className="user-notifications notification-list">
<ul className="list-unstyled">
{EnrollmentNotification &&
groupSubscriptionsPendingEnrollment.map(subscription => (
<EnrollmentNotification
groupId={subscription.groupId}
groupName={subscription.groupName}
key={subscription.groupId}
/>
))}
<GroupSsoSetupSuccess />
<Common />
<Institution />
<ConfirmEmail />
<ReconfirmationInfo />
<GeoBanners />
<GroupsAndEnterpriseBanner />
{USGovBanner && <USGovBanner />}
{showPapersNotificationBanner && <PapersNotificationBanner />}
<AccessibilitySurveyBanner />
{isDeprecatedBrowser() && <DeprecatedBrowser />}
</ul>
</div>
)
}
export default UserNotifications

View File

@@ -0,0 +1,119 @@
import { useProjectListContext } from '../context/project-list-context'
import { useTranslation } from 'react-i18next'
import CurrentPlanWidget from './current-plan-widget/current-plan-widget'
import NewProjectButton from './new-project-button'
import ProjectListTable from './table/project-list-table'
import SurveyWidget from './survey-widget'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import ProjectsDropdown from './dropdown/projects-dropdown'
import SortByDropdown from './dropdown/sort-by-dropdown'
import ProjectTools from './table/project-tools/project-tools'
import ProjectListTitle from './title/project-list-title'
import Sidebar from './sidebar/sidebar'
import LoadMore from './load-more'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import { TableContainer } from '@/features/ui/components/bootstrap-5/table'
import DashApiError from '@/features/project-list/components/dash-api-error'
export default function ProjectListDefault() {
const { t } = useTranslation()
const {
error,
searchText,
setSearchText,
selectedProjects,
filter,
tags,
selectedTagId,
} = useProjectListContext()
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</div>
)
return (
<>
<Sidebar />
<div className="project-list-main-react">
{error ? <DashApiError /> : ''}
<OLRow>
<OLCol>
<UserNotifications />
</OLCol>
</OLRow>
<div className="project-list-header-row">
<ProjectListTitle
filter={filter}
selectedTag={selectedTag}
selectedTagId={selectedTagId}
className="text-truncate d-none d-md-block"
/>
<div className="project-tools">
<div className="d-none d-md-block">
{selectedProjects.length === 0 ? (
<CurrentPlanWidget />
) : (
<ProjectTools />
)}
</div>
<div className="d-md-none">
<CurrentPlanWidget />
</div>
</div>
</div>
<OLRow className="d-none d-md-block">
<OLCol lg={7}>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
</OLCol>
</OLRow>
<div className="project-list-sidebar-survey-wrapper d-md-none">
<SurveyWidget />
</div>
<div className="mt-1 d-md-none">
<div
role="toolbar"
className="projects-toolbar"
aria-label={t('projects')}
>
<ProjectsDropdown />
<SortByDropdown />
</div>
</div>
<OLRow className="row-spaced">
<OLCol>
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</OLCol>
</OLRow>
<OLRow className="row-spaced">
<OLCol>
<LoadMore />
</OLCol>
</OLRow>
</div>
</>
)
}

View File

@@ -0,0 +1,129 @@
import { useProjectListContext } from '../context/project-list-context'
import { useTranslation } from 'react-i18next'
import CurrentPlanWidget from './current-plan-widget/current-plan-widget'
import NewProjectButton from './new-project-button'
import ProjectListTable from './table/project-list-table'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import ProjectsDropdown from './dropdown/projects-dropdown'
import SortByDropdown from './dropdown/sort-by-dropdown'
import ProjectTools from './table/project-tools/project-tools'
import ProjectListTitle from './title/project-list-title'
import LoadMore from './load-more'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import { TableContainer } from '@/features/ui/components/bootstrap-5/table'
import DashApiError from '@/features/project-list/components/dash-api-error'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import FatFooter from '@/features/ui/components/bootstrap-5/footer/fat-footer'
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
import SystemMessages from '@/shared/components/system-messages'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
export function ProjectListDsNav() {
const navbarProps = getMeta('ol-navbar')
const { t } = useTranslation()
const {
error,
searchText,
setSearchText,
selectedProjects,
filter,
tags,
selectedTagId,
} = useProjectListContext()
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</div>
)
return (
<div className="project-ds-nav-page website-redesign">
<DefaultNavbar {...navbarProps} customLogo={overleafLogo} showCloseIcon />
<main className="project-list-wrapper">
<SidebarDsNav />
<div className="project-ds-nav-content-and-messages">
<div className="project-ds-nav-content">
<div className="project-ds-nav-main">
{error ? <DashApiError /> : ''}
<UserNotifications />
<div className="project-list-header-row">
<ProjectListTitle
filter={filter}
selectedTag={selectedTag}
selectedTagId={selectedTagId}
className="text-truncate d-none d-md-block"
/>
<div className="project-tools">
<div className="d-none d-md-block">
{selectedProjects.length === 0 ? (
<CurrentPlanWidget />
) : (
<ProjectTools />
)}
</div>
<div className="d-md-none">
<CurrentPlanWidget />
</div>
</div>
</div>
<div className="project-ds-nav-project-list">
<OLRow className="d-none d-md-block">
<OLCol lg={7}>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
</OLCol>
</OLRow>
<div className="project-list-sidebar-survey-wrapper d-md-none">
{/* Omit the survey card in mobile view for now */}
</div>
<div className="mt-1 d-md-none">
<div
role="toolbar"
className="projects-toolbar"
aria-label={t('projects')}
>
<ProjectsDropdown />
<SortByDropdown />
</div>
</div>
<div className="mt-3">
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
</div>
</div>
</div>
<FatFooter />
</div>
<div>
<SystemMessages />
</div>
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react'
import { sendMB } from '@/infrastructure/event-tracking'
export type ExtraSegmentations = {
'menu-expand': {
item: 'help' | 'account' | 'features' | 'admin'
location: 'top-menu' | 'sidebar'
}
'menu-click': {
item:
| 'login'
| 'register'
| 'premium-features'
| 'enterprises'
| 'universities'
| 'publishers'
| 'edu'
| 'government'
| 'why-latex'
| 'learn'
| 'contact'
| 'templates'
| 'plans'
location: 'top-menu' | 'sidebar'
destinationURL?: string
}
'new-project-expand': undefined
'new-project-click': {
item:
| 'blank-project'
| 'example-project'
| 'upload'
| 'github-import'
| 'all-templates'
| (string & {})
destinationURL?: string
}
}
export const useSendProjectListMB = () => {
return useCallback(
<T extends keyof ExtraSegmentations>(
event: T,
payload: ExtraSegmentations[T]
) => sendMB(event, payload),
[]
)
}

View File

@@ -0,0 +1,122 @@
import { ReactNode, useEffect } from 'react'
import {
ProjectListProvider,
useProjectListContext,
} from '../context/project-list-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { ColorPickerProvider } from '../context/color-picker-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import LoadingBranded from '../../../shared/components/loading-branded'
import SystemMessages from '../../../shared/components/system-messages'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
import Footer from '@/features/ui/components/bootstrap-5/footer/footer'
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
import ProjectListDefault from '@/features/project-list/components/project-list-default'
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
import {
DsNavStyleProvider,
hasDsNav,
} from '@/features/project-list/components/use-is-ds-nav'
function ProjectListRoot() {
const { isReady } = useWaitForI18n()
if (!isReady) {
return null
}
return <ProjectListRootInner />
}
export function ProjectListRootInner() {
return (
<ProjectListProvider>
<ColorPickerProvider>
<SplitTestProvider>
<ProjectListPageContent />
</SplitTestProvider>
</ColorPickerProvider>
</ProjectListProvider>
)
}
function DefaultNavbarAndFooter({ children }: { children: ReactNode }) {
const navbarProps = getMeta('ol-navbar')
const footerProps = getMeta('ol-footer')
return (
<>
<DefaultNavbar {...navbarProps} />
<main
id="main-content"
className="content content-alt project-list-react"
>
{children}
</main>
<Footer {...footerProps} />
</>
)
}
function DefaultPageContentWrapper({ children }: { children: ReactNode }) {
return (
<DefaultNavbarAndFooter>
<SystemMessages />
<div className="project-list-wrapper">{children}</div>
</DefaultNavbarAndFooter>
)
}
function ProjectListPageContent() {
const { totalProjectsCount, isLoading, loadProgress } =
useProjectListContext()
useEffect(() => {
eventTracking.sendMB('loads_v2_dash', {})
}, [])
const { t } = useTranslation()
if (isLoading) {
const loadingComponent = (
<LoadingBranded loadProgress={loadProgress} label={t('loading')} />
)
if (hasDsNav()) {
return loadingComponent
} else {
return (
<DefaultNavbarAndFooter>
<div className="loading-container">{loadingComponent}</div>
</DefaultNavbarAndFooter>
)
}
}
if (totalProjectsCount === 0) {
return (
<DefaultPageContentWrapper>
<WelcomePageContent />
</DefaultPageContentWrapper>
)
} else if (hasDsNav()) {
return (
<DsNavStyleProvider>
<ProjectListDsNav />
</DsNavStyleProvider>
)
} else {
return (
<DefaultPageContentWrapper>
<ProjectListDefault />
</DefaultPageContentWrapper>
)
}
}
export default withErrorBoundary(ProjectListRoot, GenericErrorBoundaryFallback)

View File

@@ -0,0 +1,15 @@
import { Filter, useProjectListContext } from '../context/project-list-context'
type ProjectsMenuFilterType = {
children: (isActive: boolean) => React.ReactElement
filter: Filter
}
function ProjectsFilterMenu({ children, filter }: ProjectsMenuFilterType) {
const { filter: activeFilter, selectedTagId } = useProjectListContext()
const isActive = selectedTagId === undefined && filter === activeFilter
return children(isActive)
}
export default ProjectsFilterMenu

View File

@@ -0,0 +1,106 @@
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import classnames from 'classnames'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import { MergeAndOverride } from '../../../../../types/utils'
import { Filter } from '../context/project-list-context'
import { isSmallDevice } from '../../../infrastructure/event-tracking'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
type SearchFormOwnProps = {
inputValue: string
setInputValue: (input: string) => void
filter: Filter
selectedTag: Tag | undefined
}
type SearchFormProps = MergeAndOverride<
React.ComponentProps<typeof OLForm>,
SearchFormOwnProps
>
function SearchForm({
inputValue,
setInputValue,
filter,
selectedTag,
className,
...props
}: SearchFormProps) {
const { t } = useTranslation()
let placeholderMessage = t('search_projects')
if (selectedTag) {
placeholderMessage = `${t('search')} ${selectedTag.name}`
} else {
switch (filter) {
case 'all':
placeholderMessage = t('search_in_all_projects')
break
case 'owned':
placeholderMessage = t('search_in_your_projects')
break
case 'shared':
placeholderMessage = t('search_in_shared_projects')
break
case 'archived':
placeholderMessage = t('search_in_archived_projects')
break
case 'trashed':
placeholderMessage = t('search_in_trashed_projects')
break
}
}
const placeholder = `${placeholderMessage}`
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
eventTracking.sendMB('project-list-page-interaction', {
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
}
const handleClear = () => setInputValue('')
return (
<OLForm
className={classnames('project-search', className)}
role="search"
onSubmit={e => e.preventDefault()}
{...props}
>
<OLFormGroup>
<OLCol>
<OLFormControl
type="text"
value={inputValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
aria-label={t('clear_search')}
onClick={handleClear}
>
<MaterialIcon type="clear" />
</button>
)
}
/>
</OLCol>
</OLFormGroup>
</OLForm>
)
}
export default SearchForm

View File

@@ -0,0 +1,175 @@
import { useState } from 'react'
import classnames from 'classnames'
import { Question, User } from '@phosphor-icons/react'
import NewProjectButton from '../new-project-button'
import SidebarFilters from './sidebar-filters'
import AddAffiliation, { useAddAffiliation } from '../add-affiliation'
import { usePersistedResize } from '@/shared/hooks/use-resize'
import { Dropdown } from 'react-bootstrap-5'
import getMeta from '@/utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { useTranslation } from 'react-i18next'
import { NavDropdownMenuItems } from '@/features/ui/components/bootstrap-5/navbar/nav-dropdown-from-data'
import { NavbarDropdownItemData } from '@/features/ui/components/types/navbar'
import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
import { UserProvider } from '@/shared/context/user-context'
import { AccountMenuItems } from '@/features/ui/components/bootstrap-5/navbar/account-menu-items'
import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll'
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
import { SurveyWidgetDsNav } from '@/features/project-list/components/survey-widget-ds-nav'
function SidebarDsNav() {
const { t } = useTranslation()
const [showAccountDropdown, setShowAccountDropdown] = useState(false)
const [showHelpDropdown, setShowHelpDropdown] = useState(false)
const { showModal: showContactUsModal, modal: contactUsModal } =
useContactUsModal({
autofillProjectUrl: false,
})
const { show: showAddAffiliationWidget } = useAddAffiliation()
const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
name: 'project-sidebar',
})
const sendMB = useSendProjectListMB()
const { sessionUser, showSubscriptionLink, items } = getMeta('ol-navbar')
const helpItem = items.find(
item => item.text === 'help'
) as NavbarDropdownItemData
const { containerRef, scrolledUp, scrolledDown } = useScrolled()
return (
<div
className="project-list-sidebar-wrapper-react d-none d-md-flex"
{...getTargetProps({
style: {
...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
},
})}
>
<NewProjectButton
id="new-project-button-sidebar"
className={scrolledDown ? 'show-shadow' : undefined}
/>
<div className="project-list-sidebar-scroll" ref={containerRef}>
<SidebarFilters />
{showAddAffiliationWidget && <hr />}
<AddAffiliation />
</div>
<div
className={classnames(
'ds-nav-sidebar-lower',
scrolledUp && 'show-shadow'
)}
>
<div className="project-list-sidebar-survey-wrapper">
<SurveyWidgetDsNav />
</div>
<div className="d-flex gap-3 mb-2">
{helpItem && (
<Dropdown
className="ds-nav-icon-dropdown"
onToggle={show => {
setShowHelpDropdown(show)
if (show) {
sendMB('menu-expand', { item: 'help', location: 'sidebar' })
}
}}
>
<Dropdown.Toggle role="menuitem" aria-label={t('help')}>
<OLTooltip
description={t('help')}
id="help-icon"
overlayProps={{
placement: 'top',
show: showHelpDropdown ? false : undefined,
}}
>
<div>
<Question size={24} />
</div>
</OLTooltip>
</Dropdown.Toggle>
<Dropdown.Menu
as="ul"
role="menu"
align="end"
popperConfig={{
modifiers: [{ name: 'offset', options: { offset: [0, 5] } }],
}}
>
<NavDropdownMenuItems
dropdown={helpItem.dropdown}
showContactUsModal={showContactUsModal}
location="sidebar"
/>
</Dropdown.Menu>
</Dropdown>
)}
{sessionUser && (
<>
<Dropdown
className="ds-nav-icon-dropdown"
onToggle={show => {
setShowAccountDropdown(show)
if (show) {
sendMB('menu-expand', {
item: 'account',
location: 'sidebar',
})
}
}}
>
<Dropdown.Toggle role="menuitem" aria-label={t('Account')}>
<OLTooltip
description={t('Account')}
id="open-account"
overlayProps={{
placement: 'top',
show: showAccountDropdown ? false : undefined,
}}
>
<div>
<User size={24} />
</div>
</OLTooltip>
</Dropdown.Toggle>
<Dropdown.Menu
as="ul"
role="menu"
align="end"
popperConfig={{
modifiers: [
{ name: 'offset', options: { offset: [-50, 5] } },
],
}}
>
<AccountMenuItems
sessionUser={sessionUser}
showSubscriptionLink={showSubscriptionLink}
/>
</Dropdown.Menu>
</Dropdown>
<UserProvider>{contactUsModal}</UserProvider>
</>
)}
</div>
<div className="ds-nav-ds-name">
<span>Digital Science</span>
</div>
</div>
<div
{...getHandleProps({
style: {
position: 'absolute',
zIndex: 1,
top: 0,
right: '-2px',
height: '100%',
width: '4px',
},
})}
/>
</div>
)
}
export default SidebarDsNav

View File

@@ -0,0 +1,49 @@
import { useTranslation } from 'react-i18next'
import {
Filter,
useProjectListContext,
} from '../../context/project-list-context'
import TagsList from './tags-list'
import ProjectsFilterMenu from '../projects-filter-menu'
import { hasDsNav } from '@/features/project-list/components/use-is-ds-nav'
type SidebarFilterProps = {
filter: Filter
text: React.ReactNode
}
export function SidebarFilter({ filter, text }: SidebarFilterProps) {
const { selectFilter } = useProjectListContext()
return (
<ProjectsFilterMenu filter={filter}>
{isActive => (
<li className={isActive ? 'active' : ''}>
<button type="button" onClick={() => selectFilter(filter)}>
{text}
</button>
</li>
)}
</ProjectsFilterMenu>
)
}
export default function SidebarFilters() {
const { t } = useTranslation()
return (
<ul className="list-unstyled project-list-filters">
<SidebarFilter filter="all" text={t('all_projects')} />
<SidebarFilter filter="owned" text={t('your_projects')} />
<SidebarFilter filter="shared" text={t('shared_with_you')} />
<SidebarFilter filter="archived" text={t('archived_projects')} />
<SidebarFilter filter="trashed" text={t('trashed_projects')} />
{hasDsNav() && (
<li role="none">
<hr />
</li>
)}
<TagsList />
</ul>
)
}

View File

@@ -0,0 +1,49 @@
import NewProjectButton from '../new-project-button'
import SidebarFilters from './sidebar-filters'
import AddAffiliation, { useAddAffiliation } from '../add-affiliation'
import SurveyWidget from '../survey-widget'
import { usePersistedResize } from '../../../../shared/hooks/use-resize'
function Sidebar() {
const { show: showAddAffiliationWidget } = useAddAffiliation()
const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
name: 'project-sidebar',
})
return (
<div
className="project-list-sidebar-wrapper-react d-none d-md-block"
{...getTargetProps({
style: {
...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
},
})}
>
<div className="project-list-sidebar-subwrapper">
<aside className="project-list-sidebar-react">
<NewProjectButton id="new-project-button-sidebar" />
<SidebarFilters />
{showAddAffiliationWidget && <hr />}
<AddAffiliation />
</aside>
<div className="project-list-sidebar-survey-wrapper">
<SurveyWidget />
</div>
</div>
<div
{...getHandleProps({
style: {
position: 'absolute',
zIndex: 1,
top: 0,
right: '-2px',
height: '100%',
width: '4px',
},
})}
/>
</div>
)
}
export default Sidebar

View File

@@ -0,0 +1,141 @@
import { sortBy } from 'lodash'
import { useTranslation } from 'react-i18next'
import { DotsThreeVertical, Plus, TagSimple } from '@phosphor-icons/react'
import MaterialIcon from '../../../../shared/components/material-icon'
import {
UNCATEGORIZED_KEY,
useProjectListContext,
} from '../../context/project-list-context'
import useTag from '../../hooks/use-tag'
import { getTagColor } from '../../util/tag'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { hasDsNav } from '@/features/project-list/components/use-is-ds-nav'
export default function TagsList() {
const { t } = useTranslation()
const {
tags,
projectsPerTag,
untaggedProjectsCount,
selectedTagId,
selectTag,
} = useProjectListContext()
const {
handleSelectTag,
openCreateTagModal,
handleEditTag,
handleDeleteTag,
CreateTagModal,
EditTagModal,
DeleteTagModal,
} = useTag()
return (
<>
<li
className="dropdown-header"
aria-hidden="true"
data-testid="organize-projects"
>
{hasDsNav() ? t('organize_tags') : t('organize_projects')}
</li>
<li className="tag">
<button type="button" className="tag-name" onClick={openCreateTagModal}>
{hasDsNav() ? (
<Plus weight="bold" />
) : (
<MaterialIcon type="add" className="tag-list-icon" />
)}
<span className="name">{t('new_tag')}</span>
</button>
</li>
{sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => {
return (
<li
className={`tag ${selectedTagId === tag._id ? 'active' : ''}`}
key={tag._id}
>
<button
type="button"
className="tag-name"
onClick={e =>
handleSelectTag(e as unknown as React.MouseEvent, tag._id)
}
>
<span
style={{
color: getTagColor(tag),
}}
>
{hasDsNav() ? (
<TagSimple weight="fill" className="tag-list-icon" />
) : (
<MaterialIcon type="label" className="tag-list-icon" />
)}
</span>
<span className="name">
{tag.name}{' '}
<span className="subdued">
({projectsPerTag[tag._id].length})
</span>
</span>
</button>
<Dropdown align="end" className="tag-menu">
<DropdownToggle
aria-label={t('open_action_menu', { name: tag.name })}
id={`${tag._id}-dropdown-toggle`}
data-testid="tag-dropdown-toggle"
>
{hasDsNav() && <DotsThreeVertical weight="bold" />}
</DropdownToggle>
<DropdownMenu className="dropdown-menu-sm-width">
<DropdownItem
as="li"
className="tag-action"
onClick={e => handleEditTag(e, tag._id)}
>
{t('edit')}
</DropdownItem>
<DropdownItem
as="li"
className="tag-action"
onClick={e => handleDeleteTag(e, tag._id)}
>
{t('delete')}
</DropdownItem>
</DropdownMenu>
</Dropdown>
</li>
)
})}
{tags.length > 0 && (
<li
className={`tag untagged ${
selectedTagId === UNCATEGORIZED_KEY ? 'active' : ''
}`}
>
<button
type="button"
className="tag-name"
onClick={() => selectTag(UNCATEGORIZED_KEY)}
>
<span className="name fst-italic">
{t('uncategorized')}{' '}
<span className="subdued">({untaggedProjectsCount})</span>
</span>
</button>
</li>
)}
<CreateTagModal id="create-tag-modal" />
<EditTagModal id="edit-tag-modal" />
<DeleteTagModal id="delete-tag-modal" />
</>
)
}

View File

@@ -0,0 +1,39 @@
import { throttle } from 'lodash'
import { useEffect, useRef, useState } from 'react'
export const useScrolled = () => {
const containerRef = useRef<HTMLDivElement>(null)
const [scrolledUp, setScrolledUp] = useState(false)
const [scrolledDown, setScrolledDown] = useState(false)
const checkScrollPosition = useRef(
throttle(() => {
const container = containerRef.current
if (!container) {
return
}
setScrolledDown(container.scrollTop > 0)
const isAtBottom =
Math.abs(
// On Firefox, this value happen to be at 1 when scrolled to the bottom
container.scrollHeight - container.clientHeight - container.scrollTop
) <= 1
setScrolledUp(!isAtBottom)
}, 80)
).current
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
checkScrollPosition()
container.addEventListener('scroll', checkScrollPosition)
return () => {
checkScrollPosition.cancel()
container.removeEventListener('scroll', checkScrollPosition)
}
}, [checkScrollPosition])
return { containerRef, scrolledDown, scrolledUp }
}

View File

@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next'
import { Sort } from '../../../../../../types/project/dashboard/api'
type SortBtnOwnProps = {
column: string
sort: Sort
text: string
onClick: () => void
}
type WithContentProps = {
iconType?: string
screenReaderText: string
}
export type SortBtnProps = SortBtnOwnProps & WithContentProps
function withContent<T extends SortBtnOwnProps>(
WrappedComponent: React.ComponentType<T & WithContentProps>
) {
function WithContent(hocProps: T) {
const { t } = useTranslation()
const { column, text, sort } = hocProps
let iconType
let screenReaderText = t('sort_by_x', { x: text })
if (column === sort.by) {
iconType =
sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt'
screenReaderText = t('reverse_x_sort_order', { x: text })
}
return (
<WrappedComponent
{...hocProps}
iconType={iconType}
screenReaderText={screenReaderText}
/>
)
}
return WithContent
}
export default withContent

View File

@@ -0,0 +1,62 @@
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import getMeta from '../../../utils/meta'
import { useCallback } from 'react'
import classnames from 'classnames'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { X } from '@phosphor-icons/react'
import { useHideDsSurvey } from '@/features/project-list/components/use-is-ds-nav'
export function SurveyWidgetDsNav() {
const { t } = useTranslation()
const survey = getMeta('ol-survey')
const [dismissedSurvey, setDismissedSurvey] = usePersistedState(
`dismissed-${survey?.name}`,
false
)
const hideDsSurvey = useHideDsSurvey()
const dismissSurvey = useCallback(() => {
setDismissedSurvey(true)
}, [setDismissedSurvey])
if (!survey?.name || dismissedSurvey) {
return null
}
// Hide the survey for users who have sidebar-navigation-ui-update:
// They've had it for months. We don't need their feedback anymore
if (hideDsSurvey && survey?.name === 'ds-nav') {
return null
}
return (
<div className={classnames('user-notifications', `survey-${survey.name}`)}>
<div className="notification-entry">
<div role="alert" className="survey-notification">
<div className="notification-body">
<p className="fw-bold fs-6 pe-4">{survey.preText}</p>
<p>{survey.linkText}</p>
<OLButton
variant="secondary"
size="sm"
href={survey.url}
target="_blank"
rel="noreferrer noopener"
>
{t('take_survey')}
</OLButton>
</div>
<OLButton
variant="ghost"
className="user-notification-close"
onClick={() => dismissSurvey()}
>
<X size={16} onClick={() => dismissSurvey()} />
<span className="visually-hidden">{t('close')}</span>
</OLButton>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import getMeta from '../../../utils/meta'
import { useCallback } from 'react'
import Close from '@/shared/components/close'
export default function SurveyWidget() {
const survey = getMeta('ol-survey')
const [dismissedSurvey, setDismissedSurvey] = usePersistedState(
`dismissed-${survey?.name}`,
false
)
const dismissSurvey = useCallback(() => {
setDismissedSurvey(true)
}, [setDismissedSurvey])
if (!survey?.name || dismissedSurvey) {
return null
}
// Short-term hard-coded special case: hide the "DS nav" survey for users on
// the default variant
if (survey?.name === 'ds-nav') {
return null
}
return (
<div className="user-notifications">
<div className="notification-entry">
<div role="alert" className="survey-notification">
<div className="notification-body">
{survey.preText}&nbsp;
<a
className="project-list-sidebar-survey-link"
href={survey.url}
target="_blank"
rel="noreferrer noopener"
>
{survey.linkText}
</a>
</div>
<div className="notification-close notification-close-button-style">
<Close variant="dark" onDismiss={() => dismissSurvey()} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { memo, useCallback, useState } from 'react'
import ArchiveProjectModal from '../../../modals/archive-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { archiveProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type ArchiveProjectButtonProps = {
project: Project
children: (text: string, handleOpenModal: () => void) => React.ReactElement
}
function ArchiveProjectButton({
project,
children,
}: ArchiveProjectButtonProps) {
const { toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const { t } = useTranslation()
const text = t('archive')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleArchiveProject = useCallback(async () => {
await archiveProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({
...project,
archived: true,
trashed: false,
})
}, [project, toggleSelectedProject, updateProjectViewData])
if (project.archived) return null
return (
<>
{children(text, handleOpenModal)}
<ArchiveProjectModal
projects={[project]}
actionHandler={handleArchiveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
const ArchiveProjectButtonTooltip = memo(function ArchiveProjectButtonTooltip({
project,
}: Pick<ArchiveProjectButtonProps, 'project'>) {
return (
<ArchiveProjectButton project={project}>
{(text, handleOpenModal) => (
<OLTooltip
key={`tooltip-archive-project-${project.id}`}
id={`archive-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleOpenModal}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="inbox"
/>
</span>
</OLTooltip>
)}
</ArchiveProjectButton>
)
})
export default memo(ArchiveProjectButton)
export { ArchiveProjectButtonTooltip }

View File

@@ -0,0 +1,171 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback, useState } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import useAbortController from '../../../../../../shared/hooks/use-abort-controller'
import { postJSON } from '../../../../../../infrastructure/fetch-json'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type CompileAndDownloadProjectPDFButtonProps = {
project: Project
children: (
text: string,
pendingDownload: boolean,
downloadProject: <T extends React.MouseEvent>(
e?: T,
fn?: (e?: T) => void
) => void
) => React.ReactElement
}
function CompileAndDownloadProjectPDFButton({
project,
children,
}: CompileAndDownloadProjectPDFButtonProps) {
const { t } = useTranslation()
const location = useLocation()
const { signal } = useAbortController()
const [pendingCompile, setPendingCompile] = useState(false)
const downloadProject = useCallback(
<T extends React.MouseEvent>(e?: T, onDone?: (e?: T) => void) => {
setPendingCompile(pendingCompile => {
if (pendingCompile) return true
eventTracking.sendMB('project-list-page-interaction', {
action: 'downloadPDF',
projectId: project.id,
isSmallDevice,
})
postJSON(`/project/${project.id}/compile`, {
body: {
check: 'silent',
draft: false,
incrementalCompilesEnabled: true,
},
signal,
})
.catch(() => ({ status: 'error' }))
.then(data => {
setPendingCompile(false)
if (data.status === 'success') {
const outputFile = data.outputFiles
.filter((file: { path: string }) => file.path === 'output.pdf')
.pop()
const params = new URLSearchParams({
compileGroup: data.compileGroup,
popupDownload: 'true',
})
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
// Note: Triggering concurrent downloads does not work.
// Note: This is affecting the download of .zip files as well.
// When creating a dynamic `a` element with `download` attribute,
// another "actual" UI click is needed to trigger downloads.
// Forwarding the click `event` to the dynamic `a` element does
// not work either.
location.assign(
`/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
)
onDone?.(e)
} else {
setShowErrorModal(true)
}
})
return true
})
},
[project, signal, location]
)
const [showErrorModal, setShowErrorModal] = useState(false)
return (
<>
{children(
pendingCompile ? t('compiling') + '…' : t('download_pdf'),
pendingCompile,
downloadProject
)}
{showErrorModal && (
<CompileErrorModal
project={project}
handleClose={() => {
setShowErrorModal(false)
}}
/>
)}
</>
)
}
function CompileErrorModal({
project,
handleClose,
}: { project: Project } & { handleClose: () => void }) {
const { t } = useTranslation()
return (
<>
<OLModal show onHide={handleClose}>
<OLModalHeader closeButton>
<OLModalTitle>
{project.name}: {t('pdf_unavailable_for_download')}
</OLModalTitle>
</OLModalHeader>
<OLModalBody>{t('generic_linked_file_compile_error')}</OLModalBody>
<OLModalFooter>
<OLButton variant="primary" href={`/project/${project.id}`}>
{t('open_project')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
const CompileAndDownloadProjectPDFButtonTooltip = memo(
function CompileAndDownloadProjectPDFButtonTooltip({
project,
}: Pick<CompileAndDownloadProjectPDFButtonProps, 'project'>) {
return (
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, compileAndDownloadProject) => (
<OLTooltip
key={`tooltip-compile-and-download-project-${project.id}`}
id={`compile-and-download-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={compileAndDownloadProject}
variant="link"
accessibilityLabel={text}
loadingLabel={text}
isLoading={pendingCompile}
className="action-btn"
icon="picture_as_pdf"
/>
</span>
</OLTooltip>
)}
</CompileAndDownloadProjectPDFButton>
)
}
)
export default memo(CompileAndDownloadProjectPDFButton)
export { CompileAndDownloadProjectPDFButtonTooltip }

View File

@@ -0,0 +1,123 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import {
ClonedProject,
Project,
} from '../../../../../../../../types/project/dashboard/api'
import { useProjectTags } from '@/features/project-list/hooks/use-project-tags'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type CopyButtonProps = {
project: Project
children: (
text: string,
handleOpenModal: <T extends React.MouseEvent>(
e?: T,
fn?: (e?: T) => void
) => void
) => React.ReactElement
}
function CopyProjectButton({ project, children }: CopyButtonProps) {
const {
addClonedProjectToViewData,
addProjectToTagInView,
toggleSelectedProject,
updateProjectViewData,
} = useProjectListContext()
const { t } = useTranslation()
const text = t('copy')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const projectTags = useProjectTags(project.id)
const handleOpenModal = useCallback(
<T extends React.MouseEvent>(e?: T, onOpen?: (e?: T) => void) => {
setShowModal(true)
onOpen?.(e)
},
[]
)
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleAfterCloned = useCallback(
(clonedProject: ClonedProject, tags: { _id: string }[]) => {
eventTracking.sendMB('project-list-page-interaction', {
action: 'clone',
projectId: project.id,
isSmallDevice,
})
addClonedProjectToViewData(clonedProject)
for (const tag of tags) {
addProjectToTagInView(tag._id, clonedProject.project_id)
}
toggleSelectedProject(project.id, false)
updateProjectViewData({ ...project })
setShowModal(false)
},
[
addClonedProjectToViewData,
addProjectToTagInView,
project,
toggleSelectedProject,
updateProjectViewData,
]
)
if (project.archived || project.trashed) return null
return (
<>
{children(text, handleOpenModal)}
<CloneProjectModal
show={showModal}
handleHide={handleCloseModal}
handleAfterCloned={handleAfterCloned}
projectId={project.id}
projectName={project.name}
projectTags={projectTags}
/>
</>
)
}
const CopyProjectButtonTooltip = memo(function CopyProjectButtonTooltip({
project,
}: Pick<CopyButtonProps, 'project'>) {
return (
<CopyProjectButton project={project}>
{(text, handleOpenModal) => (
<OLTooltip
key={`tooltip-copy-project-${project.id}`}
id={`copy-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleOpenModal}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="file_copy"
/>
</span>
</OLTooltip>
)}
</CopyProjectButton>
)
})
export default memo(CopyProjectButton)
export { CopyProjectButtonTooltip }

View File

@@ -0,0 +1,88 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import DeleteProjectModal from '../../../modals/delete-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { deleteProject } from '../../../../util/api'
import { useProjectListContext } from '../../../../context/project-list-context'
import getMeta from '@/utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type DeleteProjectButtonProps = {
project: Project
children: (text: string, handleOpenModal: () => void) => React.ReactElement
}
function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) {
const { removeProjectFromView } = useProjectListContext()
const { t } = useTranslation()
const text = t('delete')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const isOwner = useMemo(() => {
return project.owner && getMeta('ol-user_id') === project.owner.id
}, [project])
const handleDeleteProject = useCallback(async () => {
await deleteProject(project.id)
// update view
removeProjectFromView(project)
}, [project, removeProjectFromView])
if (!project.trashed || !isOwner) return null
return (
<>
{children(text, handleOpenModal)}
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({
project,
}: Pick<DeleteProjectButtonProps, 'project'>) {
return (
<DeleteProjectButton project={project}>
{(text, handleOpenModal) => (
<OLTooltip
key={`tooltip-delete-project-${project.id}`}
id={`delete-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleOpenModal}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="block"
/>
</span>
</OLTooltip>
)}
</DeleteProjectButton>
)
})
export default memo(DeleteProjectButton)
export { DeleteProjectButtonTooltip }

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type DownloadProjectButtonProps = {
project: Project
children: (text: string, downloadProject: () => void) => React.ReactElement
}
function DownloadProjectButton({
project,
children,
}: DownloadProjectButtonProps) {
const { t } = useTranslation()
const text = t('download_zip_file')
const location = useLocation()
const downloadProject = useCallback(() => {
eventTracking.sendMB('project-list-page-interaction', {
action: 'downloadZip',
projectId: project.id,
isSmallDevice,
})
location.assign(`/project/${project.id}/download/zip`)
}, [project, location])
return children(text, downloadProject)
}
const DownloadProjectButtonTooltip = memo(
function DownloadProjectButtonTooltip({
project,
}: Pick<DownloadProjectButtonProps, 'project'>) {
return (
<DownloadProjectButton project={project}>
{(text, downloadProject) => (
<OLTooltip
key={`tooltip-download-project-${project.id}`}
id={`download-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={downloadProject}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="download"
/>
</span>
</OLTooltip>
)}
</DownloadProjectButton>
)
}
)
export default memo(DownloadProjectButton)
export { DownloadProjectButtonTooltip }

View File

@@ -0,0 +1,87 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import LeaveProjectModal from '../../../modals/leave-project-modal'
import { useProjectListContext } from '../../../../context/project-list-context'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { leaveProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import getMeta from '@/utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type LeaveProjectButtonProps = {
project: Project
children: (text: string, handleOpenModal: () => void) => React.ReactElement
}
function LeaveProjectButton({ project, children }: LeaveProjectButtonProps) {
const { removeProjectFromView } = useProjectListContext()
const { t } = useTranslation()
const text = t('leave')
const isOwner = useMemo(() => {
return project.owner && getMeta('ol-user_id') === project.owner.id
}, [project])
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleLeaveProject = useCallback(async () => {
await leaveProject(project.id)
// update view
removeProjectFromView(project)
}, [project, removeProjectFromView])
if (!project.trashed || isOwner) return null
return (
<>
{children(text, handleOpenModal)}
<LeaveProjectModal
projects={[project]}
actionHandler={handleLeaveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
const LeaveProjectButtonTooltip = memo(function LeaveProjectButtonTooltip({
project,
}: Pick<LeaveProjectButtonProps, 'project'>) {
return (
<LeaveProjectButton project={project}>
{(text, handleOpenModal) => (
<OLTooltip
key={`tooltip-leave-project-${project.id}`}
id={`leave-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleOpenModal}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="logout"
/>
</span>
</OLTooltip>
)}
</LeaveProjectButton>
)
})
export default memo(LeaveProjectButton)
export { LeaveProjectButtonTooltip }

View File

@@ -0,0 +1,43 @@
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { useTranslation } from 'react-i18next'
import { memo, useCallback, useState } from 'react'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import RenameProjectModal from '../../../modals/rename-project-modal'
type RenameProjectButtonProps = {
project: Project
children: (text: string, handleOpenModal: () => void) => React.ReactElement
}
function RenameProjectButton({ project, children }: RenameProjectButtonProps) {
const { t } = useTranslation()
const text = t('rename')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
if (project.accessLevel !== 'owner') {
return null
}
return (
<>
{children(text, handleOpenModal)}
<RenameProjectModal
handleCloseModal={handleCloseModal}
project={project}
showModal={showModal}
/>
</>
)
}
export default memo(RenameProjectButton)

View File

@@ -0,0 +1,87 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback, useState } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import TrashProjectModal from '../../../modals/trash-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { trashProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type TrashProjectButtonProps = {
project: Project
children: (text: string, handleOpenModal: () => void) => React.ReactElement
}
function TrashProjectButton({ project, children }: TrashProjectButtonProps) {
const { toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const { t } = useTranslation()
const text = t('trash')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleTrashProject = useCallback(async () => {
await trashProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({
...project,
trashed: true,
archived: false,
})
}, [project, toggleSelectedProject, updateProjectViewData])
if (project.trashed) return null
return (
<>
{children(text, handleOpenModal)}
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({
project,
}: Pick<TrashProjectButtonProps, 'project'>) {
return (
<TrashProjectButton project={project}>
{(text, handleOpenModal) => (
<OLTooltip
key={`tooltip-trash-project-${project.id}`}
id={`trash-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleOpenModal}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="delete"
/>
</span>
</OLTooltip>
)}
</TrashProjectButton>
)
})
export default memo(TrashProjectButton)
export { TrashProjectButtonTooltip }

View File

@@ -0,0 +1,67 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { useProjectListContext } from '../../../../context/project-list-context'
import { unarchiveProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type UnarchiveProjectButtonProps = {
project: Project
children: (
text: string,
handleUnarchiveProject: () => Promise<void>
) => React.ReactElement
}
function UnarchiveProjectButton({
project,
children,
}: UnarchiveProjectButtonProps) {
const { t } = useTranslation()
const text = t('unarchive')
const { toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const handleUnarchiveProject = useCallback(async () => {
await unarchiveProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({ ...project, archived: false })
}, [project, toggleSelectedProject, updateProjectViewData])
if (!project.archived) return null
return children(text, handleUnarchiveProject)
}
const UnarchiveProjectButtonTooltip = memo(
function UnarchiveProjectButtonTooltip({
project,
}: Pick<UnarchiveProjectButtonProps, 'project'>) {
return (
<UnarchiveProjectButton project={project}>
{(text, handleUnarchiveProject) => (
<OLTooltip
key={`tooltip-unarchive-project-${project.id}`}
id={`unarchive-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleUnarchiveProject}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="restore_page"
/>
</span>
</OLTooltip>
)}
</UnarchiveProjectButton>
)
}
)
export default memo(UnarchiveProjectButton)
export { UnarchiveProjectButtonTooltip }

View File

@@ -0,0 +1,65 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { useProjectListContext } from '../../../../context/project-list-context'
import { untrashProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type UntrashProjectButtonProps = {
project: Project
children: (
text: string,
untrashProject: () => Promise<void>
) => React.ReactElement
}
function UntrashProjectButton({
project,
children,
}: UntrashProjectButtonProps) {
const { t } = useTranslation()
const text = t('untrash')
const { toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const handleUntrashProject = useCallback(async () => {
await untrashProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({ ...project, trashed: false })
}, [project, toggleSelectedProject, updateProjectViewData])
if (!project.trashed) return null
return children(text, handleUntrashProject)
}
const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({
project,
}: Pick<UntrashProjectButtonProps, 'project'>) {
return (
<UntrashProjectButton project={project}>
{(text, handleUntrashProject) => (
<OLTooltip
key={`tooltip-untrash-project-${project.id}`}
id={`untrash-project-${project.id}`}
description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
<span>
<OLIconButton
onClick={handleUntrashProject}
variant="link"
accessibilityLabel={text}
className="action-btn"
icon="restore_page"
/>
</span>
</OLTooltip>
)}
</UntrashProjectButton>
)
})
export default memo(UntrashProjectButton)
export { UntrashProjectButtonTooltip }

View File

@@ -0,0 +1,30 @@
import { Project } from '../../../../../../../types/project/dashboard/api'
import { CopyProjectButtonTooltip } from './action-buttons/copy-project-button'
import { ArchiveProjectButtonTooltip } from './action-buttons/archive-project-button'
import { TrashProjectButtonTooltip } from './action-buttons/trash-project-button'
import { UnarchiveProjectButtonTooltip } from './action-buttons/unarchive-project-button'
import { UntrashProjectButtonTooltip } from './action-buttons/untrash-project-button'
import { DownloadProjectButtonTooltip } from './action-buttons/download-project-button'
import { LeaveProjectButtonTooltip } from './action-buttons/leave-project-button'
import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button'
import { CompileAndDownloadProjectPDFButtonTooltip } from './action-buttons/compile-and-download-project-pdf-button'
type ActionsCellProps = {
project: Project
}
export default function ActionsCell({ project }: ActionsCellProps) {
return (
<>
<CopyProjectButtonTooltip project={project} />
<DownloadProjectButtonTooltip project={project} />
<CompileAndDownloadProjectPDFButtonTooltip project={project} />
<ArchiveProjectButtonTooltip project={project} />
<TrashProjectButtonTooltip project={project} />
<UnarchiveProjectButtonTooltip project={project} />
<UntrashProjectButtonTooltip project={project} />
<LeaveProjectButtonTooltip project={project} />
<DeleteProjectButtonTooltip project={project} />
</>
)
}

View File

@@ -0,0 +1,66 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag as TagType } from '../../../../../../../app/src/Features/Tags/types'
import { useProjectListContext } from '../../../context/project-list-context'
import { removeProjectFromTag } from '../../../util/api'
import { getTagColor } from '../../../util/tag'
import Tag from '@/features/ui/components/bootstrap-5/tag'
type InlineTagsProps = {
projectId: string
className?: string
}
function InlineTags({ projectId, ...props }: InlineTagsProps) {
const { tags } = useProjectListContext()
return (
<span {...props}>
{tags
.filter(tag => tag.project_ids?.includes(projectId))
.map((tag, index) => (
<InlineTag tag={tag} projectId={projectId} key={index} />
))}
</span>
)
}
type InlineTagProps = {
tag: TagType
projectId: string
}
function InlineTag({ tag, projectId }: InlineTagProps) {
const { t } = useTranslation()
const { selectTag, removeProjectFromTagInView } = useProjectListContext()
const handleRemoveTag = useCallback(
async (tagId: string, projectId: string) => {
removeProjectFromTagInView(tagId, projectId)
await removeProjectFromTag(tagId, projectId)
},
[removeProjectFromTagInView]
)
return (
<Tag
prepend={
<i
className="badge-tag-circle"
style={{ backgroundColor: getTagColor(tag) }}
/>
}
contentProps={{
'aria-label': t('select_tag', { tagName: tag.name }),
onClick: () => selectTag(tag._id),
}}
closeBtnProps={{
onClick: () => handleRemoveTag(tag._id, projectId),
}}
className="ms-2"
>
{tag.name}
</Tag>
)
}
export default InlineTags

View File

@@ -0,0 +1,22 @@
import { FC } from 'react'
import { UserRef } from '../../../../../../../types/project/dashboard/api'
import { useTranslation } from 'react-i18next'
import { getUserName } from '@/features/project-list/util/user'
export const LastUpdatedBy: FC<{
lastUpdatedBy: UserRef
lastUpdatedDate: string
}> = ({ lastUpdatedBy, lastUpdatedDate }) => {
const { t } = useTranslation()
const userName = getUserName(lastUpdatedBy)
return (
<>
{t('last_updated_date_by_x', {
lastUpdatedDate,
person: userName === 'You' ? t('you') : userName,
})}
</>
)
}

View File

@@ -0,0 +1,33 @@
import { formatDate, fromNowDate } from '../../../../../utils/dates'
import { Project } from '../../../../../../../types/project/dashboard/api'
import { LastUpdatedBy } from '@/features/project-list/components/table/cells/last-updated-by'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type LastUpdatedCellProps = {
project: Project
}
export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
const lastUpdatedDate = fromNowDate(project.lastUpdated)
const tooltipText = formatDate(project.lastUpdated)
return (
<OLTooltip
key={`tooltip-last-updated-${project.id}`}
id={`tooltip-last-updated-${project.id}`}
description={tooltipText}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
>
{project.lastUpdatedBy ? (
<span>
<LastUpdatedBy
lastUpdatedBy={project.lastUpdatedBy}
lastUpdatedDate={lastUpdatedDate}
/>
</span>
) : (
<span>{lastUpdatedDate}</span>
)}
</OLTooltip>
)
}

View File

@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { getOwnerName } from '../../../util/project'
import { Project } from '../../../../../../../types/project/dashboard/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
type LinkSharingIconProps = {
prependSpace: boolean
project: Project
className?: string
}
function LinkSharingIcon({
project,
prependSpace,
className,
}: LinkSharingIconProps) {
const { t } = useTranslation()
return (
<OLTooltip
key={`tooltip-link-sharing-${project.id}`}
id={`tooltip-link-sharing-${project.id}`}
description={t('link_sharing')}
overlayProps={{ placement: 'right', trigger: ['hover', 'focus'] }}
>
{/* OverlayTrigger won't fire unless icon is wrapped in a span */}
<span className={className}>
{prependSpace ? ' ' : ''}
<MaterialIcon
type="link"
className="align-text-bottom"
accessibilityLabel={t('link_sharing')}
/>
</span>
</OLTooltip>
)
}
type OwnerCellProps = {
project: Project
}
export default function OwnerCell({ project }: OwnerCellProps) {
const { t } = useTranslation()
const ownerName = getOwnerName(project)
return (
<>
{ownerName === 'You' ? t('you') : ownerName}
{project.source === 'token' && (
<LinkSharingIcon project={project} prependSpace={!!project.owner} />
)}
</>
)
}

View File

@@ -0,0 +1,31 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '@/features/project-list/context/project-list-context'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
({ projectId, projectName }) => {
const { t } = useTranslation()
const { selectedProjectIds, toggleSelectedProject } =
useProjectListContext()
const handleCheckboxChange = useCallback(
event => {
toggleSelectedProject(projectId, event.target.checked)
},
[projectId, toggleSelectedProject]
)
return (
<OLFormCheckbox
autoComplete="off"
onChange={handleCheckboxChange}
checked={selectedProjectIds.has(projectId)}
aria-label={t('select_project', { project: projectName })}
data-project-id={projectId}
/>
)
}
)
ProjectCheckbox.displayName = 'ProjectCheckbox'

View File

@@ -0,0 +1,13 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
export const ProjectListOwnerName = memo<{ ownerName: string }>(
({ ownerName }) => {
const { t } = useTranslation()
const x = ownerName === 'You' ? t('you') : ownerName
return <> {t('owned_by_x', { x })}</>
}
)
ProjectListOwnerName.displayName = 'ProjectListOwnerName'

View File

@@ -0,0 +1,52 @@
import { memo } from 'react'
import InlineTags from './cells/inline-tags'
import OwnerCell from './cells/owner-cell'
import LastUpdatedCell from './cells/last-updated-cell'
import ActionsCell from './cells/actions-cell'
import ActionsDropdown from '../dropdown/actions-dropdown'
import { getOwnerName } from '../../util/project'
import { Project } from '../../../../../../types/project/dashboard/api'
import { ProjectCheckbox } from './project-checkbox'
import { ProjectListOwnerName } from '@/features/project-list/components/table/project-list-owner-name'
type ProjectListTableRowProps = {
project: Project
selected: boolean
}
function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
const ownerName = getOwnerName(project)
return (
<tr className={selected ? 'table-active' : undefined}>
<td className="dash-cell-checkbox d-none d-md-table-cell">
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</td>
<td className="dash-cell-name">
<a href={`/project/${project.id}`}>{project.name}</a>{' '}
<InlineTags className="d-none d-md-inline" projectId={project.id} />
</td>
<td className="dash-cell-date-owner pb-0 d-md-none">
<LastUpdatedCell project={project} />
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
</td>
<td className="dash-cell-owner d-none d-md-table-cell">
<OwnerCell project={project} />
</td>
<td className="dash-cell-date d-none d-md-table-cell">
<LastUpdatedCell project={project} />
</td>
<td className="dash-cell-tag pt-0 d-md-none">
<InlineTags projectId={project.id} />
</td>
<td className="dash-cell-actions">
<div className="d-none d-md-block">
<ActionsCell project={project} />
</div>
<div className="d-md-none">
<ActionsDropdown project={project} />
</div>
</td>
</tr>
)
}
export default memo(ProjectListTableRow)

View File

@@ -0,0 +1,162 @@
import { useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectListTableRow from './project-list-table-row'
import { useProjectListContext } from '../../context/project-list-context'
import useSort from '../../hooks/use-sort'
import withContent, { SortBtnProps } from '../sort/with-content'
import OLTable from '@/features/ui/components/ol/ol-table'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import MaterialIcon from '@/shared/components/material-icon'
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return (
<button
className="table-header-sort-btn d-none d-md-inline-block"
onClick={onClick}
aria-label={screenReaderText}
>
<span>{text}</span>
{iconType && <MaterialIcon type={iconType} />}
</button>
)
}
const SortByButton = withContent(SortBtn)
function ProjectListTable() {
const { t } = useTranslation()
const {
visibleProjects,
sort,
selectedProjects,
selectOrUnselectAllProjects,
} = useProjectListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
const handleAllProjectsCheckboxChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
selectOrUnselectAllProjects(event.target.checked)
},
[selectOrUnselectAllProjects]
)
useEffect(() => {
if (checkAllRef.current) {
checkAllRef.current.indeterminate =
selectedProjects.length > 0 &&
selectedProjects.length !== visibleProjects.length
}
}, [selectedProjects, visibleProjects])
return (
<OLTable className="project-dash-table" container={false} hover>
<caption className="visually-hidden">{t('projects_list')}</caption>
<thead className="visually-hidden-max-md">
<tr>
<th
className="dash-cell-checkbox d-none d-md-table-cell"
aria-label={t('select_projects')}
>
<OLFormCheckbox
autoComplete="off"
onChange={handleAllProjectsCheckboxChange}
checked={
visibleProjects.length === selectedProjects.length &&
visibleProjects.length !== 0
}
disabled={visibleProjects.length === 0}
aria-label={t('select_all_projects')}
inputRef={checkAllRef}
/>
</th>
<th
className="dash-cell-name"
aria-label={t('title')}
aria-sort={
sort.by === 'title'
? sort.order === 'asc'
? 'ascending'
: 'descending'
: undefined
}
>
<SortByButton
column="title"
text={t('title')}
sort={sort}
onClick={() => handleSort('title')}
/>
</th>
<th
className="dash-cell-date-owner d-md-none"
aria-label={t('date_and_owner')}
>
{t('date_and_owner')}
</th>
<th
className="dash-cell-owner d-none d-md-table-cell"
aria-label={t('owner')}
aria-sort={
sort.by === 'owner'
? sort.order === 'asc'
? 'ascending'
: 'descending'
: undefined
}
>
<SortByButton
column="owner"
text={t('owner')}
sort={sort}
onClick={() => handleSort('owner')}
/>
</th>
<th
className="dash-cell-date d-none d-md-table-cell"
aria-label={t('last_modified')}
aria-sort={
sort.by === 'lastUpdated'
? sort.order === 'asc'
? 'ascending'
: 'descending'
: undefined
}
>
<SortByButton
column="lastUpdated"
text={t('last_modified')}
sort={sort}
onClick={() => handleSort('lastUpdated')}
/>
</th>
<th className="dash-cell-tag d-md-none" aria-label={t('tags')}>
{t('tags')}
</th>
<th className="dash-cell-actions" aria-label={t('actions')}>
{t('actions')}
</th>
</tr>
</thead>
<tbody>
{visibleProjects.length > 0 ? (
visibleProjects.map(p => (
<ProjectListTableRow
project={p}
selected={selectedProjects.some(({ id }) => id === p.id)}
key={p.id}
/>
))
) : (
<tr className="no-projects">
<td className="text-center" colSpan={5}>
{t('no_projects')}
</td>
</tr>
)}
</tbody>
</OLTable>
)
}
export default ProjectListTable

View File

@@ -0,0 +1,65 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import ArchiveProjectModal from '../../../modals/archive-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { archiveProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
function ArchiveProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const { t } = useTranslation()
const text = t('archive')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleArchiveProject = async (project: Project) => {
await archiveProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({
...project,
archived: true,
trashed: false,
})
}
return (
<>
<OLTooltip
id="tooltip-archive-projects"
description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<OLIconButton
onClick={handleOpenModal}
variant="secondary"
accessibilityLabel={text}
icon="inbox"
/>
</OLTooltip>
<ArchiveProjectModal
projects={selectedProjects}
actionHandler={handleArchiveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default memo(ArchiveProjectsButton)

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import DeleteLeaveProjectModal from '../../../modals/delete-leave-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { deleteProject, leaveProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
function DeleteLeaveProjectsButton() {
const { t } = useTranslation()
const {
selectedProjects,
removeProjectFromView,
hasLeavableProjectsSelected,
hasDeletableProjectsSelected,
} = useProjectListContext()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleDeleteAndLeaveProject = async (project: Project) => {
if (project.accessLevel === 'owner') {
await deleteProject(project.id)
} else {
await leaveProject(project.id)
}
removeProjectFromView(project)
}
return (
<>
{hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete_and_leave')}
</OLButton>
)}
<DeleteLeaveProjectModal
projects={selectedProjects}
actionHandler={handleDeleteAndLeaveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default DeleteLeaveProjectsButton

View File

@@ -0,0 +1,54 @@
import { useState } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import DeleteProjectModal from '../../../modals/delete-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { deleteProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
function DeleteProjectsButton() {
const { t } = useTranslation()
const {
selectedProjects,
removeProjectFromView,
hasLeavableProjectsSelected,
hasDeletableProjectsSelected,
} = useProjectListContext()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleDeleteProject = async (project: Project) => {
await deleteProject(project.id)
removeProjectFromView(project)
}
return (
<>
{hasDeletableProjectsSelected && !hasLeavableProjectsSelected && (
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
)}
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default DeleteProjectsButton

View File

@@ -0,0 +1,47 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useProjectListContext } from '../../../../context/project-list-context'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
function DownloadProjectsButton() {
const { selectedProjects, selectOrUnselectAllProjects } =
useProjectListContext()
const { t } = useTranslation()
const text = t('download')
const location = useLocation()
const projectIds = selectedProjects.map(p => p.id)
const handleDownloadProjects = useCallback(() => {
eventTracking.sendMB('project-list-page-interaction', {
action: 'downloadZips',
isSmallDevice,
})
location.assign(`/project/download/zip?project_ids=${projectIds.join(',')}`)
const selected = false
selectOrUnselectAllProjects(selected)
}, [projectIds, selectOrUnselectAllProjects, location])
return (
<OLTooltip
id="tooltip-download-projects"
description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<OLIconButton
onClick={handleDownloadProjects}
variant="secondary"
accessibilityLabel={text}
icon="download"
/>
</OLTooltip>
)
}
export default memo(DownloadProjectsButton)

View File

@@ -0,0 +1,54 @@
import { useState } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import LeaveProjectModal from '../../../modals/leave-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { leaveProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
function LeaveProjectsButton() {
const { t } = useTranslation()
const {
selectedProjects,
removeProjectFromView,
hasLeavableProjectsSelected,
hasDeletableProjectsSelected,
} = useProjectListContext()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = () => {
setShowModal(true)
}
const handleCloseModal = () => {
if (isMounted.current) {
setShowModal(false)
}
}
const handleLeaveProject = async (project: Project) => {
await leaveProject(project.id)
removeProjectFromView(project)
}
return (
<>
{!hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
<OLButton variant="danger" onClick={handleOpenModal}>
{t('leave')}
</OLButton>
)}
<LeaveProjectModal
projects={selectedProjects}
actionHandler={handleLeaveProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default LeaveProjectsButton

View File

@@ -0,0 +1,31 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import CopyProjectMenuItem from '../menu-items/copy-project-menu-item'
import RenameProjectMenuItem from '../menu-items/rename-project-menu-item'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
function ProjectToolsMoreDropdownButton() {
const { t } = useTranslation()
return (
<Dropdown align="end">
<DropdownToggle id="project-tools-more-dropdown" variant="secondary">
{t('more')}
</DropdownToggle>
<DropdownMenu flip={false} data-testid="project-tools-more-dropdown-menu">
<li role="none">
<RenameProjectMenuItem />
</li>
<li role="none">
<CopyProjectMenuItem />
</li>
</DropdownMenu>
</Dropdown>
)
}
export default memo(ProjectToolsMoreDropdownButton)

View File

@@ -0,0 +1,143 @@
import { sortBy } from 'lodash'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '../../../../../../shared/components/material-icon'
import { useProjectListContext } from '../../../../context/project-list-context'
import useTag from '../../../../hooks/use-tag'
import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
import { getTagColor } from '../../../../util/tag'
import {
Dropdown,
DropdownDivider,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
function TagsDropdown() {
const {
tags,
selectedProjects,
addProjectToTagInView,
removeProjectFromTagInView,
} = useProjectListContext()
const { t } = useTranslation()
const { openCreateTagModal, CreateTagModal } = useTag()
const handleOpenCreateTagModal = useCallback(
e => {
e.preventDefault()
openCreateTagModal()
},
[openCreateTagModal]
)
const handleAddTagToSelectedProjects = useCallback(
(e, tagId) => {
e.preventDefault()
const tag = tags.find(tag => tag._id === tagId)
const projectIds = []
for (const selectedProject of selectedProjects) {
if (!tag?.project_ids?.includes(selectedProject.id)) {
addProjectToTagInView(tagId, selectedProject.id)
projectIds.push(selectedProject.id)
}
}
addProjectsToTag(tagId, projectIds)
},
[tags, selectedProjects, addProjectToTagInView]
)
const handleRemoveTagFromSelectedProjects = useCallback(
(e, tagId) => {
e.preventDefault()
for (const selectedProject of selectedProjects) {
removeProjectFromTagInView(tagId, selectedProject.id)
}
removeProjectsFromTag(
tagId,
selectedProjects.map(project => project.id)
)
},
[selectedProjects, removeProjectFromTagInView]
)
const containsAllSelectedProjects = useCallback(
tag => {
for (const project of selectedProjects) {
if (!(tag.project_ids || []).includes(project.id)) {
return false
}
}
return true
},
[selectedProjects]
)
return (
<>
<Dropdown align="end" autoClose="outside">
<DropdownToggle
id="project-tools-more-dropdown"
variant="secondary"
aria-label={t('tags')}
>
<MaterialIcon type="label" className="align-text-top" />
</DropdownToggle>
<DropdownMenu
flip={false}
data-testid="project-tools-more-dropdown-menu"
>
<DropdownHeader>{t('add_to_tag')}</DropdownHeader>
{sortBy(tags, tag => tag.name?.toLowerCase()).map((tag, index) => (
<li role="none" key={tag._id}>
<DropdownItem
onClick={e =>
containsAllSelectedProjects(tag)
? handleRemoveTagFromSelectedProjects(e, tag._id)
: handleAddTagToSelectedProjects(e, tag._id)
}
aria-label={t('add_or_remove_project_from_tag', {
tagName: tag.name,
})}
as="button"
tabIndex={-1}
leadingIcon={
containsAllSelectedProjects(tag) ? (
'check'
) : (
<DropdownItem.EmptyLeadingIcon />
)
}
>
<div className="badge-tag-content">
<span className="badge-prepend">
<i
className="badge-tag-circle align-self-center ms-0"
style={{ backgroundColor: getTagColor(tag) }}
/>
</span>
<span className="text-truncate">{tag.name}</span>
</div>
</DropdownItem>
</li>
))}
<DropdownDivider />
<li role="none">
<DropdownItem
onClick={handleOpenCreateTagModal}
as="button"
tabIndex={-1}
>
{t('create_new_tag')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
<CreateTagModal id="toolbar-create-tag-modal" />
</>
)
}
export default memo(TagsDropdown)

View File

@@ -0,0 +1,65 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import TrashProjectModal from '../../../modals/trash-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { trashProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
function TrashProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const { t } = useTranslation()
const text = t('trash')
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleTrashProject = async (project: Project) => {
await trashProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({
...project,
trashed: true,
archived: false,
})
}
return (
<>
<OLTooltip
id="tooltip-trash-projects"
description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<OLIconButton
onClick={handleOpenModal}
variant="secondary"
accessibilityLabel={text}
icon="delete"
/>
</OLTooltip>
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
</>
)
}
export default memo(TrashProjectsButton)

View File

@@ -0,0 +1,27 @@
import { memo } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
import { unarchiveProject } from '../../../../util/api'
function UnarchiveProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const { t } = useTranslation()
const handleUnarchiveProjects = async () => {
for (const project of selectedProjects) {
await unarchiveProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({ ...project, archived: false })
}
}
return (
<OLButton variant="secondary" onClick={handleUnarchiveProjects}>
{t('untrash')}
</OLButton>
)
}
export default memo(UnarchiveProjectsButton)

View File

@@ -0,0 +1,27 @@
import { memo } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
import { untrashProject } from '../../../../util/api'
function UntrashProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const { t } = useTranslation()
const handleUntrashProjects = async () => {
for (const project of selectedProjects) {
await untrashProject(project.id)
toggleSelectedProject(project.id, false)
updateProjectViewData({ ...project, trashed: false })
}
}
return (
<OLButton variant="secondary" onClick={handleUntrashProjects}>
{t('untrash')}
</OLButton>
)
}
export default memo(UntrashProjectsButton)

View File

@@ -0,0 +1,82 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { ClonedProject } from '../../../../../../../../types/project/dashboard/api'
import { useProjectTags } from '@/features/project-list/hooks/use-project-tags'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
function CopyProjectMenuItem() {
const {
addClonedProjectToViewData,
addProjectToTagInView,
toggleSelectedProject,
selectedProjects,
} = useProjectListContext()
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const projectTags = useProjectTags(selectedProjects[0]?.id)
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
const handleAfterCloned = useCallback(
(clonedProject: ClonedProject, tags: { _id: string }[]) => {
const project = selectedProjects[0]
eventTracking.sendMB('project-list-page-interaction', {
action: 'clone',
projectId: project.id,
isSmallDevice,
})
addClonedProjectToViewData(clonedProject)
for (const tag of tags) {
addProjectToTagInView(tag._id, clonedProject.project_id)
}
toggleSelectedProject(project.id, false)
if (isMounted.current) {
setShowModal(false)
}
},
[
isMounted,
selectedProjects,
addClonedProjectToViewData,
addProjectToTagInView,
toggleSelectedProject,
]
)
if (selectedProjects.length !== 1) return null
if (selectedProjects[0].archived || selectedProjects[0].trashed) return null
return (
<>
<OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('make_a_copy')}
</OLDropdownMenuItem>
<CloneProjectModal
show={showModal}
handleHide={handleCloseModal}
handleAfterCloned={handleAfterCloned}
projectId={selectedProjects[0].id}
projectName={selectedProjects[0].name}
projectTags={projectTags}
/>
</>
)
}
export default memo(CopyProjectMenuItem)

View File

@@ -0,0 +1,49 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import RenameProjectModal from '../../../modals/rename-project-modal'
function RenameProjectMenuItem() {
const { selectedProjects } = useProjectListContext()
const [showModal, setShowModal] = useState(false)
const isMounted = useIsMounted()
const { t } = useTranslation()
const handleOpenModal = useCallback(() => {
setShowModal(true)
}, [])
const handleCloseModal = useCallback(() => {
if (isMounted.current) {
setShowModal(false)
}
}, [isMounted])
if (selectedProjects.length !== 1) {
return null
}
const [selectedProject] = selectedProjects
// only show Rename if the current user is the project owner
if (selectedProject.accessLevel !== 'owner') {
return null
}
return (
<>
<OLDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('rename')}
</OLDropdownMenuItem>
<RenameProjectModal
handleCloseModal={handleCloseModal}
showModal={showModal}
project={selectedProject}
/>
</>
)
}
export default memo(RenameProjectMenuItem)

View File

@@ -0,0 +1,55 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../context/project-list-context'
import ArchiveProjectsButton from './buttons/archive-projects-button'
import DownloadProjectsButton from './buttons/download-projects-button'
import ProjectToolsMoreDropdownButton from './buttons/project-tools-more-dropdown-button'
import TagsDropdown from './buttons/tags-dropdown'
import TrashProjectsButton from './buttons/trash-projects-button'
import UnarchiveProjectsButton from './buttons/unarchive-projects-button'
import UntrashProjectsButton from './buttons/untrash-projects-button'
import DeleteLeaveProjectsButton from './buttons/delete-leave-projects-button'
import LeaveProjectsButton from './buttons/leave-projects-button'
import DeleteProjectsButton from './buttons/delete-projects-button'
import OLButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import OLButtonGroup from '@/features/ui/components/ol/ol-button-group'
function ProjectTools() {
const { t } = useTranslation()
const { filter, selectedProjects } = useProjectListContext()
return (
<OLButtonToolbar aria-label={t('toolbar_selected_projects')}>
<OLButtonGroup
aria-label={t('toolbar_selected_projects_management_actions')}
>
<DownloadProjectsButton />
{filter !== 'archived' && <ArchiveProjectsButton />}
{filter !== 'trashed' && <TrashProjectsButton />}
</OLButtonGroup>
{(filter === 'trashed' || filter === 'archived') && (
<OLButtonGroup aria-label={t('toolbar_selected_projects_restore')}>
{filter === 'trashed' && <UntrashProjectsButton />}
{filter === 'archived' && <UnarchiveProjectsButton />}
</OLButtonGroup>
)}
{filter === 'trashed' && (
<OLButtonGroup aria-label={t('toolbar_selected_projects_remove')}>
<LeaveProjectsButton />
<DeleteProjectsButton />
<DeleteLeaveProjectsButton />
</OLButtonGroup>
)}
{!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
{selectedProjects.length === 1 &&
filter !== 'archived' &&
filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}
</OLButtonToolbar>
)
}
export default memo(ProjectTools)

Some files were not shown because too many files have changed in this diff Show More