Merge pull request #36230 from overleaf/ii-workspaces-projects-page

Empty states for the project list views

GitOrigin-RevId: 3ce8e327bb675f3c63763e7599396020a4252025
This commit is contained in:
ilkin-overleaf
2026-09-17 12:00:54 +00:00
committed by Copybot
parent baa9cf78d9
commit 7cfdd0f75a
13 changed files with 466 additions and 41 deletions
@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import { Filter } from '@/features/project-list/context/project-list-context'
import NewProjectButton from '@/features/project-list/components/new-project-button'
import getMeta from '@/utils/meta'
type NoProjectsCommonProps = {
showNewProjectButton?: boolean
}
type NoProjectsProps = NoProjectsCommonProps & {
activeSection: Filter
}
function NoProjects({ activeSection, showNewProjectButton }: NoProjectsProps) {
const { t } = useTranslation()
const usersBestSubscription = getMeta('ol-usersBestSubscription')
const isGroupPlan = usersBestSubscription?.type === 'group'
return (
<div className="project-ds-empty-project-list">
<div className="project-ds-empty-project-list-inner">
<div className="project-ds-empty-project-list-icon">
{activeSection === 'all' && (
<MaterialIcon type="find_in_page" unfilled />
)}
{activeSection === 'owned' && <MaterialIcon type="person" unfilled />}
{activeSection === 'shared' && <MaterialIcon type="group" unfilled />}
{activeSection === 'archived' && (
<MaterialIcon type="archive" unfilled />
)}
{activeSection === 'trashed' && (
<MaterialIcon type="delete" unfilled />
)}
</div>
<div className="d-flex flex-column gap-1">
<div className="project-ds-empty-project-list-title">
{(activeSection === 'all' || activeSection === 'owned') &&
t('no_projects_yet')}
{activeSection === 'shared' && t('nothing_shared_with_you_yet')}
{activeSection === 'archived' && t('no_archived_projects')}
{activeSection === 'trashed' && t('no_trashed_projects')}
</div>
<div className="project-ds-empty-project-list-description">
{activeSection === 'all' &&
t('projects_you_own_or_have_access_to_will_appear_here')}
{activeSection === 'owned' &&
(isGroupPlan
? t(
'projects_you_create_across_all_your_workspaces_will_appear_here'
)
: t('projects_you_create_will_appear_here'))}
{activeSection === 'shared' &&
(isGroupPlan
? t(
'projects_shared_with_you_directly_or_in_shared_workspaces_will_appear_here'
)
: t('projects_shared_with_you_directly_will_appear_here'))}
{activeSection === 'archived' &&
t('projects_you_archive_will_appear_here')}
{activeSection === 'trashed' && (
<>
{t('projects_you_trash_will_appear_here')}
<br />
{t('you_can_restore_or_permanently_delete_them_at_any_time')}
</>
)}
</div>
</div>
{showNewProjectButton && (
<div className="text-center">
<NewProjectButton
id="new-project-button-no-projects"
showAddAffiliationWidget
/>
</div>
)}
</div>
</div>
)
}
export default NoProjects
@@ -24,8 +24,10 @@ import SystemMessages from '@/shared/components/system-messages'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
import CookieBanner from '@/shared/components/cookie-banner'
import NoProjects from '@/features/project-list/components/no-projects'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export function ProjectListDsNav() {
const navbarProps = getMeta('ol-navbar')
@@ -39,9 +41,11 @@ export function ProjectListDsNav() {
filter,
tags,
selectedTagId,
currentFilterProjectsCount,
} = useProjectListContext()
const activeOverallTheme = useActiveOverallTheme()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace')
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const showTrashHeader = isLibraryEnabled && filter === 'trashed'
@@ -142,49 +146,52 @@ export function ProjectListDsNav() {
</div>
</div>
{showTrashHeader && <TrashPageTabs activeTab="projects" />}
<div className="project-ds-nav-project-list">
<OLRow className="d-none d-md-flex align-items-center">
<OLCol md={isLibraryEnabled ? 8 : undefined} lg={7}>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
</OLCol>
{isLibraryEnabled && showNewProjectButton && (
<OLCol className="ms-auto" xs="auto">
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
align="end"
{isSharedWorkspaceEnabled &&
selectedTagId === undefined &&
currentFilterProjectsCount === 0 ? (
<>
<ProjectsToolbarMobile />
<NoProjects
activeSection={filter}
showNewProjectButton={showNewProjectButton}
/>
</>
) : (
<div className="project-ds-nav-project-list">
<OLRow className="d-none d-md-flex align-items-center">
<OLCol md={isLibraryEnabled ? 8 : undefined} 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')}
>
<NavigationDropdown activePage="projects" />
<SortByDropdown />
{isLibraryEnabled && showNewProjectButton && (
<OLCol className="ms-auto" xs="auto">
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
align="end"
/>
</OLCol>
)}
</OLRow>
<div className="project-list-sidebar-survey-wrapper d-md-none">
{/* Omit the survey card in mobile view for now */}
</div>
<ProjectsToolbarMobile />
<div className="mt-3">
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
</div>
</div>
<div className="mt-3">
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
</div>
</div>
)}
</main>
</div>
<Footer {...footerProps} />
@@ -195,3 +202,20 @@ export function ProjectListDsNav() {
</div>
)
}
function ProjectsToolbarMobile() {
const { t } = useTranslation()
return (
<div className="mt-1 d-md-none">
<div
role="toolbar"
className="projects-toolbar"
aria-label={t('projects')}
>
<NavigationDropdown activePage="projects" />
<SortByDropdown />
</div>
</div>
)
}
@@ -13,9 +13,11 @@ import {
OLDropdownMenu,
OLDropdownToggle,
} from '@/shared/components/ol/ol-dropdown-menu'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function TagsList() {
const { t } = useTranslation()
const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace')
const {
tags,
projectsPerTag,
@@ -40,7 +42,7 @@ export default function TagsList() {
aria-hidden="true"
data-testid="organize-projects"
>
{t('organize_tags')}
{isSharedWorkspaceEnabled ? t('your_tags') : t('organize_tags')}
</li>
<li className="tag">
<button type="button" className="tag-name" onClick={openCreateTagModal}>
@@ -7,6 +7,7 @@ import withContent, { SortBtnProps } from '../sort/with-content'
import OLTable from '@/shared/components/ol/ol-table'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import MaterialIcon from '@/shared/components/material-icon'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return (
@@ -30,7 +31,9 @@ function ProjectListTable() {
sort,
selectedProjects,
selectOrUnselectAllProjects,
searchText,
} = useProjectListContext()
const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace')
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
@@ -150,7 +153,9 @@ function ProjectListTable() {
) : (
<tr className="no-projects">
<td className="text-center" colSpan={5}>
{t('no_projects')}
{isSharedWorkspaceEnabled && searchText
? t('no_projects_match_query', { query: searchText })
: t('no_projects')}
</td>
</tr>
)}
@@ -89,6 +89,7 @@ export type ProjectListContextValue = {
tags: Tag[]
untaggedProjectsCount: number
projectsPerTag: Record<Tag['_id'], Project[]>
currentFilterProjectsCount: number
filter: Filter
selectFilter: (filter: Filter) => void
selectedTagId?: string | undefined
@@ -378,6 +379,16 @@ export function ProjectListProvider({
}, {})
}, [tags, loadedProjects])
// How many projects the current filter matches, ignoring the search text.
// `visibleProjects` can't answer "is this view empty?" on its own, because it
// is also narrowed by the search text (which persists across filter changes)
// and by pagination. Callers need the distinction to tell an empty view apart
// from a search that matched nothing.
const currentFilterProjectsCount = useMemo(
() => arrayFilter(loadedProjects, filters[filter]).length,
[loadedProjects, filter]
)
const selectFilter = useCallback(
(filter: Filter) => {
const url = getNavigationUrl({ type: 'filter', filter })
@@ -561,6 +572,7 @@ export function ProjectListProvider({
updateProjectViewData,
updateTag,
projectsPerTag,
currentFilterProjectsCount,
visibleProjects,
}),
[
@@ -600,6 +612,7 @@ export function ProjectListProvider({
updateProjectViewData,
updateTag,
projectsPerTag,
currentFilterProjectsCount,
visibleProjects,
]
)