first commit
This commit is contained in:
@@ -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
|
@@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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}
|
||||
<MaterialIcon type="info" className="current-plan-label-icon" />
|
||||
</a>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommonsPlan
|
@@ -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
|
@@ -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}
|
||||
<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
|
@@ -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}
|
||||
<MaterialIcon type="info" className="current-plan-label-icon" />
|
||||
</a>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupPlan
|
@@ -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}
|
||||
<MaterialIcon type="info" className="current-plan-label-icon" />
|
||||
</a>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndividualPlan
|
@@ -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}
|
||||
<MaterialIcon type="info" className="current-plan-label-icon" />
|
||||
</a>
|
||||
</OLTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PausedPlan
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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
|
@@ -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)
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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)
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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
|
||||
}
|
@@ -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 } }}
|
||||
/>
|
||||
|
||||
{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 } }}
|
||||
/>
|
||||
|
||||
<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}`} />]}
|
||||
/>
|
||||
|
||||
<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
|
@@ -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
|
@@ -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
|
@@ -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 }
|
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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')}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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,
|
||||
}
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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 }
|
||||
}
|
@@ -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
|
@@ -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
|
@@ -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)
|
@@ -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
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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),
|
||||
[]
|
||||
)
|
||||
}
|
@@ -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)
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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 }
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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}
|
||||
<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>
|
||||
)
|
||||
}
|
@@ -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 }
|
@@ -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 }
|
@@ -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 }
|
@@ -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 }
|
@@ -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 }
|
@@ -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 }
|
@@ -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)
|
@@ -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 }
|
@@ -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 }
|
@@ -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 }
|
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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,
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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'
|
@@ -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'
|
@@ -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)
|
@@ -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
|
@@ -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)
|
@@ -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
|
@@ -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
|
@@ -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)
|
@@ -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
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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)
|
@@ -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
Reference in New Issue
Block a user