Merge pull request #19027 from overleaf/ii-bs5-projects-list-table

[web] BS5 projects table migration

GitOrigin-RevId: 237bd8113c68d7fd1b66712f7361eb956b1e10e7
This commit is contained in:
ilkin-overleaf 2024-07-10 16:34:19 +03:00 committed by Copybot
parent bac35566ff
commit c3ed95bc48
64 changed files with 1301 additions and 540 deletions

View file

@ -1224,7 +1224,6 @@
"shortcut_to_open_advanced_reference_search": "", "shortcut_to_open_advanced_reference_search": "",
"show_all": "", "show_all": "",
"show_all_projects": "", "show_all_projects": "",
"show_all_uppercase": "",
"show_document_preamble": "", "show_document_preamble": "",
"show_hotkeys": "", "show_hotkeys": "",
"show_in_code": "", "show_in_code": "",
@ -1232,7 +1231,6 @@
"show_less": "", "show_less": "",
"show_local_file_contents": "", "show_local_file_contents": "",
"show_outline": "", "show_outline": "",
"show_x_more": "",
"show_x_more_projects": "", "show_x_more_projects": "",
"showing_1_result": "", "showing_1_result": "",
"showing_1_result_of_total": "", "showing_1_result_of_total": "",

View file

@ -2,16 +2,20 @@
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import {
Modal,
Alert,
Button,
ControlLabel,
FormControl,
FormGroup,
} from 'react-bootstrap'
import { postJSON } from '../../../infrastructure/fetch-json' import { postJSON } from '../../../infrastructure/fetch-json'
import { CloneProjectTag } from './clone-project-tag' import { CloneProjectTag } from './clone-project-tag'
import {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import Notification from '@/shared/components/notification'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function CloneProjectModalContent({ export default function CloneProjectModalContent({
handleHide, handleHide,
@ -77,19 +81,15 @@ export default function CloneProjectModalContent({
return ( return (
<> <>
<Modal.Header closeButton> <OLModalHeader closeButton>
<Modal.Title>{t('copy_project')}</Modal.Title> <OLModalTitle>{t('copy_project')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
<form id="clone-project-form" onSubmit={handleSubmit}> <OLForm id="clone-project-form" onSubmit={handleSubmit}>
<FormGroup> <OLFormGroup controlId="clone-project-form-name">
<ControlLabel htmlFor="clone-project-form-name"> <OLFormLabel>{t('new_name')}</OLFormLabel>
{t('new_name')} <OLFormControl
</ControlLabel>
<FormControl
id="clone-project-form-name"
type="text" type="text"
placeholder="New Project Name" placeholder="New Project Name"
required required
@ -97,13 +97,14 @@ export default function CloneProjectModalContent({
onChange={event => setClonedProjectName(event.target.value)} onChange={event => setClonedProjectName(event.target.value)}
autoFocus autoFocus
/> />
</FormGroup> </OLFormGroup>
{clonedProjectTags.length > 0 && ( {clonedProjectTags.length > 0 && (
<FormGroup className="clone-project-tag"> <OLFormGroup
<ControlLabel htmlFor="clone-project-tags-list"> controlId="clone-project-tags-list"
{t('tags')}:{' '} className="clone-project-tag mb-3"
</ControlLabel> >
<OLFormLabel>{t('tags')}: </OLFormLabel>
<div role="listbox" id="clone-project-tags-list"> <div role="listbox" id="clone-project-tags-list">
{clonedProjectTags.map(tag => ( {clonedProjectTags.map(tag => (
<CloneProjectTag <CloneProjectTag
@ -113,37 +114,31 @@ export default function CloneProjectModalContent({
/> />
))} ))}
</div> </div>
</FormGroup> </OLFormGroup>
)} )}
</form> </OLForm>
{error && ( {error && (
<Alert bsStyle="danger"> <Notification
{error.length ? error : t('generic_something_went_wrong')} content={error.length ? error : t('generic_something_went_wrong')}
</Alert> type="error"
/>
)} )}
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<Button <OLButton variant="secondary" disabled={inFlight} onClick={handleHide}>
type="button"
bsStyle={null}
className="btn-secondary"
disabled={inFlight}
onClick={handleHide}
>
{t('cancel')} {t('cancel')}
</Button> </OLButton>
<OLButton
<Button variant="primary"
disabled={inFlight || !valid}
form="clone-project-form" form="clone-project-form"
type="submit" type="submit"
bsStyle="primary"
disabled={inFlight || !valid}
> >
{inFlight ? <>{t('copying')}</> : t('copy')} {inFlight ? <>{t('copying')}</> : t('copy')}
</Button> </OLButton>
</Modal.Footer> </OLModalFooter>
</> </>
) )
} }

View file

@ -1,7 +1,7 @@
import React, { memo, useCallback, useState } from 'react' import React, { memo, useCallback, useState } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import CloneProjectModalContent from './clone-project-modal-content' import CloneProjectModalContent from './clone-project-modal-content'
import AccessibleModal from '../../../shared/components/accessible-modal' import OLModal from '@/features/ui/components/ol/ol-modal'
function CloneProjectModal({ function CloneProjectModal({
show, show,
@ -20,7 +20,7 @@ function CloneProjectModal({
}, [handleHide, inFlight]) }, [handleHide, inFlight])
return ( return (
<AccessibleModal <OLModal
animation animation
show={show} show={show}
onHide={onHide} onHide={onHide}
@ -38,7 +38,7 @@ function CloneProjectModal({
projectName={projectName} projectName={projectName}
projectTags={projectTags} projectTags={projectTags}
/> />
</AccessibleModal> </OLModal>
) )
} }

View file

@ -1,5 +1,13 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { Dropdown } from 'react-bootstrap' import { useTranslation } from 'react-i18next'
import { Dropdown as BS3Dropdown } from 'react-bootstrap'
import { Spinner } from 'react-bootstrap-5'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import MenuItemButton from './menu-item-button' import MenuItemButton from './menu-item-button'
import Icon from '../../../../shared/components/icon' import Icon from '../../../../shared/components/icon'
import CopyProjectButton from '../table/cells/action-buttons/copy-project-button' import CopyProjectButton from '../table/cells/action-buttons/copy-project-button'
@ -13,10 +21,12 @@ import DeleteProjectButton from '../table/cells/action-buttons/delete-project-bu
import { Project } from '../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../types/project/dashboard/api'
import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button' import CompileAndDownloadProjectPDFButton from '../table/cells/action-buttons/compile-and-download-project-pdf-button'
import RenameProjectButton from '../table/cells/action-buttons/rename-project-button' import RenameProjectButton from '../table/cells/action-buttons/rename-project-button'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type ActionButtonProps = { type ActionButtonProps = {
project: Project project: Project
onClick: () => void // eslint-disable-line react/no-unused-prop-types onClick: <T extends React.MouseEvent>(e?: T, fn?: (e?: T) => void) => void
} }
function CopyProjectButtonMenuItem({ project, onClick }: ActionButtonProps) { function CopyProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
@ -24,7 +34,7 @@ function CopyProjectButtonMenuItem({ project, onClick }: ActionButtonProps) {
<CopyProjectButton project={project}> <CopyProjectButton project={project}>
{(text, handleOpenModal) => ( {(text, handleOpenModal) => (
<MenuItemButton <MenuItemButton
onClick={() => handleOpenModal(onClick)} onClick={e => handleOpenModal(e, onClick)}
className="projects-action-menu-item" className="projects-action-menu-item"
> >
<Icon type="files-o" className="menu-item-button-icon" />{' '} <Icon type="files-o" className="menu-item-button-icon" />{' '}
@ -43,7 +53,7 @@ function CompileAndDownloadProjectPDFButtonMenuItem({
<CompileAndDownloadProjectPDFButton project={project}> <CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => ( {(text, pendingCompile, downloadProject) => (
<MenuItemButton <MenuItemButton
onClick={() => downloadProject(onClick)} onClick={e => downloadProject(e, onClick)}
className="projects-action-menu-item" className="projects-action-menu-item"
> >
{pendingCompile ? ( {pendingCompile ? (
@ -219,7 +229,7 @@ type ActionDropdownProps = {
project: Project project: Project
} }
function ActionsDropdown({ project }: ActionDropdownProps) { export function BS3ActionsDropdown({ project }: ActionDropdownProps) {
const [isOpened, setIsOpened] = useState(false) const [isOpened, setIsOpened] = useState(false)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@ -227,16 +237,16 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
}, [setIsOpened]) }, [setIsOpened])
return ( return (
<Dropdown <BS3Dropdown
id={`project-actions-dropdown-${project.id}`} id={`project-actions-dropdown-${project.id}`}
pullRight pullRight
open={isOpened} open={isOpened}
onToggle={open => setIsOpened(open)} onToggle={open => setIsOpened(open)}
> >
<Dropdown.Toggle noCaret className="btn-transparent"> <BS3Dropdown.Toggle noCaret className="btn-transparent">
<Icon type="ellipsis-h" fw /> <Icon type="ellipsis-h" fw />
</Dropdown.Toggle> </BS3Dropdown.Toggle>
<Dropdown.Menu className="projects-dropdown-menu text-left"> <BS3Dropdown.Menu className="projects-dropdown-menu text-left">
<RenameProjectButtonMenuItem project={project} onClick={handleClose} /> <RenameProjectButtonMenuItem project={project} onClick={handleClose} />
<CopyProjectButtonMenuItem project={project} onClick={handleClose} /> <CopyProjectButtonMenuItem project={project} onClick={handleClose} />
<DownloadProjectButtonMenuItem <DownloadProjectButtonMenuItem
@ -256,9 +266,181 @@ function ActionsDropdown({ project }: ActionDropdownProps) {
<UntrashProjectButtonMenuItem project={project} onClick={handleClose} /> <UntrashProjectButtonMenuItem project={project} onClick={handleClose} />
<LeaveProjectButtonMenuItem project={project} onClick={handleClose} /> <LeaveProjectButtonMenuItem project={project} onClick={handleClose} />
<DeleteProjectButtonMenuItem project={project} onClick={handleClose} /> <DeleteProjectButtonMenuItem project={project} onClick={handleClose} />
</Dropdown.Menu> </BS3Dropdown.Menu>
</BS3Dropdown>
)
}
function BS5ActionsDropdown({ project }: ActionDropdownProps) {
const { t } = useTranslation()
return (
<Dropdown align="end">
<DropdownToggle
id={`project-actions-dropdown-toggle-btn-${project.id}`}
bsPrefix="dropdown-table-button-toggle"
>
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
</DropdownToggle>
<DropdownMenu flip={false}>
<RenameProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="edit"
>
{text}
</DropdownItem>
</li>
)}
</RenameProjectButton>
<CopyProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="file_copy"
>
{text}
</DropdownItem>
</li>
)}
</CopyProjectButton>
<DownloadProjectButton project={project}>
{(text, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={downloadProject}
leadingIcon="cloud_download"
>
{text}
</DropdownItem>
</li>
)}
</DownloadProjectButton>
<CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, downloadProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={e => {
e.stopPropagation()
downloadProject()
}}
leadingIcon={
pendingCompile ? (
<Spinner
animation="border"
aria-hidden="true"
as="span"
className="dropdown-item-leading-icon spinner"
size="sm"
role="status"
/>
) : (
'picture_as_pdf'
)
}
>
{text}
</DropdownItem>
</li>
)}
</CompileAndDownloadProjectPDFButton>
<ArchiveProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="inbox"
>
{text}
</DropdownItem>
</li>
)}
</ArchiveProjectButton>
<TrashProjectButton project={project}>
{(text, handleOpenModal) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleOpenModal}
leadingIcon="delete"
>
{text}
</DropdownItem>
</li>
)}
</TrashProjectButton>
<UnarchiveProjectButton project={project}>
{(text, unarchiveProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={unarchiveProject}
leadingIcon="restore_page"
>
{text}
</DropdownItem>
</li>
)}
</UnarchiveProjectButton>
<UntrashProjectButton project={project}>
{(text, untrashProject) => (
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={untrashProject}
leadingIcon="restore_page"
>
{text}
</DropdownItem>
</li>
)}
</UntrashProjectButton>
<LeaveProjectButton project={project}>
{text => (
<li role="none">
<DropdownItem as="button" tabIndex={-1} leadingIcon="logout">
{text}
</DropdownItem>
</li>
)}
</LeaveProjectButton>
<DeleteProjectButton project={project}>
{text => (
<li role="none">
<DropdownItem as="button" tabIndex={-1} leadingIcon="block">
{text}
</DropdownItem>
</li>
)}
</DeleteProjectButton>
</DropdownMenu>
</Dropdown> </Dropdown>
) )
} }
function ActionsDropdown({ project }: ActionDropdownProps) {
return (
<BootstrapVersionSwitcher
bs3={<BS3ActionsDropdown project={project} />}
bs5={<BS5ActionsDropdown project={project} />}
/>
)
}
export default ActionsDropdown export default ActionsDropdown

View file

@ -2,7 +2,7 @@ import { ReactNode } from 'react'
type MenuItemButtonProps = { type MenuItemButtonProps = {
children: ReactNode children: ReactNode
onClick?: (...args: unknown[]) => void onClick?: (e?: React.MouseEvent) => void
className?: string className?: string
afterNode?: React.ReactNode afterNode?: React.ReactNode
} }

View file

@ -1,6 +1,6 @@
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context' import { useProjectListContext } from '../context/project-list-context'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function LoadMore() { export default function LoadMore() {
const { const {
@ -13,16 +13,17 @@ export default function LoadMore() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className="text-centered"> <div className="text-center">
{hiddenProjectsCount > 0 ? ( {hiddenProjectsCount > 0 ? (
<Button <>
bsStyle={null} <OLButton
className="project-list-load-more-button btn-secondary-info btn-secondary" variant="secondary"
onClick={() => loadMoreProjects()} className="project-list-load-more-button"
aria-label={t('show_x_more_projects', { x: loadMoreCount })} onClick={() => loadMoreProjects()}
> >
{t('show_x_more', { x: loadMoreCount })} {t('show_x_more_projects', { x: loadMoreCount })}
</Button> </OLButton>
</>
) : null} ) : null}
<p> <p>
{hiddenProjectsCount > 0 ? ( {hiddenProjectsCount > 0 ? (
@ -33,15 +34,13 @@ export default function LoadMore() {
n: visibleProjects.length + hiddenProjectsCount, n: visibleProjects.length + hiddenProjectsCount,
})} })}
</span>{' '} </span>{' '}
<button <OLButton
type="button" variant="link"
onClick={() => showAllProjects()} onClick={() => showAllProjects()}
style={{ padding: 0 }} className="btn-inline-link"
className="btn-link"
aria-label={t('show_all_projects')}
> >
{t('show_all_uppercase')} {t('show_all_projects')}
</button> </OLButton>
</> </>
) : ( ) : (
<span aria-live="polite"> <span aria-live="polite">

View file

@ -1,5 +1,4 @@
import { memo, useEffect, useState } from 'react' import { memo, useEffect, useState } from 'react'
import { Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../types/project/dashboard/api'
import { getUserFacingMessage } from '../../../../infrastructure/fetch-json' import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
@ -12,6 +11,7 @@ import OLModal, {
OLModalBody, OLModalBody,
OLModalFooter, OLModalFooter,
OLModalHeader, OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal' } from '@/features/ui/components/ol/ol-modal'
type ProjectsActionModalProps = { type ProjectsActionModalProps = {
@ -80,7 +80,7 @@ function ProjectsActionModal({
backdrop="static" backdrop="static"
> >
<OLModalHeader closeButton> <OLModalHeader closeButton>
<Modal.Title>{title}</Modal.Title> <OLModalTitle>{title}</OLModalTitle>
</OLModalHeader> </OLModalHeader>
<OLModalBody> <OLModalBody>
{children} {children}

View file

@ -8,7 +8,7 @@ type ProjectsToDisplayProps = {
function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) { function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) {
return ( return (
<ul className="projects-action-list"> <ul>
{projectsToDisplay.map(project => ( {projectsToDisplay.map(project => (
<li <li
key={`projects-action-list-${project.id}`} key={`projects-action-list-${project.id}`}

View file

@ -1,13 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import {
Button,
ControlLabel,
FormControl,
FormGroup,
Modal,
} from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import * as eventTracking from '../../../../infrastructure/event-tracking' import * as eventTracking from '../../../../infrastructure/event-tracking'
import { Project } from '../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../types/project/dashboard/api'
import { renameProject } from '../../util/api' import { renameProject } from '../../util/api'
@ -17,6 +9,17 @@ import { getUserFacingMessage } from '../../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { isSmallDevice } from '../../../../infrastructure/event-tracking' import { isSmallDevice } from '../../../../infrastructure/event-tracking'
import Notification from '@/shared/components/notification' import Notification from '@/shared/components/notification'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
type RenameProjectModalProps = { type RenameProjectModalProps = {
handleCloseModal: () => void handleCloseModal: () => void
@ -82,24 +85,22 @@ function RenameProjectModal({
] ]
) )
const handleOnChange = ( const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event: React.ChangeEvent<HTMLFormElement & FormControl>
) => {
setNewProjectName(event.target.value) setNewProjectName(event.target.value)
} }
return ( return (
<AccessibleModal <OLModal
animation animation
show={showModal} show={showModal}
onHide={handleCloseModal} onHide={handleCloseModal}
id="rename-project-modal" id="rename-project-modal"
backdrop="static" backdrop="static"
> >
<Modal.Header closeButton> <OLModalHeader closeButton>
<Modal.Title>{t('rename_project')}</Modal.Title> <OLModalTitle>{t('rename_project')}</OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body> <OLModalBody>
{isError && ( {isError && (
<div className="notification-list"> <div className="notification-list">
<Notification <Notification
@ -108,41 +109,33 @@ function RenameProjectModal({
/> />
</div> </div>
)} )}
<form id="rename-project-form" onSubmit={handleSubmit}> <OLForm id="rename-project-form" onSubmit={handleSubmit}>
<FormGroup> <OLFormGroup controlId="rename-project-form-name">
<ControlLabel htmlFor="rename-project-form-name"> <OLFormLabel>{t('new_name')}</OLFormLabel>
{t('new_name')} <OLFormControl
</ControlLabel>
<FormControl
id="rename-project-form-name"
type="text" type="text"
placeholder={t('project_name')} placeholder={t('project_name')}
required required
value={newProjectName} value={newProjectName}
onChange={handleOnChange} onChange={handleOnChange}
/> />
</FormGroup> </OLFormGroup>
</form> </OLForm>
</Modal.Body> </OLModalBody>
<Modal.Footer> <OLModalFooter>
<Button <OLButton variant="secondary" onClick={handleCloseModal}>
bsStyle={null}
className="btn-secondary"
onClick={handleCloseModal}
>
{t('cancel')} {t('cancel')}
</Button> </OLButton>
<Button <OLButton
form="rename-project-form" variant="primary"
bsStyle="primary"
disabled={isLoading || !isValid}
type="submit" type="submit"
form="rename-project-form"
disabled={isLoading || !isValid}
> >
{t('rename')} {t('rename')}
</Button> </OLButton>
</Modal.Footer> </OLModalFooter>
</AccessibleModal> </OLModal>
) )
} }

View file

@ -4,7 +4,6 @@ import {
} from '../context/project-list-context' } from '../context/project-list-context'
import { ColorPickerProvider } from '../context/color-picker-context' import { ColorPickerProvider } from '../context/color-picker-context'
import * as eventTracking from '../../../infrastructure/event-tracking' import * as eventTracking from '../../../infrastructure/event-tracking'
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n' import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import CurrentPlanWidget from './current-plan-widget/current-plan-widget' import CurrentPlanWidget from './current-plan-widget/current-plan-widget'
@ -30,6 +29,9 @@ import OLCol from '@/features/ui/components/ol/ol-col'
import { bsVersion } from '@/features/utils/bootstrap-5' import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames' import classnames from 'classnames'
import Notification from '@/shared/components/notification' import Notification from '@/shared/components/notification'
import OLRow from '@/features/ui/components/ol/ol-row'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { TableContainer } from '@/features/ui/components/bootstrap-5/table'
function ProjectListRoot() { function ProjectListRoot() {
const { isReady } = useWaitForI18n() const { isReady } = useWaitForI18n()
@ -75,6 +77,32 @@ function ProjectListPageContent() {
const { t } = useTranslation() const { t } = useTranslation()
const tableTopArea = (
<div
className={classnames(
'pt-2',
'pb-3',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
<div className="clearfix">
<NewProjectButton
id="new-project-button-projects-table"
className="pull-left me-2"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden"
formGroupProps={{ className: 'mb-0' }}
/>
</div>
</div>
)
return isLoading ? ( return isLoading ? (
<div className="loading-container"> <div className="loading-container">
<LoadingBranded loadProgress={loadProgress} label={t('loading')} /> <LoadingBranded loadProgress={loadProgress} label={t('loading')} />
@ -94,41 +122,62 @@ function ProjectListPageContent() {
<Sidebar /> <Sidebar />
<div className="project-list-main-react"> <div className="project-list-main-react">
{error ? <DashApiError /> : ''} {error ? <DashApiError /> : ''}
<Row> <OLRow>
<Col xs={12}> <OLCol>
<UserNotifications /> <UserNotifications />
</Col> </OLCol>
</Row> </OLRow>
<div className="project-list-header-row"> <div className="project-list-header-row">
<ProjectListTitle <ProjectListTitle
filter={filter} filter={filter}
selectedTag={selectedTag} selectedTag={selectedTag}
selectedTagId={selectedTagId} selectedTagId={selectedTagId}
className="hidden-xs text-truncate" className={classnames(
'text-truncate',
bsVersion({
bs5: 'd-none d-md-block',
bs3: 'hidden-xs',
})
)}
/> />
<div className="project-tools"> <div className="project-tools">
<div className="hidden-xs"> <div
className={bsVersion({
bs5: 'd-none d-md-block',
bs3: 'hidden-xs',
})}
>
{selectedProjects.length === 0 ? ( {selectedProjects.length === 0 ? (
<CurrentPlanWidget /> <CurrentPlanWidget />
) : ( ) : (
<ProjectTools /> <ProjectTools />
)} )}
</div> </div>
<div className="visible-xs"> <div
className={bsVersion({
bs5: 'd-md-none',
bs3: 'visible-xs',
})}
>
<CurrentPlanWidget /> <CurrentPlanWidget />
</div> </div>
</div> </div>
</div> </div>
<Row className="hidden-xs"> <OLRow
<Col md={7}> className={bsVersion({
bs5: 'd-none d-md-block',
bs3: 'hidden-xs',
})}
>
<OLCol lg={7}>
<SearchForm <SearchForm
inputValue={searchText} inputValue={searchText}
setInputValue={setSearchText} setInputValue={setSearchText}
filter={filter} filter={filter}
selectedTag={selectedTag} selectedTag={selectedTag}
/> />
</Col> </OLCol>
</Row> </OLRow>
<div <div
className={classnames( className={classnames(
'project-list-sidebar-survey-wrapper', 'project-list-sidebar-survey-wrapper',
@ -137,60 +186,59 @@ function ProjectListPageContent() {
> >
<SurveyWidget /> <SurveyWidget />
</div> </div>
<div className="visible-xs mt-1"> <div
className={classnames(
'mt-1',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
<div role="toolbar" className="projects-toolbar"> <div role="toolbar" className="projects-toolbar">
<ProjectsDropdown /> <ProjectsDropdown />
<SortByDropdown /> <SortByDropdown />
</div> </div>
</div> </div>
<Row className="row-spaced"> <OLRow className="row-spaced">
<Col xs={12}> <OLCol>
<div className="card project-list-card"> <BootstrapVersionSwitcher
<div className="visible-xs pt-2 pb-3"> bs3={
<div className="clearfix"> <div className="card project-list-card">
<NewProjectButton {tableTopArea}
id="new-project-button-projects-table" <ProjectListTable />
className="pull-left me-2"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden"
formGroupProps={{ className: 'mb-0' }}
/>
</div> </div>
</div> }
<ProjectListTable /> bs5={
</div> <TableContainer bordered>
</Col> {tableTopArea}
</Row> <ProjectListTable />
<Row className="row-spaced"> </TableContainer>
<Col xs={12}> }
/>
</OLCol>
</OLRow>
<OLRow className="row-spaced">
<OLCol>
<LoadMore /> <LoadMore />
</Col> </OLCol>
</Row> </OLRow>
</div> </div>
</> </>
) : ( ) : (
<div className="project-list-welcome-wrapper"> <div className="project-list-welcome-wrapper">
{error ? <DashApiError /> : ''} {error ? <DashApiError /> : ''}
<Row className="row-spaced mx-0"> <OLRow className="row-spaced mx-0">
<OLCol <OLCol
md={{ span: 10, offset: 1 }} md={{ span: 10, offset: 1 }}
lg={{ span: 8, offset: 2 }} lg={{ span: 8, offset: 2 }}
className="project-list-empty-col" className="project-list-empty-col"
> >
<Row> <OLRow>
<OLCol> <OLCol>
<UserNotifications /> <UserNotifications />
</OLCol> </OLCol>
</Row> </OLRow>
<WelcomeMessage /> <WelcomeMessage />
</OLCol> </OLCol>
</Row> </OLRow>
</div> </div>
)} )}
</div> </div>
@ -201,7 +249,7 @@ function ProjectListPageContent() {
function DashApiError() { function DashApiError() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Row className="row-spaced"> <OLRow className="row-spaced">
<OLCol <OLCol
xs={{ span: 8, offset: 2 }} xs={{ span: 8, offset: 2 }}
bs3Props={{ xs: 8, xsOffset: 2 }} bs3Props={{ xs: 8, xsOffset: 2 }}
@ -214,7 +262,7 @@ function DashApiError() {
/> />
</div> </div>
</OLCol> </OLCol>
</Row> </OLRow>
) )
} }

View file

@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Sort } from '../../../../../../types/project/dashboard/api' import { Sort } from '../../../../../../types/project/dashboard/api'
import { bsVersion } from '@/features/utils/bootstrap-5'
type SortBtnOwnProps = { type SortBtnOwnProps = {
column: string column: string
@ -26,7 +27,10 @@ function withContent<T extends SortBtnOwnProps>(
let screenReaderText = t('sort_by_x', { x: text }) let screenReaderText = t('sort_by_x', { x: text })
if (column === sort.by) { if (column === sort.by) {
iconType = sort.order === 'asc' ? 'caret-up' : 'caret-down' iconType =
sort.order === 'asc'
? bsVersion({ bs5: 'arrow_upward_alt', bs3: 'caret-up' })
: bsVersion({ bs5: 'arrow_downward_alt', bs3: 'caret-down' })
screenReaderText = t('reverse_x_sort_order', { x: text }) screenReaderText = t('reverse_x_sort_order', { x: text })
} }

View file

@ -1,12 +1,13 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import ArchiveProjectModal from '../../../modals/archive-project-modal' import ArchiveProjectModal from '../../../modals/archive-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import { archiveProject } from '../../../../util/api' import { archiveProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type ArchiveProjectButtonProps = { type ArchiveProjectButtonProps = {
project: Project project: Project
@ -65,20 +66,28 @@ const ArchiveProjectButtonTooltip = memo(function ArchiveProjectButtonTooltip({
return ( return (
<ArchiveProjectButton project={project}> <ArchiveProjectButton project={project}>
{(text, handleOpenModal) => ( {(text, handleOpenModal) => (
<Tooltip <OLTooltip
key={`tooltip-archive-project-${project.id}`} key={`tooltip-archive-project-${project.id}`}
id={`archive-project-${project.id}`} id={`archive-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleOpenModal}
onClick={handleOpenModal} variant="link"
> accessibilityLabel={text}
<Icon type="inbox" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'inbox',
bs3: 'inbox',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</ArchiveProjectButton> </ArchiveProjectButton>
) )

View file

@ -2,21 +2,31 @@ import { useTranslation } from 'react-i18next'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon' import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import * as eventTracking from '../../../../../../infrastructure/event-tracking' import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useLocation } from '../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../shared/hooks/use-location'
import useAbortController from '../../../../../../shared/hooks/use-abort-controller' import useAbortController from '../../../../../../shared/hooks/use-abort-controller'
import { postJSON } from '../../../../../../infrastructure/fetch-json' import { postJSON } from '../../../../../../infrastructure/fetch-json'
import AccessibleModal from '../../../../../../shared/components/accessible-modal'
import { Button, Modal } from 'react-bootstrap'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking' import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import { bsVersion } from '@/features/utils/bootstrap-5'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
type CompileAndDownloadProjectPDFButtonProps = { type CompileAndDownloadProjectPDFButtonProps = {
project: Project project: Project
children: ( children: (
text: string, text: string,
pendingDownload: boolean, pendingDownload: boolean,
downloadProject: (fn: () => void) => void downloadProject: <T extends React.MouseEvent>(
e?: T,
fn?: (e?: T) => void
) => void
) => React.ReactElement ) => React.ReactElement
} }
@ -31,7 +41,7 @@ function CompileAndDownloadProjectPDFButton({
const [pendingCompile, setPendingCompile] = useState(false) const [pendingCompile, setPendingCompile] = useState(false)
const downloadProject = useCallback( const downloadProject = useCallback(
onDone => { <T extends React.MouseEvent>(e?: T, onDone?: (e?: T) => void) => {
setPendingCompile(pendingCompile => { setPendingCompile(pendingCompile => {
if (pendingCompile) return true if (pendingCompile) return true
eventTracking.sendMB('project-list-page-interaction', { eventTracking.sendMB('project-list-page-interaction', {
@ -72,7 +82,7 @@ function CompileAndDownloadProjectPDFButton({
location.assign( location.assign(
`/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}` `/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
) )
onDone() onDone?.(e)
} else { } else {
setShowErrorModal(true) setShowErrorModal(true)
} }
@ -111,19 +121,19 @@ function CompileErrorModal({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<> <>
<AccessibleModal show onHide={handleClose}> <OLModal show onHide={handleClose}>
<Modal.Header closeButton> <OLModalHeader closeButton>
<Modal.Title> <OLModalTitle>
{project.name}: {t('pdf_unavailable_for_download')} {project.name}: {t('pdf_unavailable_for_download')}
</Modal.Title> </OLModalTitle>
</Modal.Header> </OLModalHeader>
<Modal.Body>{t('generic_linked_file_compile_error')}</Modal.Body> <OLModalBody>{t('generic_linked_file_compile_error')}</OLModalBody>
<Modal.Footer> <OLModalFooter>
<a href={`/project/${project.id}`}> <OLButton variant="primary" href={`/project/${project.id}`}>
<Button bsStyle="primary">{t('open_project')}</Button> {t('open_project')}
</a> </OLButton>
</Modal.Footer> </OLModalFooter>
</AccessibleModal> </OLModal>
</> </>
) )
} }
@ -135,24 +145,35 @@ const CompileAndDownloadProjectPDFButtonTooltip = memo(
return ( return (
<CompileAndDownloadProjectPDFButton project={project}> <CompileAndDownloadProjectPDFButton project={project}>
{(text, pendingCompile, compileAndDownloadProject) => ( {(text, pendingCompile, compileAndDownloadProject) => (
<Tooltip <OLTooltip
key={`tooltip-compile-and-download-project-${project.id}`} key={`tooltip-compile-and-download-project-${project.id}`}
id={`compile-and-download-project-${project.id}`} id={`compile-and-download-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={compileAndDownloadProject}
onClick={() => compileAndDownloadProject(() => {})} variant="link"
> accessibilityLabel={text}
{pendingCompile ? ( loadingLabel={text}
<Icon type="spinner" spin /> isLoading={pendingCompile}
) : ( className="action-btn"
<Icon type="file-pdf-o" /> icon={
)} bsVersion({
</button> bs5: 'picture_as_pdf',
</Tooltip> bs3: 'file-pdf-o',
}) as string
}
bs3Props={{
fw: true,
loading: pendingCompile ? (
<Icon type="spinner" fw accessibilityLabel={text} spin />
) : null,
}}
/>
</span>
</OLTooltip>
)} )}
</CompileAndDownloadProjectPDFButton> </CompileAndDownloadProjectPDFButton>
) )

View file

@ -1,7 +1,5 @@
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
@ -12,14 +10,18 @@ import {
} from '../../../../../../../../types/project/dashboard/api' } from '../../../../../../../../types/project/dashboard/api'
import { useProjectTags } from '@/features/project-list/hooks/use-project-tags' import { useProjectTags } from '@/features/project-list/hooks/use-project-tags'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking' import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type HandleOpenModal = (fn?: () => void) => void import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type CopyButtonProps = { type CopyButtonProps = {
project: Project project: Project
children: ( children: (
text: string, text: string,
handleOpenModal: HandleOpenModal handleOpenModal: <T extends React.MouseEvent>(
e?: T,
fn?: (e?: T) => void
) => void
) => React.ReactElement ) => React.ReactElement
} }
@ -37,9 +39,9 @@ function CopyProjectButton({ project, children }: CopyButtonProps) {
const projectTags = useProjectTags(project.id) const projectTags = useProjectTags(project.id)
const handleOpenModal = useCallback( const handleOpenModal = useCallback(
(onOpen?: Parameters<HandleOpenModal>[0]) => { <T extends React.MouseEvent>(e?: T, onOpen?: (e?: T) => void) => {
setShowModal(true) setShowModal(true)
onOpen?.() onOpen?.(e)
}, },
[] []
) )
@ -97,20 +99,28 @@ const CopyProjectButtonTooltip = memo(function CopyProjectButtonTooltip({
return ( return (
<CopyProjectButton project={project}> <CopyProjectButton project={project}>
{(text, handleOpenModal) => ( {(text, handleOpenModal) => (
<Tooltip <OLTooltip
key={`tooltip-copy-project-${project.id}`} key={`tooltip-copy-project-${project.id}`}
id={`copy-project-${project.id}`} id={`copy-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleOpenModal}
onClick={() => handleOpenModal()} variant="link"
> accessibilityLabel={text}
<Icon type="files-o" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'file_copy',
bs3: 'files-o',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</CopyProjectButton> </CopyProjectButton>
) )

View file

@ -1,13 +1,14 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import DeleteProjectModal from '../../../modals/delete-project-modal' import DeleteProjectModal from '../../../modals/delete-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { deleteProject } from '../../../../util/api' import { deleteProject } from '../../../../util/api'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type DeleteProjectButtonProps = { type DeleteProjectButtonProps = {
project: Project project: Project
@ -63,20 +64,28 @@ const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({
return ( return (
<DeleteProjectButton project={project}> <DeleteProjectButton project={project}>
{(text, handleOpenModal) => ( {(text, handleOpenModal) => (
<Tooltip <OLTooltip
key={`tooltip-delete-project-${project.id}`} key={`tooltip-delete-project-${project.id}`}
id={`delete-project-${project.id}`} id={`delete-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleOpenModal}
onClick={handleOpenModal} variant="link"
> accessibilityLabel={text}
<Icon type="ban" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'block',
bs3: 'ban',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</DeleteProjectButton> </DeleteProjectButton>
) )

View file

@ -1,11 +1,12 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import * as eventTracking from '../../../../../../infrastructure/event-tracking' import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useLocation } from '../../../../../../shared/hooks/use-location' import { useLocation } from '../../../../../../shared/hooks/use-location'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking' import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type DownloadProjectButtonProps = { type DownloadProjectButtonProps = {
project: Project project: Project
@ -39,20 +40,28 @@ const DownloadProjectButtonTooltip = memo(
return ( return (
<DownloadProjectButton project={project}> <DownloadProjectButton project={project}>
{(text, downloadProject) => ( {(text, downloadProject) => (
<Tooltip <OLTooltip
key={`tooltip-download-project-${project.id}`} key={`tooltip-download-project-${project.id}`}
id={`download-project-${project.id}`} id={`download-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={downloadProject}
onClick={downloadProject} variant="link"
> accessibilityLabel={text}
<Icon type="cloud-download" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'cloud_download',
bs3: 'cloud-download',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</DownloadProjectButton> </DownloadProjectButton>
) )

View file

@ -1,13 +1,14 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 LeaveProjectModal from '../../../modals/leave-project-modal'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { leaveProject } from '../../../../util/api' import { leaveProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import getMeta from '@/utils/meta' import getMeta from '@/utils/meta'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type LeaveProjectButtonProps = { type LeaveProjectButtonProps = {
project: Project project: Project
@ -62,20 +63,28 @@ const LeaveProjectButtonTooltip = memo(function LeaveProjectButtonTooltip({
return ( return (
<LeaveProjectButton project={project}> <LeaveProjectButton project={project}>
{(text, handleOpenModal) => ( {(text, handleOpenModal) => (
<Tooltip <OLTooltip
key={`tooltip-leave-project-${project.id}`} key={`tooltip-leave-project-${project.id}`}
id={`leave-project-${project.id}`} id={`leave-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleOpenModal}
onClick={handleOpenModal} variant="link"
> accessibilityLabel={text}
<Icon type="sign-out" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'logout',
bs3: 'sign-out',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</LeaveProjectButton> </LeaveProjectButton>
) )

View file

@ -1,12 +1,13 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import TrashProjectModal from '../../../modals/trash-project-modal' import TrashProjectModal from '../../../modals/trash-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted' import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import { trashProject } from '../../../../util/api' import { trashProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type TrashProjectButtonProps = { type TrashProjectButtonProps = {
project: Project project: Project
@ -62,20 +63,28 @@ const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({
return ( return (
<TrashProjectButton project={project}> <TrashProjectButton project={project}>
{(text, handleOpenModal) => ( {(text, handleOpenModal) => (
<Tooltip <OLTooltip
key={`tooltip-trash-project-${project.id}`} key={`tooltip-trash-project-${project.id}`}
id={`trash-project-${project.id}`} id={`trash-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleOpenModal}
onClick={handleOpenModal} variant="link"
> accessibilityLabel={text}
<Icon type="trash" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'delete',
bs3: 'trash',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</TrashProjectButton> </TrashProjectButton>
) )

View file

@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import { unarchiveProject } from '../../../../util/api' import { unarchiveProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type UnarchiveProjectButtonProps = { type UnarchiveProjectButtonProps = {
project: Project project: Project
@ -41,20 +42,28 @@ const UnarchiveProjectButtonTooltip = memo(
return ( return (
<UnarchiveProjectButton project={project}> <UnarchiveProjectButton project={project}>
{(text, handleUnarchiveProject) => ( {(text, handleUnarchiveProject) => (
<Tooltip <OLTooltip
key={`tooltip-unarchive-project-${project.id}`} key={`tooltip-unarchive-project-${project.id}`}
id={`unarchive-project-${project.id}`} id={`unarchive-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleUnarchiveProject}
onClick={handleUnarchiveProject} variant="link"
> accessibilityLabel={text}
<Icon type="reply" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'restore_page',
bs3: 'reply',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</UnarchiveProjectButton> </UnarchiveProjectButton>
) )

View file

@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import { useProjectListContext } from '../../../../context/project-list-context' import { useProjectListContext } from '../../../../context/project-list-context'
import { untrashProject } from '../../../../util/api' import { untrashProject } from '../../../../util/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import { bsVersion } from '@/features/utils/bootstrap-5'
type UntrashProjectButtonProps = { type UntrashProjectButtonProps = {
project: Project project: Project
@ -40,20 +41,28 @@ const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({
return ( return (
<UntrashProjectButton project={project}> <UntrashProjectButton project={project}>
{(text, handleUntrashProject) => ( {(text, handleUntrashProject) => (
<Tooltip <OLTooltip
key={`tooltip-untrash-project-${project.id}`} key={`tooltip-untrash-project-${project.id}`}
id={`untrash-project-${project.id}`} id={`untrash-project-${project.id}`}
description={text} description={text}
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
> >
<button <span>
className="btn btn-link action-btn" <OLIconButton
aria-label={text} onClick={handleUntrashProject}
onClick={handleUntrashProject} variant="link"
> accessibilityLabel={text}
<Icon type="reply" fw /> className="action-btn"
</button> icon={
</Tooltip> bsVersion({
bs5: 'restore_page',
bs3: 'reply',
}) as string
}
bs3Props={{ fw: true }}
/>
</span>
</OLTooltip>
)} )}
</UntrashProjectButton> </UntrashProjectButton>
) )

View file

@ -1,7 +1,7 @@
import { formatDate, fromNowDate } from '../../../../../utils/dates' import { formatDate, fromNowDate } from '../../../../../utils/dates'
import { Project } from '../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../types/project/dashboard/api'
import Tooltip from '../../../../../shared/components/tooltip'
import { LastUpdatedBy } from '@/features/project-list/components/table/cells/last-updated-by' import { LastUpdatedBy } from '@/features/project-list/components/table/cells/last-updated-by'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type LastUpdatedCellProps = { type LastUpdatedCellProps = {
project: Project project: Project
@ -12,7 +12,7 @@ export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
const tooltipText = formatDate(project.lastUpdated) const tooltipText = formatDate(project.lastUpdated)
return ( return (
<Tooltip <OLTooltip
key={`tooltip-last-updated-${project.id}`} key={`tooltip-last-updated-${project.id}`}
id={`tooltip-last-updated-${project.id}`} id={`tooltip-last-updated-${project.id}`}
description={tooltipText} description={tooltipText}
@ -28,6 +28,6 @@ export default function LastUpdatedCell({ project }: LastUpdatedCellProps) {
) : ( ) : (
<span>{lastUpdatedDate}</span> <span>{lastUpdatedDate}</span>
)} )}
</Tooltip> </OLTooltip>
) )
} }

View file

@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Icon from '../../../../../shared/components/icon' import Icon from '../../../../../shared/components/icon'
import Tooltip from '../../../../../shared/components/tooltip'
import { getOwnerName } from '../../../util/project' import { getOwnerName } from '../../../util/project'
import { Project } from '../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../types/project/dashboard/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
type LinkSharingIconProps = { type LinkSharingIconProps = {
prependSpace: boolean prependSpace: boolean
@ -17,7 +17,7 @@ function LinkSharingIcon({
}: LinkSharingIconProps) { }: LinkSharingIconProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Tooltip <OLTooltip
key={`tooltip-link-sharing-${project.id}`} key={`tooltip-link-sharing-${project.id}`}
id={`tooltip-link-sharing-${project.id}`} id={`tooltip-link-sharing-${project.id}`}
description={t('link_sharing')} description={t('link_sharing')}
@ -32,7 +32,7 @@ function LinkSharingIcon({
accessibilityLabel={t('link_sharing')} accessibilityLabel={t('link_sharing')}
/> />
</span> </span>
</Tooltip> </OLTooltip>
) )
} }

View file

@ -1,26 +1,32 @@
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '@/features/project-list/context/project-list-context' import { useProjectListContext } from '@/features/project-list/context/project-list-context'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
export const ProjectCheckbox = memo<{ projectId: string }>(({ projectId }) => { export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
const { selectedProjectIds, toggleSelectedProject } = useProjectListContext() ({ projectId, projectName }) => {
const { t } = useTranslation()
const { selectedProjectIds, toggleSelectedProject } =
useProjectListContext()
const handleCheckboxChange = useCallback( const handleCheckboxChange = useCallback(
event => { event => {
toggleSelectedProject(projectId, event.target.checked) toggleSelectedProject(projectId, event.target.checked)
}, },
[projectId, toggleSelectedProject] [projectId, toggleSelectedProject]
) )
return ( return (
<input <OLFormCheckbox
type="checkbox" autoComplete="off"
id={`select-project-${projectId}`} onChange={handleCheckboxChange}
autoComplete="off" checked={selectedProjectIds.has(projectId)}
checked={selectedProjectIds.has(projectId)} aria-label={t('select_project', { project: projectName })}
onChange={handleCheckboxChange} data-project-id={projectId}
data-project-id={projectId} bs3Props={{ bsClass: 'dash-cell-checkbox-wrapper' }}
/> />
) )
}) }
)
ProjectCheckbox.displayName = 'ProjectCheckbox' ProjectCheckbox.displayName = 'ProjectCheckbox'

View file

@ -1,5 +1,4 @@
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import InlineTags from './cells/inline-tags' import InlineTags from './cells/inline-tags'
import OwnerCell from './cells/owner-cell' import OwnerCell from './cells/owner-cell'
import LastUpdatedCell from './cells/last-updated-cell' import LastUpdatedCell from './cells/last-updated-cell'
@ -9,45 +8,90 @@ import { getOwnerName } from '../../util/project'
import { Project } from '../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../types/project/dashboard/api'
import { ProjectCheckbox } from './project-checkbox' import { ProjectCheckbox } from './project-checkbox'
import { ProjectListOwnerName } from '@/features/project-list/components/table/project-list-owner-name' import { ProjectListOwnerName } from '@/features/project-list/components/table/project-list-owner-name'
import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
type ProjectListTableRowProps = { type ProjectListTableRowProps = {
project: Project project: Project
selected: boolean
} }
function ProjectListTableRow({ project }: ProjectListTableRowProps) { function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
const { t } = useTranslation()
const ownerName = getOwnerName(project) const ownerName = getOwnerName(project)
return ( return (
<tr> <tr className={selected ? bsVersion({ bs5: 'table-active' }) : undefined}>
<td className="dash-cell-checkbox hidden-xs"> <td
<ProjectCheckbox projectId={project.id} /> className={classnames(
<label htmlFor={`select-project-${project.id}`} className="sr-only"> 'dash-cell-checkbox',
{t('select_project', { project: project.name })} bsVersion({
</label> bs5: 'd-none d-md-table-cell',
bs3: 'hidden-xs',
})
)}
>
<ProjectCheckbox projectId={project.id} projectName={project.name} />
</td> </td>
<td className="dash-cell-name"> <td className="dash-cell-name">
<a href={`/project/${project.id}`}>{project.name}</a>{' '} <a href={`/project/${project.id}`}>{project.name}</a>{' '}
<InlineTags className="hidden-xs" projectId={project.id} /> <InlineTags
className={bsVersion({
bs5: 'd-none d-md-inline',
bs3: 'hidden-xs',
})}
projectId={project.id}
/>
</td> </td>
<td className="dash-cell-date-owner visible-xs pb-0"> <td
className={classnames(
'dash-cell-date-owner',
'pb-0',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
<LastUpdatedCell project={project} /> <LastUpdatedCell project={project} />
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null} {ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
</td> </td>
<td className="dash-cell-owner hidden-xs"> <td
className={classnames(
'dash-cell-owner',
bsVersion({
bs5: 'd-none d-md-table-cell',
bs3: 'hidden-xs',
})
)}
>
<OwnerCell project={project} /> <OwnerCell project={project} />
</td> </td>
<td className="dash-cell-date hidden-xs"> <td
className={classnames(
'dash-cell-date',
bsVersion({
bs5: 'd-none d-md-table-cell',
bs3: 'hidden-xs',
})
)}
>
<LastUpdatedCell project={project} /> <LastUpdatedCell project={project} />
</td> </td>
<td className="dash-cell-tag visible-xs pt-0"> <td
className={classnames(
'dash-cell-tag',
'pt-0',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
<InlineTags projectId={project.id} /> <InlineTags projectId={project.id} />
</td> </td>
<td className="dash-cell-actions"> <td className="dash-cell-actions">
<div className="hidden-xs"> <div
className={bsVersion({
bs5: 'd-none d-md-block',
bs3: 'hidden-xs',
})}
>
<ActionsCell project={project} /> <ActionsCell project={project} />
</div> </div>
<div className="visible-xs"> <div className={bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })}>
<ActionsDropdown project={project} /> <ActionsDropdown project={project} />
</div> </div>
</td> </td>

View file

@ -1,21 +1,37 @@
import { useCallback } from 'react' import { useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Icon from '../../../../shared/components/icon' import Icon from '../../../../shared/components/icon'
import ProjectListTableRow from './project-list-table-row' import ProjectListTableRow from './project-list-table-row'
import { useProjectListContext } from '../../context/project-list-context' import { useProjectListContext } from '../../context/project-list-context'
import useSort from '../../hooks/use-sort' import useSort from '../../hooks/use-sort'
import withContent, { SortBtnProps } from '../sort/with-content' import withContent, { SortBtnProps } from '../sort/with-content'
import { Project } from '../../../../../../types/project/dashboard/api' import OLTable from '@/features/ui/components/ol/ol-table'
import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return ( return (
<button <button
className="btn-link table-header-sort-btn hidden-xs" className={classnames(
'table-header-sort-btn',
bsVersion({
bs5: 'd-none d-md-inline-block',
bs3: 'hidden-xs',
})
)}
onClick={onClick} onClick={onClick}
aria-label={screenReaderText} aria-label={screenReaderText}
> >
<span className="tablesort-text">{text}</span> <span className={bsVersion({ bs3: 'tablesort-text' })}>{text}</span>
{iconType && <Icon type={iconType} />} {iconType && (
<BootstrapVersionSwitcher
bs3={<Icon type={iconType} />}
bs5={<MaterialIcon type={iconType} />}
/>
)}
</button> </button>
) )
} }
@ -31,6 +47,7 @@ function ProjectListTable() {
selectOrUnselectAllProjects, selectOrUnselectAllProjects,
} = useProjectListContext() } = useProjectListContext()
const { handleSort } = useSort() const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>()
const handleAllProjectsCheckboxChange = useCallback( const handleAllProjectsCheckboxChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>) => {
@ -39,18 +56,39 @@ function ProjectListTable() {
[selectOrUnselectAllProjects] [selectOrUnselectAllProjects]
) )
useEffect(() => {
if (checkAllRef.current) {
checkAllRef.current.indeterminate =
selectedProjects.length > 0 &&
selectedProjects.length !== visibleProjects.length
}
}, [selectedProjects, visibleProjects])
return ( return (
<table className="project-dash-table"> <OLTable className="project-dash-table" container={false} hover>
<caption className="sr-only">{t('projects_list')}</caption> <caption
<thead className="sr-only-xs"> className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}
>
{t('projects_list')}
</caption>
<thead
className={bsVersion({
bs5: 'visually-hidden-max-md',
bs3: 'sr-only-xs',
})}
>
<tr> <tr>
<th <th
className="dash-cell-checkbox hidden-xs" className={classnames(
'dash-cell-checkbox',
bsVersion({
bs5: 'd-none d-md-table-cell',
bs3: 'hidden-xs',
})
)}
aria-label={t('select_projects')} aria-label={t('select_projects')}
> >
<input <OLFormCheckbox
type="checkbox"
id="project-list-table-select-all"
autoComplete="off" autoComplete="off"
onChange={handleAllProjectsCheckboxChange} onChange={handleAllProjectsCheckboxChange}
checked={ checked={
@ -58,10 +96,13 @@ function ProjectListTable() {
visibleProjects.length !== 0 visibleProjects.length !== 0
} }
disabled={visibleProjects.length === 0} disabled={visibleProjects.length === 0}
aria-label={t('select_all_projects')}
bs3Props={{
bsClass: 'dash-cell-checkbox-wrapper',
inputRef: undefined,
}}
inputRef={checkAllRef}
/> />
<label htmlFor="project-list-table-select-all" className="sr-only">
{t('select_all_projects')}
</label>
</th> </th>
<th <th
className="dash-cell-name" className="dash-cell-name"
@ -82,13 +123,22 @@ function ProjectListTable() {
/> />
</th> </th>
<th <th
className="dash-cell-date-owner visible-xs" className={classnames(
'dash-cell-date-owner',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
aria-label={t('date_and_owner')} aria-label={t('date_and_owner')}
> >
{t('date_and_owner')} {t('date_and_owner')}
</th> </th>
<th <th
className="dash-cell-owner hidden-xs" className={classnames(
'dash-cell-owner',
bsVersion({
bs5: 'd-none d-md-table-cell',
bs3: 'hidden-xs',
})
)}
aria-label={t('owner')} aria-label={t('owner')}
aria-sort={ aria-sort={
sort.by === 'owner' sort.by === 'owner'
@ -106,7 +156,13 @@ function ProjectListTable() {
/> />
</th> </th>
<th <th
className="dash-cell-date hidden-xs" className={classnames(
'dash-cell-date',
bsVersion({
bs5: 'd-none d-md-table-cell',
bs3: 'hidden-xs',
})
)}
aria-label={t('last_modified')} aria-label={t('last_modified')}
aria-sort={ aria-sort={
sort.by === 'lastUpdated' sort.by === 'lastUpdated'
@ -123,7 +179,13 @@ function ProjectListTable() {
onClick={() => handleSort('lastUpdated')} onClick={() => handleSort('lastUpdated')}
/> />
</th> </th>
<th className="dash-cell-tag visible-xs" aria-label={t('tags')}> <th
className={classnames(
'dash-cell-tag',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
aria-label={t('tags')}
>
{t('tags')} {t('tags')}
</th> </th>
<th className="dash-cell-actions" aria-label={t('actions')}> <th className="dash-cell-actions" aria-label={t('actions')}>
@ -131,21 +193,24 @@ function ProjectListTable() {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleProjects.length ? ( {visibleProjects.length > 0 ? (
visibleProjects.map((p: Project) => ( visibleProjects.map(p => (
<ProjectListTableRow project={p} key={p.id} /> <ProjectListTableRow
project={p}
selected={selectedProjects.some(({ id }) => id === p.id)}
key={p.id}
/>
)) ))
) : ( ) : (
<tr className="no-projects"> <tr className="no-projects">
<td className="project-list-table-no-projects-cell" colSpan={5}> <td className="text-center" colSpan={5}>
{t('no_projects')} {t('no_projects')}
</td> </td>
</tr> </tr>
)} )}
</tbody> </tbody>
</table> </OLTable>
) )
} }

View file

@ -15,6 +15,7 @@ export default function Button({
className, className,
leadingIcon, leadingIcon,
isLoading = false, isLoading = false,
loadingLabel,
size = 'default', size = 'default',
trailingIcon, trailingIcon,
variant = 'primary', variant = 'primary',
@ -41,7 +42,9 @@ export default function Button({
className={loadingSpinnerClassName} className={loadingSpinnerClassName}
role="status" role="status"
/> />
<span className="visually-hidden">{t('loading')}</span> <span className="visually-hidden">
{loadingLabel ?? t('loading')}
</span>
</span> </span>
)} )}
<span className="button-content" aria-hidden={isLoading}> <span className="button-content" aria-hidden={isLoading}>

View file

@ -29,7 +29,44 @@ export const DropdownItem = forwardRef<
{ active, children, description, leadingIcon, trailingIcon, ...props }, { active, children, description, leadingIcon, trailingIcon, ...props },
ref ref
) => { ) => {
const trailingIconType = active ? 'check' : trailingIcon let leadingIconComponent = null
if (leadingIcon) {
if (typeof leadingIcon === 'string') {
leadingIconComponent = (
<MaterialIcon
className="dropdown-item-leading-icon"
type={leadingIcon}
/>
)
} else {
leadingIconComponent = (
<span className="dropdown-item-leading-icon" aria-hidden="true">
{leadingIcon}
</span>
)
}
}
let trailingIconComponent = null
if (trailingIcon) {
if (typeof trailingIcon === 'string') {
const trailingIconType = active ? 'check' : trailingIcon
trailingIconComponent = (
<MaterialIcon
className="dropdown-item-trailing-icon"
type={trailingIconType}
/>
)
} else {
trailingIconComponent = (
<span className="dropdown-item-leading-icon" aria-hidden="true">
{trailingIcon}
</span>
)
}
}
return ( return (
<BS5DropdownItem <BS5DropdownItem
active={active} active={active}
@ -38,19 +75,9 @@ export const DropdownItem = forwardRef<
{...props} {...props}
ref={ref} ref={ref}
> >
{leadingIcon && ( {leadingIconComponent}
<MaterialIcon
className="dropdown-item-leading-icon"
type={leadingIcon}
/>
)}
{children} {children}
{trailingIconType && ( {trailingIconComponent}
<MaterialIcon
className="dropdown-item-trailing-icon"
type={trailingIconType}
/>
)}
{description && ( {description && (
<span className="dropdown-item-description">{description}</span> <span className="dropdown-item-description">{description}</span>
)} )}

View file

@ -1,22 +1,40 @@
import { Table as BS5Table } from 'react-bootstrap-5' import { Table as BS5Table } from 'react-bootstrap-5'
import classnames from 'classnames' import classnames from 'classnames'
function Table({ responsive, ...rest }: React.ComponentProps<typeof BS5Table>) { export function TableContainer({
const content = ( responsive,
bordered,
children,
}: React.ComponentProps<typeof BS5Table>) {
return (
<div <div
className={classnames('table-container', { className={classnames('table-container', {
'table-container-bordered': rest.bordered, 'table-container-bordered': bordered,
'table-responsive': responsive,
})} })}
> >
<BS5Table {...rest} /> {children}
</div> </div>
) )
}
if (responsive) { type TableProps = React.ComponentProps<typeof BS5Table> & {
return <div className="table-responsive d-flex">{content}</div> container?: boolean
} }
return content function Table({
container = true,
responsive,
bordered,
...rest
}: TableProps) {
return container ? (
<TableContainer responsive={responsive} bordered={bordered}>
<BS5Table {...rest} />
</TableContainer>
) : (
<BS5Table {...rest} />
)
} }
export default Table export default Table

View file

@ -1,13 +1,15 @@
import { Form } from 'react-bootstrap-5' import { Form } from 'react-bootstrap-5'
import { Checkbox as BS3Checkbox } from 'react-bootstrap' import { Checkbox as BS3Checkbox, Radio as BS3Radio } from 'react-bootstrap'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
type OLFormCheckboxProps = React.ComponentProps<(typeof Form)['Check']> & { type OLFormCheckboxProps = React.ComponentProps<(typeof Form)['Check']> & {
inputRef?: React.MutableRefObject<HTMLInputElement | undefined>
bs3Props?: Record<string, unknown> bs3Props?: Record<string, unknown>
} }
function OLFormCheckbox(props: OLFormCheckboxProps) { function OLFormCheckbox(props: OLFormCheckboxProps) {
const { bs3Props, ...rest } = props const { bs3Props, inputRef, ...rest } = props
const bs3FormLabelProps: React.ComponentProps<typeof BS3Checkbox> = { const bs3FormLabelProps: React.ComponentProps<typeof BS3Checkbox> = {
children: rest.label, children: rest.label,
@ -17,14 +19,27 @@ function OLFormCheckbox(props: OLFormCheckboxProps) {
disabled: rest.disabled, disabled: rest.disabled,
inline: rest.inline, inline: rest.inline,
title: rest.title, title: rest.title,
autoComplete: rest.autoComplete,
onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void, onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void,
inputRef: node => {
if (inputRef) {
inputRef.current = node
}
},
...getAriaAndDataProps(rest),
...bs3Props, ...bs3Props,
} }
return ( return (
<BootstrapVersionSwitcher <BootstrapVersionSwitcher
bs3={<BS3Checkbox {...bs3FormLabelProps} />} bs3={
bs5={<Form.Check {...rest} />} rest.type === 'radio' ? (
<BS3Radio {...bs3FormLabelProps} />
) : (
<BS3Checkbox {...bs3FormLabelProps} />
)
}
bs5={<Form.Check ref={inputRef} {...rest} />}
/> />
) )
} }

View file

@ -2,6 +2,7 @@ import { forwardRef } from 'react'
import { Form } from 'react-bootstrap-5' import { Form } from 'react-bootstrap-5'
import { FormControl as BS3FormControl } from 'react-bootstrap' import { FormControl as BS3FormControl } from 'react-bootstrap'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
type OLFormControlProps = React.ComponentProps<(typeof Form)['Control']> & { type OLFormControlProps = React.ComponentProps<(typeof Form)['Control']> & {
bs3Props?: Record<string, unknown> bs3Props?: Record<string, unknown>
@ -22,6 +23,7 @@ const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
placeholder: rest.placeholder, placeholder: rest.placeholder,
readOnly: rest.readOnly, readOnly: rest.readOnly,
autoComplete: rest.autoComplete, autoComplete: rest.autoComplete,
autoFocus: rest.autoFocus,
minLength: rest.minLength, minLength: rest.minLength,
maxLength: rest.maxLength, maxLength: rest.maxLength,
onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void, onChange: rest.onChange as (e: React.ChangeEvent<unknown>) => void,
@ -38,20 +40,9 @@ const OLFormControl = forwardRef<HTMLInputElement, OLFormControlProps>(
...bs3Props, ...bs3Props,
} }
// get all `aria-*` and `data-*` attributes
const extraProps = Object.entries(rest).reduce(
(acc, [key, value]) => {
if (key.startsWith('aria-') || key.startsWith('data-')) {
acc[key] = value
}
return acc
},
{} as Record<string, string>
)
bs3FormControlProps = { bs3FormControlProps = {
...bs3FormControlProps, ...bs3FormControlProps,
...extraProps, ...getAriaAndDataProps(rest),
'data-ol-dirty': rest['data-ol-dirty'], 'data-ol-dirty': rest['data-ol-dirty'],
} as typeof bs3FormControlProps & Record<string, unknown> } as typeof bs3FormControlProps & Record<string, unknown>

View file

@ -13,6 +13,7 @@ function OLForm(props: OLFormProps) {
componentClass: rest.as, componentClass: rest.as,
bsClass: rest.className, bsClass: rest.className,
children: rest.children, children: rest.children,
id: rest.id,
onSubmit: rest.onSubmit as React.FormEventHandler<BS3Form> | undefined, onSubmit: rest.onSubmit as React.FormEventHandler<BS3Form> | undefined,
...bs3Props, ...bs3Props,
} }

View file

@ -17,18 +17,19 @@ export type OLIconButtonProps = IconButtonProps & {
export default function OLIconButton(props: OLIconButtonProps) { export default function OLIconButton(props: OLIconButtonProps) {
const { bs3Props, ...rest } = props const { bs3Props, ...rest } = props
const { fw, ...bs3Rest } = bs3Props || {} const { fw, loading, ...bs3Rest } = bs3Props || {}
return ( return (
<BootstrapVersionSwitcher <BootstrapVersionSwitcher
bs3={ bs3={
<BS3Button {...bs3ButtonProps(rest)} {...bs3Rest}> <BS3Button {...bs3ButtonProps(rest)} {...bs3Rest}>
{bs3Props?.loading} {loading || (
<Icon <Icon
type={rest.icon} type={rest.icon}
fw={fw} fw={fw}
accessibilityLabel={rest.accessibilityLabel} accessibilityLabel={rest.accessibilityLabel}
/> />
)}
</BS3Button> </BS3Button>
} }
bs5={<IconButton {...rest} />} bs5={<IconButton {...rest} />}

View file

@ -7,7 +7,7 @@ type OLFormProps = React.ComponentProps<typeof Table> & {
} }
function OLTable(props: OLFormProps) { function OLTable(props: OLFormProps) {
const { bs3Props, ...rest } = props const { bs3Props, container, ...rest } = props
const bs3FormProps: React.ComponentProps<typeof BS3Table> = { const bs3FormProps: React.ComponentProps<typeof BS3Table> = {
bsClass: rest.className, bsClass: rest.className,
@ -21,7 +21,7 @@ function OLTable(props: OLFormProps) {
return ( return (
<BootstrapVersionSwitcher <BootstrapVersionSwitcher
bs3={<BS3Table {...bs3FormProps} />} bs3={<BS3Table {...bs3FormProps} />}
bs5={<Table {...rest} />} bs5={<Table container={container} {...rest} />}
/> />
) )
} }

View file

@ -13,7 +13,9 @@ function OLTooltip(props: OLTooltipProps) {
children: bs5Props.children, children: bs5Props.children,
id: bs5Props.id, id: bs5Props.id,
description: bs5Props.description, description: bs5Props.description,
overlayProps: {}, overlayProps: {
placement: bs5Props.overlayProps?.placement,
},
...bs3Props, ...bs3Props,
} }

View file

@ -10,6 +10,7 @@ export type ButtonProps = {
target?: string target?: string
rel?: string rel?: string
isLoading?: boolean isLoading?: boolean
loadingLabel?: string
onClick?: MouseEventHandler<HTMLButtonElement> onClick?: MouseEventHandler<HTMLButtonElement>
size?: 'small' | 'default' | 'large' size?: 'small' | 'default' | 'large'
trailingIcon?: string trailingIcon?: string

View file

@ -24,9 +24,9 @@ export type DropdownItemProps = PropsWithChildren<{
disabled?: boolean disabled?: boolean
eventKey?: string | number eventKey?: string | number
href?: string href?: string
leadingIcon?: string leadingIcon?: string | React.ReactNode
onClick?: React.MouseEventHandler onClick?: React.MouseEventHandler
trailingIcon?: string trailingIcon?: string | React.ReactNode
variant?: 'default' | 'danger' variant?: 'default' | 'danger'
className?: string className?: string
role?: string role?: string

View file

@ -5,3 +5,16 @@ export const isBootstrap5 = getMeta('ol-bootstrapVersion') === 5
export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => { export const bsVersion = ({ bs5, bs3 }: { bs5?: string; bs3?: string }) => {
return isBootstrap5 ? bs5 : bs3 return isBootstrap5 ? bs5 : bs3
} }
// get all `aria-*` and `data-*` attributes
export const getAriaAndDataProps = (obj: Record<string, unknown>) => {
return Object.entries(obj).reduce(
(acc, [key, value]) => {
if (key.startsWith('aria-') || key.startsWith('data-')) {
acc[key] = value
}
return acc
},
{} as Record<string, unknown>
)
}

View file

@ -1,5 +1,6 @@
import classNames from 'classnames' import classNames from 'classnames'
import React from 'react' import React from 'react'
import { bsVersion } from '@/features/utils/bootstrap-5'
type IconProps = React.ComponentProps<'i'> & { type IconProps = React.ComponentProps<'i'> & {
type: string type: string
@ -20,7 +21,9 @@ function MaterialIcon({
{type} {type}
</span> </span>
{accessibilityLabel && ( {accessibilityLabel && (
<span className="sr-only">{accessibilityLabel}</span> <span className={bsVersion({ bs5: 'visually-hidden', bs3: 'sr-only' })}>
{accessibilityLabel}
</span>
)} )}
</> </>
) )

View file

@ -417,11 +417,13 @@
border: 0; border: 0;
text-align: left; text-align: left;
color: @ol-type-color; color: @ol-type-color;
background-color: transparent;
padding: 0; padding: 0;
font-weight: bold; font-weight: bold;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-decoration: none;
&:hover, &:hover,
&:focus { &:focus {
@ -437,6 +439,14 @@
input[type='checkbox'] { input[type='checkbox'] {
margin-top: 5px; margin-top: 5px;
} }
.dash-cell-checkbox-wrapper {
label {
display: block;
margin: 0;
line-height: 1;
}
}
} }
.dash-cell-name { .dash-cell-name {
@ -827,13 +837,6 @@
} }
} }
.project-list-load-more {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.project-list-load-more-button { .project-list-load-more-button {
margin-bottom: @margin-sm; margin-bottom: @margin-sm;
} }

View file

@ -121,16 +121,11 @@
.project-list-table-name-cell, .project-list-table-name-cell,
.project-list-table-owner-cell, .project-list-table-owner-cell,
.project-list-table-lastupdated-cell, .project-list-table-lastupdated-cell,
.project-list-table-actions-cell, .project-list-table-actions-cell {
.project-list-table-no-projects-cell {
padding: (@line-height-computed / 4) 0; padding: (@line-height-computed / 4) 0;
vertical-align: top; vertical-align: top;
} }
.project-list-table-no-projects-cell {
text-align: center;
}
.project-list-table-name-cell { .project-list-table-name-cell {
width: 50%; width: 50%;
padding-right: @line-height-computed / 2; padding-right: @line-height-computed / 2;

View file

@ -52,5 +52,8 @@
// Components custom style // Components custom style
@import '../components/all'; @import '../components/all';
// Custom helpers
@import '../helpers/all';
// Pages custom style // Pages custom style
@import '../pages/all'; @import '../pages/all';

View file

@ -63,3 +63,7 @@ pre,
samp { samp {
@include body-base; @include body-base;
} }
.list-style-check-green {
list-style-image: url('../../../../public/img/fa-check-green.svg');
}

View file

@ -186,6 +186,10 @@
.icon-large { .icon-large {
font-size: 24px; font-size: 24px;
} }
.spinner {
margin: var(--spacing-01);
}
} }
.icon-button-small { .icon-button-small {

View file

@ -92,6 +92,11 @@
.dropdown-item-leading-icon { .dropdown-item-leading-icon {
padding-right: var(--spacing-04); padding-right: var(--spacing-04);
&.spinner {
margin-left: var(--spacing-01);
margin-right: var(--spacing-01);
}
} }
// description text should look disabled when the dropdown item is disabled // description text should look disabled when the dropdown item is disabled

View file

@ -1,74 +1,50 @@
.table-container { .table-container {
flex: 1; flex: 1;
margin-bottom: var(--spacing-06); margin-bottom: var(--spacing-06);
background-color: var(--white);
.table { .table {
margin-bottom: initial; margin-bottom: initial;
} }
} }
.table-container-bordered { .table {
--table-container-border-width: var(--bs-border-width); tr {
&:last-child {
border-color: $table-border-color;
border-radius: var(--border-radius-base);
border-width: var(--table-container-border-width);
border-style: solid;
.table {
th,
td {
&:first-child {
border-left-width: 0;
}
&:last-child {
border-right-width: 0;
}
}
tr:first-child {
border-top-width: 0;
th,
td { td {
&:first-child { border-bottom-width: 0;
border-top-left-radius: calc(
var(--border-radius-base) - var(--table-container-border-width)
);
}
}
th,
td {
&:last-child {
border-top-right-radius: calc(
var(--border-radius-base) - var(--table-container-border-width)
);
}
}
}
tr:last-child {
border-bottom-width: 0;
th,
td {
&:first-child {
border-bottom-left-radius: calc(
var(--border-radius-base) - var(--table-container-border-width)
);
}
}
th,
td {
&:last-child {
border-bottom-right-radius: calc(
var(--border-radius-base) - var(--table-container-border-width)
);
}
} }
} }
} }
th,
td {
a {
text-decoration: none;
}
}
}
.table-container-bordered {
padding: var(--spacing-04);
border-color: $table-border-color;
border-radius: var(--border-radius-base);
border-width: var(--bs-border-width);
border-style: solid;
}
.table-hover {
th {
&:hover {
background-color: $table-hover-bg;
}
}
}
.table-striped {
tr,
td {
border-top-width: 0;
border-bottom-width: 0;
}
} }

View file

@ -0,0 +1 @@
@import 'visually-hidden';

View file

@ -0,0 +1,5 @@
.visually-hidden-max-md {
@include media-breakpoint-down(md) {
@include visually-hidden();
}
}

View file

@ -12,6 +12,10 @@
} }
} }
.action-btn {
padding: 0 var(--spacing-02);
}
.project-list-react { .project-list-react {
body > &.content { body > &.content {
padding-top: $header-height; padding-top: $header-height;
@ -155,6 +159,240 @@
overflow-x: hidden; overflow-x: hidden;
padding: var(--spacing-08) var(--spacing-06); padding: var(--spacing-08) var(--spacing-06);
} }
.project-dash-table {
width: 100%;
table-layout: fixed;
@include media-breakpoint-down(md) {
tr:not(:last-child) {
border-bottom: 1px solid $table-border-color;
}
td {
border-bottom-width: 0;
}
}
tbody {
tr.no-projects:hover {
td {
box-shadow: none;
}
}
}
.table-header-sort-btn {
border: 0;
text-align: left;
color: var(--content-secondary);
background-color: transparent;
padding: 0;
font-weight: bold;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
&:hover,
&:focus {
color: var(--content-secondary);
text-decoration: none;
}
.material-symbols {
vertical-align: bottom;
font-size: var(--font-size-06);
}
}
.dash-cell-name {
hyphens: auto;
width: 50%;
word-break: break-word;
}
.dash-cell-owner {
width: 20%;
}
.dash-cell-date {
width: 25%;
}
.dash-cell-actions {
display: none;
text-align: right;
.btn {
text-decoration: none;
}
}
.dash-cell-date-owner {
font-size: $font-size-sm;
@include text-truncate();
}
@include media-breakpoint-up(sm) {
.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%;
}
}
@include media-breakpoint-up(md) {
.dash-cell-checkbox {
width: 4%;
}
.dash-cell-name {
width: 44%;
}
.dash-cell-owner {
width: 16%;
}
.dash-cell-date {
width: 21%;
}
.dash-cell-actions {
display: table-cell;
width: 15%;
}
.project-tools {
float: none;
}
}
@include media-breakpoint-up(lg) {
.dash-cell-checkbox {
width: 3%;
}
.dash-cell-name {
width: 46%;
}
.dash-cell-owner {
width: 13%;
}
.dash-cell-date {
width: 16%;
}
.dash-cell-actions {
width: 22%;
}
tbody {
.dash-cell-actions {
white-space: nowrap;
}
}
}
@include media-breakpoint-up(xl) {
.dash-cell-checkbox {
width: 3%;
}
.dash-cell-name {
width: 46%;
}
.dash-cell-owner {
width: 15%;
}
.dash-cell-date {
width: 19%;
}
.dash-cell-actions {
width: 17%;
}
}
@include media-breakpoint-up(xxl) {
.dash-cell-checkbox {
width: 2%;
}
.dash-cell-name {
width: 49%;
}
.dash-cell-owner {
width: 16%;
}
.dash-cell-date {
width: 19%;
}
.dash-cell-actions {
width: 14%;
}
}
@include media-breakpoint-down(md) {
tr {
position: relative;
display: flex;
flex-direction: column;
}
.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: var(--spacing-04);
right: var(--spacing-04);
padding: 0 !important;
}
.dropdown-table-button-toggle {
padding: var(--spacing-04);
font-size: 0;
line-height: 1;
border-radius: 50%;
color: var(--content-primary);
background-color: transparent;
&:hover,
&:active {
background-color: rgba($neutral-90, 0.08);
}
}
}
}
} }
.project-list-upload-project-modal-uppy-dashboard .uppy-Root { .project-list-upload-project-modal-uppy-dashboard .uppy-Root {
@ -222,3 +460,7 @@
} }
} }
} }
.project-list-load-more-button {
margin-bottom: var(--spacing-05);
}

View file

@ -1763,7 +1763,6 @@
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)", "shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
"show_all": "show all", "show_all": "show all",
"show_all_projects": "Show all projects", "show_all_projects": "Show all projects",
"show_all_uppercase": "Show all",
"show_document_preamble": "Show document preamble", "show_document_preamble": "Show document preamble",
"show_hotkeys": "Show Hotkeys", "show_hotkeys": "Show Hotkeys",
"show_in_code": "Show in code", "show_in_code": "Show in code",
@ -1771,7 +1770,6 @@
"show_less": "show less", "show_less": "show less",
"show_local_file_contents": "Show Local File Contents", "show_local_file_contents": "Show Local File Contents",
"show_outline": "Show File outline", "show_outline": "Show File outline",
"show_x_more": "Show __x__ more",
"show_x_more_projects": "Show __x__ more projects", "show_x_more_projects": "Show __x__ more projects",
"show_your_support": "Show your support", "show_your_support": "Show your support",
"showing_1_result": "Showing 1 result", "showing_1_result": "Showing 1 result",

View file

@ -72,9 +72,9 @@ describe('<LoadMore />', function () {
}) })
await waitFor(() => { await waitFor(() => {
screen.getByLabelText( screen.getByRole('button', {
`Show ${currentList.length - 20 - 20} more projects` name: `Show ${currentList.length - 20 - 20} more projects`,
) })
screen.getByText(`Showing 40 out of ${currentList.length} projects.`) screen.getByText(`Showing 40 out of ${currentList.length} projects.`)
}) })
}) })

View file

@ -202,7 +202,9 @@ describe('<ProjectListRoot />', function () {
let checked = allCheckboxes.filter(c => c.checked) let checked = allCheckboxes.filter(c => c.checked)
expect(checked.length).to.equal(21) // max projects viewable by default is 20, and plus one for check all expect(checked.length).to.equal(21) // max projects viewable by default is 20, and plus one for check all
const loadMoreButton = screen.getByLabelText('Show 17 more projects') const loadMoreButton = screen.getByRole('button', {
name: 'Show 17 more projects',
})
fireEvent.click(loadMoreButton) fireEvent.click(loadMoreButton)
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox') allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
@ -212,7 +214,9 @@ describe('<ProjectListRoot />', function () {
}) })
it('maintains viewable and selected projects after loading more and then selecting all', async function () { it('maintains viewable and selected projects after loading more and then selecting all', async function () {
const loadMoreButton = screen.getByLabelText('Show 17 more projects') const loadMoreButton = screen.getByRole('button', {
name: 'Show 17 more projects',
})
fireEvent.click(loadMoreButton) fireEvent.click(loadMoreButton)
// verify button gone // verify button gone
screen.getByText( screen.getByText(
@ -225,8 +229,8 @@ describe('<ProjectListRoot />', function () {
`Showing ${currentList.length} out of ${currentList.length} projects.` `Showing ${currentList.length} out of ${currentList.length} projects.`
) )
allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox') // allCheckboxes = screen.getAllByRole<HTMLInputElement>('checkbox')
expect(allCheckboxes.length).to.equal(currentList.length + 1) // expect(allCheckboxes.length).to.equal(currentList.length + 1)
}) })
}) })
@ -1095,7 +1099,9 @@ describe('<ProjectListRoot />', function () {
archived: false, archived: false,
}, },
}) })
const copyButton = within(tableRows[1]).getAllByLabelText('Copy')[0] const copyButton = within(tableRows[1]).getAllByRole('button', {
name: 'Copy',
})[0]
fireEvent.click(copyButton) fireEvent.click(copyButton)
// confirm in modal // confirm in modal

View file

@ -20,7 +20,7 @@ describe('<ArchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={archiveableProject} /> <ArchiveProjectButtonTooltip project={archiveableProject} />
) )
const btn = screen.getByLabelText('Archive') const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Archive' }) screen.getByRole('tooltip', { name: 'Archive' })
}) })
@ -29,7 +29,7 @@ describe('<ArchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={archiveableProject} /> <ArchiveProjectButtonTooltip project={archiveableProject} />
) )
const btn = screen.getByLabelText('Archive') const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Archive Projects') screen.getByText('Archive Projects')
screen.getByText(archiveableProject.name) screen.getByText(archiveableProject.name)
@ -39,7 +39,7 @@ describe('<ArchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={archivedProject} /> <ArchiveProjectButtonTooltip project={archivedProject} />
) )
expect(screen.queryByLabelText('Archive')).to.be.null expect(screen.queryByRole('button', { name: 'Archive' })).to.be.null
}) })
it('should archive the projects', async function () { it('should archive the projects', async function () {
@ -54,7 +54,7 @@ describe('<ArchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<ArchiveProjectButtonTooltip project={project} /> <ArchiveProjectButtonTooltip project={project} />
) )
const btn = screen.getByLabelText('Archive') const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Archive Projects') screen.getByText('Archive Projects')
screen.getByText('You are about to archive the following projects:') screen.getByText('You are about to archive the following projects:')

View file

@ -32,7 +32,7 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
}) })
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
const btn = screen.getByLabelText('Download PDF') const btn = screen.getByRole('button', { name: 'Download PDF' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Download PDF' }) screen.getByRole('tooltip', { name: 'Download PDF' })
}) })
@ -49,11 +49,11 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
{ delay: 10 } { delay: 10 }
) )
const btn = screen.getByLabelText('Download PDF') as HTMLButtonElement const btn = screen.getByRole('button', { name: 'Download PDF' })
fireEvent.click(btn) fireEvent.click(btn)
await waitFor(() => { await waitFor(() => {
screen.getByLabelText('Compiling…') screen.getByRole('button', { name: 'Compiling…' })
}) })
await waitFor(() => { await waitFor(() => {
@ -79,7 +79,9 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
status: 'failure', status: 'failure',
}) })
const btn = screen.getByLabelText('Download PDF') as HTMLButtonElement const btn = screen.getByRole('button', {
name: 'Download PDF',
}) as HTMLButtonElement
fireEvent.click(btn) fireEvent.click(btn)
await waitFor(() => { await waitFor(() => {

View file

@ -21,7 +21,7 @@ describe('<CopyProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} /> <CopyProjectButtonTooltip project={copyableProject} />
) )
const btn = screen.getByLabelText('Copy') const btn = screen.getByRole('button', { name: 'Copy' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Copy' }) screen.getByRole('tooltip', { name: 'Copy' })
}) })
@ -30,17 +30,17 @@ describe('<CopyProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<CopyProjectButtonTooltip project={archivedProject} /> <CopyProjectButtonTooltip project={archivedProject} />
) )
expect(screen.queryByLabelText('Copy')).to.be.null expect(screen.queryByRole('button', { name: 'Copy' })).to.be.null
}) })
it('does not render the button when project is trashed', function () { it('does not render the button when project is trashed', function () {
renderWithProjectListContext( renderWithProjectListContext(
<CopyProjectButtonTooltip project={trashedProject} /> <CopyProjectButtonTooltip project={trashedProject} />
) )
expect(screen.queryByLabelText('Copy')).to.be.null expect(screen.queryByRole('button', { name: 'Copy' })).to.be.null
}) })
it('opens the modal and copies the project ', async function () { it('opens the modal and copies the project', async function () {
const copyProjectMock = fetchMock.post( const copyProjectMock = fetchMock.post(
`express:/project/:projectId/clone`, `express:/project/:projectId/clone`,
{ {
@ -51,12 +51,14 @@ describe('<CopyProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<CopyProjectButtonTooltip project={copyableProject} /> <CopyProjectButtonTooltip project={copyableProject} />
) )
const btn = screen.getByLabelText('Copy') const btn = screen.getByRole('button', { name: 'Copy' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Copy Project') screen.getByText('Copy Project')
screen.getByLabelText('New Name') screen.getByLabelText('New Name')
screen.getByDisplayValue(`${copyableProject.name} (Copy)`) screen.getByDisplayValue(`${copyableProject.name} (Copy)`)
const copyBtn = screen.getByText('Copy') as HTMLButtonElement const copyBtn = screen.getByRole<HTMLButtonElement>('button', {
name: 'Copy',
})
fireEvent.click(copyBtn) fireEvent.click(copyBtn)
expect(copyBtn.disabled).to.be.true expect(copyBtn.disabled).to.be.true

View file

@ -22,7 +22,7 @@ describe('<DeleteProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<DeleteProjectButtonTooltip project={trashedProject} /> <DeleteProjectButtonTooltip project={trashedProject} />
) )
const btn = screen.getByLabelText('Delete') const btn = screen.getByRole('button', { name: 'Delete' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Delete' }) screen.getByRole('tooltip', { name: 'Delete' })
}) })
@ -32,7 +32,7 @@ describe('<DeleteProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<DeleteProjectButtonTooltip project={trashedAndNotOwnedProject} /> <DeleteProjectButtonTooltip project={trashedAndNotOwnedProject} />
) )
const btn = screen.queryByLabelText('Delete') const btn = screen.queryByRole('button', { name: 'Delete' })
expect(btn).to.be.null expect(btn).to.be.null
}) })
@ -40,7 +40,7 @@ describe('<DeleteProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<DeleteProjectButtonTooltip project={archiveableProject} /> <DeleteProjectButtonTooltip project={archiveableProject} />
) )
expect(screen.queryByLabelText('Delete')).to.be.null expect(screen.queryByRole('button', { name: 'Delete' })).to.be.null
}) })
it('opens the modal and deletes the project', async function () { it('opens the modal and deletes the project', async function () {
@ -56,7 +56,7 @@ describe('<DeleteProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<DeleteProjectButtonTooltip project={project} /> <DeleteProjectButtonTooltip project={project} />
) )
const btn = screen.getByLabelText('Delete') const btn = screen.getByRole('button', { name: 'Delete' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Delete Projects') screen.getByText('Delete Projects')
screen.getByText('You are about to delete the following projects:') screen.getByText('You are about to delete the following projects:')

View file

@ -23,13 +23,15 @@ describe('<DownloadProjectButton />', function () {
}) })
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
const btn = screen.getByLabelText('Download .zip file') const btn = screen.getByRole('button', { name: 'Download .zip file' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Download .zip file' }) screen.getByRole('tooltip', { name: 'Download .zip file' })
}) })
it('downloads the project when clicked', async function () { it('downloads the project when clicked', async function () {
const btn = screen.getByLabelText('Download .zip file') as HTMLButtonElement const btn = screen.getByRole('button', {
name: 'Download .zip file',
}) as HTMLButtonElement
fireEvent.click(btn) fireEvent.click(btn)
await waitFor(() => { await waitFor(() => {

View file

@ -22,7 +22,7 @@ describe('<LeaveProjectButtton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} /> <LeaveProjectButtonTooltip project={trashedAndNotOwnedProject} />
) )
const btn = screen.getByLabelText('Leave') const btn = screen.getByRole('button', { name: 'Leave' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Leave' }) screen.getByRole('tooltip', { name: 'Leave' })
}) })
@ -32,7 +32,7 @@ describe('<LeaveProjectButtton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<LeaveProjectButtonTooltip project={trashedProject} /> <LeaveProjectButtonTooltip project={trashedProject} />
) )
const btn = screen.queryByLabelText('Leave') const btn = screen.queryByRole('button', { name: 'Leave' })
expect(btn).to.be.null expect(btn).to.be.null
}) })
@ -40,14 +40,14 @@ describe('<LeaveProjectButtton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<LeaveProjectButtonTooltip project={archivedProject} /> <LeaveProjectButtonTooltip project={archivedProject} />
) )
expect(screen.queryByLabelText('Leave')).to.be.null expect(screen.queryByRole('button', { name: 'Leave' })).to.be.null
}) })
it('does not render the button when project is current', function () { it('does not render the button when project is current', function () {
renderWithProjectListContext( renderWithProjectListContext(
<LeaveProjectButtonTooltip project={archiveableProject} /> <LeaveProjectButtonTooltip project={archiveableProject} />
) )
expect(screen.queryByLabelText('Leave')).to.be.null expect(screen.queryByRole('button', { name: 'Leave' })).to.be.null
}) })
it('opens the modal and leaves the project', async function () { it('opens the modal and leaves the project', async function () {
@ -62,7 +62,7 @@ describe('<LeaveProjectButtton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<LeaveProjectButtonTooltip project={project} /> <LeaveProjectButtonTooltip project={project} />
) )
const btn = screen.getByLabelText('Leave') const btn = screen.getByRole('button', { name: 'Leave' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Leave Projects') screen.getByText('Leave Projects')
screen.getByText('You are about to leave the following projects:') screen.getByText('You are about to leave the following projects:')

View file

@ -20,7 +20,7 @@ describe('<TrashProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<TrashProjectButtonTooltip project={archivedProject} /> <TrashProjectButtonTooltip project={archivedProject} />
) )
const btn = screen.getByLabelText('Trash') const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Trash' }) screen.getByRole('tooltip', { name: 'Trash' })
}) })
@ -29,7 +29,7 @@ describe('<TrashProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<TrashProjectButtonTooltip project={trashedProject} /> <TrashProjectButtonTooltip project={trashedProject} />
) )
expect(screen.queryByLabelText('Trash')).to.be.null expect(screen.queryByRole('button', { name: 'Trash' })).to.be.null
}) })
it('opens the modal and trashes the project', async function () { it('opens the modal and trashes the project', async function () {
@ -44,7 +44,7 @@ describe('<TrashProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<TrashProjectButtonTooltip project={project} /> <TrashProjectButtonTooltip project={project} />
) )
const btn = screen.getByLabelText('Trash') const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Trash Projects') screen.getByText('Trash Projects')
screen.getByText('You are about to trash the following projects:') screen.getByText('You are about to trash the following projects:')

View file

@ -21,7 +21,7 @@ describe('<UnarchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={archivedProject} /> <UnarchiveProjectButtonTooltip project={archivedProject} />
) )
const btn = screen.getByLabelText('Restore') const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Restore' }) screen.getByRole('tooltip', { name: 'Restore' })
}) })
@ -30,14 +30,14 @@ describe('<UnarchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={trashedProject} /> <UnarchiveProjectButtonTooltip project={trashedProject} />
) )
expect(screen.queryByLabelText('Restore')).to.be.null expect(screen.queryByRole('button', { name: 'Restore' })).to.be.null
}) })
it('does not render the button when project is current', function () { it('does not render the button when project is current', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={archiveableProject} /> <UnarchiveProjectButtonTooltip project={archiveableProject} />
) )
expect(screen.queryByLabelText('Restore')).to.be.null expect(screen.queryByRole('button', { name: 'Restore' })).to.be.null
}) })
it('unarchive the project and updates the view data', async function () { it('unarchive the project and updates the view data', async function () {
@ -52,7 +52,7 @@ describe('<UnarchiveProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UnarchiveProjectButtonTooltip project={project} /> <UnarchiveProjectButtonTooltip project={project} />
) )
const btn = screen.getByLabelText('Restore') const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.click(btn) fireEvent.click(btn)
await waitFor( await waitFor(

View file

@ -20,7 +20,7 @@ describe('<UntrashProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UntrashProjectButtonTooltip project={trashedProject} /> <UntrashProjectButtonTooltip project={trashedProject} />
) )
const btn = screen.getByLabelText('Restore') const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Restore' }) screen.getByRole('tooltip', { name: 'Restore' })
}) })
@ -29,7 +29,7 @@ describe('<UntrashProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UntrashProjectButtonTooltip project={archiveableProject} /> <UntrashProjectButtonTooltip project={archiveableProject} />
) )
expect(screen.queryByLabelText('Restore')).to.be.null expect(screen.queryByRole('button', { name: 'Restore' })).to.be.null
}) })
it('untrashes the project and updates the view data', async function () { it('untrashes the project and updates the view data', async function () {
@ -44,7 +44,7 @@ describe('<UntrashProjectButton />', function () {
renderWithProjectListContext( renderWithProjectListContext(
<UntrashProjectButtonTooltip project={project} /> <UntrashProjectButtonTooltip project={project} />
) )
const btn = screen.getByLabelText('Restore') const btn = screen.getByRole('button', { name: 'Restore' })
fireEvent.click(btn) fireEvent.click(btn)
await waitFor( await waitFor(

View file

@ -108,15 +108,25 @@ describe('<ProjectListTable />', function () {
// Action Column // Action Column
// temporary count tests until we add filtering for archived/trashed // temporary count tests until we add filtering for archived/trashed
const copyButtons = screen.getAllByLabelText('Copy') const copyButtons = screen.getAllByRole('button', {
name: 'Copy',
})
expect(copyButtons.length).to.equal(currentProjects.length) expect(copyButtons.length).to.equal(currentProjects.length)
const downloadButtons = screen.getAllByLabelText('Download .zip file') const downloadButtons = screen.getAllByRole('button', {
name: 'Download .zip file',
})
expect(downloadButtons.length).to.equal(currentProjects.length) expect(downloadButtons.length).to.equal(currentProjects.length)
const downloadPDFButtons = screen.getAllByLabelText('Download PDF') const downloadPDFButtons = screen.getAllByRole('button', {
name: 'Download PDF',
})
expect(downloadPDFButtons.length).to.equal(currentProjects.length) expect(downloadPDFButtons.length).to.equal(currentProjects.length)
const archiveButtons = screen.getAllByLabelText('Archive') const archiveButtons = screen.getAllByRole('button', {
name: 'Archive',
})
expect(archiveButtons.length).to.equal(currentProjects.length) expect(archiveButtons.length).to.equal(currentProjects.length)
const trashButtons = screen.getAllByLabelText('Trash') const trashButtons = screen.getAllByRole('button', {
name: 'Trash',
})
expect(trashButtons.length).to.equal(currentProjects.length) expect(trashButtons.length).to.equal(currentProjects.length)
// TODO to be implemented when the component renders trashed & archived projects // TODO to be implemented when the component renders trashed & archived projects

View file

@ -12,14 +12,14 @@ describe('<ArchiveProjectsButton />', function () {
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
renderWithProjectListContext(<ArchiveProjectsButton />) renderWithProjectListContext(<ArchiveProjectsButton />)
const btn = screen.getByLabelText('Archive') const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Archive' }) screen.getByRole('tooltip', { name: 'Archive' })
}) })
it('opens the modal when clicked', function () { it('opens the modal when clicked', function () {
renderWithProjectListContext(<ArchiveProjectsButton />) renderWithProjectListContext(<ArchiveProjectsButton />)
const btn = screen.getByLabelText('Archive') const btn = screen.getByRole('button', { name: 'Archive' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Archive Projects') screen.getByText('Archive Projects')
}) })

View file

@ -12,7 +12,7 @@ describe('<DownloadProjectsButton />', function () {
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
renderWithProjectListContext(<DownloadProjectsButton />) renderWithProjectListContext(<DownloadProjectsButton />)
const btn = screen.getByLabelText('Download') const btn = screen.getByRole('button', { name: 'Download' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Download' }) screen.getByRole('tooltip', { name: 'Download' })
}) })

View file

@ -12,14 +12,14 @@ describe('<TrashProjectsButton />', function () {
it('renders tooltip for button', function () { it('renders tooltip for button', function () {
renderWithProjectListContext(<TrashProjectsButton />) renderWithProjectListContext(<TrashProjectsButton />)
const btn = screen.getByLabelText('Trash') const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.mouseOver(btn) fireEvent.mouseOver(btn)
screen.getByRole('tooltip', { name: 'Trash' }) screen.getByRole('tooltip', { name: 'Trash' })
}) })
it('opens the modal when clicked', function () { it('opens the modal when clicked', function () {
renderWithProjectListContext(<TrashProjectsButton />) renderWithProjectListContext(<TrashProjectsButton />)
const btn = screen.getByLabelText('Trash') const btn = screen.getByRole('button', { name: 'Trash' })
fireEvent.click(btn) fireEvent.click(btn)
screen.getByText('Trash Projects') screen.getByText('Trash Projects')
}) })