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
@@ -577,6 +577,7 @@ async function projectListPage(req, res, next) {
'overleaf-library', 'overleaf-library',
'import-markdown', 'import-markdown',
'themed-modals', 'themed-modals',
'shared-workspace',
].filter(Boolean) ].filter(Boolean)
await Promise.all( await Promise.all(
@@ -1344,6 +1344,7 @@
"no_actions": "", "no_actions": "",
"no_add_on_purchase_while_paused": "", "no_add_on_purchase_while_paused": "",
"no_ai_assistant": "", "no_ai_assistant": "",
"no_archived_projects": "",
"no_audit_logs_found": "", "no_audit_logs_found": "",
"no_borders": "", "no_borders": "",
"no_caption": "", "no_caption": "",
@@ -1370,6 +1371,8 @@
"no_preview_available": "", "no_preview_available": "",
"no_project_notifications_description": "", "no_project_notifications_description": "",
"no_projects": "", "no_projects": "",
"no_projects_match_query": "",
"no_projects_yet": "",
"no_references_found": "", "no_references_found": "",
"no_references_found_in_this_file": "", "no_references_found_in_this_file": "",
"no_resolved_comments": "", "no_resolved_comments": "",
@@ -1379,6 +1382,7 @@
"no_selection_select_file": "", "no_selection_select_file": "",
"no_symbols_found": "", "no_symbols_found": "",
"no_thanks_cancel_now": "", "no_thanks_cancel_now": "",
"no_trashed_projects": "",
"no_trashed_references": "", "no_trashed_references": "",
"non_blinking_cursor": "", "non_blinking_cursor": "",
"normal": "", "normal": "",
@@ -1386,6 +1390,7 @@
"normally_x_price_per_year": "", "normally_x_price_per_year": "",
"not_managed": "", "not_managed": "",
"not_now": "", "not_now": "",
"nothing_shared_with_you_yet": "",
"notification": "", "notification": "",
"notification_personal_and_group_subscriptions": "", "notification_personal_and_group_subscriptions": "",
"notification_project_invite_accepted_message": "", "notification_project_invite_accepted_message": "",
@@ -1608,6 +1613,13 @@
"projects": "", "projects": "",
"projects_count": "", "projects_count": "",
"projects_list": "", "projects_list": "",
"projects_shared_with_you_directly_or_in_shared_workspaces_will_appear_here": "",
"projects_shared_with_you_directly_will_appear_here": "",
"projects_you_archive_will_appear_here": "",
"projects_you_create_across_all_your_workspaces_will_appear_here": "",
"projects_you_create_will_appear_here": "",
"projects_you_own_or_have_access_to_will_appear_here": "",
"projects_you_trash_will_appear_here": "",
"provide_details_of_your_sso_configuration": "", "provide_details_of_your_sso_configuration": "",
"provider_rate_limited": "", "provider_rate_limited": "",
"public": "", "public": "",
@@ -2573,6 +2585,7 @@
"you_can_now_log_in_sso": "", "you_can_now_log_in_sso": "",
"you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "", "you_can_now_search_and_add_references_from_your_rm_library_without_needing_to_import_files": "",
"you_can_only_be_affiliated_with_one_group": "", "you_can_only_be_affiliated_with_one_group": "",
"you_can_restore_or_permanently_delete_them_at_any_time": "",
"you_can_select_or_invite_collaborator": "", "you_can_select_or_invite_collaborator": "",
"you_can_select_or_invite_collaborator_plural": "", "you_can_select_or_invite_collaborator_plural": "",
"you_can_still_use_your_premium_features": "", "you_can_still_use_your_premium_features": "",
@@ -2639,6 +2652,7 @@
"your_subscription_will_pause_on": "", "your_subscription_will_pause_on": "",
"your_subscription_will_pause_on_short": "", "your_subscription_will_pause_on_short": "",
"your_subscriptions": "", "your_subscriptions": "",
"your_tags": "",
"youre_a_member_of_overleaf_labs": "", "youre_a_member_of_overleaf_labs": "",
"youre_about_to_enable_single_sign_on": "", "youre_about_to_enable_single_sign_on": "",
"youre_about_to_enable_single_sign_on_sso_only": "", "youre_about_to_enable_single_sign_on_sso_only": "",
@@ -5,6 +5,7 @@
export default /** @type {const} */ ([ export default /** @type {const} */ ([
'account_balance', 'account_balance',
'archive',
'arrow_back_ios_new', 'arrow_back_ios_new',
'arrow_circle_up', 'arrow_circle_up',
'auto_delete', 'auto_delete',
@@ -30,6 +31,7 @@ export default /** @type {const} */ ([
'edit_square', 'edit_square',
'error', 'error',
'experiment', 'experiment',
'find_in_page',
'find_replace', 'find_replace',
'format_strikethrough', 'format_strikethrough',
'forum', 'forum',
@@ -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 overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg' import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
import CookieBanner from '@/shared/components/cookie-banner' 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 { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import { isSplitTestEnabled } from '@/utils/splitTestUtils' import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export function ProjectListDsNav() { export function ProjectListDsNav() {
const navbarProps = getMeta('ol-navbar') const navbarProps = getMeta('ol-navbar')
@@ -39,9 +41,11 @@ export function ProjectListDsNav() {
filter, filter,
tags, tags,
selectedTagId, selectedTagId,
currentFilterProjectsCount,
} = useProjectListContext() } = useProjectListContext()
const activeOverallTheme = useActiveOverallTheme() const activeOverallTheme = useActiveOverallTheme()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library') const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace')
const selectedTag = tags.find(tag => tag._id === selectedTagId) const selectedTag = tags.find(tag => tag._id === selectedTagId)
const showTrashHeader = isLibraryEnabled && filter === 'trashed' const showTrashHeader = isLibraryEnabled && filter === 'trashed'
@@ -142,49 +146,52 @@ export function ProjectListDsNav() {
</div> </div>
</div> </div>
{showTrashHeader && <TrashPageTabs activeTab="projects" />} {showTrashHeader && <TrashPageTabs activeTab="projects" />}
<div className="project-ds-nav-project-list"> {isSharedWorkspaceEnabled &&
<OLRow className="d-none d-md-flex align-items-center"> selectedTagId === undefined &&
<OLCol md={isLibraryEnabled ? 8 : undefined} lg={7}> currentFilterProjectsCount === 0 ? (
<SearchForm <>
inputValue={searchText} <ProjectsToolbarMobile />
setInputValue={setSearchText} <NoProjects
filter={filter} activeSection={filter}
selectedTag={selectedTag} showNewProjectButton={showNewProjectButton}
/> />
</OLCol> </>
{isLibraryEnabled && showNewProjectButton && ( ) : (
<OLCol className="ms-auto" xs="auto"> <div className="project-ds-nav-project-list">
<NewProjectButton <OLRow className="d-none d-md-flex align-items-center">
id="new-project-button-projects-table" <OLCol md={isLibraryEnabled ? 8 : undefined} lg={7}>
showAddAffiliationWidget <SearchForm
align="end" inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/> />
</OLCol> </OLCol>
)} {isLibraryEnabled && showNewProjectButton && (
</OLRow> <OLCol className="ms-auto" xs="auto">
<div className="project-list-sidebar-survey-wrapper d-md-none"> <NewProjectButton
{/* Omit the survey card in mobile view for now */} id="new-project-button-projects-table"
</div> showAddAffiliationWidget
<div className="mt-1 d-md-none"> align="end"
<div />
role="toolbar" </OLCol>
className="projects-toolbar" )}
aria-label={t('projects')} </OLRow>
> <div className="project-list-sidebar-survey-wrapper d-md-none">
<NavigationDropdown activePage="projects" /> {/* Omit the survey card in mobile view for now */}
<SortByDropdown /> </div>
<ProjectsToolbarMobile />
<div className="mt-3">
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
</div> </div>
</div> </div>
<div className="mt-3"> )}
<TableContainer bordered>
{tableTopArea}
<ProjectListTable />
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
</div>
</div>
</main> </main>
</div> </div>
<Footer {...footerProps} /> <Footer {...footerProps} />
@@ -195,3 +202,20 @@ export function ProjectListDsNav() {
</div> </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, OLDropdownMenu,
OLDropdownToggle, OLDropdownToggle,
} from '@/shared/components/ol/ol-dropdown-menu' } from '@/shared/components/ol/ol-dropdown-menu'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function TagsList() { export default function TagsList() {
const { t } = useTranslation() const { t } = useTranslation()
const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace')
const { const {
tags, tags,
projectsPerTag, projectsPerTag,
@@ -40,7 +42,7 @@ export default function TagsList() {
aria-hidden="true" aria-hidden="true"
data-testid="organize-projects" data-testid="organize-projects"
> >
{t('organize_tags')} {isSharedWorkspaceEnabled ? t('your_tags') : t('organize_tags')}
</li> </li>
<li className="tag"> <li className="tag">
<button type="button" className="tag-name" onClick={openCreateTagModal}> <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 OLTable from '@/shared/components/ol/ol-table'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return ( return (
@@ -30,7 +31,9 @@ function ProjectListTable() {
sort, sort,
selectedProjects, selectedProjects,
selectOrUnselectAllProjects, selectOrUnselectAllProjects,
searchText,
} = useProjectListContext() } = useProjectListContext()
const isSharedWorkspaceEnabled = useFeatureFlag('shared-workspace')
const { handleSort } = useSort() const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null) const checkAllRef = useRef<HTMLInputElement>(null)
@@ -150,7 +153,9 @@ function ProjectListTable() {
) : ( ) : (
<tr className="no-projects"> <tr className="no-projects">
<td className="text-center" colSpan={5}> <td className="text-center" colSpan={5}>
{t('no_projects')} {isSharedWorkspaceEnabled && searchText
? t('no_projects_match_query', { query: searchText })
: t('no_projects')}
</td> </td>
</tr> </tr>
)} )}
@@ -89,6 +89,7 @@ export type ProjectListContextValue = {
tags: Tag[] tags: Tag[]
untaggedProjectsCount: number untaggedProjectsCount: number
projectsPerTag: Record<Tag['_id'], Project[]> projectsPerTag: Record<Tag['_id'], Project[]>
currentFilterProjectsCount: number
filter: Filter filter: Filter
selectFilter: (filter: Filter) => void selectFilter: (filter: Filter) => void
selectedTagId?: string | undefined selectedTagId?: string | undefined
@@ -378,6 +379,16 @@ export function ProjectListProvider({
}, {}) }, {})
}, [tags, loadedProjects]) }, [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( const selectFilter = useCallback(
(filter: Filter) => { (filter: Filter) => {
const url = getNavigationUrl({ type: 'filter', filter }) const url = getNavigationUrl({ type: 'filter', filter })
@@ -561,6 +572,7 @@ export function ProjectListProvider({
updateProjectViewData, updateProjectViewData,
updateTag, updateTag,
projectsPerTag, projectsPerTag,
currentFilterProjectsCount,
visibleProjects, visibleProjects,
}), }),
[ [
@@ -600,6 +612,7 @@ export function ProjectListProvider({
updateProjectViewData, updateProjectViewData,
updateTag, updateTag,
projectsPerTag, projectsPerTag,
currentFilterProjectsCount,
visibleProjects, visibleProjects,
] ]
) )
@@ -2,6 +2,7 @@
--ds-nav-active-bg: var(--bg-accent-03); --ds-nav-active-bg: var(--bg-accent-03);
--ds-nav-active-color: var(--green-60); --ds-nav-active-color: var(--green-60);
--theme-toggle-selected-background: var(--green-20); --theme-toggle-selected-background: var(--green-20);
--ds-nav-content-bg-primary: var(--bg-light-primary);
--ds-nav-content-bg-secondary: var(--bg-light-secondary); --ds-nav-content-bg-secondary: var(--bg-light-secondary);
--table-icon-bg-hover: 27 34 44; --table-icon-bg-hover: 27 34 44;
--themed-dashboard-popover-bg: var(--bg-dark-primary); --themed-dashboard-popover-bg: var(--bg-dark-primary);
@@ -15,6 +16,7 @@
--ds-nav-active-bg: var(--green-70); --ds-nav-active-bg: var(--green-70);
--ds-nav-active-color: var(--green-10); --ds-nav-active-color: var(--green-10);
--theme-toggle-selected-background: var(--green-70); --theme-toggle-selected-background: var(--green-70);
--ds-nav-content-bg-primary: var(--bg-dark-primary);
--ds-nav-content-bg-secondary: var(--bg-dark-secondary); --ds-nav-content-bg-secondary: var(--bg-dark-secondary);
--table-icon-bg-hover: 255 255 255; --table-icon-bg-hover: 255 255 255;
--themed-dashboard-popover-bg: var(--bg-light-primary); --themed-dashboard-popover-bg: var(--bg-light-primary);
@@ -404,12 +406,70 @@ body {
} }
.project-ds-nav-main { .project-ds-nav-main {
display: flex;
flex-direction: column;
min-height: 100%; min-height: 100%;
padding: var(--spacing-08) var(--spacing-06); padding: var(--spacing-08) var(--spacing-06);
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
padding: var(--spacing-08); padding: var(--spacing-08);
} }
main {
display: flex;
flex-direction: column;
flex: 1;
}
.project-ds-empty-project-list {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex: 1;
padding: var(--spacing-09);
background: var(--ds-nav-content-bg-primary);
border-radius: var(--border-radius-base);
@include media-breakpoint-down(md) {
margin-top: var(--spacing-06);
}
.project-ds-empty-project-list-inner {
display: flex;
flex-direction: column;
gap: var(--spacing-06);
max-width: 560px;
.project-ds-empty-project-list-icon {
display: flex;
justify-content: center;
align-items: center;
margin: 0 auto;
height: 80px;
width: 80px;
padding: var(--spacing-08);
border-radius: 50%;
background-color: var(--bg-secondary-themed);
color: var(--content-secondary-themed);
.material-symbols {
font-size: var(--font-size-08);
}
}
.project-ds-empty-project-list-title {
@include heading-sm;
text-align: center;
font-weight: 600;
}
.project-ds-empty-project-list-description {
text-align: center;
}
}
}
} }
.project-dash-table { .project-dash-table {
+14
View File
@@ -1735,6 +1735,7 @@
"no_actions": "No actions", "no_actions": "No actions",
"no_add_on_purchase_while_paused": "You need to unpause or cancel your existing subscription to buy an add-on.", "no_add_on_purchase_while_paused": "You need to unpause or cancel your existing subscription to buy an add-on.",
"no_ai_assistant": "No AI Assistant", "no_ai_assistant": "No AI Assistant",
"no_archived_projects": "No archived projects",
"no_articles_matching_your_tags": "There are no articles matching your tags", "no_articles_matching_your_tags": "There are no articles matching your tags",
"no_audit_logs_found": "No audit logs found", "no_audit_logs_found": "No audit logs found",
"no_borders": "No borders", "no_borders": "No borders",
@@ -1765,6 +1766,8 @@
"no_preview_available": "Sorry, no preview is available.", "no_preview_available": "Sorry, no preview is available.",
"no_project_notifications_description": "You won’t be notified about this project.", "no_project_notifications_description": "You won’t be notified about this project.",
"no_projects": "No projects", "no_projects": "No projects",
"no_projects_match_query": "No projects match “__query__”",
"no_projects_yet": "No projects yet",
"no_references_found": "No references found", "no_references_found": "No references found",
"no_references_found_in_this_file": "No references found in this file", "no_references_found_in_this_file": "No references found in this file",
"no_resolved_comments": "No resolved comments", "no_resolved_comments": "No resolved comments",
@@ -1774,6 +1777,7 @@
"no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.", "no_selection_select_file": "Currently, no file is selected. Please select a file from the file tree.",
"no_symbols_found": "No symbols found", "no_symbols_found": "No symbols found",
"no_thanks_cancel_now": "No thanks, I still want to cancel", "no_thanks_cancel_now": "No thanks, I still want to cancel",
"no_trashed_projects": "No trashed projects",
"no_trashed_references": "No references in Trash", "no_trashed_references": "No references in Trash",
"no_update_email": "No, update email", "no_update_email": "No, update email",
"non_blinking_cursor": "Non-blinking cursor", "non_blinking_cursor": "Non-blinking cursor",
@@ -1787,6 +1791,7 @@
"not_now": "Not now", "not_now": "Not now",
"not_registered": "Not registered", "not_registered": "Not registered",
"note_features_under_development": "<0>Please note</0> that features in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a premium plan</0>", "note_features_under_development": "<0>Please note</0> that features in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a premium plan</0>",
"nothing_shared_with_you_yet": "Nothing shared with you yet",
"notification": "Notification", "notification": "Notification",
"notification_features_upgraded_by_affiliation": "Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to all of Overleaf premium features.", "notification_features_upgraded_by_affiliation": "Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to all of Overleaf premium features.",
"notification_old_debug_projects": "You have some old debug projects that you should delete, if you no longer need them.", "notification_old_debug_projects": "You have some old debug projects that you should delete, if you no longer need them.",
@@ -2092,6 +2097,13 @@
"projects": "Projects", "projects": "Projects",
"projects_count": "Projects count", "projects_count": "Projects count",
"projects_list": "Projects list", "projects_list": "Projects list",
"projects_shared_with_you_directly_or_in_shared_workspaces_will_appear_here": "Projects shared with you directly, or ones you’ve joined in a shared workspace, will appear here.",
"projects_shared_with_you_directly_will_appear_here": "Projects shared with you directly will appear here.",
"projects_you_archive_will_appear_here": "Projects you archive will appear here. Archiving won’t affect anyone else working on the project.",
"projects_you_create_across_all_your_workspaces_will_appear_here": "Projects you create across all your workspaces will appear here.",
"projects_you_create_will_appear_here": "Projects you create will appear here.",
"projects_you_own_or_have_access_to_will_appear_here": "Projects you own or have access to will appear here.",
"projects_you_trash_will_appear_here": "Projects you trash will appear here.",
"provide_details_of_your_sso_configuration": "Add, edit, or delete your Identity Provider’s SAML metadata.", "provide_details_of_your_sso_configuration": "Add, edit, or delete your Identity Provider’s SAML metadata.",
"provider_rate_limited": "Your reference provider is temporarily rate limited. Please try again later.", "provider_rate_limited": "Your reference provider is temporarily rate limited. Please try again later.",
"pt": "Portuguese", "pt": "Portuguese",
@@ -3233,6 +3245,7 @@
"you_can_now_sign_in_with_new_password": "You can now sign in with your new password.", "you_can_now_sign_in_with_new_password": "You can now sign in with your new password.",
"you_can_only_be_affiliated_with_one_group": "You can only be affiliated with one group.", "you_can_only_be_affiliated_with_one_group": "You can only be affiliated with one group.",
"you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page", "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page",
"you_can_restore_or_permanently_delete_them_at_any_time": "You can restore or permanently delete them at any time.",
"you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.",
"you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.",
"you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.", "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.",
@@ -3307,6 +3320,7 @@
"your_subscription_will_pause_on": "Your <0>__planName__</0> subscription will pause on <0>__pauseDate__</0>. It’ll automatically unpause on <0>__reactivationDate__</0>. Or you can unpause it yourself at any time.", "your_subscription_will_pause_on": "Your <0>__planName__</0> subscription will pause on <0>__pauseDate__</0>. It’ll automatically unpause on <0>__reactivationDate__</0>. Or you can unpause it yourself at any time.",
"your_subscription_will_pause_on_short": "Your subscription will pause on <0>__pauseDate__</0>.", "your_subscription_will_pause_on_short": "Your subscription will pause on <0>__pauseDate__</0>.",
"your_subscriptions": "Your subscriptions", "your_subscriptions": "Your subscriptions",
"your_tags": "Your tags",
"youre_a_member_of_overleaf_labs": "You’re a member of Overleaf Labs. Don’t forget to check in regularly to see what experiments you can sign up to.", "youre_a_member_of_overleaf_labs": "You’re a member of Overleaf Labs. Don’t forget to check in regularly to see what experiments you can sign up to.",
"youre_about_to_enable_single_sign_on": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct and all your group members have managed user accounts.", "youre_about_to_enable_single_sign_on": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct and all your group members have managed user accounts.",
"youre_about_to_enable_single_sign_on_sso_only": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct.", "youre_about_to_enable_single_sign_on_sso_only": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct.",
@@ -0,0 +1,153 @@
import NoProjects from '@/features/project-list/components/no-projects'
import {
Filter,
ProjectListProvider,
} from '@/features/project-list/context/project-list-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<NoProjects />', function () {
beforeEach(function () {
cy.intercept('post', '/api/project', {
body: { projects: [], totalSize: 0 },
})
cy.window().then(win => {
win.metaAttributesCache.set('ol-ExposedSettings', {
isOverleaf: true,
templateLinks: [],
})
})
})
const setGroupPlan = () =>
cy.window().then(win => {
win.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'group',
})
})
const setIndividualPlan = () =>
cy.window().then(win => {
win.metaAttributesCache.set('ol-usersBestSubscription', {
type: 'individual',
})
})
const mountNoProjects = (
activeSection: Filter,
showNewProjectButton = false
) =>
cy.mount(
<SplitTestProvider>
<ProjectListProvider>
<NoProjects
activeSection={activeSection}
showNewProjectButton={showNewProjectButton}
/>
</ProjectListProvider>
</SplitTestProvider>
)
describe('all projects', function () {
it('shows the generic empty state', function () {
mountNoProjects('all')
cy.findByText('No projects yet').should('be.visible')
cy.findByText(
'Projects you own or have access to will appear here.'
).should('be.visible')
})
})
describe('your projects', function () {
it('shows the non-group copy when the user is not on a group plan', function () {
setIndividualPlan()
mountNoProjects('owned')
cy.findByText('No projects yet').should('be.visible')
cy.findByText('Projects you create will appear here.').should(
'be.visible'
)
cy.findByText(
'Projects you create across all your workspaces will appear here.'
).should('not.exist')
})
it('shows the workspaces copy when the user is on a group plan', function () {
setGroupPlan()
mountNoProjects('owned')
cy.findByText('No projects yet').should('be.visible')
cy.findByText(
'Projects you create across all your workspaces will appear here.'
).should('be.visible')
cy.findByText('Projects you create will appear here.').should('not.exist')
})
})
describe('shared with you', function () {
it('shows the non-group copy when the user is not on a group plan', function () {
setIndividualPlan()
mountNoProjects('shared')
cy.findByText('Nothing shared with you yet').should('be.visible')
cy.findByText(
'Projects shared with you directly will appear here.'
).should('be.visible')
cy.findByText(
'Projects shared with you directly, or ones you’ve joined in a shared workspace, will appear here.'
).should('not.exist')
})
it('shows the workspaces copy when the user is on a group plan', function () {
setGroupPlan()
mountNoProjects('shared')
cy.findByText('Nothing shared with you yet').should('be.visible')
cy.findByText(
'Projects shared with you directly, or ones you’ve joined in a shared workspace, will appear here.'
).should('be.visible')
cy.findByText(
'Projects shared with you directly will appear here.'
).should('not.exist')
})
})
describe('archived projects', function () {
it('shows the archived empty state', function () {
mountNoProjects('archived')
cy.findByText('No archived projects').should('be.visible')
cy.findByText(
'Projects you archive will appear here. Archiving won’t affect anyone else working on the project.'
).should('be.visible')
})
})
describe('trashed projects', function () {
it('shows the trashed empty state with the restore hint', function () {
mountNoProjects('trashed')
cy.findByText('No trashed projects').should('be.visible')
cy.findByText(/projects you trash will appear here/i).should('be.visible')
cy.findByText(
/you can restore or permanently delete them at any time/i
).should('be.visible')
})
})
describe('new project button', function () {
it('shows the button when showNewProjectButton is true', function () {
mountNoProjects('all', true)
cy.findByRole('button', { name: 'New project' }).should('be.visible')
})
it('hides the button when showNewProjectButton is false', function () {
mountNoProjects('all', false)
cy.findByText('No projects yet').should('be.visible')
cy.findByRole('button', { name: 'New project' }).should('not.exist')
})
})
})
@@ -1,6 +1,8 @@
import { screen, within, fireEvent, waitFor } from '@testing-library/react' import { screen, within, fireEvent, waitFor } from '@testing-library/react'
import { expect } from 'chai' import { expect } from 'chai'
import { useEffect } from 'react'
import ProjectListTable from '../../../../../../frontend/js/features/project-list/components/table/project-list-table' import ProjectListTable from '../../../../../../frontend/js/features/project-list/components/table/project-list-table'
import { useProjectListContext } from '@/features/project-list/context/project-list-context'
import { currentProjects } from '../../fixtures/projects-data' import { currentProjects } from '../../fixtures/projects-data'
import fetchMock from 'fetch-mock' import fetchMock from 'fetch-mock'
import { renderWithProjectListContext } from '../../helpers/render-with-context' import { renderWithProjectListContext } from '../../helpers/render-with-context'
@@ -236,4 +238,56 @@ describe('<ProjectListTable />', function () {
const allCheckboxesChecked = allCheckboxes.filter(c => c.checked) const allCheckboxesChecked = allCheckboxes.filter(c => c.checked)
expect(allCheckboxesChecked.length).to.equal(1) expect(allCheckboxesChecked.length).to.equal(1)
}) })
describe('when no projects are visible', function () {
function SetSearchText({ value }: { value: string }) {
const { setSearchText } = useProjectListContext()
useEffect(() => {
setSearchText(value)
}, [setSearchText, value])
return null
}
const renderWithSearchText = (
value: string,
options?: { projects?: typeof currentProjects }
) =>
renderWithProjectListContext(
<>
<SetSearchText value={value} />
<ProjectListTable />
</>,
options
)
it('names the search query when the shared-workspace split test is enabled', async function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'shared-workspace': 'enabled',
})
renderWithSearchText('Literature review')
await fetchMock.callHistory.flush(true)
await screen.findByText('No projects match “Literature review”')
})
it('does not name the search query when the split test is disabled', async function () {
window.metaAttributesCache.set('ol-splitTestVariants', {})
renderWithSearchText('Literature review')
await fetchMock.callHistory.flush(true)
await screen.findByText('No projects')
expect(screen.queryByText('No projects match “Literature review”')).to.be
.null
})
it('shows the generic message when there is no search query', async function () {
window.metaAttributesCache.set('ol-splitTestVariants', {
'shared-workspace': 'enabled',
})
renderWithSearchText('', { projects: [] })
await fetchMock.callHistory.flush(true)
await screen.findByText('No projects')
})
})
}) })