Merge pull request #36521 from overleaf/mj-project-list-themed-modals

[web] Add theme support to most project list modals

GitOrigin-RevId: fd61f096c2a3f021795c30a490c7fe2d4dc13100
This commit is contained in:
Mathias Jakobsen
2026-09-17 11:55:59 +00:00
committed by Copybot
parent a6b7329e47
commit 531718c874
25 changed files with 183 additions and 51 deletions
@@ -576,6 +576,7 @@ async function projectListPage(req, res, next) {
'import-docx',
'overleaf-library',
'import-markdown',
'themed-modals',
].filter(Boolean)
await Promise.all(
@@ -17,6 +17,7 @@ import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLButton from '@/shared/components/ol/ol-button'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import getMeta from '@/utils/meta.ts'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function CloneProjectModalContent({
handleHide,
@@ -37,6 +38,7 @@ export default function CloneProjectModalContent({
}) {
const { t } = useTranslation()
const { maxUploadSize } = getMeta('ol-ExposedSettings')
const themed = useFeatureFlag('themed-modals')
const [error, setError] = useState<FetchError | null>(null)
const [clonedProjectName, setClonedProjectName] = useState(
@@ -116,6 +118,7 @@ export default function CloneProjectModalContent({
key={tag._id}
tag={tag}
removeTag={removeTag}
themed={themed}
/>
))}
</div>
@@ -3,6 +3,7 @@ import CloneProjectModalContent from './clone-project-modal-content'
import { OLModal } from '@/shared/components/ol/ol-modal'
import { ClonedProject } from '../../../../../types/project/dashboard/api'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function CloneProjectModal({
show,
@@ -20,6 +21,7 @@ function CloneProjectModal({
projectTags: Tag[]
}) {
const [inFlight, setInFlight] = useState(false)
const themed = useFeatureFlag('themed-modals')
const onHide = useCallback(() => {
if (!inFlight) {
@@ -36,6 +38,8 @@ function CloneProjectModal({
// backdrop="static" will disable closing the modal by clicking
// outside of the modal element
backdrop={inFlight ? 'static' : undefined}
themed={themed}
className="clone-project-modal"
>
<CloneProjectModalContent
handleHide={onHide}
@@ -2,11 +2,13 @@ import { FC } from 'react'
import { Tag as TagType } from '../../../../../app/src/Features/Tags/types'
import { getTagColor } from '@/features/project-list/util/tag'
import OLTag from '@/shared/components/ol/ol-tag'
import classnames from 'classnames'
export const CloneProjectTag: FC<{
tag: TagType
removeTag: (tag: TagType) => void
}> = ({ tag, removeTag }) => {
themed?: boolean
}> = ({ tag, removeTag, themed = false }) => {
return (
<OLTag
prepend={
@@ -18,7 +20,7 @@ export const CloneProjectTag: FC<{
closeBtnProps={{
onClick: () => removeTag(tag),
}}
className="ms-2 mb-2"
className={classnames('ms-2 mb-2', { 'badge-themed': themed })}
>
{tag.name}
</OLTag>
@@ -22,6 +22,7 @@ import OLButton from '@/shared/components/ol/ol-button'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import OLForm from '@/shared/components/ol/ol-form'
import Notification from '@/shared/components/notification'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type CreateTagModalProps = {
id: string
@@ -43,6 +44,7 @@ export default function CreateTagModal({
const { t } = useTranslation()
const { isLoading, isError, runAsync, status } = useAsync<Tag>()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const themed = useFeatureFlag('themed-modals')
const [tagName, setTagName] = useState<string>()
const [validationError, setValidationError] = useState<string>()
@@ -80,7 +82,15 @@ export default function CreateTagModal({
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModal
show
animation
onHide={onClose}
id={id}
backdrop="static"
themed={themed}
className="project-list-modal"
>
<OLModalHeader>
<OLModalTitle>{t('create_new_tag')}</OLModalTitle>
</OLModalHeader>
@@ -13,6 +13,7 @@ import {
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type DeleteTagModalProps = {
id: string
@@ -29,6 +30,7 @@ export default function DeleteTagModal({
}: DeleteTagModalProps) {
const { t } = useTranslation()
const { isLoading, isError, runAsync } = useAsync()
const themed = useFeatureFlag('themed-modals')
const runDeleteTag = useCallback(
(tagId: string) => {
@@ -46,7 +48,14 @@ export default function DeleteTagModal({
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModal
show
animation
onHide={onClose}
id={id}
backdrop="static"
themed={themed}
>
<OLModalHeader>
<OLModalTitle>{t('delete_tag')}</OLModalTitle>
</OLModalHeader>
@@ -22,6 +22,7 @@ import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type EditTagModalProps = {
id: string
@@ -35,6 +36,7 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
const { t } = useTranslation()
const { isLoading, isError, runAsync, status } = useAsync()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const themed = useFeatureFlag('themed-modals')
const [newTagName, setNewTagName] = useState<string | undefined>()
const [validationError, setValidationError] = useState<string>()
@@ -88,7 +90,15 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModal
show
animation
onHide={onClose}
id={id}
backdrop="static"
themed={themed}
className="project-list-modal"
>
<OLModalHeader>
<OLModalTitle>{t('edit_tag')}</OLModalTitle>
</OLModalHeader>
@@ -21,6 +21,7 @@ import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLButton from '@/shared/components/ol/ol-button'
import Notification from '@/shared/components/notification'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type ManageTagModalProps = {
id: string
@@ -51,6 +52,7 @@ export function ManageTagModal({
} = useAsync()
const [newTagName, setNewTagName] = useState<string | undefined>(tag?.name)
const { selectedColor } = useSelectColor(tag?.color)
const themed = useFeatureFlag('themed-modals')
const runDeleteTag = useCallback(
(tagId: string) => {
@@ -89,7 +91,15 @@ export function ManageTagModal({
}
return (
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModal
show
animation
onHide={onClose}
id={id}
backdrop="static"
themed={themed}
className="project-list-modal"
>
<OLModalHeader>
<OLModalTitle>{t('edit_tag')}</OLModalTitle>
</OLModalHeader>
@@ -14,6 +14,7 @@ import {
OLModalHeader,
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type ProjectsActionModalProps = {
title?: string
@@ -38,6 +39,7 @@ function ProjectsActionModal({
const [errors, setErrors] = useState<Array<any>>([])
const [isProcessing, setIsProcessing] = useState(false)
const isMounted = useIsMounted()
const themed = useFeatureFlag('themed-modals')
async function handleActionForProjects(projects: Array<Project>) {
const errored = []
@@ -79,6 +81,8 @@ function ProjectsActionModal({
onHide={handleCloseModal}
id="action-project-modal"
backdrop="static"
themed={themed}
className="project-list-action-modal"
>
<OLModalHeader>
<OLModalTitle>{title}</OLModalTitle>
@@ -28,6 +28,7 @@ import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type RenameProjectModalProps = {
handleCloseModal: () => void
@@ -45,6 +46,7 @@ function RenameProjectModal({
const { error, isError, isLoading, runAsync } = useAsync()
const { toggleSelectedProject, updateProjectViewData } =
useProjectListContext()
const themed = useFeatureFlag('themed-modals')
useEffect(() => {
if (showModal) {
@@ -104,6 +106,8 @@ function RenameProjectModal({
onHide={handleCloseModal}
id="rename-project-modal"
backdrop="static"
themed={themed}
className="project-list-modal"
>
<OLModalHeader>
<OLModalTitle>{t('rename_project')}</OLModalTitle>
@@ -1,6 +1,7 @@
import ModalContentNewProjectForm from './modal-content-new-project-form'
import { OLModal } from '@/shared/components/ol/ol-modal'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type BlankProjectModalProps = {
onHide: () => void
@@ -8,6 +9,8 @@ type BlankProjectModalProps = {
}
function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
const themed = useFeatureFlag('themed-modals')
return (
<OLModal
show
@@ -15,6 +18,8 @@ function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
onHide={onHide}
id="blank-project-modal"
backdrop="static"
themed={themed}
className="project-list-modal"
>
<ModalContentNewProjectForm onCancel={onHide} initialTags={initialTags} />
</OLModal>
@@ -1,6 +1,7 @@
import { OLModal } from '@/shared/components/ol/ol-modal'
import ModalContentNewProjectForm from './modal-content-new-project-form'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type ExampleProjectModalProps = {
onHide: () => void
@@ -11,6 +12,8 @@ function ExampleProjectModal({
onHide,
initialTags,
}: ExampleProjectModalProps) {
const themed = useFeatureFlag('themed-modals')
return (
<OLModal
show
@@ -18,6 +21,8 @@ function ExampleProjectModal({
onHide={onHide}
id="example-project-modal"
backdrop="static"
themed={themed}
className="project-list-modal"
>
<ModalContentNewProjectForm
onCancel={onHide}
@@ -13,6 +13,8 @@ import OLButton from '@/shared/components/ol/ol-button'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import Notification from '@/shared/components/notification'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
function ImportDocumentModal({
type,
@@ -25,6 +27,8 @@ function ImportDocumentModal({
}) {
const { t } = useTranslation()
const [error, setError] = useState<string | null>(null)
const themed = useFeatureFlag('themed-modals')
const uppyTheme = useActiveOverallTheme('themed-modals')
const IMPORT_CONFIGS = useMemo(
() => ({
docx: {
@@ -69,6 +73,7 @@ function ImportDocumentModal({
onHide={onHide}
id="upload-project-modal"
backdrop="static"
themed={themed}
>
<OLModalHeader>
<OLModalTitle as="h3">{config.title}</OLModalTitle>
@@ -90,6 +95,7 @@ function ImportDocumentModal({
},
}}
className="project-list-upload-project-modal-uppy-dashboard"
theme={uppyTheme}
/>
</OLModalBody>
<OLModalFooter>
@@ -23,6 +23,7 @@ import { CloneProjectTag } from '@/features/clone-project-modal/components/clone
import { addProjectsToTag } from '@/features/project-list/util/api'
import { captureException } from '@/infrastructure/error-reporter'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type NewProjectData = {
project_id: string
@@ -53,6 +54,7 @@ function ModalContentNewProjectForm({
const [redirecting, setRedirecting] = useState(false)
const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
const location = useLocation()
const themed = useFeatureFlag('themed-modals')
const removeTag = useCallback((tag: Tag) => {
setProjectTags(value => value.filter(item => item._id !== tag._id))
@@ -128,6 +130,7 @@ function ModalContentNewProjectForm({
key={tag._id}
tag={tag}
removeTag={removeTag}
themed={themed}
/>
))}
</div>
@@ -9,6 +9,8 @@ import {
OLModalTitle,
} from '@/shared/components/ol/ol-modal'
import OLButton from '@/shared/components/ol/ol-button'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
@@ -21,6 +23,8 @@ function UploadProjectModal({
openProject: (id: string) => void
}) {
const { t } = useTranslation()
const themed = useFeatureFlag('themed-modals')
const uppyTheme = useActiveOverallTheme('themed-modals')
const uppy = useProjectUploader({
endpoint: '/project/new/upload',
@@ -35,6 +39,7 @@ function UploadProjectModal({
onHide={onHide}
id="upload-project-modal"
backdrop="static"
themed={themed}
>
<OLModalHeader>
<OLModalTitle as="h3">{t('upload_zipped_project')}</OLModalTitle>
@@ -54,6 +59,7 @@ function UploadProjectModal({
},
}}
className="project-list-upload-project-modal-uppy-dashboard"
theme={uppyTheme}
/>
</OLModalBody>
<OLModalFooter>
@@ -18,6 +18,7 @@ import {
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import getMeta from '@/utils/meta'
import { v4 as uuid } from 'uuid'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export const FAKE_EDITOR_ID = uuid()
@@ -150,9 +151,10 @@ function CompileErrorModal({
handleClose,
}: { project: Project } & { handleClose: () => void }) {
const { t } = useTranslation()
const themed = useFeatureFlag('themed-modals')
return (
<>
<OLModal show onHide={handleClose}>
<OLModal show onHide={handleClose} themed={themed}>
<OLModalHeader>
<OLModalTitle>
{project.name}: {t('pdf_unavailable_for_download')}
@@ -17,6 +17,7 @@
@import 'list';
@import 'select';
@import 'modal';
@import 'clone-project-modal';
@import 'quote';
@import 'footer';
@import 'nav';
@@ -0,0 +1,3 @@
.clone-project-modal .form-label {
color: var(--content-secondary-themed);
}
@@ -1,3 +1,21 @@
:root {
--project-list-upload-modal-hint-color: var(--neutral-60);
}
@include theme('default') {
.modal-themed {
--project-list-upload-modal-hint-color: var(--content-secondary-dark);
}
}
.project-list-modal .form-label {
color: var(--content-secondary-themed);
}
.project-list-action-modal {
@include themed-links;
}
.project-list-empty-col {
display: flex;
height: 100%;
@@ -594,7 +612,7 @@ ul.project-list-filters {
.uppy-Dashboard-AddFiles-title {
display: flex;
flex-direction: column;
color: var(--neutral-60);
color: var(--project-list-upload-modal-hint-color);
white-space: pre-line;
@include body-base;
@@ -706,7 +724,7 @@ form.project-search {
&:focus-visible {
box-shadow:
0 0 0 2px var(--white),
0 0 0 2px var(--bg-primary-themed),
0 0 0 3px var(--blue-50),
0 0 0 5px var(--blue-30);
}
@@ -5,6 +5,7 @@ import sinon from 'sinon'
import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
import { location } from '@/shared/components/location'
import * as projectListApi from '@/features/project-list/util/api'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<ModalContentNewProjectForm />', function () {
beforeEach(function () {
@@ -27,7 +28,11 @@ describe('<ModalContentNewProjectForm />', function () {
},
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
render(
<SplitTestProvider>
<ModalContentNewProjectForm onCancel={() => {}} />
</SplitTestProvider>
)
const createButton = screen.getByRole('button', {
name: 'Create',
@@ -67,10 +72,12 @@ describe('<ModalContentNewProjectForm />', function () {
.rejects(new Error('tag add failed'))
render(
<ModalContentNewProjectForm
onCancel={() => {}}
initialTags={[{ _id: 'tag-1', user_id: 'user-1', name: 'Tag 1' }]}
/>
<SplitTestProvider>
<ModalContentNewProjectForm
onCancel={() => {}}
initialTags={[{ _id: 'tag-1', user_id: 'user-1', name: 'Tag 1' }]}
/>
</SplitTestProvider>
)
fireEvent.change(screen.getByLabelText('Project name'), {
@@ -99,7 +106,11 @@ describe('<ModalContentNewProjectForm />', function () {
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
render(
<SplitTestProvider>
<ModalContentNewProjectForm onCancel={() => {}} />
</SplitTestProvider>
)
fireEvent.change(screen.getByLabelText('Project name'), {
target: { value: '/' },
@@ -125,7 +136,11 @@ describe('<ModalContentNewProjectForm />', function () {
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
render(
<SplitTestProvider>
<ModalContentNewProjectForm onCancel={() => {}} />
</SplitTestProvider>
)
fireEvent.change(screen.getByLabelText('Project name'), {
target: { value: '\\' },
@@ -151,7 +166,11 @@ describe('<ModalContentNewProjectForm />', function () {
body: errorMessage,
})
render(<ModalContentNewProjectForm onCancel={() => {}} />)
render(
<SplitTestProvider>
<ModalContentNewProjectForm onCancel={() => {}} />
</SplitTestProvider>
)
fireEvent.change(screen.getByLabelText('Project name'), {
target: {
@@ -1,4 +1,19 @@
import UploadProjectModal from '../../../../../../frontend/js/features/project-list/components/new-project-button/upload-project-modal'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
function mountModal() {
cy.mount(
<SplitTestProvider>
<UserSettingsProvider>
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
</UserSettingsProvider>
</SplitTestProvider>
)
}
describe('<UploadProjectModal />', function () {
const maxUploadSize = 10 * 1024 * 1024 // 10 MB
@@ -14,12 +29,7 @@ describe('<UploadProjectModal />', function () {
body: { success: true, project_id: '123abc' },
}).as('uploadProject')
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
mountModal()
cy.findByRole('button', {
name: 'Select a .zip file',
@@ -34,12 +44,7 @@ describe('<UploadProjectModal />', function () {
})
it('shows error on file type other than zip', function () {
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
mountModal()
cy.findByRole('button', {
name: 'Select a .zip file',
@@ -54,12 +59,7 @@ describe('<UploadProjectModal />', function () {
})
it('shows error for files bigger than maxUploadSize', function () {
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
mountModal()
const file = new File(['test'], 'test.zip', { type: 'application/zip' })
Object.defineProperty(file, 'size', { value: maxUploadSize + 1 })
@@ -82,12 +82,7 @@ describe('<UploadProjectModal />', function () {
body: { success: false },
}).as('uploadProject')
cy.mount(
<UploadProjectModal
onHide={cy.stub()}
openProject={cy.stub().as('openProject')}
/>
)
mountModal()
cy.findByRole('button', {
name: 'Select a .zip file',
@@ -10,6 +10,7 @@ import {
import fetchMock from 'fetch-mock'
import * as eventTracking from '@/infrastructure/event-tracking'
import getMeta from '@/utils/meta'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<CompileAndDownloadProjectPDFButton />', function () {
let sendMBSpy: sinon.SinonSpy
@@ -19,7 +20,9 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
this.locationWrapperSandbox = sinon.createSandbox()
this.locationWrapperStub = this.locationWrapperSandbox.stub(location)
render(
<CompileAndDownloadProjectPDFButtonTooltip project={projectsData[0]} />
<SplitTestProvider>
<CompileAndDownloadProjectPDFButtonTooltip project={projectsData[0]} />
</SplitTestProvider>
)
})
@@ -5,6 +5,7 @@ import {
ProjectListContext,
ProjectListContextValue,
} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const { deletableList, leavableList } = makeLongProjectList(40)
@@ -17,9 +18,11 @@ describe('<DeleteLeaveProjectsButton />', function () {
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<DeleteLeaveProjectsButton />
</ProjectListContext.Provider>
<SplitTestProvider>
<ProjectListContext.Provider value={value}>
<DeleteLeaveProjectsButton />
</ProjectListContext.Provider>
</SplitTestProvider>
)
const btn = screen.getByRole('button', { name: /delete \/ leave/i })
@@ -5,6 +5,7 @@ import {
ProjectListContext,
ProjectListContextValue,
} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const { deletableList } = makeLongProjectList(40)
@@ -17,9 +18,11 @@ describe('<DeleteProjectsButton />', function () {
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<DeleteProjectsButton />
</ProjectListContext.Provider>
<SplitTestProvider>
<ProjectListContext.Provider value={value}>
<DeleteProjectsButton />
</ProjectListContext.Provider>
</SplitTestProvider>
)
const btn = screen.getByRole('button', { name: /delete/i })
@@ -5,6 +5,7 @@ import {
ProjectListContext,
ProjectListContextValue,
} from '../../../../../../../../frontend/js/features/project-list/context/project-list-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const { leavableList } = makeLongProjectList(40)
@@ -17,9 +18,11 @@ describe('<LeaveProjectsButton />', function () {
} as ProjectListContextValue
render(
<ProjectListContext.Provider value={value}>
<LeaveProjectsButton />
</ProjectListContext.Provider>
<SplitTestProvider>
<ProjectListContext.Provider value={value}>
<LeaveProjectsButton />
</ProjectListContext.Provider>
</SplitTestProvider>
)
const btn = screen.getByRole('button', { name: /leave/i })
fireEvent.click(btn)