From e12c93c537109bab6d5afac0958a643d08b290cc Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Fri, 23 Sep 2022 11:37:02 +0300 Subject: [PATCH] Merge pull request #9700 from overleaf/ii-dashboard-mobile-view [web] Projects dashboard mobile view GitOrigin-RevId: 84894e19c814a2cc1ce751181952c0ade6b62044 --- .../web/app/views/layout/navbar-marketing.pug | 21 +- .../web/frontend/extracted-translations.json | 14 +- .../current-plan-widget/free-plan.tsx | 6 +- .../components/dropdown/actions-dropdown.tsx | 211 ++++++++++++++++ .../components/dropdown/menu-item-button.tsx | 24 ++ .../components/dropdown/projects-dropdown.tsx | 108 ++++++++ .../components/dropdown/sort-by-dropdown.tsx | 83 +++++++ .../modals/archive-project-modal.tsx | 42 ++++ .../{sidebar => modals}/create-tag-modal.tsx | 10 +- .../modals/delete-project-modal.tsx | 37 +++ .../{sidebar => modals}/delete-tag-modal.tsx | 18 +- .../components/modals/edit-tag-modal.tsx | 128 ++++++++++ .../components/modals/leave-project-modal.tsx | 37 +++ .../projects-action-modal.tsx | 58 +---- .../{sidebar => modals}/rename-tag-modal.tsx | 24 +- .../components/modals/trash-project-modal.tsx | 42 ++++ .../components/new-project-button.tsx | 15 +- .../components/project-list-root.tsx | 63 +++-- .../components/projects-filter-menu.ts | 15 ++ .../project-list/components/search-form.tsx | 53 ++-- .../components/sidebar/sidebar-filters.tsx | 27 +- .../components/sidebar/tags-list.tsx | 110 ++------ .../components/sort/with-content.tsx | 45 ++++ .../action-buttons/archive-project-button.tsx | 55 ++-- .../action-buttons/copy-project-button.tsx | 49 ++-- .../action-buttons/delete-project-button.tsx | 54 ++-- .../download-project-button.tsx | 51 ++-- ...t-buttton.tsx => leave-project-button.tsx} | 56 +++-- .../action-buttons/trash-project-button.tsx | 52 ++-- .../unarchive-project-button.tsx | 54 ++-- .../action-buttons/untrash-project-button.tsx | 52 ++-- .../components/table/cells/actions-cell.tsx | 33 +-- .../components/table/cells/inline-tags.tsx | 32 ++- .../components/table/cells/owner-cell.tsx | 15 +- .../table/project-list-table-row.tsx | 34 ++- .../components/table/project-list-table.tsx | 233 ++++++++--------- .../buttons/archive-projects-button.tsx | 7 +- .../buttons/trash-projects-button.tsx | 7 +- .../project-list/components/tags-list.tsx | 111 +++++++++ .../components/welcome-message.tsx | 5 +- .../context/project-list-context.tsx | 3 + .../features/project-list/hooks/use-sort.ts | 22 ++ .../features/project-list/hooks/use-tag.tsx | 161 ++++++++++++ .../shared/components/controlled-dropdown.js | 1 + .../new-project-button.stories.tsx | 4 +- .../stylesheets/app/project-list-react.less | 235 ++++++++++++++++++ .../stylesheets/app/project-list.less | 1 + .../stylesheets/components/navbar.less | 7 +- .../frontend/stylesheets/core/spacing.less | 16 ++ .../web/frontend/stylesheets/core/type.less | 5 +- .../frontend/stylesheets/core/utilities.less | 14 ++ services/web/locales/en.json | 5 + .../components/new-project-button.test.tsx | 2 +- .../components/project-list-root.test.tsx | 16 +- .../components/project-search.test.tsx | 27 +- .../components/sidebar/tags-list.test.tsx | 4 +- .../archive-project-button.test.tsx | 12 +- .../copy-project-button.test.tsx | 12 +- .../delete-project-button.test.tsx | 12 +- .../download-project-button.test.tsx | 4 +- .../leave-project-button.test.tsx | 14 +- .../trash-project-button.test.tsx | 10 +- .../unarchive-project-button.test.tsx | 12 +- .../untrash-project-button.test.tsx | 10 +- .../table/project-list-table.test.tsx | 10 +- .../table/projects-action-modal.test.tsx | 2 +- ...ith-context.js => render-with-context.tsx} | 16 +- 67 files changed, 2089 insertions(+), 639 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/dropdown/actions-dropdown.tsx create mode 100644 services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx create mode 100644 services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx create mode 100644 services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx create mode 100644 services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx rename services/web/frontend/js/features/project-list/components/{sidebar => modals}/create-tag-modal.tsx (95%) create mode 100644 services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx rename services/web/frontend/js/features/project-list/components/{sidebar => modals}/delete-tag-modal.tsx (81%) create mode 100644 services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx rename services/web/frontend/js/features/project-list/components/{table => modals}/projects-action-modal.tsx (65%) rename services/web/frontend/js/features/project-list/components/{sidebar => modals}/rename-tag-modal.tsx (83%) create mode 100644 services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx create mode 100644 services/web/frontend/js/features/project-list/components/projects-filter-menu.ts create mode 100644 services/web/frontend/js/features/project-list/components/sort/with-content.tsx rename services/web/frontend/js/features/project-list/components/table/cells/action-buttons/{leave-project-buttton.tsx => leave-project-button.tsx} (60%) create mode 100644 services/web/frontend/js/features/project-list/components/tags-list.tsx create mode 100644 services/web/frontend/js/features/project-list/hooks/use-sort.ts create mode 100644 services/web/frontend/js/features/project-list/hooks/use-tag.tsx rename services/web/test/frontend/features/project-list/helpers/{render-with-context.js => render-with-context.tsx} (66%) diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index d679b847ab..2aee392da9 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -9,6 +9,17 @@ nav.navbar.navbar-default.navbar-main aria-label="Toggle " + translate('navigation') ) i.fa.fa-bars(aria-hidden="true") + if (usersBestSubscription && usersBestSubscription.type === 'free') + a.btn.btn-primary.pull-right.me-2.visible-xs( + href="/user/subscription/plans" + event-tracking="upgrade-button-click" + event-tracking-mb="true" + event-tracking-ga="subscription-funnel" + event-tracking-action="dashboard-top" + event-tracking-label="upgrade" + event-tracking-trigger="click" + event-segmentation='{"source": "dashboard-top"}' + ) #{translate("upgrade")} if settings.nav.custom_logo a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand else if (nav.title) @@ -57,9 +68,9 @@ nav.navbar.navbar-default.navbar-main each item in ((splitTestVariants && (splitTestVariants['unified-navigation'] === 'show-unified-navigation')) ? nav.header_extras_unified : nav.header_extras) - if ((item.only_when_logged_in && getSessionUser()) - || (item.only_when_logged_out && (!getSessionUser())) - || (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages) - || (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks)) + || (item.only_when_logged_out && (!getSessionUser())) + || (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages) + || (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks)) ){ var showNavItem = true } else { @@ -82,7 +93,7 @@ nav.navbar.navbar-default.navbar-main each child in item.dropdown if child.divider li.divider - if child.splitTest + if child.splitTest if (splitTestVariants && (splitTestVariants[child.splitTest.name] === child.splitTest.variant)) li if child.url @@ -99,7 +110,7 @@ nav.navbar.navbar-default.navbar-main li if child.url a( - href=child.url, + href=child.url, class=child.class, event-tracking=child.event event-tracking-mb="true" diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 59bbc42c89..80cd045a94 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -153,6 +153,7 @@ "edit_dictionary": "", "edit_dictionary_empty": "", "edit_dictionary_remove": "", + "edit_folder": "", "editing": "", "editor_and_pdf": "", "editor_only_hide_pdf": "", @@ -209,6 +210,7 @@ "galileo_insert_instruction_button": "", "galileo_insert_math_button": "", "galileo_is": "", + "galileo_only_available_in_cm6": "", "galileo_promo_autocomplete_content": "", "galileo_promo_autocomplete_title": "", "galileo_promo_shadow_text_content": "", @@ -218,7 +220,6 @@ "galileo_suggestion_feedback_button": "", "galileo_suggestions_loading_error": "", "galileo_toggle_description": "", - "galileo_only_available_in_cm6": "", "generic_linked_file_compile_error": "", "generic_something_went_wrong": "", "get_collaborative_benefits": "", @@ -303,6 +304,9 @@ "is_email_affiliated": "", "join_project": "", "joining": "", + "labs_program_already_participating": "", + "labs_program_benefits": "<0>", + "labs_program_not_participating": "", "last_modified": "", "last_name": "", "last_updated_date_by_x": "", @@ -353,6 +357,7 @@ "make_private": "", "manage_beta_program_membership": "", "manage_files_from_your_dropbox_folder": "", + "manage_labs_program_membership": "", "manage_newsletter": "", "manage_sessions": "", "math_display": "", @@ -410,10 +415,7 @@ "other_logs_and_files": "", "other_output_files": "", "overleaf_labs": "", - "labs_program_benefits": "", - "labs_program_already_participating": "", - "labs_program_not_participating": "", - "manage_labs_program_membership": "", + "owned_by": "", "owner": "", "page_current": "", "pagination_navigation": "", @@ -511,6 +513,7 @@ "revoke": "", "revoke_invite": "", "role": "", + "save_changes": "", "save_or_cancel-cancel": "", "save_or_cancel-or": "", "save_or_cancel-save": "", @@ -569,6 +572,7 @@ "something_went_wrong_rendering_pdf": "", "something_went_wrong_server": "", "somthing_went_wrong_compiling": "", + "sort_by": "", "sort_by_x": "", "sso_link_error": "", "start_by_adding_your_email": "", diff --git a/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx b/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx index 161ea38528..0f0122ecb2 100644 --- a/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx +++ b/services/web/frontend/js/features/project-list/components/current-plan-widget/free-plan.tsx @@ -13,6 +13,9 @@ function FreePlan() { return ( <> + + }} /> + }} />{' '} @@ -28,6 +31,7 @@ function FreePlan() { {' '} + {afterNode} + + ) +} diff --git a/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx new file mode 100644 index 0000000000..20ed1c03c8 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown, MenuItem } from 'react-bootstrap' +import Icon from '../../../../shared/components/icon' +import { + Filter, + UNCATEGORIZED_KEY, + useProjectListContext, +} from '../../context/project-list-context' +import ProjectsFilterMenu from '../projects-filter-menu' +import TagsList from '../tags-list' +import MenuItemButton from './menu-item-button' + +type ItemProps = { + filter: Filter + text: string + onClick: () => void +} + +export function Item({ filter, text, onClick }: ItemProps) { + const { selectFilter } = useProjectListContext() + const handleClick = () => { + selectFilter(filter) + onClick() + } + + return ( + + {isActive => ( + + {isActive ? ( + + ) : null} + {text} + + )} + + ) +} + +function ProjectsDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('all_projects')) + const [isOpened, setIsOpened] = useState(false) + const { filter, selectedTagId, tags } = useProjectListContext() + const filterTranslations = useRef>({ + all: t('all_projects'), + owned: t('your_projects'), + shared: t('shared_with_you'), + archived: t('archived_projects'), + trashed: t('trashed_projects'), + }) + const handleClose = () => setIsOpened(false) + + useEffect(() => { + if (selectedTagId === undefined) { + setTitle(filterTranslations.current[filter]) + } + + if (selectedTagId === UNCATEGORIZED_KEY) { + setTitle(t('uncategorized')) + } else { + const tag = tags.find(({ _id: id }) => id === selectedTagId) + + if (tag) { + setTitle(tag.name) + } + } + }, [filter, tags, selectedTagId, t]) + + return ( + setIsOpened(open)} + > + + {title} + + + + + + + + {t('tags_slash_folders')}: + + + + ) +} + +export default ProjectsDropdown diff --git a/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx new file mode 100644 index 0000000000..0b7de56a99 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/dropdown/sort-by-dropdown.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown, MenuItem } from 'react-bootstrap' +import Icon from '../../../../shared/components/icon' +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 MenuItemButton from './menu-item-button' + +function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) { + return ( + + {iconType ? ( + + ) : null} + {text} + {screenReaderText} + + ) +} + +const ItemWithContent = withContent(Item) + +function SortByDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('last_modified')) + const [isOpened, setIsOpened] = useState(false) + const { sort } = useProjectListContext() + const { handleSort } = useSort() + const sortByTranslations = useRef>({ + title: t('title'), + owner: t('owner'), + lastUpdated: t('last_modified'), + }) + + const handleClick = (by: Sort['by']) => { + setTitle(sortByTranslations.current[by]) + setIsOpened(false) + handleSort(by) + } + + useEffect(() => { + setTitle(sortByTranslations.current[sort.by]) + }, [sort.by]) + + return ( + setIsOpened(open)} + > + + {title} + + + {t('sort_by')}: + handleClick('title')} + /> + handleClick('owner')} + /> + handleClick('lastUpdated')} + /> + + + ) +} + +export default SortByDropdown diff --git a/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx new file mode 100644 index 0000000000..da03aafdcb --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/archive-project-modal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' + +type ArchiveProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function ArchiveProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: ArchiveProjectModalProps) { + const { t } = useTranslation() + + return ( + {t('about_to_archive_projects')}

} + bodyBottom={ +

+ {t('archiving_projects_wont_affect_collaborators')}{' '} + + {t('find_out_more_nt')} + +

+ } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={projects} + /> + ) +} + +export default ArchiveProjectModal diff --git a/services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx similarity index 95% rename from services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx rename to services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx index 569d62a264..3b71974366 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/create-tag-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/create-tag-modal.tsx @@ -8,12 +8,14 @@ import { createTag } from '../../util/api' import { MAX_TAG_LENGTH } from '../../util/tag' type CreateTagModalProps = { + id: string show: boolean onCreate: (tag: Tag) => void onClose: () => void } export default function CreateTagModal({ + id, show, onCreate, onClose, @@ -55,13 +57,7 @@ export default function CreateTagModal({ } return ( - + {t('create_new_folder')} diff --git a/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx new file mode 100644 index 0000000000..4fb6260f4c --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/delete-project-modal.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import Icon from '../../../../shared/components/icon' + +type DeleteProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function DeleteProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: DeleteProjectModalProps) { + const { t } = useTranslation() + + return ( + {t('about_to_delete_projects')}

} + bodyBottom={ +
+ {' '} + {t('this_action_cannot_be_undone')} +
+ } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={projects} + /> + ) +} + +export default DeleteProjectModal diff --git a/services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx similarity index 81% rename from services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx rename to services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx index f362f91ab2..1d04dfb391 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/delete-tag-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/delete-tag-modal.tsx @@ -7,18 +7,20 @@ import useAsync from '../../../../shared/hooks/use-async' import { deleteTag } from '../../util/api' 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 { isError, runAsync, status } = useAsync() + const { isLoading, isError, runAsync } = useAsync() const runDeleteTag = useCallback( (tagId: string) => { @@ -36,13 +38,7 @@ export default function DeleteTagModal({ } return ( - + {t('delete_folder')} @@ -62,15 +58,15 @@ export default function DeleteTagModal({ )} - diff --git a/services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx new file mode 100644 index 0000000000..f575057da5 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/edit-tag-modal.tsx @@ -0,0 +1,128 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Form, Modal } from 'react-bootstrap' +import AccessibleModal from '../../../../shared/components/accessible-modal' +import useAsync from '../../../../shared/hooks/use-async' +import { deleteTag, renameTag } from '../../util/api' +import { Tag } from '../../../../../../app/src/Features/Tags/types' + +type EditTagModalProps = { + id: string + tag?: Tag + onRename: (tagId: string, newTagName: string) => void + onDelete: (tagId: string) => void + onClose: () => void +} + +export default function EditTagModal({ + id, + tag, + onRename, + onDelete, + onClose, +}: EditTagModalProps) { + const { t } = useTranslation() + const { + isLoading: isDeleteLoading, + isError: isDeleteError, + runAsync: runDeleteAsync, + } = useAsync() + const { + isLoading: isRenameLoading, + isError: isRenameError, + runAsync: runRenameAsync, + } = useAsync() + const [newTagName, setNewTagName] = useState() + + const runDeleteTag = useCallback( + (tagId: string) => { + runDeleteAsync(deleteTag(tagId)) + .then(() => { + onDelete(tagId) + }) + .catch(console.error) + }, + [runDeleteAsync, onDelete] + ) + + const runRenameTag = useCallback( + (tagId: string) => { + if (newTagName) { + runRenameAsync(renameTag(tagId, newTagName)) + .then(() => onRename(tagId, newTagName)) + .catch(console.error) + } + }, + [runRenameAsync, newTagName, onRename] + ) + + const handleSubmit = useCallback( + e => { + e.preventDefault() + if (tag) { + runRenameTag(tag._id) + } + }, + [tag, runRenameTag] + ) + + if (!tag) { + return null + } + + return ( + + + {t('edit_folder')} + + + +
+ setNewTagName(e.target.value)} + /> +
+
+ + +
+
+ +
+ + +
+ {(isDeleteError || isRenameError) && ( +
+ + {t('generic_something_went_wrong')} + +
+ )} +
+
+ ) +} diff --git a/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx new file mode 100644 index 0000000000..f708caa01d --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/leave-project-modal.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import Icon from '../../../../shared/components/icon' + +type LeaveProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function LeaveProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: LeaveProjectModalProps) { + const { t } = useTranslation() + + return ( + {t('about_to_leave_projects')}

} + bodyBottom={ +
+ {' '} + {t('this_action_cannot_be_undone')} +
+ } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={projects} + /> + ) +} + +export default LeaveProjectModal diff --git a/services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx similarity index 65% rename from services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx rename to services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx index 87acb89e02..7c9df72b77 100644 --- a/services/web/frontend/js/features/project-list/components/table/projects-action-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/projects-action-modal.tsx @@ -6,24 +6,28 @@ import AccessibleModal from '../../../../shared/components/accessible-modal' import { getUserFacingMessage } from '../../../../infrastructure/fetch-json' import useIsMounted from '../../../../shared/hooks/use-is-mounted' import * as eventTracking from '../../../../infrastructure/event-tracking' -import Icon from '../../../../shared/components/icon' type ProjectsActionModalProps = { + title?: string action: 'archive' | 'trash' | 'delete' | 'leave' actionHandler: (project: Project) => Promise handleCloseModal: () => void + bodyTop?: React.ReactNode + bodyBottom?: React.ReactNode projects: Array showModal: boolean } function ProjectsActionModal({ + title, action, actionHandler, handleCloseModal, + bodyTop, + bodyBottom, showModal, projects, }: ProjectsActionModalProps) { - let bodyTop, bodyBottom, title const { t } = useTranslation() const [errors, setErrors] = useState>([]) const [isProcessing, setIsProcessing] = useState(false) @@ -63,56 +67,6 @@ function ProjectsActionModal({ } }, [action, showModal]) - if (action === 'archive') { - title = t('archive_projects') - bodyTop =

{t('about_to_archive_projects')}

- bodyBottom = ( -

- {t('archiving_projects_wont_affect_collaborators')}{' '} - - {t('find_out_more_nt')} - -

- ) - } else if (action === 'leave') { - title = t('leave_projects') - bodyTop =

{t('about_to_leave_projects')}

- bodyBottom = ( -
- {' '} - {t('this_action_cannot_be_undone')} -
- ) - } else if (action === 'trash') { - title = t('trash_projects') - bodyTop =

{t('about_to_trash_projects')}

- bodyBottom = ( -

- {t('trashing_projects_wont_affect_collaborators')}{' '} - - {t('find_out_more_nt')} - -

- ) - } else if (action === 'delete') { - title = t('delete_projects') - bodyTop =

{t('about_to_delete_projects')}

- bodyBottom = ( -
- {' '} - {t('this_action_cannot_be_undone')} -
- ) - } - return ( void onClose: () => void } export default function RenameTagModal({ + id, tag, onRename, onClose, }: RenameTagModalProps) { const { t } = useTranslation() - const { isError, runAsync, status } = useAsync() + const { isLoading, isError, runAsync } = useAsync() - const [newTagName, setNewTageName] = useState() + const [newTagName, setNewTagName] = useState() const [validationError, setValidationError] = useState() const runRenameTag = useCallback( @@ -60,13 +62,7 @@ export default function RenameTagModal({ } return ( - + {t('rename_folder')} @@ -80,7 +76,7 @@ export default function RenameTagModal({ name="new-tag-name" value={newTagName === undefined ? tag.name : newTagName} required - onChange={e => setNewTageName(e.target.value)} + onChange={e => setNewTagName(e.target.value)} /> @@ -98,17 +94,15 @@ export default function RenameTagModal({ )} - diff --git a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx new file mode 100644 index 0000000000..7a3962e937 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' + +type TrashProjectPropsModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function TrashProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: TrashProjectPropsModalProps) { + const { t } = useTranslation() + + return ( + {t('about_to_trash_projects')}

} + bodyBottom={ +

+ {t('trashing_projects_wont_affect_collaborators')}{' '} + + {t('find_out_more_nt')} + +

+ } + showModal={showModal} + handleCloseModal={handleCloseModal} + projects={projects} + /> + ) +} + +export default TrashProjectModal diff --git a/services/web/frontend/js/features/project-list/components/new-project-button.tsx b/services/web/frontend/js/features/project-list/components/new-project-button.tsx index 7d848d2604..2fbdc3551b 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button.tsx @@ -7,8 +7,19 @@ import getMeta from '../../../utils/meta' import NewProjectButtonModal, { NewProjectButtonModalVariant, } from './new-project-button/new-project-button-modal' +import { Nullable } from '../../../../../types/utils' -function NewProjectButton({ buttonText }: { buttonText?: string }) { +type NewProjectButtonProps = { + id: string + buttonText?: string + className?: string +} + +function NewProjectButton({ + id, + buttonText, + className, +}: NewProjectButtonProps) { const { t } = useTranslation() const { templateLinks } = getMeta('ol-ExposedSettings') as ExposedSettings const [modal, setModal] = @@ -16,7 +27,7 @@ function NewProjectButton({ buttonText }: { buttonText?: string }) { return ( <> - + ) : ( -
-
+
+
{error ? : ''} {totalProjectsCount > 0 ? ( <> - +
- - +
+
- - + + - +
- {selectedProjects.length === 0 ? ( +
+ {selectedProjects.length === 0 ? ( + + ) : ( + + )} +
+
- ) : ( - - )} +
+
+
+ + +
+
- +
+
+
+ + +
+
+ +
- +
) : ( diff --git a/services/web/frontend/js/features/project-list/components/projects-filter-menu.ts b/services/web/frontend/js/features/project-list/components/projects-filter-menu.ts new file mode 100644 index 0000000000..7c45debd95 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/projects-filter-menu.ts @@ -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 diff --git a/services/web/frontend/js/features/project-list/components/search-form.tsx b/services/web/frontend/js/features/project-list/components/search-form.tsx index fb3c70c463..c15a92316f 100644 --- a/services/web/frontend/js/features/project-list/components/search-form.tsx +++ b/services/web/frontend/js/features/project-list/components/search-form.tsx @@ -1,21 +1,35 @@ -import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Form, FormGroup, Col, FormControl } from 'react-bootstrap' +import { + Form, + FormGroup, + FormGroupProps, + Col, + FormControl, +} from 'react-bootstrap' import Icon from '../../../shared/components/icon' import * as eventTracking from '../../../infrastructure/event-tracking' +import classnames from 'classnames' -type SearchFormProps = { - onChange: (input: string) => void +type SearchFormOwnProps = { + inputValue: string + setInputValue: (input: string) => void + formGroupProps?: FormGroupProps & + Omit, keyof FormGroupProps> } -function SearchForm({ onChange }: SearchFormProps) { - const { t } = useTranslation() - const [input, setInput] = useState('') - const placeholder = `${t('search_projects')}…` +type SearchFormProps = SearchFormOwnProps & + Omit, keyof SearchFormOwnProps> - useEffect(() => { - onChange(input) - }, [input, onChange]) +function SearchForm({ + inputValue, + setInputValue, + formGroupProps, + ...props +}: SearchFormProps) { + const { t } = useTranslation() + const placeholder = `${t('search_projects')}…` + const { className: formGroupClassName, ...restFormGroupProps } = + formGroupProps || {} const handleChange = ( e: React.ChangeEvent< @@ -27,10 +41,10 @@ function SearchForm({ onChange }: SearchFormProps) { 'project-search', 'keydown' ) - setInput(e.target.value) + setInputValue(e.target.value) } - const handleClear = () => setInput('') + const handleClear = () => setInputValue('') return (
e.preventDefault()} + {...props} > - + - {input.length ? ( + {inputValue.length ? (
- + + {isActive => ( +
  • + +
  • + )} +
    ) } diff --git a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx index 0987ef94c3..c915b561cb 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx +++ b/services/web/frontend/js/features/project-list/components/sidebar/tags-list.tsx @@ -1,95 +1,31 @@ import _ from 'lodash' -import { useCallback, useState } from 'react' import { Button } from 'react-bootstrap' import { useTranslation } from 'react-i18next' -import { Tag } from '../../../../../../app/src/Features/Tags/types' import ColorManager from '../../../../ide/colors/ColorManager' import Icon from '../../../../shared/components/icon' import { UNCATEGORIZED_KEY, useProjectListContext, } from '../../context/project-list-context' -import CreateTagModal from './create-tag-modal' -import DeleteTagModal from './delete-tag-modal' -import RenameTagModal from './rename-tag-modal' +import useTag from '../../hooks/use-tag' export default function TagsList() { const { t } = useTranslation() + const { tags, untaggedProjectsCount, selectedTagId, selectTag } = + useProjectListContext() const { - tags, - untaggedProjectsCount, - selectedTagId, - selectTag, - addTag, - renameTag, - deleteTag, - } = useProjectListContext() - - const [creatingTag, setCreatingTag] = useState(false) - const [renamingTag, setRenamingTag] = useState() - const [deletingTag, setDeletingTag] = useState() - - const handleSelectTag = useCallback( - (e, tagId) => { - e.preventDefault() - selectTag(tagId) - }, - [selectTag] - ) - - const openCreateTagModal = useCallback(() => { - setCreatingTag(true) - }, [setCreatingTag]) - - const onCreate = useCallback( - (tag: Tag) => { - setCreatingTag(false) - addTag(tag) - }, - [addTag] - ) - - const handleRenameTag = useCallback( - (e, tagId) => { - e.preventDefault() - const tag = _.find(tags, ['_id', tagId]) - if (tag) { - setRenamingTag(tag) - } - }, - [tags, setRenamingTag] - ) - - const onRename = useCallback( - (tagId: string, newTagName: string) => { - renameTag(tagId, newTagName) - setRenamingTag(undefined) - }, - [renameTag, setRenamingTag] - ) - - const handleDeleteTag = useCallback( - (e, tagId) => { - e.preventDefault() - const tag = _.find(tags, ['_id', tagId]) - if (tag) { - setDeletingTag(tag) - } - }, - [tags, setDeletingTag] - ) - - const onDelete = useCallback( - tagId => { - deleteTag(tagId) - setDeletingTag(undefined) - }, - [deleteTag, setDeletingTag] - ) + handleSelectTag, + openCreateTagModal, + handleRenameTag, + handleDeleteTag, + CreateTagModal, + RenameTagModal, + DeleteTagModal, + } = useTag() return ( <> -
  • +
  • {t('tags_slash_folders')}

  • @@ -106,7 +42,9 @@ export default function TagsList() { >
  • - setCreatingTag(false)} - /> - setRenamingTag(undefined)} - /> - setDeletingTag(undefined)} - /> + + + ) } diff --git a/services/web/frontend/js/features/project-list/components/sort/with-content.tsx b/services/web/frontend/js/features/project-list/components/sort/with-content.tsx new file mode 100644 index 0000000000..15e33a8b08 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/sort/with-content.tsx @@ -0,0 +1,45 @@ +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( + WrappedComponent: React.ComponentType +) { + 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' ? 'caret-up' : 'caret-down' + screenReaderText = t('reverse_x_sort_order', { x: text }) + } + + return ( + + ) + } + + return WithContent +} + +export default withContent diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx index 9ac2f1bd20..936fb20993 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button.tsx @@ -3,16 +3,20 @@ import { Project } from '../../../../../../../../types/project/dashboard/api' import { memo, useCallback, useState } from 'react' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' -import ProjectsActionModal from '../../projects-action-modal' +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' type ArchiveProjectButtonProps = { project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement } -function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) { +function ArchiveProjectButton({ + project, + children, +}: ArchiveProjectButtonProps) { const { updateProjectViewData } = useProjectListContext() const { t } = useTranslation() const text = t('archive') @@ -41,30 +45,41 @@ function ArchiveProjectButton({ project }: ArchiveProjectButtonProps) { return ( <> - - - - - ) } +const ArchiveProjectButtonTooltip = memo(function ArchiveProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + export default memo(ArchiveProjectButton) +export { ArchiveProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx index 9780f1be04..dd5bc5f129 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button.tsx @@ -1,18 +1,19 @@ -import { useTranslation } from 'react-i18next' import { memo, useCallback, useState } from 'react' -import { Project } from '../../../../../../../../types/project/dashboard/api' +import { useTranslation } from 'react-i18next' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' 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 { Project } from '../../../../../../../../types/project/dashboard/api' type CopyButtonProps = { project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement } -function CopyProjectButton({ project }: CopyButtonProps) { +function CopyProjectButton({ project, children }: CopyButtonProps) { const { addClonedProjectToViewData } = useProjectListContext() const { t } = useTranslation() const text = t('copy') @@ -46,21 +47,7 @@ function CopyProjectButton({ project }: CopyButtonProps) { return ( <> - - - - + {children(text, handleOpenModal)} ) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + export default memo(CopyProjectButton) +export { CopyProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx index b04e07c343..0d3742bb1a 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button.tsx @@ -1,18 +1,19 @@ -import { useTranslation } from 'react-i18next' import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Project } from '../../../../../../../../types/project/dashboard/api' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' -import ProjectsActionModal from '../../projects-action-modal' +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' type DeleteProjectButtonProps = { project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement } -function DeleteProjectButton({ project }: DeleteProjectButtonProps) { +function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) { const { removeProjectFromView } = useProjectListContext() const { t } = useTranslation() const text = t('delete') @@ -44,30 +45,41 @@ function DeleteProjectButton({ project }: DeleteProjectButtonProps) { return ( <> - - - - - ) } +const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + export default memo(DeleteProjectButton) +export { DeleteProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx index e724dcd607..de4756894f 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button.tsx @@ -7,9 +7,13 @@ import * as eventTracking from '../../../../../../infrastructure/event-tracking' type DownloadProjectButtonProps = { project: Project + children: (text: string, downloadProject: () => void) => React.ReactElement } -function DownloadProjectButton({ project }: DownloadProjectButtonProps) { +function DownloadProjectButton({ + project, + children, +}: DownloadProjectButtonProps) { const { t } = useTranslation() const text = t('download') @@ -22,22 +26,35 @@ function DownloadProjectButton({ project }: DownloadProjectButtonProps) { window.location.assign(`/project/${project.id}/download/zip`) }, [project]) - return ( - - - - ) + return children(text, downloadProject) } +const DownloadProjectButtonTooltip = memo( + function DownloadProjectButtonTooltip({ + project, + }: Pick) { + return ( + + {(text, downloadProject) => ( + + + + )} + + ) + } +) + export default memo(DownloadProjectButton) +export { DownloadProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button.tsx similarity index 60% rename from services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx rename to services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button.tsx index e456339676..be0ec07f19 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button.tsx @@ -1,18 +1,19 @@ -import { useTranslation } from 'react-i18next' import { memo, useCallback, useMemo, useState } from 'react' -import { Project } from '../../../../../../../../types/project/dashboard/api' +import { useTranslation } from 'react-i18next' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' +import LeaveProjectModal from '../../../modals/leave-project-modal' import { useProjectListContext } from '../../../../context/project-list-context' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' -import ProjectsActionModal from '../../projects-action-modal' import { leaveProject } from '../../../../util/api' +import { Project } from '../../../../../../../../types/project/dashboard/api' type LeaveProjectButtonProps = { project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement } -function LeaveProjectButton({ project }: LeaveProjectButtonProps) { +function LeaveProjectButton({ project, children }: LeaveProjectButtonProps) { const { removeProjectFromView } = useProjectListContext() const { t } = useTranslation() const text = t('leave') @@ -43,30 +44,41 @@ function LeaveProjectButton({ project }: LeaveProjectButtonProps) { return ( <> - - - - - ) } +const LeaveProjectButtonTooltip = memo(function LeaveProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + export default memo(LeaveProjectButton) +export { LeaveProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx index d4893837da..69bd99c183 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button.tsx @@ -3,16 +3,17 @@ import { memo, useCallback, useState } from 'react' import { Project } from '../../../../../../../../types/project/dashboard/api' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' -import ProjectsActionModal from '../../projects-action-modal' +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' type TrashProjectButtonProps = { project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement } -function TrashProjectButton({ project }: TrashProjectButtonProps) { +function TrashProjectButton({ project, children }: TrashProjectButtonProps) { const { updateProjectViewData } = useProjectListContext() const { t } = useTranslation() const text = t('trash') @@ -42,30 +43,41 @@ function TrashProjectButton({ project }: TrashProjectButtonProps) { return ( <> - - - - - ) } +const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + export default memo(TrashProjectButton) +export { TrashProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx index e2925c720a..ea0c258524 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button.tsx @@ -8,9 +8,16 @@ import { unarchiveProject } from '../../../../util/api' type UnarchiveProjectButtonProps = { project: Project + children: ( + text: string, + handleUnarchiveProject: () => Promise + ) => React.ReactElement } -function UnarchiveProjectButton({ project }: UnarchiveProjectButtonProps) { +function UnarchiveProjectButton({ + project, + children, +}: UnarchiveProjectButtonProps) { const { t } = useTranslation() const text = t('unarchive') const { updateProjectViewData } = useProjectListContext() @@ -25,22 +32,35 @@ function UnarchiveProjectButton({ project }: UnarchiveProjectButtonProps) { if (!project.archived) return null - return ( - - - - ) + return children(text, handleUnarchiveProject) } +const UnarchiveProjectButtonTooltip = memo( + function UnarchiveProjectButtonTooltip({ + project, + }: Pick) { + return ( + + {(text, handleUnarchiveProject) => ( + + + + )} + + ) + } +) + export default memo(UnarchiveProjectButton) +export { UnarchiveProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx index a6cd41c1de..9caf4949a1 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button.tsx @@ -8,9 +8,16 @@ import { untrashProject } from '../../../../util/api' type UntrashProjectButtonProps = { project: Project + children: ( + text: string, + untrashProject: () => Promise + ) => React.ReactElement } -function UntrashProjectButton({ project }: UntrashProjectButtonProps) { +function UntrashProjectButton({ + project, + children, +}: UntrashProjectButtonProps) { const { t } = useTranslation() const text = t('untrash') const { updateProjectViewData } = useProjectListContext() @@ -24,22 +31,33 @@ function UntrashProjectButton({ project }: UntrashProjectButtonProps) { if (!project.trashed) return null - return ( - - - - ) + return children(text, handleUntrashProject) } +const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleUntrashProject) => ( + + + + )} + + ) +}) + export default memo(UntrashProjectButton) +export { UntrashProjectButtonTooltip } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx index fd76fabad9..e6c2d8bdb4 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/actions-cell.tsx @@ -1,27 +1,28 @@ import { Project } from '../../../../../../../types/project/dashboard/api' -import CopyProjectButton from './action-buttons/copy-project-button' -import ArchiveProjectButton from './action-buttons/archive-project-button' -import TrashProjectButton from './action-buttons/trash-project-button' -import UnarchiveProjectButton from './action-buttons/unarchive-project-button' -import UntrashProjectButton from './action-buttons/untrash-project-button' -import DownloadProjectButton from './action-buttons/download-project-button' -import LeaveProjectButton from './action-buttons/leave-project-buttton' -import DeleteProjectButton from './action-buttons/delete-project-button' +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' type ActionsCellProps = { project: Project } + export default function ActionsCell({ project }: ActionsCellProps) { return ( <> - - - - - - - - + + + + + + + + ) } diff --git a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx index f889d8b871..3753877683 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/inline-tags.tsx @@ -1,18 +1,21 @@ +import { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Tag } from '../../../../../../../app/src/Features/Tags/types' import ColorManager from '../../../../../ide/colors/ColorManager' import Icon from '../../../../../shared/components/icon' import { useProjectListContext } from '../../../context/project-list-context' +import classnames from 'classnames' type InlineTagsProps = { projectId: string + className?: string } -function InlineTags({ projectId }: InlineTagsProps) { +function InlineTags({ projectId, ...props }: InlineTagsProps) { const { tags } = useProjectListContext() return ( - + {tags .filter(tag => tag.project_ids?.includes(projectId)) .map((tag, index) => ( @@ -24,12 +27,32 @@ function InlineTags({ projectId }: InlineTagsProps) { function InlineTag({ tag }: { tag: Tag }) { const { t } = useTranslation() + const [classNames, setClassNames] = useState('') + const tagLabelRef = useRef(null) + const tagBtnRef = useRef(null) + + const handleLabelClick = (e: React.MouseEvent) => { + // trigger the click on the button only when the event + // is triggered from the wrapper element + if (e.target === tagLabelRef.current) { + tagBtnRef.current?.click() + } + } + + const handleCloseMouseOver = () => setClassNames('tag-label-close-hover') + const handleCloseMouseOut = () => setClassNames('') return ( -
    + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
    + {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} diff --git a/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx b/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx index cb1b69140c..8e3d605a0b 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/owner-cell.tsx @@ -7,9 +7,14 @@ import { Project } from '../../../../../../../types/project/dashboard/api' type LinkSharingIconProps = { prependSpace: boolean project: Project + className?: string } -function LinkSharingIcon({ project, prependSpace }: LinkSharingIconProps) { +function LinkSharingIcon({ + project, + prependSpace, + className, +}: LinkSharingIconProps) { const { t } = useTranslation() return ( {/* OverlayTrigger won't fire unless icon is wrapped in a span */} - + {prependSpace ? ' ' : ''} {ownerName} {project.source === 'token' ? ( - + ) : ( '' )} diff --git a/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx b/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx index aeb15d396a..1b6a7b5f94 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx @@ -1,11 +1,13 @@ +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Project } from '../../../../../../types/project/dashboard/api' 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 { useProjectListContext } from '../../context/project-list-context' -import { useCallback } from 'react' +import { getOwnerName } from '../../util/project' +import { Project } from '../../../../../../types/project/dashboard/api' type ProjectListTableRowProps = { project: Project @@ -14,6 +16,7 @@ export default function ProjectListTableRow({ project, }: ProjectListTableRowProps) { const { t } = useTranslation() + const ownerName = getOwnerName(project) const { selectedProjects, setSelectedProjects } = useProjectListContext() const handleCheckboxChange = useCallback( @@ -35,7 +38,7 @@ export default function ProjectListTableRow({ return ( - + {project.name}{' '} - + - + + {' '} + {ownerName ? ( + <> + — {t('owned_by')}{' '} + {ownerName} + + ) : null} + + - + + + + - +
    + +
    +
    + +
    ) diff --git a/services/web/frontend/js/features/project-list/components/table/project-list-table.tsx b/services/web/frontend/js/features/project-list/components/table/project-list-table.tsx index 973e49ac3a..9b30ef3a2c 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-list-table.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-list-table.tsx @@ -3,57 +3,27 @@ import { useTranslation } from 'react-i18next' import Icon from '../../../../shared/components/icon' import ProjectListTableRow from './project-list-table-row' import { useProjectListContext } from '../../context/project-list-context' -import { Project, Sort } from '../../../../../../types/project/dashboard/api' -import { SortingOrder } from '../../../../../../types/sorting-order' - -type SortByIconTableProps = { - column: string - sort: Sort - text: string - onClick: () => void -} - -function SortByButton({ column, sort, text, onClick }: SortByIconTableProps) { - const { t } = useTranslation() - let icon - - let screenReaderText = t('sort_by_x', { x: text }) - - if (column === sort.by) { - const iconType = sort.order === 'asc' ? 'caret-up' : 'caret-down' - icon = - screenReaderText = t('reverse_x_sort_order', { x: text }) - } +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import { Project } from '../../../../../../types/project/dashboard/api' +function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { return ( ) } -const toggleSort = (order: SortingOrder): SortingOrder => { - return order === 'asc' ? 'desc' : 'asc' -} +const SortByButton = withContent(SortBtn) function ProjectListTable() { const { t } = useTranslation() - const { - visibleProjects, - sort, - setSort, - selectedProjects, - setSelectedProjects, - } = useProjectListContext() - - const handleSortClick = (by: Sort['by']) => { - setSort(prev => ({ - by, - order: prev.by === by ? toggleSort(sort.order) : sort.order, - })) - } + const { visibleProjects, sort, selectedProjects, setSelectedProjects } = + useProjectListContext() + const { handleSort } = useSort() const handleAllProjectsCheckboxChange = useCallback( (event: React.ChangeEvent) => { @@ -67,103 +37,98 @@ function ProjectListTable() { ) return ( -
    - - - - - + + - - - - - + disabled={visibleProjects.length === 0} + /> + + + + + + - - {visibleProjects.length ? ( - visibleProjects.map((p: Project) => ( - - )) - ) : ( - - - - )} - -
    - - +
    + - handleSortClick('title')} - /> - - handleSortClick('owner')} - /> - - handleSortClick('lastUpdated')} - /> - {t('actions')}
    + handleSort('title')} + /> + + handleSort('owner')} + /> + + handleSort('lastUpdated')} + /> + {t('actions')}
    - {t('no_projects')} -
    -
    + + {visibleProjects.length ? ( + visibleProjects.map((p: Project) => ( + + )) + ) : ( + + + {t('no_projects')} + + + )} + + ) } diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx index a168eb4f6c..39b79c7dd1 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/archive-projects-button.tsx @@ -2,10 +2,10 @@ import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' +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 ProjectsActionModal from '../../projects-action-modal' function ArchiveProjectsButton() { const { selectedProjects, updateProjectViewData, setSelectedProjects } = @@ -51,12 +51,11 @@ function ArchiveProjectsButton() {
    - ) diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx index 6b19ec163c..e7661c519b 100644 --- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/trash-projects-button.tsx @@ -2,10 +2,10 @@ import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Icon from '../../../../../../shared/components/icon' import Tooltip from '../../../../../../shared/components/tooltip' +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 ProjectsActionModal from '../../projects-action-modal' function TrashProjectsButton() { const { selectedProjects, setSelectedProjects, updateProjectViewData } = @@ -52,12 +52,11 @@ function TrashProjectsButton() { - ) diff --git a/services/web/frontend/js/features/project-list/components/tags-list.tsx b/services/web/frontend/js/features/project-list/components/tags-list.tsx new file mode 100644 index 0000000000..155bd171e1 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/tags-list.tsx @@ -0,0 +1,111 @@ +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import MenuItemButton from './dropdown/menu-item-button' +import Icon from '../../../shared/components/icon' +import { + UNCATEGORIZED_KEY, + useProjectListContext, +} from '../context/project-list-context' +import ColorManager from '../../../ide/colors/ColorManager' +import useTag from '../hooks/use-tag' +import { sortBy } from 'lodash' +import { Tag } from '../../../../../app/src/Features/Tags/types' + +type TagsListProps = { + onTagClick: () => void + onEditClick: () => void +} + +function TagsList({ onTagClick, onEditClick }: TagsListProps) { + const { t } = useTranslation() + const { tags, untaggedProjectsCount, selectedTagId, selectTag } = + useProjectListContext() + + const { + handleSelectTag, + openCreateTagModal, + handleEditTag, + CreateTagModal, + EditTagModal, + } = useTag() + + const handleClick = (e: React.MouseEvent, tag: Tag) => { + handleSelectTag(e, tag._id) + onTagClick() + } + + return ( + <> + {sortBy(tags, ['name']).map((tag, index) => ( + handleClick(e as unknown as React.MouseEvent, tag)} + className="projects-types-menu-item projects-types-menu-tag-item" + afterNode={ + + } + > + + {selectedTagId === tag._id ? ( + + ) : null} + + + + + {tag.name}{' '} + ({tag.project_ids?.length}) + + + + ))} + { + selectTag(UNCATEGORIZED_KEY) + onTagClick() + }} + > + {selectedTagId === UNCATEGORIZED_KEY ? ( + + ) : null} + + {t('uncategorized')}  + ({untaggedProjectsCount}) + + + { + openCreateTagModal() + onTagClick() + }} + className="projects-types-menu-item" + > + + + {t('new_folder')} + + + + + + ) +} + +export default TagsList diff --git a/services/web/frontend/js/features/project-list/components/welcome-message.tsx b/services/web/frontend/js/features/project-list/components/welcome-message.tsx index 8022991875..eb64c87e33 100644 --- a/services/web/frontend/js/features/project-list/components/welcome-message.tsx +++ b/services/web/frontend/js/features/project-list/components/welcome-message.tsx @@ -18,7 +18,10 @@ export default function WelcomeMessage() {
    - +
    diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx index 6450c98120..fa99cb826d 100644 --- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx +++ b/services/web/frontend/js/features/project-list/context/project-list-context.tsx @@ -71,6 +71,7 @@ type ProjectListContextValue = { deleteTag: (tagId: string) => void updateProjectViewData: (project: Project) => void removeProjectFromView: (project: Project) => void + searchText: string setSearchText: React.Dispatch> selectedProjects: Project[] setSelectedProjects: React.Dispatch> @@ -292,6 +293,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { selectFilter, selectedProjects, selectTag, + searchText, setSearchText, setSelectedProjects, setSort, @@ -316,6 +318,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) { selectFilter, selectedProjects, selectTag, + searchText, setSearchText, setSelectedProjects, setSort, diff --git a/services/web/frontend/js/features/project-list/hooks/use-sort.ts b/services/web/frontend/js/features/project-list/hooks/use-sort.ts new file mode 100644 index 0000000000..c3cebfdc42 --- /dev/null +++ b/services/web/frontend/js/features/project-list/hooks/use-sort.ts @@ -0,0 +1,22 @@ +import { useProjectListContext } from '../context/project-list-context' +import { Sort } from '../../../../../types/project/dashboard/api' +import { SortingOrder } from '../../../../../types/sorting-order' + +const toggleSort = (order: SortingOrder): SortingOrder => { + return order === 'asc' ? 'desc' : 'asc' +} + +function useSort() { + const { sort, setSort } = useProjectListContext() + + const handleSort = (by: Sort['by']) => { + setSort(prev => ({ + by, + order: prev.by === by ? toggleSort(sort.order) : sort.order, + })) + } + + return { handleSort } +} + +export default useSort diff --git a/services/web/frontend/js/features/project-list/hooks/use-tag.tsx b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx new file mode 100644 index 0000000000..15546e18b8 --- /dev/null +++ b/services/web/frontend/js/features/project-list/hooks/use-tag.tsx @@ -0,0 +1,161 @@ +import { useState, useCallback } from 'react' +import { useProjectListContext } from '../context/project-list-context' +import { Tag } from '../../../../../app/src/Features/Tags/types' +import CreateTagModal from '../components/modals/create-tag-modal' +import RenameTagModal from '../components/modals/rename-tag-modal' +import DeleteTagModal from '../components/modals/delete-tag-modal' +import EditTagModal from '../components/modals/edit-tag-modal' +import { find } from 'lodash' + +function useTag() { + const { tags, selectTag, addTag, renameTag, deleteTag } = + useProjectListContext() + const [creatingTag, setCreatingTag] = useState(false) + const [renamingTag, setRenamingTag] = useState() + const [deletingTag, setDeletingTag] = useState() + const [editingTag, setEditingTag] = useState() + + const handleSelectTag = useCallback( + (e: React.MouseEvent, tagId: string) => { + e.preventDefault() + selectTag(tagId) + }, + [selectTag] + ) + + const openCreateTagModal = useCallback(() => { + setCreatingTag(true) + }, [setCreatingTag]) + + const onCreate = useCallback( + (tag: Tag) => { + setCreatingTag(false) + addTag(tag) + }, + [addTag] + ) + + const handleRenameTag = useCallback( + (e, tagId) => { + e.preventDefault() + const tag = find(tags, ['_id', tagId]) + if (tag) { + setRenamingTag(tag) + } + }, + [tags, setRenamingTag] + ) + + const onRename = useCallback( + (tagId: string, newTagName: string) => { + renameTag(tagId, newTagName) + setRenamingTag(undefined) + }, + [renameTag, setRenamingTag] + ) + + const handleDeleteTag = useCallback( + (e, tagId) => { + e.preventDefault() + const tag = find(tags, ['_id', tagId]) + if (tag) { + setDeletingTag(tag) + } + }, + [tags, setDeletingTag] + ) + + const onDelete = useCallback( + tagId => { + deleteTag(tagId) + setDeletingTag(undefined) + }, + [deleteTag, setDeletingTag] + ) + + const handleEditTag = useCallback( + (e, tagId) => { + e.preventDefault() + const tag = find(tags, ['_id', tagId]) + if (tag) { + setEditingTag(tag) + } + }, + [tags, setEditingTag] + ) + + const onEditRename = useCallback( + (tagId: string, newTagName: string) => { + renameTag(tagId, newTagName) + setEditingTag(undefined) + }, + [renameTag, setEditingTag] + ) + + const onEditDelete = useCallback( + (tagId: string) => { + deleteTag(tagId) + setEditingTag(undefined) + }, + [deleteTag, setEditingTag] + ) + + function CreateModal({ id }: { id: string }) { + return ( + setCreatingTag(false)} + /> + ) + } + + function RenameModal({ id }: { id: string }) { + return ( + setRenamingTag(undefined)} + /> + ) + } + + function DeleteModal({ id }: { id: string }) { + return ( + setDeletingTag(undefined)} + /> + ) + } + + function EditModal({ id }: { id: string }) { + return ( + setEditingTag(undefined)} + /> + ) + } + + return { + handleSelectTag, + openCreateTagModal, + handleRenameTag, + handleDeleteTag, + handleEditTag, + CreateTagModal: CreateModal, + RenameTagModal: RenameModal, + DeleteTagModal: DeleteModal, + EditTagModal: EditModal, + } +} + +export default useTag diff --git a/services/web/frontend/js/shared/components/controlled-dropdown.js b/services/web/frontend/js/shared/components/controlled-dropdown.js index 9be833565e..368456cb2b 100644 --- a/services/web/frontend/js/shared/components/controlled-dropdown.js +++ b/services/web/frontend/js/shared/components/controlled-dropdown.js @@ -33,4 +33,5 @@ ControlledDropdown.propTypes = { children: PropTypes.any, defaultOpen: PropTypes.bool, id: PropTypes.string, + className: PropTypes.string, } diff --git a/services/web/frontend/stories/project-list/new-project-button.stories.tsx b/services/web/frontend/stories/project-list/new-project-button.stories.tsx index 74bcad9395..81ef4f50d1 100644 --- a/services/web/frontend/stories/project-list/new-project-button.stories.tsx +++ b/services/web/frontend/stories/project-list/new-project-button.stories.tsx @@ -62,7 +62,7 @@ export const Success = () => { ) }) - return + return } export const Error = () => { @@ -83,7 +83,7 @@ export const Error = () => { ) }) - return + return } export default { diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less index c24ec3305b..ffe9c7968a 100644 --- a/services/web/frontend/stylesheets/app/project-list-react.less +++ b/services/web/frontend/stylesheets/app/project-list-react.less @@ -38,10 +38,13 @@ .project-list-sidebar-wrapper { float: left; position: static; + width: 15%; + min-width: 160px; .project-list-sidebar { > .dropdown { width: 100%; + .new-project-button { width: 100%; } @@ -52,16 +55,22 @@ .project-list-main { position: static; overflow: auto; + padding-left: @grid-gutter-width / 2; + padding-right: @grid-gutter-width / 2; + margin-left: initial; } ul.folders-menu { margin: @folders-menu-margin; + .subdued { color: @gray-light; } + > li { cursor: pointer; position: relative; + > button { display: block; width: 100%; @@ -73,10 +82,12 @@ border: none; border-bottom: solid 1px transparent; padding: @folders-menu-item-v-padding @folders-menu-item-h-padding; + &:hover { background-color: @sidebar-hover-bg; text-decoration: @sidebar-hover-text-decoration; } + &:focus { text-decoration: none; outline: none; @@ -88,17 +99,21 @@ cursor: auto; } } + > li.active { border-radius: @sidebar-active-border-radius; + > button { background-color: @sidebar-active-bg; font-weight: @sidebar-active-font-weight; color: @sidebar-active-color; + .subdued { color: @sidebar-active-color; } } } + h2 { margin-top: @folders-title-margin-top; margin-bottom: @folders-title-margin-bottom; @@ -109,16 +124,19 @@ font-weight: @folders-title-font-weight; font-family: @font-family-sans-serif; } + > li.tag { &.active { .tag-menu > button { color: white; border-color: white; + &:hover { background-color: @folders-tag-menu-active-hover; } } } + &.untagged { button.tag-name { span.name { @@ -127,29 +145,35 @@ } } } + &:hover { &:not(.active) { background-color: @folders-tag-hover; } + .tag-menu { display: block; } } + &:not(.active) { .tag-menu > a:hover { background-color: @folders-tag-menu-hover; } } + button.tag-name { position: relative; padding: @folders-tag-padding; display: @folders-tag-display; + span.name { padding-left: 0.5em; line-height: @folders-tag-line-height; } } } + .tag-menu { button.dropdown-toggle { border: 1px solid @folders-tag-border-color; @@ -160,21 +184,25 @@ width: 16px; height: 16px; position: relative; + .caret { position: absolute; top: 6px; left: 1px; } } + display: none; width: auto; position: absolute; top: 50%; margin-top: -8px; // Half the element height. right: 4px; + &.open { display: block; } + button.tag-action { border-radius: unset; width: 100%; @@ -188,6 +216,7 @@ color: @white; background-color: @ol-green; } + &:active { outline: none; } @@ -242,6 +271,7 @@ color: @ol-type-color; padding: 0; font-weight: bold; + &:hover, &:focus { color: @ol-type-color; @@ -251,44 +281,80 @@ .dash-cell-checkbox { width: 5%; + input[type='checkbox'] { margin-top: 5px; } } + .dash-cell-name { width: 50%; overflow: hidden; text-overflow: ellipsis; } + .dash-cell-owner { width: 20%; overflow: hidden; text-overflow: ellipsis; } + .dash-cell-date { width: 25%; overflow: hidden; text-overflow: ellipsis; } + .dash-cell-actions { display: none; text-align: right; white-space: nowrap; } + .dash-cell-date-owner { + font-size: 14px; + .text-overflow(); + } + + .dash-cell-tag { + .tag-label { + padding: 14px 0; + cursor: pointer; + + &:not(.tag-label-close-hover) { + &:hover, + &:focus { + .label.tag-label-name { + color: @tag-color; + background-color: @tag-bg-hover-color; + outline-width: 0; + } + } + } + + &:first-child { + margin-left: initial; + } + } + } + @media (min-width: @screen-xs) { .dash-cell-checkbox { width: 4%; } + .dash-cell-name { width: 50%; } + .dash-cell-owner { width: 21%; } + .dash-cell-date { width: 25%; } + .dash-cell-actions { width: 0%; } @@ -298,34 +364,46 @@ .dash-cell-checkbox { width: 3%; } + .dash-cell-name { width: 48%; } + .dash-cell-owner { width: 13%; } + .dash-cell-date { width: 15%; } + .dash-cell-actions { display: table-cell; width: 21%; } + + .project-tools { + float: none; + } } @media (min-width: @screen-md) { .dash-cell-checkbox { width: 3%; } + .dash-cell-name { width: 50%; } + .dash-cell-owner { width: 13%; } + .dash-cell-date { width: 16%; } + .dash-cell-actions { width: 18%; } @@ -335,15 +413,19 @@ .dash-cell-checkbox { width: 3%; } + .dash-cell-name { width: 50%; } + .dash-cell-owner { width: 15%; } + .dash-cell-date { width: 19%; } + .dash-cell-actions { width: 13%; } @@ -353,19 +435,64 @@ .dash-cell-checkbox { width: 2%; } + .dash-cell-name { width: 50%; } + .dash-cell-owner { width: 16%; } + .dash-cell-date { width: 19%; } + .dash-cell-actions { width: 13%; } } + + @media (max-width: @screen-xs-max) { + @actions-btn-size: 48px; + + tr { + position: relative; + display: flex; + flex-direction: column; + + td { + padding-top: @line-height-computed / 6; + padding-bottom: @line-height-computed / 6; + } + + td:not(.dash-cell-actions) { + padding-right: @actions-btn-size + 12.5px; + } + } + + .dash-cell-name, + .dash-cell-owner, + .dash-cell-date, + .dash-cell-tag, + .dash-cell-actions { + display: block; + width: auto; + } + + .dash-cell-actions { + position: absolute; + top: 0; + right: 0; + padding: 0 !important; + + .dropdown-toggle { + padding: 13px 15px; + border-radius: 0; + font-size: 14px; + } + } + } } .loading-container { @@ -377,6 +504,74 @@ margin: 0 auto; } } + + @media (max-width: @screen-xs-max) { + .project-tools { + float: left; + } + + .row-spaced { + margin-top: 5px; + } + } + + .projects-toolbar, + .tag-item { + display: flex; + align-items: center; + } + + .projects-toolbar { + flex-wrap: wrap; + } + + #projects-types-dropdown { + font-family: @font-family-serif; + + & + .projects-dropdown-menu { + min-width: 226px; + } + } + + #projects-sort-dropdown { + & + .projects-dropdown-menu { + min-width: 156px; + } + } + + .projects-dropdown-menu { + .dropdown-header { + padding: 14px 20px; + font-size: 13px; + text-transform: uppercase; + } + } + + .projects-types-menu-item { + .menu-item-button-icon { + left: 10px; + } + + &.projects-types-menu-tag-item { + display: flex; + + .edit-btn { + padding: 12px 14px; + } + } + } + + .projects-sort-menu-item { + .menu-item-button-icon { + left: 14px; + } + } + + .projects-action-menu-item { + .menu-item-button-icon { + left: 11px; + } + } } .project-list-react.container, @@ -388,6 +583,7 @@ .current-plan { vertical-align: middle; line-height: @line-height-base; + a.current-plan-label { text-decoration: none; color: @text-color; @@ -417,3 +613,42 @@ } } } + +.btn-transparent { + box-shadow: none !important; + background: none !important; + border-radius: 0 !important; + color: inherit !important; + font-weight: 400; + + &:hover { + box-shadow: none !important; + background: none !important; + color: inherit !important; + } +} + +.menu-item-button, +#new-project-button-projects-table + .dropdown-menu [role='menuitem'] { + padding: 12px 20px; +} + +.menu-item-button { + position: relative; + width: 100%; + border: none; + box-shadow: none; + background: inherit; + color: @ol-blue-gray-5; + text-align: left; + + .menu-item-button-text { + padding-left: 14px; + } + + .menu-item-button-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + } +} diff --git a/services/web/frontend/stylesheets/app/project-list.less b/services/web/frontend/stylesheets/app/project-list.less index ebac08c963..74cb67095f 100644 --- a/services/web/frontend/stylesheets/app/project-list.less +++ b/services/web/frontend/stylesheets/app/project-list.less @@ -484,6 +484,7 @@ i.tablesort { margin-left: @line-height-computed / 4; position: relative; display: inline-block; + line-height: 1; white-space: nowrap; top: @tag-top-adjustment; } diff --git a/services/web/frontend/stylesheets/components/navbar.less b/services/web/frontend/stylesheets/components/navbar.less index 29d03a7873..0b9bf19c3d 100755 --- a/services/web/frontend/stylesheets/components/navbar.less +++ b/services/web/frontend/stylesheets/components/navbar.less @@ -213,11 +213,16 @@ .navbar-toggle { position: relative; float: right; + padding: 3px 10px 0px; background-color: transparent; background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 - border: 2px solid @navbar-default-link-color; + border: 0; border-radius: @border-radius-base; + .fa { + font-size: @navbar-height / 2; + } + // We remove the `outline` here, but later compensate by attaching `:hover` // styles to `:focus`. &:focus { diff --git a/services/web/frontend/stylesheets/core/spacing.less b/services/web/frontend/stylesheets/core/spacing.less index c4011545ab..0d9bd72e92 100644 --- a/services/web/frontend/stylesheets/core/spacing.less +++ b/services/web/frontend/stylesheets/core/spacing.less @@ -100,3 +100,19 @@ each(@spacers, { margin-top: auto !important; margin-bottom: auto !important; } + +.ms-auto { + margin-left: auto !important; +} + +.me-auto { + margin-right: auto !important; +} + +.mt-auto { + margin-top: auto !important; +} + +.mb-auto { + margin-bottom: auto !important; +} diff --git a/services/web/frontend/stylesheets/core/type.less b/services/web/frontend/stylesheets/core/type.less index cfc96953b8..bd31abc3a7 100755 --- a/services/web/frontend/stylesheets/core/type.less +++ b/services/web/frontend/stylesheets/core/type.less @@ -188,6 +188,9 @@ cite { .text-lowercase { text-transform: lowercase; } +.text-uppercase { + text-transform: uppercase; +} // Contextual backgrounds // For now we'll leave these alongside the text classes until v4 when we can @@ -301,7 +304,7 @@ dd { // Abbreviations and acronyms abbr[title], -// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257 + // Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257 abbr[data-original-title] { cursor: help; border-bottom: 1px dotted @abbr-border-color; diff --git a/services/web/frontend/stylesheets/core/utilities.less b/services/web/frontend/stylesheets/core/utilities.less index 1c65ecc220..3bd222362d 100755 --- a/services/web/frontend/stylesheets/core/utilities.less +++ b/services/web/frontend/stylesheets/core/utilities.less @@ -54,3 +54,17 @@ .w-100 { width: 100%; } + +// Overflow utils +.overflow-auto { + overflow: auto !important; +} +.overflow-hidden { + overflow: hidden !important; +} +.overflow-visible { + overflow: visible !important; +} +.overflow-scroll { + overflow: scroll !important; +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index c7b721fc26..ed4d18adda 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -856,6 +856,7 @@ "fast": "Fast", "rename_folder": "Rename Folder", "delete_folder": "Delete Folder", + "edit_folder": "Edit Folder", "select_tag": "Select tag __tagName__", "remove_tag": "Remove tag __tagName__", "about_to_delete_folder": "You are about to delete the following folders (any projects in them will not be deleted):", @@ -1872,6 +1873,8 @@ "history_entry_origin_github": "via GitHub", "history_entry_origin_dropbox": "via Dropbox", "sort_by_x": "Sort by __x__", + "sort_by": "Sort by", + "owned_by": "Owned by", "last_updated_date_by_x": "__lastUpdatedDate__ by __person__", "select_projects": "Select Projects", "ascending": "Ascending", @@ -1880,6 +1883,8 @@ "create_first_project": "Create First Project", "you_dont_have_any_repositories": "You don’t have any repositories", "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters", + "save_changes": "Save changes", + "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters", "overleaf_labs": "Overleaf Labs", "labs_program_already_participating": "You are enrolled in Labs", "labs_program_not_participating": "You are not enrolled in Labs", diff --git a/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx index f8f5c36252..d578320f0d 100644 --- a/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/new-project-button.test.tsx @@ -17,7 +17,7 @@ describe('', function () { ], }) - render() + render() const newProjectButton = screen.getByRole('button', { name: 'New Project', diff --git a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx index fbfb309aa2..2b5037591e 100644 --- a/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-list-root.test.tsx @@ -32,7 +32,7 @@ describe('', function () { describe('checkboxes', function () { let allCheckboxes: Array = [] - let toolbar: HTMLElement + let actionsToolbar: HTMLElement let project1Id: string | null, project2Id: string | null beforeEach(async function () { @@ -52,11 +52,11 @@ describe('', function () { project1Id = allCheckboxes[1].getAttribute('data-project-id') project2Id = allCheckboxes[2].getAttribute('data-project-id') - toolbar = screen.getByRole('toolbar') + actionsToolbar = screen.getAllByRole('toolbar')[0] }) it('downloads all selected projects and then unselects them', async function () { - const downloadButton = within(toolbar).getByLabelText('Download') + const downloadButton = within(actionsToolbar).getByLabelText('Download') fireEvent.click(downloadButton) await waitFor(() => { @@ -89,7 +89,7 @@ describe('', function () { { delay: 0 } ) - const archiveButton = within(toolbar).getByLabelText('Archive') + const archiveButton = within(actionsToolbar).getByLabelText('Archive') fireEvent.click(archiveButton) const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement @@ -124,7 +124,7 @@ describe('', function () { { delay: 0 } ) - const archiveButton = within(toolbar).getByLabelText('Trash') + const archiveButton = within(actionsToolbar).getByLabelText('Trash') fireEvent.click(archiveButton) const confirmBtn = screen.getByText('Confirm') as HTMLButtonElement @@ -146,7 +146,7 @@ describe('', function () { describe('archived projects', function () { beforeEach(function () { - const filterButton = screen.getByText('Archived Projects') + const filterButton = screen.getAllByText('Archived Projects')[0] fireEvent.click(filterButton) allCheckboxes = screen.getAllByRole('checkbox') @@ -162,7 +162,7 @@ describe('', function () { describe('trashed projects', function () { beforeEach(function () { - const filterButton = screen.getByText('Trashed Projects') + const filterButton = screen.getAllByText('Trashed Projects')[0] fireEvent.click(filterButton) allCheckboxes = screen.getAllByRole('checkbox') @@ -176,7 +176,7 @@ describe('', function () { }) it('clears selected projects when filter changed', function () { - const filterButton = screen.getByText('All Projects') + const filterButton = screen.getAllByText('All Projects')[0] fireEvent.click(filterButton) const allCheckboxes = screen.getAllByRole('checkbox') diff --git a/services/web/test/frontend/features/project-list/components/project-search.test.tsx b/services/web/test/frontend/features/project-list/components/project-search.test.tsx index a214dde2ae..b2b9228a0c 100644 --- a/services/web/test/frontend/features/project-list/components/project-search.test.tsx +++ b/services/web/test/frontend/features/project-list/components/project-search.test.tsx @@ -11,7 +11,7 @@ import * as eventTracking from '../../../../../frontend/js/infrastructure/event- import fetchMock from 'fetch-mock' import { projectsData } from '../fixtures/projects-data' -describe('', function () { +describe('Project list search form', function () { beforeEach(function () { fetchMock.reset() }) @@ -21,35 +21,32 @@ describe('', function () { }) it('renders the search form', function () { - render( {}} />) + render( {}} />) screen.getByRole('search') screen.getByRole('textbox', { name: /search projects/i }) }) - it('clears text when clear button is clicked', function () { - render( {}} />) + it('calls clear text when clear button is clicked', function () { + const setInputValueMock = sinon.stub() + render() + const input = screen.getByRole('textbox', { name: /search projects/i, }) - expect(input.value).to.equal('') - expect(screen.queryByRole('button', { name: 'clear search' })).to.be.null // clear button - - fireEvent.change(input, { - target: { value: 'abc' }, - }) + expect(input.value).to.equal('abc') const clearBtn = screen.getByRole('button', { name: 'clear search' }) fireEvent.click(clearBtn) - expect(input.value).to.equal('') + expect(setInputValueMock).to.be.calledWith('') }) it('changes text', function () { - const onChangeMock = sinon.stub() + const setInputValueMock = sinon.stub() const sendSpy = sinon.spy(eventTracking, 'send') - render() + render() const input = screen.getByRole('textbox', { name: /search projects/i }) const value = 'abc' @@ -59,7 +56,7 @@ describe('', function () { 'project-search', 'keydown' ) - expect(onChangeMock).to.be.calledWith(value) + expect(setInputValueMock).to.be.calledWith(value) sendSpy.restore() }) @@ -93,7 +90,7 @@ describe('', function () { ) const handleChange = result.current.setSearchText - render() + render() const input = screen.getByRole('textbox', { name: /search projects/i }) const value = projectsData[0].name diff --git a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx index 658a4df2b7..799d202510 100644 --- a/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx +++ b/services/web/test/frontend/features/project-list/components/sidebar/tags-list.test.tsx @@ -181,13 +181,13 @@ describe('', function () { beforeEach(async function () { const tag1Button = screen.getByText('Another tag') - const renameButton = within( + const deleteButton = within( tag1Button.closest('li') as HTMLElement ).getByRole('button', { name: 'Delete', }) - await fireEvent.click(renameButton) + await fireEvent.click(deleteButton) }) it('modal is open', async function () { diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx index 292a86f9bb..e67918b0cb 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/archive-project-button.test.tsx @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' -import ArchiveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button' +import { ArchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/archive-project-button' import { archiveableProject, archivedProject, @@ -18,7 +18,7 @@ describe('', function () { it('renders tooltip for button', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Archive') fireEvent.mouseOver(btn) @@ -27,7 +27,7 @@ describe('', function () { it('opens the modal when clicked', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Archive') fireEvent.click(btn) @@ -37,7 +37,7 @@ describe('', function () { it('does not render the button when already archived', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Archive')).to.be.null }) @@ -51,7 +51,9 @@ describe('', function () { }, { delay: 0 } ) - renderWithProjectListContext() + renderWithProjectListContext( + + ) const btn = screen.getByLabelText('Archive') fireEvent.click(btn) screen.getByText('Archive Projects') diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx index 20704adb9a..48e44c90ff 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/copy-project-button.test.tsx @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' -import CopyProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button' +import { CopyProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/copy-project-button' import { archivedProject, copyableProject, @@ -18,7 +18,7 @@ describe('', function () { }) it('renders tooltip for button', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Copy') fireEvent.mouseOver(btn) @@ -27,13 +27,15 @@ describe('', function () { it('does not render the button when project is archived', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Copy')).to.be.null }) it('does not render the button when project is trashed', function () { - renderWithProjectListContext() + renderWithProjectListContext( + + ) expect(screen.queryByLabelText('Copy')).to.be.null }) @@ -46,7 +48,7 @@ describe('', function () { { delay: 0 } ) renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Copy') fireEvent.click(btn) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx index d29cebe6ae..73bd95b758 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/delete-project-button.test.tsx @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' -import DeleteProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button' +import { DeleteProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/delete-project-button' import { archiveableProject, trashedAndNotOwnedProject, @@ -20,7 +20,7 @@ describe('', function () { it('renders tooltip for button', function () { window.user_id = trashedProject?.owner?.id renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Delete') fireEvent.mouseOver(btn) @@ -30,7 +30,7 @@ describe('', function () { it('does not render button when trashed and not owner', function () { window.user_id = '123abc' renderWithProjectListContext( - + ) const btn = screen.queryByLabelText('Delete') expect(btn).to.be.null @@ -38,7 +38,7 @@ describe('', function () { it('does not render the button when project is current', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Delete')).to.be.null }) @@ -53,7 +53,9 @@ describe('', function () { }, { delay: 0 } ) - renderWithProjectListContext() + renderWithProjectListContext( + + ) const btn = screen.getByLabelText('Delete') fireEvent.click(btn) screen.getByText('Delete Projects') diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx index b3991dd0f0..63e0146a7e 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/download-project-button.test.tsx @@ -1,7 +1,7 @@ import { expect } from 'chai' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import sinon from 'sinon' -import DownloadProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button' +import { DownloadProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/download-project-button' import { projectsData } from '../../../../fixtures/projects-data' describe('', function () { @@ -13,7 +13,7 @@ describe('', function () { value: { assign: locationStub }, }) - render() + render() }) afterEach(function () { diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx index 610247462a..1da2a16046 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/leave-project-button.test.tsx @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' -import LeaveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-buttton' +import { LeaveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/leave-project-button' import { trashedProject, trashedAndNotOwnedProject, @@ -19,7 +19,7 @@ describe('', function () { }) it('renders tooltip for button', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Leave') fireEvent.mouseOver(btn) @@ -29,7 +29,7 @@ describe('', function () { it('does not render button when owner', function () { window.user_id = trashedProject?.owner?.id renderWithProjectListContext( - + ) const btn = screen.queryByLabelText('Leave') expect(btn).to.be.null @@ -37,14 +37,14 @@ describe('', function () { it('does not render the button when project is archived', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Leave')).to.be.null }) it('does not render the button when project is current', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Leave')).to.be.null }) @@ -58,7 +58,9 @@ describe('', function () { }, { delay: 0 } ) - renderWithProjectListContext() + renderWithProjectListContext( + + ) const btn = screen.getByLabelText('Leave') fireEvent.click(btn) screen.getByText('Leave Projects') diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx index 8f1b7fc399..6da100e745 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/trash-project-button.test.tsx @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' -import TrashProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button' +import { TrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/trash-project-button' import { archivedProject, trashedProject, @@ -18,7 +18,7 @@ describe('', function () { it('renders tooltip for button', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Trash') fireEvent.mouseOver(btn) @@ -27,7 +27,7 @@ describe('', function () { it('does not render the button when project is trashed', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Trash')).to.be.null }) @@ -41,7 +41,9 @@ describe('', function () { }, { delay: 0 } ) - renderWithProjectListContext() + renderWithProjectListContext( + + ) const btn = screen.getByLabelText('Trash') fireEvent.click(btn) screen.getByText('Trash Projects') diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx index 5e8f99e965..be77809966 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/unarchive-project-button.test.tsx @@ -1,6 +1,6 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' -import UnarchiveProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button' +import { UnarchiveProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/unarchive-project-button' import { archiveableProject, archivedProject, @@ -19,7 +19,7 @@ describe('', function () { it('renders tooltip for button', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Restore') fireEvent.mouseOver(btn) @@ -28,14 +28,14 @@ describe('', function () { it('does not render the button when project is trashed', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Restore')).to.be.null }) it('does not render the button when project is current', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Restore')).to.be.null }) @@ -49,7 +49,9 @@ describe('', function () { }, { delay: 0 } ) - renderWithProjectListContext() + renderWithProjectListContext( + + ) const btn = screen.getByLabelText('Restore') fireEvent.click(btn) diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx index ce6cab9f98..be07239e94 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/untrash-project-button.test.tsx @@ -1,7 +1,7 @@ import { expect } from 'chai' import { fireEvent, screen } from '@testing-library/react' import fetchMock from 'fetch-mock' -import UntrashProjectButton from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button' +import { UntrashProjectButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/untrash-project-button' import { archiveableProject, trashedProject, @@ -22,7 +22,7 @@ describe('', function () { it('renders tooltip for button', function () { renderWithProjectListContext( - + ) const btn = screen.getByLabelText('Restore') fireEvent.mouseOver(btn) @@ -31,7 +31,7 @@ describe('', function () { it('does not render the button when project is current', function () { renderWithProjectListContext( - + ) expect(screen.queryByLabelText('Restore')).to.be.null }) @@ -45,7 +45,9 @@ describe('', function () { }, { delay: 0 } ) - renderWithProjectListContext() + renderWithProjectListContext( + + ) const btn = screen.getByLabelText('Restore') fireEvent.click(btn) diff --git a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx index c603d02625..325735c757 100644 --- a/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/project-list-table.test.tsx @@ -81,30 +81,30 @@ describe('', function () { .getByRole('cell', { name: currentProjects[0].name }) .closest('tr')! within(row1).getByText('You') - within(row1).getByText('a day ago by Jean-Luc Picard') + within(row1).getAllByText('a day ago by Jean-Luc Picard', { exact: false }) const row2 = screen .getByRole('cell', { name: currentProjects[1].name }) .closest('tr')! within(row2).getByText('Jean-Luc Picard') - within(row2).getByText('7 days ago by Jean-Luc Picard') + within(row2).getAllByText('7 days ago by Jean-Luc Picard') const row3 = screen .getByRole('cell', { name: currentProjects[2].name }) .closest('tr')! within(row3).getByText('worf@overleaf.com') - within(row3).getByText('a month ago by worf@overleaf.com') + within(row3).getAllByText('a month ago by worf@overleaf.com') // link sharing project const row4 = screen .getByRole('cell', { name: currentProjects[3].name }) .closest('tr')! within(row4).getByText('La Forge') within(row4).getByText('Link sharing') - within(row4).getByText('2 months ago by La Forge') + within(row4).getAllByText('2 months ago by La Forge') // link sharing read only, so it will not show an owner const row5 = screen .getByRole('cell', { name: currentProjects[4].name }) .closest('tr')! within(row5).getByText('Link sharing') - within(row5).getByText('2 years ago') + within(row5).getAllByText('2 years ago') // Action Column // temporary count tests until we add filtering for archived/trashed diff --git a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx index 41c9fa5438..7f9d32c50f 100644 --- a/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/projects-action-modal.test.tsx @@ -1,7 +1,7 @@ import { expect } from 'chai' import sinon from 'sinon' import { fireEvent, screen, waitFor } from '@testing-library/react' -import ProjectsActionModal from '../../../../../../frontend/js/features/project-list/components/table/projects-action-modal' +import ProjectsActionModal from '../../../../../../frontend/js/features/project-list/components/modals/projects-action-modal' import { projectsData } from '../../fixtures/projects-data' import { resetProjectListContextFetch, diff --git a/services/web/test/frontend/features/project-list/helpers/render-with-context.js b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx similarity index 66% rename from services/web/test/frontend/features/project-list/helpers/render-with-context.js rename to services/web/test/frontend/features/project-list/helpers/render-with-context.tsx index e8bce06250..4f7146e007 100644 --- a/services/web/test/frontend/features/project-list/helpers/render-with-context.js +++ b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx @@ -1,19 +1,21 @@ -// Disable prop type checks for test harnesses -/* eslint-disable react/prop-types */ - import { render } from '@testing-library/react' import fetchMock from 'fetch-mock' import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context' import { projectsData } from '../fixtures/projects-data' -export function renderWithProjectListContext(component) { +export function renderWithProjectListContext( + component: React.ReactElement, + contextProps = {} +) { fetchMock.post('express:/api/project', { status: 200, body: { projects: projectsData, totalSize: projectsData.length }, }) - const ProjectListProviderWrapper = ({ children }) => ( - {children} - ) + const ProjectListProviderWrapper = ({ + children, + }: { + children: React.ReactNode + }) => {children} return render(component, { wrapper: ProjectListProviderWrapper,