Merge pull request #19840 from overleaf/ii-bs5-project-tools

[web] BS5 project tools

GitOrigin-RevId: 3181c62985b6db4051292b484f53178a0736fa75
This commit is contained in:
ilkin-overleaf 2024-08-22 15:14:22 +03:00 committed by Copybot
parent 22fb9973fa
commit 0e71084600
54 changed files with 1379 additions and 585 deletions

View file

@ -41,7 +41,7 @@ describe('Project List', () => {
cy.visit('/project')
findProjectRow(projectName).within(() =>
cy.contains(`Download .zip file`).click()
cy.findByRole('button', { name: 'Download .zip file' }).click()
)
cy.task('readFileInZip', {
@ -55,7 +55,7 @@ describe('Project List', () => {
cy.visit('/project')
findProjectRow(projectName).within(() =>
cy.contains(`Download PDF`).click()
cy.findByRole('button', { name: 'Download PDF' }).click()
)
const pdfName = projectName.replaceAll('-', '_')

View file

@ -446,6 +446,7 @@
"files_cannot_include_invalid_characters": "",
"files_selected": "",
"fill_in_our_quick_survey": "",
"filter_projects": "",
"find_out_more": "",
"find_out_more_about_institution_login": "",
"find_out_more_about_the_file_outline": "",
@ -1298,6 +1299,7 @@
"sorry_your_table_cant_be_displayed_at_the_moment": "",
"sort_by": "",
"sort_by_x": "",
"sort_projects": "",
"source": "",
"spell_check": "",
"sso": "",
@ -1499,6 +1501,10 @@
"toolbar_insert_table": "",
"toolbar_numbered_list": "",
"toolbar_redo": "",
"toolbar_selected_projects": "",
"toolbar_selected_projects_management_actions": "",
"toolbar_selected_projects_remove": "",
"toolbar_selected_projects_restore": "",
"toolbar_table_insert_size_table": "",
"toolbar_table_insert_table_lowercase": "",
"toolbar_toggle_symbol_palette": "",

View file

@ -1,6 +1,10 @@
import { useTranslation, Trans } from 'react-i18next'
import { CommonsPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import Tooltip from '../../../../shared/components/tooltip'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
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'
type CommonsPlanProps = Pick<
CommonsPlanSubscription,
@ -19,8 +23,15 @@ function CommonsPlan({
return (
<>
<span className="current-plan-label visible-xs">{currentPlanLabel}</span>
<Tooltip
<span
className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
{currentPlanLabel}
</span>
<OLTooltip
description={t('commons_plan_tooltip', {
plan: plan.name,
institution: subscription.name,
@ -28,10 +39,25 @@ function CommonsPlan({
id="commons-plan"
overlayProps={{ placement: 'bottom' }}
>
<a href={featuresPageURL} className="current-plan-label hidden-xs">
{currentPlanLabel} <span className="info-badge" />
<a
href={featuresPageURL}
className={classnames(
'current-plan-label',
bsVersion({
bs5: 'd-none d-md-inline-block',
bs3: 'hidden-xs',
})
)}
>
{currentPlanLabel}&nbsp;
<BootstrapVersionSwitcher
bs3={<span className="info-badge" />}
bs5={
<MaterialIcon type="info" className="current-plan-label-icon" />
}
/>
</a>
</Tooltip>
</OLTooltip>
</>
)
}

View file

@ -1,8 +1,12 @@
import { useTranslation, Trans } from 'react-i18next'
import { Button } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
import { FreePlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import Tooltip from '../../../../shared/components/tooltip'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
type FreePlanProps = Pick<FreePlanSubscription, 'featuresPageURL'>
@ -23,24 +27,49 @@ function FreePlan({ featuresPageURL }: FreePlanProps) {
return (
<>
<span className="current-plan-label visible-xs">{currentPlanLabel}</span>
<Tooltip
<span
className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
{currentPlanLabel}
</span>
<OLTooltip
description={t('free_plan_tooltip')}
id="free-plan"
overlayProps={{ placement: 'bottom' }}
>
<a href={featuresPageURL} className="current-plan-label hidden-xs">
{currentPlanLabel} <span className="info-badge" />
<a
href={featuresPageURL}
className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-none d-md-inline-block', bs3: 'hidden-xs' })
)}
>
{currentPlanLabel}&nbsp;
<BootstrapVersionSwitcher
bs3={<span className="info-badge" />}
bs5={
<MaterialIcon type="info" className="current-plan-label-icon" />
}
/>
</a>
</Tooltip>{' '}
<Button
bsStyle="primary"
className="hidden-xs"
</OLTooltip>{' '}
<span
className={bsVersion({
bs5: 'd-none d-md-inline-block',
bs3: 'hidden-xs',
})}
>
<OLButton
variant="primary"
href="/user/subscription/plans"
onClick={handleClick}
>
{t('upgrade')}
</Button>
</OLButton>
</span>
</>
)
}

View file

@ -1,6 +1,10 @@
import { useTranslation, Trans } from 'react-i18next'
import { GroupPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import Tooltip from '../../../../shared/components/tooltip'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
type GroupPlanProps = Pick<
GroupPlanSubscription,
@ -33,8 +37,15 @@ function GroupPlan({
return (
<>
<span className="current-plan-label visible-xs">{currentPlanLabel}</span>
<Tooltip
<span
className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
{currentPlanLabel}
</span>
<OLTooltip
description={
subscription.teamName != null
? t('group_plan_with_name_tooltip', {
@ -46,10 +57,25 @@ function GroupPlan({
id="group-plan"
overlayProps={{ placement: 'bottom' }}
>
<a href={featuresPageURL} className="current-plan-label hidden-xs">
{currentPlanLabel} <span className="info-badge" />
<a
href={featuresPageURL}
className={classnames(
'current-plan-label',
bsVersion({
bs5: 'd-none d-md-inline-block',
bs3: 'hidden-xs',
})
)}
>
{currentPlanLabel}&nbsp;
<BootstrapVersionSwitcher
bs3={<span className="info-badge" />}
bs5={
<MaterialIcon type="info" className="current-plan-label-icon" />
}
/>
</a>
</Tooltip>
</OLTooltip>
</>
)
}

View file

@ -1,6 +1,10 @@
import { useTranslation, Trans } from 'react-i18next'
import { IndividualPlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import Tooltip from '../../../../shared/components/tooltip'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
type IndividualPlanProps = Pick<
IndividualPlanSubscription,
@ -32,16 +36,35 @@ function IndividualPlan({
return (
<>
<span className="current-plan-label visible-xs">{currentPlanLabel}</span>
<Tooltip
<span
className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
{currentPlanLabel}
</span>
<OLTooltip
description={t('plan_tooltip', { plan: plan.name })}
id="individual-plan"
overlayProps={{ placement: 'bottom' }}
>
<a href={featuresPageURL} className="current-plan-label hidden-xs">
{currentPlanLabel} <span className="info-badge" />
<a
href={featuresPageURL}
className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-none d-md-inline-block', bs3: 'hidden-xs' })
)}
>
{currentPlanLabel}&nbsp;
<BootstrapVersionSwitcher
bs3={<span className="info-badge" />}
bs5={
<MaterialIcon type="info" className="current-plan-label-icon" />
}
/>
</a>
</Tooltip>
</OLTooltip>
</>
)
}

View file

@ -318,7 +318,7 @@ function BS5ActionsDropdown({ project }: ActionDropdownProps) {
as="button"
tabIndex={-1}
onClick={downloadProject}
leadingIcon="cloud_download"
leadingIcon="download"
>
{text}
</DropdownItem>

View file

@ -1,32 +1,45 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown, MenuItem } from 'react-bootstrap'
import {
Dropdown as BS3Dropdown,
MenuItem as BS3MenuItem,
} from 'react-bootstrap'
import Icon from '../../../../shared/components/icon'
import {
Filter,
UNCATEGORIZED_KEY,
useProjectListContext,
} from '../../context/project-list-context'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import ProjectsFilterMenu from '../projects-filter-menu'
import TagsList from '../tags-list'
import MenuItemButton from './menu-item-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type ItemProps = {
filter: Filter
text: string
onClick: () => void
onClick?: () => void
}
export function Item({ filter, text, onClick }: ItemProps) {
const { selectFilter } = useProjectListContext()
const handleClick = () => {
selectFilter(filter)
onClick()
onClick?.()
}
return (
<ProjectsFilterMenu filter={filter}>
{isActive => (
<BootstrapVersionSwitcher
bs3={
<MenuItemButton
onClick={handleClick}
className="projects-types-menu-item"
@ -36,6 +49,19 @@ export function Item({ filter, text, onClick }: ItemProps) {
) : null}
<span className="menu-item-button-text">{text}</span>
</MenuItemButton>
}
bs5={
<DropdownItem
as="button"
tabIndex={-1}
onClick={handleClick}
trailingIcon={isActive ? 'check' : undefined}
active={isActive}
>
{text}
</DropdownItem>
}
/>
)}
</ProjectsFilterMenu>
)
@ -72,18 +98,28 @@ function ProjectsDropdown() {
}, [filter, tags, selectedTagId, t])
return (
<Dropdown
<BootstrapVersionSwitcher
bs3={
<BS3Dropdown
id="projects-types-dropdown"
open={isOpened}
onToggle={open => setIsOpened(open)}
>
<Dropdown.Toggle bsSize="large" noCaret className="ps-0 btn-transparent">
<BS3Dropdown.Toggle
bsSize="large"
noCaret
className="ps-0 btn-transparent"
>
<span className="text-truncate me-1">{title}</span>
<Icon type="angle-down" />
</Dropdown.Toggle>
<Dropdown.Menu className="projects-dropdown-menu">
</BS3Dropdown.Toggle>
<BS3Dropdown.Menu className="projects-dropdown-menu">
<Item filter="all" text={t('all_projects')} onClick={handleClose} />
<Item filter="owned" text={t('your_projects')} onClick={handleClose} />
<Item
filter="owned"
text={t('your_projects')}
onClick={handleClose}
/>
<Item
filter="shared"
text={t('shared_with_you')}
@ -99,10 +135,47 @@ function ProjectsDropdown() {
text={t('trashed_projects')}
onClick={handleClose}
/>
<MenuItem header>{t('tags')}:</MenuItem>
<BS3MenuItem header>{t('tags')}:</BS3MenuItem>
<TagsList onTagClick={handleClose} onEditClick={handleClose} />
</Dropdown.Menu>
</BS3Dropdown.Menu>
</BS3Dropdown>
}
bs5={
<Dropdown>
<DropdownToggle
id="projects-types-dropdown-toggle-btn"
className="ps-0 mb-0 btn-transparent h3"
size="lg"
aria-label={t('filter_projects')}
>
<span className="text-truncate" aria-hidden>
{title}
</span>
</DropdownToggle>
<DropdownMenu flip={false}>
<li role="none">
<Item filter="all" text={t('all_projects')} />
</li>
<li role="none">
<Item filter="owned" text={t('your_projects')} />
</li>
<li role="none">
<Item filter="shared" text={t('shared_with_you')} />
</li>
<li role="none">
<Item filter="archived" text={t('archived_projects')} />
</li>
<li role="none">
<Item filter="trashed" text={t('trashed_projects')} />
</li>
<DropdownHeader className="text-uppercase">
{t('tags')}:
</DropdownHeader>
<TagsList />
</DropdownMenu>
</Dropdown>
}
/>
)
}

View file

@ -1,15 +1,28 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown, MenuItem } from 'react-bootstrap'
import {
Dropdown as BS3Dropdown,
MenuItem as BS3MenuItem,
} from 'react-bootstrap'
import Icon from '../../../../shared/components/icon'
import useSort from '../../hooks/use-sort'
import withContent, { SortBtnProps } from '../sort/with-content'
import { useProjectListContext } from '../../context/project-list-context'
import { Sort } from '../../../../../../types/project/dashboard/api'
import MenuItemButton from './menu-item-button'
import {
Dropdown,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return (
<BootstrapVersionSwitcher
bs3={
<MenuItemButton onClick={onClick} className="projects-sort-menu-item">
{iconType ? (
<Icon type={iconType} className="menu-item-button-icon" />
@ -17,6 +30,18 @@ function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
<span className="menu-item-button-text">{text}</span>
<span className="sr-only">{screenReaderText}</span>
</MenuItemButton>
}
bs5={
<DropdownItem
as="button"
tabIndex={-1}
onClick={onClick}
trailingIcon={iconType}
>
{text}
</DropdownItem>
}
/>
)
}
@ -39,25 +64,35 @@ function SortByDropdown() {
setIsOpened(false)
handleSort(by)
}
const handleClickBS5 = (by: Sort['by']) => {
setTitle(sortByTranslations.current[by])
handleSort(by)
}
useEffect(() => {
setTitle(sortByTranslations.current[sort.by])
}, [sort.by])
return (
<Dropdown
<BootstrapVersionSwitcher
bs3={
<BS3Dropdown
id="projects-sort-dropdown"
className="projects-sort-dropdown"
pullRight
open={isOpened}
onToggle={open => setIsOpened(open)}
>
<Dropdown.Toggle bsSize="small" noCaret className="pe-0 btn-transparent">
<BS3Dropdown.Toggle
bsSize="small"
noCaret
className="pe-0 btn-transparent"
>
<span className="text-truncate me-1">{title}</span>
<Icon type="angle-down" />
</Dropdown.Toggle>
<Dropdown.Menu className="projects-dropdown-menu">
<MenuItem header>{t('sort_by')}:</MenuItem>
</BS3Dropdown.Toggle>
<BS3Dropdown.Menu className="projects-dropdown-menu">
<BS3MenuItem header>{t('sort_by')}:</BS3MenuItem>
<ItemWithContent
column="title"
text={t('title')}
@ -76,8 +111,47 @@ function SortByDropdown() {
sort={sort}
onClick={() => handleClick('lastUpdated')}
/>
</Dropdown.Menu>
</BS3Dropdown.Menu>
</BS3Dropdown>
}
bs5={
<Dropdown className="projects-sort-dropdown" align="end">
<DropdownToggle
id="projects-sort-dropdown"
className="pe-0 mb-0 btn-transparent"
size="sm"
aria-label={t('sort_projects')}
>
<span className="text-truncate" aria-hidden>
{title}
</span>
</DropdownToggle>
<DropdownMenu flip={false}>
<DropdownHeader className="text-uppercase">
{t('sort_by')}:
</DropdownHeader>
<ItemWithContent
column="title"
text={t('title')}
sort={sort}
onClick={() => handleClickBS5('title')}
/>
<ItemWithContent
column="owner"
text={t('owner')}
sort={sort}
onClick={() => handleClickBS5('owner')}
/>
<ItemWithContent
column="lastUpdated"
text={t('last_modified')}
sort={sort}
onClick={() => handleClickBS5('lastUpdated')}
/>
</DropdownMenu>
</Dropdown>
}
/>
)
}

View file

@ -18,9 +18,9 @@ import OLModal, {
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLForm from '@/features/ui/components/ol/ol-form'
import Notification from '@/shared/components/notification'
type CreateTagModalProps = {
id: string
@ -104,10 +104,10 @@ export default function CreateTagModal({
</OLFormGroup>
</OLForm>
{validationError && (
<OLNotification type="error" content={validationError} />
<Notification type="error" content={validationError} />
)}
{isError && (
<OLNotification
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>

View file

@ -11,7 +11,7 @@ import OLModal, {
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import Notification from '@/shared/components/notification'
type DeleteTagModalProps = {
id: string
@ -56,7 +56,7 @@ export default function DeleteTagModal({
<li>{tag.name}</li>
</ul>
{isError && (
<OLNotification
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>

View file

@ -19,7 +19,7 @@ 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 OLButton from '@/features/ui/components/ol/ol-button'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import Notification from '@/shared/components/notification'
type EditTagModalProps = {
id: string
@ -113,12 +113,12 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
</OLFormGroup>
</OLForm>
{validationError && (
<OLNotification type="error" content={validationError} />
<Notification content={validationError} type="error" />
)}
{isError && (
<OLNotification
type="error"
<Notification
content={t('generic_something_went_wrong')}
type="error"
/>
)}
</OLModalBody>

View file

@ -1,7 +1,5 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, ControlLabel, Form, FormGroup, Modal } from 'react-bootstrap'
import AccessibleModal from '../../../../shared/components/accessible-modal'
import useAsync from '../../../../shared/hooks/use-async'
import { useRefWithAutoFocus } from '../../../../shared/hooks/use-ref-with-auto-focus'
import useSelectColor from '../../hooks/use-select-color'
@ -10,6 +8,19 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types'
import { getTagColor } from '../../util/tag'
import { ColorPicker } from '../color-picker/color-picker'
import { debugConsole } from '@/utils/debugging'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
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 OLButton from '@/features/ui/components/ol/ol-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import Notification from '@/shared/components/notification'
import { bsVersion } from '@/features/utils/bootstrap-5'
type ManageTagModalProps = {
id: string
@ -78,14 +89,14 @@ export function ManageTagModal({
}
return (
<AccessibleModal show animation onHide={onClose} id={id} backdrop="static">
<Modal.Header closeButton>
<Modal.Title>{t('edit_tag')}</Modal.Title>
</Modal.Header>
<OLModal show animation onHide={onClose} id={id} backdrop="static">
<OLModalHeader closeButton>
<OLModalTitle>{t('edit_tag')}</OLModalTitle>
</OLModalHeader>
<Modal.Body>
<Form name="editTagRenameForm" onSubmit={handleSubmit}>
<FormGroup>
<OLModalBody>
<OLForm onSubmit={handleSubmit}>
<OLFormGroup>
<input
ref={autoFocusedRef}
className="form-control"
@ -96,61 +107,60 @@ export function ManageTagModal({
required
onChange={e => setNewTagName(e.target.value)}
/>
</FormGroup>
<FormGroup aria-hidden="true">
<ControlLabel>{t('tag_color')}</ControlLabel>:<br />
</OLFormGroup>
<OLFormGroup aria-hidden="true">
<OLFormLabel>{t('tag_color')}</OLFormLabel>:<br />
<ColorPicker disableCustomColor />
</FormGroup>
</Form>
</Modal.Body>
<Modal.Footer>
<div className="clearfix">
<div className="modal-footer-left">
<Button
onClick={() => runDeleteTag(tag._id)}
bsStyle="danger"
disabled={isDeleteLoading || isUpdateLoading}
>
{isDeleteLoading ? (
<>{t('deleting')} &hellip;</>
) : (
t('delete_tag')
</OLFormGroup>
</OLForm>
{(isDeleteError || isRenameError) && (
<Notification
type="error"
content={t('generic_something_went_wrong')}
/>
)}
</Button>
</div>
<Button
</OLModalBody>
<OLModalFooter>
<OLButton
variant="danger"
onClick={() => runDeleteTag(tag._id)}
className={bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })}
disabled={isDeleteLoading || isUpdateLoading}
isLoading={isDeleteLoading}
bs3Props={{
loading: isDeleteLoading ? `${t('deleting')}` : t('delete_tag'),
}}
>
{t('delete_tag')}
</OLButton>
<OLButton
variant="secondary"
onClick={onClose}
disabled={isDeleteLoading || isUpdateLoading}
>
{t('save_or_cancel-cancel')}
</Button>
<Button
</OLButton>
<OLButton
variant="primary"
onClick={() => runUpdateTag(tag._id)}
bsStyle={null}
className="btn-secondary"
disabled={Boolean(
isUpdateLoading ||
isDeleteLoading ||
!newTagName?.length ||
(newTagName === tag?.name && selectedColor === getTagColor(tag))
)}
isLoading={isUpdateLoading}
bs3Props={{
loading: isUpdateLoading
? `${t('saving')}`
: t('save_or_cancel-save'),
}}
>
{isUpdateLoading ? (
<>{t('saving')} &hellip;</>
) : (
t('save_or_cancel-save')
)}
</Button>
</div>
{(isDeleteError || isRenameError) && (
<div className="modal-footer-left mt-2">
<span className="text-danger error">
{t('generic_something_went_wrong')}
</span>
</div>
)}
</Modal.Footer>
</AccessibleModal>
{t('save_or_cancel-save')}
</OLButton>
<BootstrapVersionSwitcher bs3={<div className="clearfix" />} />
</OLModalFooter>
</OLModal>
)
}

View file

@ -191,7 +191,11 @@ function ProjectListPageContent() {
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
<div role="toolbar" className="projects-toolbar">
<div
role="toolbar"
className="projects-toolbar"
aria-label={t('projects')}
>
<ProjectsDropdown />
<SortByDropdown />
</div>

View file

@ -54,7 +54,7 @@ const DownloadProjectButtonTooltip = memo(
className="action-btn"
icon={
bsVersion({
bs5: 'cloud_download',
bs5: 'download',
bs3: 'cloud-download',
}) as string
}

View file

@ -3,6 +3,8 @@ import Icon from '../../../../../shared/components/icon'
import { getOwnerName } from '../../../util/project'
import { Project } from '../../../../../../../types/project/dashboard/api'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
type LinkSharingIconProps = {
prependSpace: boolean
@ -26,11 +28,22 @@ function LinkSharingIcon({
{/* OverlayTrigger won't fire unless icon is wrapped in a span */}
<span className={className}>
{prependSpace ? ' ' : ''}
<BootstrapVersionSwitcher
bs3={
<Icon
type="link"
className="small"
accessibilityLabel={t('link_sharing')}
/>
}
bs5={
<MaterialIcon
type="link"
className="align-text-bottom"
accessibilityLabel={t('link_sharing')}
/>
}
/>
</span>
</OLTooltip>
)
@ -48,14 +61,8 @@ export default function OwnerCell({ project }: OwnerCellProps) {
return (
<>
{ownerName === 'You' ? t('you') : ownerName}
{project.source === 'token' ? (
<LinkSharingIcon
className="hidden-xs"
project={project}
prependSpace={!!project.owner}
/>
) : (
''
{project.source === 'token' && (
<LinkSharingIcon project={project} prependSpace={!!project.owner} />
)}
</>
)

View file

@ -1,12 +1,13 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import ArchiveProjectModal from '../../../modals/archive-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { archiveProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { bsVersion } from '@/features/utils/bootstrap-5'
function ArchiveProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
@ -40,19 +41,23 @@ function ArchiveProjectsButton() {
return (
<>
<Tooltip
<OLTooltip
id="tooltip-archive-projects"
description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-secondary"
aria-label={text}
<OLIconButton
onClick={handleOpenModal}
>
<Icon type="inbox" />
</button>
</Tooltip>
variant="secondary"
accessibilityLabel={text}
icon={
bsVersion({
bs5: 'inbox',
bs3: 'inbox',
}) as string
}
/>
</OLTooltip>
<ArchiveProjectModal
projects={selectedProjects}
actionHandler={handleArchiveProject}

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import DeleteLeaveProjectModal from '../../../modals/delete-leave-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
@ -41,9 +41,9 @@ function DeleteLeaveProjectsButton() {
return (
<>
{hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete_and_leave')}
</Button>
</OLButton>
)}
<DeleteLeaveProjectModal
projects={selectedProjects}

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import DeleteProjectModal from '../../../modals/delete-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
@ -37,9 +37,9 @@ function DeleteProjectsButton() {
return (
<>
{hasDeletableProjectsSelected && !hasLeavableProjectsSelected && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</Button>
</OLButton>
)}
<DeleteProjectModal
projects={selectedProjects}

View file

@ -1,11 +1,12 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
import { useProjectListContext } from '../../../../context/project-list-context'
import { useLocation } from '../../../../../../shared/hooks/use-location'
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
import { bsVersion } from '@/features/utils/bootstrap-5'
function DownloadProjectsButton() {
const { selectedProjects, selectOrUnselectAllProjects } =
@ -29,19 +30,23 @@ function DownloadProjectsButton() {
}, [projectIds, selectOrUnselectAllProjects, location])
return (
<Tooltip
<OLTooltip
id="tooltip-download-projects"
description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-secondary"
aria-label={text}
<OLIconButton
onClick={handleDownloadProjects}
>
<Icon type="cloud-download" />
</button>
</Tooltip>
variant="secondary"
accessibilityLabel={text}
icon={
bsVersion({
bs5: 'download',
bs3: 'cloud-download',
}) as string
}
/>
</OLTooltip>
)
}

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import LeaveProjectModal from '../../../modals/leave-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
@ -37,9 +37,9 @@ function LeaveProjectsButton() {
return (
<>
{!hasDeletableProjectsSelected && hasLeavableProjectsSelected && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}>
<OLButton variant="danger" onClick={handleOpenModal}>
{t('leave')}
</Button>
</OLButton>
)}
<LeaveProjectModal
projects={selectedProjects}

View file

@ -1,25 +1,54 @@
import { memo } from 'react'
import { Dropdown } from 'react-bootstrap'
import { Dropdown as BS3Dropdown } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
import CopyProjectMenuItem from '../menu-items/copy-project-menu-item'
import RenameProjectMenuItem from '../menu-items/rename-project-menu-item'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
function ProjectToolsMoreDropdownButton() {
const { t } = useTranslation()
return (
<BootstrapVersionSwitcher
bs3={
<ControlledDropdown id="project-tools-more-dropdown">
<Dropdown.Toggle bsStyle={null} className="btn-secondary">
<BS3Dropdown.Toggle bsStyle={null} className="btn-secondary">
{t('more')}
</Dropdown.Toggle>
<Dropdown.Menu
</BS3Dropdown.Toggle>
<BS3Dropdown.Menu
className="dropdown-menu-right"
data-testid="project-tools-more-dropdown-menu"
>
<RenameProjectMenuItem />
<CopyProjectMenuItem />
</Dropdown.Menu>
</BS3Dropdown.Menu>
</ControlledDropdown>
}
bs5={
<Dropdown align="end">
<DropdownToggle id="project-tools-more-dropdown" variant="secondary">
{t('more')}
</DropdownToggle>
<DropdownMenu
flip={false}
data-testid="project-tools-more-dropdown-menu"
>
<li role="none">
<RenameProjectMenuItem />
</li>
<li role="none">
<CopyProjectMenuItem />
</li>
</DropdownMenu>
</Dropdown>
}
/>
)
}

View file

@ -1,6 +1,6 @@
import { sortBy } from 'lodash'
import { memo, useCallback } from 'react'
import { Button, Dropdown } from 'react-bootstrap'
import { Button, Dropdown as BS3Dropdown } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
import Icon from '../../../../../../shared/components/icon'
@ -9,6 +9,15 @@ import { useProjectListContext } from '../../../../context/project-list-context'
import useTag from '../../../../hooks/use-tag'
import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
import { getTagColor } from '../../../../util/tag'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import {
Dropdown,
DropdownDivider,
DropdownHeader,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
function TagsDropdown() {
const {
@ -84,16 +93,17 @@ function TagsDropdown() {
return (
<>
<BootstrapVersionSwitcher
bs3={
<ControlledDropdown id="tags">
<Dropdown.Toggle
<BS3Dropdown.Toggle
bsStyle={null}
className="btn-secondary"
title={t('tags')}
aria-label={t('tags')}
>
<MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
</BS3Dropdown.Toggle>
<BS3Dropdown.Menu className="dropdown-menu-right">
<li className="dropdown-header" role="heading" aria-level={3}>
{t('add_to_tag')}
</li>
@ -141,8 +151,68 @@ function TagsDropdown() {
{t('create_new_tag')}
</Button>
</li>
</Dropdown.Menu>
</BS3Dropdown.Menu>
</ControlledDropdown>
}
bs5={
<Dropdown align="end" autoClose="outside">
<DropdownToggle
id="project-tools-more-dropdown"
variant="secondary"
aria-label={t('tags')}
>
<MaterialIcon type="label" className="align-text-top" />
</DropdownToggle>
<DropdownMenu
flip={false}
data-testid="project-tools-more-dropdown-menu"
>
<DropdownHeader>{t('add_to_tag')}</DropdownHeader>
{sortBy(tags, tag => tag.name?.toLowerCase()).map(
(tag, index) => (
<li role="none" key={tag._id}>
<DropdownItem
onClick={e =>
containsAllSelectedProjects(tag)
? handleRemoveTagFromSelectedProjects(e, tag._id)
: handleAddTagToSelectedProjects(e, tag._id)
}
aria-label={t('add_or_remove_project_from_tag', {
tagName: tag.name,
})}
as="button"
tabIndex={-1}
leadingIcon={
containsAllSelectedProjects(tag) ? (
'check'
) : (
<DropdownItem.EmptyLeadingIcon />
)
}
>
<span
className="badge-tag-circle align-self-center ms-0"
style={{ backgroundColor: getTagColor(tag) }}
/>
<span className="text-truncate">{tag.name}</span>
</DropdownItem>
</li>
)
)}
<DropdownDivider />
<li role="none">
<DropdownItem
onClick={handleOpenCreateTagModal}
as="button"
tabIndex={-1}
>
{t('create_new_tag')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
}
/>
<CreateTagModal id="toolbar-create-tag-modal" />
</>
)

View file

@ -1,12 +1,13 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Icon from '../../../../../../shared/components/icon'
import Tooltip from '../../../../../../shared/components/tooltip'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
import TrashProjectModal from '../../../modals/trash-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
import { trashProject } from '../../../../util/api'
import { Project } from '../../../../../../../../types/project/dashboard/api'
import { bsVersion } from '@/features/utils/bootstrap-5'
function TrashProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
@ -40,19 +41,23 @@ function TrashProjectsButton() {
return (
<>
<Tooltip
<OLTooltip
id="tooltip-trash-projects"
description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
>
<button
className="btn btn-secondary"
aria-label={text}
<OLIconButton
onClick={handleOpenModal}
>
<Icon type="trash" />
</button>
</Tooltip>
variant="secondary"
accessibilityLabel={text}
icon={
bsVersion({
bs5: 'delete',
bs3: 'trash',
}) as string
}
/>
</OLTooltip>
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}

View file

@ -1,5 +1,5 @@
import { memo } from 'react'
import { Button } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
import { unarchiveProject } from '../../../../util/api'
@ -18,13 +18,9 @@ function UnarchiveProjectsButton() {
}
return (
<Button
bsStyle={null}
className="btn-secondary"
onClick={handleUnarchiveProjects}
>
{t('unarchive')}
</Button>
<OLButton variant="secondary" onClick={handleUnarchiveProjects}>
{t('untrash')}
</OLButton>
)
}

View file

@ -1,5 +1,5 @@
import { memo } from 'react'
import { Button } from 'react-bootstrap'
import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
import { untrashProject } from '../../../../util/api'
@ -18,13 +18,9 @@ function UntrashProjectsButton() {
}
return (
<Button
bsStyle={null}
className="btn-secondary"
onClick={handleUntrashProjects}
>
<OLButton variant="secondary" onClick={handleUntrashProjects}>
{t('untrash')}
</Button>
</OLButton>
)
}

View file

@ -1,6 +1,6 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MenuItem } from 'react-bootstrap'
import OlDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import CloneProjectModal from '../../../../../clone-project-modal/components/clone-project-modal'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import { useProjectListContext } from '../../../../context/project-list-context'
@ -64,6 +64,9 @@ function CopyProjectMenuItem() {
return (
<>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('make_a_copy')}
</OlDropdownMenuItem>
<CloneProjectModal
show={showModal}
handleHide={handleCloseModal}
@ -72,7 +75,6 @@ function CopyProjectMenuItem() {
projectName={selectedProjects[0].name}
projectTags={projectTags}
/>
<MenuItem onClick={handleOpenModal}>{t('make_a_copy')}</MenuItem>
</>
)
}

View file

@ -1,8 +1,8 @@
import { memo, useCallback, useState } from 'react'
import { MenuItem } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../../context/project-list-context'
import useIsMounted from '../../../../../../shared/hooks/use-is-mounted'
import OlDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
import RenameProjectModal from '../../../modals/rename-project-modal'
function RenameProjectMenuItem() {
@ -34,7 +34,9 @@ function RenameProjectMenuItem() {
return (
<>
<MenuItem onClick={handleOpenModal}>{t('rename')}</MenuItem>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('rename')}
</OlDropdownMenuItem>
<RenameProjectModal
handleCloseModal={handleCloseModal}
showModal={showModal}

View file

@ -1,5 +1,5 @@
import { memo } from 'react'
import { ButtonGroup, ButtonToolbar } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../../../context/project-list-context'
import ArchiveProjectsButton from './buttons/archive-projects-button'
import DownloadProjectsButton from './buttons/download-projects-button'
@ -11,38 +11,44 @@ import UntrashProjectsButton from './buttons/untrash-projects-button'
import DeleteLeaveProjectsButton from './buttons/delete-leave-projects-button'
import LeaveProjectsButton from './buttons/leave-projects-button'
import DeleteProjectsButton from './buttons/delete-projects-button'
import OlButtonToolbar from '@/features/ui/components/ol/ol-button-toolbar'
import OlButtonGroup from '@/features/ui/components/ol/ol-button-group'
function ProjectTools() {
const { t } = useTranslation()
const { filter, selectedProjects } = useProjectListContext()
return (
<ButtonToolbar>
<ButtonGroup>
<OlButtonToolbar aria-label={t('toolbar_selected_projects')}>
<OlButtonGroup
aria-label={t('toolbar_selected_projects_management_actions')}
>
<DownloadProjectsButton />
{filter !== 'archived' && <ArchiveProjectsButton />}
{filter !== 'trashed' && <TrashProjectsButton />}
</ButtonGroup>
</OlButtonGroup>
<ButtonGroup>
{(filter === 'trashed' || filter === 'archived') && (
<OlButtonGroup aria-label={t('toolbar_selected_projects_restore')}>
{filter === 'trashed' && <UntrashProjectsButton />}
{filter === 'archived' && <UnarchiveProjectsButton />}
</ButtonGroup>
</OlButtonGroup>
)}
<ButtonGroup>
{filter === 'trashed' && (
<>
<OlButtonGroup aria-label={t('toolbar_selected_projects_remove')}>
<LeaveProjectsButton />
<DeleteProjectsButton />
<DeleteLeaveProjectsButton />
</>
</OlButtonGroup>
)}
</ButtonGroup>
{!['archived', 'trashed'].includes(filter) && <TagsDropdown />}
{selectedProjects.length === 1 &&
filter !== 'archived' &&
filter !== 'trashed' && <ProjectToolsMoreDropdownButton />}
</ButtonToolbar>
</OlButtonToolbar>
)
}

View file

@ -11,10 +11,12 @@ import {
import useTag from '../hooks/use-tag'
import { sortBy } from 'lodash'
import { Tag } from '../../../../../app/src/Features/Tags/types'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type TagsListProps = {
onTagClick: () => void
onEditClick: () => void
onTagClick?: () => void
onEditClick?: () => void
}
function TagsList({ onTagClick, onEditClick }: TagsListProps) {
@ -32,22 +34,27 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
const handleClick = (e: React.MouseEvent, tag: Tag) => {
handleSelectTag(e, tag._id)
onTagClick()
onTagClick?.()
}
return (
<>
<BootstrapVersionSwitcher
bs3={
<>
{sortBy(tags, ['name']).map((tag, index) => (
<MenuItemButton
key={index}
onClick={e => handleClick(e as unknown as React.MouseEvent, tag)}
onClick={e =>
handleClick(e as unknown as React.MouseEvent, tag)
}
className="projects-types-menu-item projects-types-menu-tag-item"
afterNode={
<Button
onClick={e => {
e.stopPropagation()
handleManageTag(e, tag._id)
onEditClick()
onEditClick?.()
}}
className="btn-transparent edit-btn me-2"
bsStyle={null}
@ -66,11 +73,17 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
color: getTagColor(tag),
}}
>
<MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
<MaterialIcon
type="label"
style={{ verticalAlign: 'sub' }}
/>
</span>
<span>
{tag.name}{' '}
<span className="subdued"> ({tag.project_ids?.length})</span>
<span className="subdued">
{' '}
({tag.project_ids?.length})
</span>
</span>
</span>
</MenuItemButton>
@ -79,7 +92,7 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
className="untagged projects-types-menu-item"
onClick={() => {
selectTag(UNCATEGORIZED_KEY)
onTagClick()
onTagClick?.()
}}
>
{selectedTagId === UNCATEGORIZED_KEY ? (
@ -93,7 +106,7 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
<MenuItemButton
onClick={() => {
openCreateTagModal()
onTagClick()
onTagClick?.()
}}
className="projects-types-menu-item"
>
@ -102,6 +115,70 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
<span>{t('new_tag')}</span>
</span>
</MenuItemButton>
</>
}
bs5={
<>
{sortBy(tags, ['name']).map((tag, index) => (
<li role="none" className="position-relative" key={index}>
<DropdownItem
as="button"
tabIndex={-1}
onClick={e =>
handleClick(e as unknown as React.MouseEvent, tag)
}
leadingIcon={
<span style={{ color: getTagColor(tag) }}>
<MaterialIcon type="label" className="align-text-top" />
</span>
}
trailingIcon={selectedTagId === tag._id ? 'check' : undefined}
active={selectedTagId === tag._id}
>
<span className="project-menu-item-tag-name text-truncate">
{tag.name}&nbsp;({tag.project_ids?.length})
</span>
</DropdownItem>
<DropdownItem
as="button"
tabIndex={-1}
className="project-menu-item-edit-btn"
onClick={e => {
e.stopPropagation()
handleManageTag(e, tag._id)
}}
aria-label={t('edit_tag')}
>
<MaterialIcon type="edit" className="align-text-top" />
</DropdownItem>
</li>
))}
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={() => selectTag(UNCATEGORIZED_KEY)}
trailingIcon={
selectedTagId === UNCATEGORIZED_KEY ? 'check' : undefined
}
active={selectedTagId === UNCATEGORIZED_KEY}
>
{t('uncategorized')}&nbsp;({untaggedProjectsCount})
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
onClick={openCreateTagModal}
leadingIcon="add"
>
{t('new_tag')}
</DropdownItem>
</li>
</>
}
/>
<CreateTagModal id="create-tag-modal-dropdown" disableCustomColor />
<ManageTagModal id="manage-tag-modal-dropdown" />
</>

View file

@ -16,6 +16,7 @@ function ProjectListTitle({
}) {
const { t } = useTranslation()
let message = t('projects')
if (selectedTag) {
message = `${selectedTag.name}`
} else if (selectedTagId === UNCATEGORIZED_KEY) {
@ -39,10 +40,9 @@ function ProjectListTitle({
break
}
}
return (
<div className={classnames('project-list-title', className)}>
<span>{message}</span>
</div>
<div className={classnames('project-list-title', className)}>{message}</div>
)
}

View file

@ -1,8 +1,8 @@
import { isBootstrap5 } from '@/features/utils/bootstrap-5'
type BootstrapVersionSwitcherProps = {
bs3: React.ReactNode
bs5: React.ReactNode
bs3?: React.ReactNode
bs5?: React.ReactNode
}
function BootstrapVersionSwitcher({

View file

@ -1,3 +1,4 @@
import { forwardRef } from 'react'
import { Button as BS5Button, Spinner } from 'react-bootstrap-5'
import type { ButtonProps } from '@/features/ui/components/types/button-props'
import classNames from 'classnames'
@ -10,7 +11,9 @@ const sizeClasses = new Map<ButtonProps['size'], string>([
['large', 'btn-lg'],
])
export default function Button({
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
className,
leadingIcon,
@ -20,7 +23,9 @@ export default function Button({
trailingIcon,
variant = 'primary',
...props
}: ButtonProps) {
},
ref
) => {
const { t } = useTranslation()
const sizeClass = sizeClasses.get(size)
@ -32,7 +37,12 @@ export default function Button({
const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
return (
<BS5Button className={buttonClassName} variant={variant} {...props}>
<BS5Button
className={buttonClassName}
variant={variant}
{...props}
ref={ref}
>
{isLoading && (
<span className="spinner-container">
<Spinner
@ -49,13 +59,23 @@ export default function Button({
)}
<span className="button-content" aria-hidden={isLoading}>
{leadingIcon && (
<MaterialIcon type={leadingIcon} className={materialIconClassName} />
<MaterialIcon
type={leadingIcon}
className={materialIconClassName}
/>
)}
{children}
{trailingIcon && (
<MaterialIcon type={trailingIcon} className={materialIconClassName} />
<MaterialIcon
type={trailingIcon}
className={materialIconClassName}
/>
)}
</span>
</BS5Button>
)
}
)
Button.displayName = 'Button'
export default Button

View file

@ -16,19 +16,23 @@ import type {
DropdownHeaderProps,
} from '@/features/ui/components/types/dropdown-menu-props'
import MaterialIcon from '@/shared/components/material-icon'
import { fixedForwardRef } from '@/utils/react'
export function Dropdown({ ...props }: DropdownProps) {
return <BS5Dropdown {...props} />
}
export const DropdownItem = forwardRef<
typeof BS5DropdownItem,
DropdownItemProps
>(
(
{ active, children, description, leadingIcon, trailingIcon, ...props },
ref
) => {
function DropdownItem(
{
active,
children,
description,
leadingIcon,
trailingIcon,
...props
}: DropdownItemProps,
ref: React.ForwardedRef<typeof BS5DropdownItem>
) {
let leadingIconComponent = null
if (leadingIcon) {
if (typeof leadingIcon === 'string') {
@ -60,7 +64,7 @@ export const DropdownItem = forwardRef<
)
} else {
trailingIconComponent = (
<span className="dropdown-item-leading-icon" aria-hidden="true">
<span className="dropdown-item-trailing-icon" aria-hidden="true">
{trailingIcon}
</span>
)
@ -84,8 +88,16 @@ export const DropdownItem = forwardRef<
</BS5DropdownItem>
)
}
)
DropdownItem.displayName = 'DropdownItem'
function EmptyLeadingIcon() {
return <span className="dropdown-item-leading-icon-empty" />
}
const ForwardReferredDropdownItem = fixedForwardRef(DropdownItem, {
EmptyLeadingIcon,
})
export { ForwardReferredDropdownItem as DropdownItem }
export function DropdownToggle({ ...props }: DropdownToggleProps) {
return <BS5DropdownToggle {...props} />

View file

@ -1,15 +1,14 @@
import { forwardRef } from 'react'
import classNames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import Button from './button'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
export default function IconButton({
accessibilityLabel,
icon,
isLoading = false,
size = 'default',
...props
}: IconButtonProps) {
const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(
{ accessibilityLabel, icon, isLoading = false, size = 'default', ...props },
ref
) => {
const iconButtonClassName = `icon-button-${size}`
const iconSizeClassName = size === 'large' ? 'icon-large' : 'icon-small'
const materialIconClassName = classNames(iconSizeClassName, {
@ -17,12 +16,18 @@ export default function IconButton({
})
return (
<Button className={iconButtonClassName} isLoading={isLoading} {...props}>
<MaterialIcon
accessibilityLabel={accessibilityLabel}
className={materialIconClassName}
type={icon}
/>
<Button
className={iconButtonClassName}
isLoading={isLoading}
aria-label={accessibilityLabel}
{...props}
ref={ref}
>
<MaterialIcon className={materialIconClassName} type={icon} />
</Button>
)
}
)
IconButton.displayName = 'IconButton'
export default IconButton

View file

@ -1,11 +1,13 @@
import { cloneElement, useEffect, forwardRef } from 'react'
import { OverlayTrigger, Tooltip as BSTooltip } from 'react-bootstrap-5'
import {
OverlayTrigger,
OverlayTriggerProps,
Tooltip as BSTooltip,
TooltipProps as BSTooltipProps,
} from 'react-bootstrap-5'
import { callFnsInSequence } from '@/utils/functions'
type OverlayProps = Omit<
React.ComponentProps<typeof OverlayTrigger>,
'overlay' | 'children'
>
type OverlayProps = Omit<OverlayTriggerProps, 'overlay' | 'children'>
type UpdatingTooltipProps = {
popper: {
@ -34,7 +36,7 @@ export type TooltipProps = {
description: React.ReactNode
id: string
overlayProps?: OverlayProps
tooltipProps?: React.ComponentProps<typeof BSTooltip>
tooltipProps?: BSTooltipProps
hidden?: boolean
children: React.ReactElement
}

View file

@ -0,0 +1,30 @@
import { ButtonGroup, ButtonGroupProps } from 'react-bootstrap-5'
import {
ButtonGroup as BS3ButtonGroup,
ButtonGroupProps as BS3ButtonGroupProps,
} from 'react-bootstrap'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
type OLButtonGroupProps = ButtonGroupProps & {
bs3Props?: Record<string, unknown>
}
function OlButtonGroup({ bs3Props, as, ...rest }: OLButtonGroupProps) {
const bs3ButtonGroupProps: BS3ButtonGroupProps = {
children: rest.children,
className: rest.className,
vertical: rest.vertical,
...getAriaAndDataProps(rest),
...bs3Props,
}
return (
<BootstrapVersionSwitcher
bs3={<BS3ButtonGroup {...bs3ButtonGroupProps} />}
bs5={<ButtonGroup {...rest} as={as} />}
/>
)
}
export default OlButtonGroup

View file

@ -0,0 +1,31 @@
import { ButtonToolbar, ButtonToolbarProps } from 'react-bootstrap-5'
import {
ButtonToolbar as BS3ButtonToolbar,
ButtonToolbarProps as BS3ButtonToolbarProps,
} from 'react-bootstrap'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
type OLButtonToolbarProps = ButtonToolbarProps & {
bs3Props?: Record<string, unknown>
}
function OlButtonToolbar(props: OLButtonToolbarProps) {
const { bs3Props, ...rest } = props
const bs3ButtonToolbarProps: BS3ButtonToolbarProps = {
children: rest.children,
className: rest.className,
...getAriaAndDataProps(rest),
...bs3Props,
}
return (
<BootstrapVersionSwitcher
bs3={<BS3ButtonToolbar {...bs3ButtonToolbarProps} />}
bs5={<ButtonToolbar {...rest} />}
/>
)
}
export default OlButtonToolbar

View file

@ -0,0 +1,27 @@
import { MenuItem, MenuItemProps } from 'react-bootstrap'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { DropdownItemProps } from '@/features/ui/components/types/dropdown-menu-props'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type OlDropdownMenuItemProps = DropdownItemProps & {
bs3Props?: MenuItemProps
}
function OlDropdownMenuItem(props: OlDropdownMenuItemProps) {
const { bs3Props, ...rest } = props
const bs3MenuItemProps: MenuItemProps = {
children: rest.children,
onClick: rest.onClick,
...bs3Props,
}
return (
<BootstrapVersionSwitcher
bs3={<MenuItem {...bs3MenuItemProps} />}
bs5={<DropdownItem {...rest} />}
/>
)
}
export default OlDropdownMenuItem

View file

@ -1,9 +1,11 @@
import { forwardRef } from 'react'
import { bs3ButtonProps, BS3ButtonSize } from './ol-button'
import { Button as BS3Button } from 'react-bootstrap'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
import BootstrapVersionSwitcher from '../bootstrap-5/bootstrap-version-switcher'
import Icon, { IconProps } from '@/shared/components/icon'
import IconButton from '../bootstrap-5/icon-button'
import { callFnsInSequence } from '@/utils/functions'
export type OLIconButtonProps = IconButtonProps & {
bs3Props?: {
@ -11,28 +13,44 @@ export type OLIconButtonProps = IconButtonProps & {
fw?: IconProps['fw']
className?: string
bsSize?: BS3ButtonSize
onMouseOver?: React.MouseEventHandler<HTMLButtonElement>
onMouseOut?: React.MouseEventHandler<HTMLButtonElement>
onFocus?: React.FocusEventHandler<HTMLButtonElement>
onBlur?: React.FocusEventHandler<HTMLButtonElement>
}
}
export default function OLIconButton(props: OLIconButtonProps) {
const OLIconButton = forwardRef<HTMLButtonElement, OLIconButtonProps>(
(props, ref) => {
const { bs3Props, ...rest } = props
const { fw, loading, ...bs3Rest } = bs3Props || {}
// BS3 OverlayTrigger automatically provides 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' event handlers
const bs3FinalProps = {
'aria-label': rest.accessibilityLabel,
...bs3ButtonProps(rest),
...bs3Rest,
onMouseOver: callFnsInSequence(bs3Props?.onMouseOver, rest.onMouseOver),
onMouseOut: callFnsInSequence(bs3Props?.onMouseOut, rest.onMouseOut),
onFocus: callFnsInSequence(bs3Props?.onFocus, rest.onFocus),
onBlur: callFnsInSequence(bs3Props?.onBlur, rest.onBlur),
}
// BS3 tooltip relies on the 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' props
// BS5 tooltip relies on the ref
return (
<BootstrapVersionSwitcher
bs3={
<BS3Button {...bs3ButtonProps(rest)} {...bs3Rest}>
{loading || (
<Icon
type={rest.icon}
fw={fw}
accessibilityLabel={rest.accessibilityLabel}
/>
)}
<BS3Button {...bs3FinalProps}>
{loading || <Icon type={rest.icon} fw={fw} />}
</BS3Button>
}
bs5={<IconButton {...rest} />}
bs5={<IconButton {...rest} ref={ref} />}
/>
)
}
)
OLIconButton.displayName = 'OLIconButton'
export default OLIconButton

View file

@ -1,4 +1,4 @@
import type { MouseEventHandler, ReactNode } from 'react'
import type { ReactNode } from 'react'
export type ButtonProps = {
children?: ReactNode
@ -11,7 +11,11 @@ export type ButtonProps = {
rel?: string
isLoading?: boolean
loadingLabel?: string
onClick?: MouseEventHandler<HTMLButtonElement>
onClick?: React.MouseEventHandler<HTMLButtonElement>
onMouseOver?: React.MouseEventHandler<HTMLButtonElement>
onMouseOut?: React.MouseEventHandler<HTMLButtonElement>
onFocus?: React.FocusEventHandler<HTMLButtonElement>
onBlur?: React.FocusEventHandler<HTMLButtonElement>
size?: 'small' | 'default' | 'large'
trailingIcon?: string
type?: 'button' | 'reset' | 'submit'

View file

@ -16,6 +16,7 @@ export type DropdownProps = {
onSelect?: (eventKey: any, event: object) => any
onToggle?: (show: boolean) => void
show?: boolean
autoClose?: boolean | 'inside' | 'outside'
}
export type DropdownItemProps = PropsWithChildren<{
@ -43,6 +44,7 @@ export type DropdownToggleProps = PropsWithChildren<{
id?: string // necessary for assistive technologies
variant?: SplitButtonVariants
as?: ElementType
size?: 'sm' | 'lg'
}>
export type DropdownMenuProps = PropsWithChildren<{
@ -60,4 +62,5 @@ export type DropdownDividerProps = PropsWithChildren<{
export type DropdownHeaderProps = PropsWithChildren<{
as?: ElementType
className?: string
}>

View file

@ -1,6 +1,6 @@
export function callFnsInSequence<
Args,
Fn extends ((...args: Args[]) => void) | void,
Args extends Array<any>,
Fn extends ((...args: Args) => void) | void,
>(...fns: Fn[]) {
return (...args: Args[]) => fns.forEach(fn => fn?.(...args))
return (...args: Args) => fns.forEach(fn => fn?.(...args))
}

View file

@ -0,0 +1,21 @@
import { forwardRef } from 'react'
export const fixedForwardRef = <
T,
P = object,
A extends Record<string, React.FunctionComponent> = Record<
string,
React.FunctionComponent
>,
>(
render: (props: P, ref: React.Ref<T>) => React.ReactElement | null,
propsToAttach: A = {} as A
): ((props: P & React.RefAttributes<T>) => React.ReactElement | null) & A => {
const ForwardReferredComponent = forwardRef(render) as any
for (const i in propsToAttach) {
ForwardReferredComponent[i] = propsToAttach[i]
}
return ForwardReferredComponent
}

View file

@ -2,6 +2,7 @@ import {
DropdownMenu,
DropdownItem,
DropdownDivider,
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import type { Meta } from '@storybook/react'
@ -58,6 +59,35 @@ export const Active = (args: Args) => {
)
}
export const MultipleSelection = (args: Args) => {
console.log('DropdownItem.EmptyLeadingIcon', DropdownItem.EmptyLeadingIcon)
return (
<DropdownMenu show>
<DropdownHeader>Header</DropdownHeader>
<li>
<DropdownItem
eventKey="1"
href="#/action-1"
leadingIcon={<DropdownItem.EmptyLeadingIcon />}
>
Example
</DropdownItem>
</li>
<li>
<DropdownItem eventKey="2" href="#/action-2" leadingIcon="check">
Example
</DropdownItem>
</li>
<li>
<DropdownItem eventKey="3" href="#/action-3" leadingIcon="check">
Example
</DropdownItem>
</li>
</DropdownMenu>
)
}
export const Danger = (args: Args) => {
return (
<DropdownMenu show>

View file

@ -205,8 +205,6 @@
font-size: @font-size-large;
line-height: 28px;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
}
ul.project-list-filters {

View file

@ -31,6 +31,7 @@
@import 'bootstrap-5/scss/forms';
@import 'bootstrap-5/scss/buttons';
@import 'bootstrap-5/scss/dropdown';
@import 'bootstrap-5/scss/button-group';
@import 'bootstrap-5/scss/badge';
@import 'bootstrap-5/scss/modal';
@import 'bootstrap-5/scss/tooltip';

View file

@ -1,4 +1,5 @@
@import 'button';
@import 'button-group';
@import 'dropdown-menu';
@import 'split-button';
@import 'notifications';

View file

@ -0,0 +1,15 @@
.btn-group {
> .btn {
&:first-child {
padding-left: var(--spacing-05);
}
&:last-child {
padding-right: var(--spacing-05);
}
}
}
.btn-toolbar {
gap: var(--spacing-03);
}

View file

@ -221,3 +221,19 @@ a.btn:visited {
.copy-button {
text-decoration: none;
}
.btn-reset {
@include reset-button;
}
.btn-transparent {
background: none !important;
border-radius: 0 !important;
color: inherit !important;
font-weight: 400;
&:hover {
background: none !important;
color: inherit !important;
}
}

View file

@ -104,6 +104,18 @@
}
}
.dropdown-item-leading-icon,
.dropdown-item-trailing-icon {
.material-symbols {
vertical-align: top;
}
}
.dropdown-item-leading-icon-empty {
display: inline-block;
width: 20px;
}
// description text should look disabled when the dropdown item is disabled
.dropdown-item.disabled .dropdown-item-description,
.dropdown-item[aria-disabled='true'] .dropdown-item-description {

View file

@ -164,6 +164,65 @@
padding: var(--spacing-08) var(--spacing-06);
}
.project-list-header-row {
display: flex;
align-items: center;
margin-bottom: var(--spacing-05);
min-height: 36px;
}
.project-list-title {
min-width: 0;
color: $content-secondary;
@include heading-sm;
font-weight: bold;
}
.project-tools {
flex-shrink: 0;
margin-left: auto;
}
@include media-breakpoint-down(md) {
.project-tools {
float: left;
margin-left: initial;
}
}
.projects-toolbar {
display: flex;
align-items: center;
.dropdown,
.dropdown-toggle {
max-width: 100%;
}
.dropdown {
min-width: 0;
}
}
.projects-sort-dropdown {
flex-shrink: 0;
margin-left: auto;
}
.project-menu-item-edit-btn {
position: absolute;
top: 0;
right: var(--spacing-09);
width: initial;
background-color: transparent;
}
.project-menu-item-tag-name {
padding-right: var(--spacing-13);
}
ul.project-list-filters {
margin: var(--spacing-05) calc(-1 * var(--spacing-06));
@ -609,6 +668,18 @@
}
}
.current-plan {
a.current-plan-label {
text-decoration: none;
color: $content-secondary;
}
.current-plan-label-icon {
vertical-align: text-bottom;
color: var(--bg-info-01);
}
}
/* stylelint-disable selector-class-pattern */
.project-list-upload-project-modal-uppy-dashboard .uppy-Root {
.uppy-Dashboard-AddFiles-title {
@ -747,7 +818,7 @@ form.project-search {
margin: 3px; // it's centered, no matching spacing variable
font-weight: bold;
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
margin: 5px; // it's centered, no matching spacing variable
}
}
@ -757,7 +828,7 @@ form.project-search {
margin: 3px;
font-weight: bold;
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
margin: 5px;
}
}
@ -769,7 +840,7 @@ form.project-search {
font-weight: bold;
}
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
height: 32px;
width: 32px;
margin: var(--spacing-08);

View file

@ -653,6 +653,7 @@
"files_cannot_include_invalid_characters": "File name is empty or contains invalid characters",
"files_selected": "files selected.",
"fill_in_our_quick_survey": "Fill in our quick survey.",
"filter_projects": "Filter projects",
"filters": "Filters",
"find_out_more": "Find out More",
"find_out_more_about_institution_login": "Find out more about institutional login",
@ -1863,6 +1864,7 @@
"sorry_your_token_expired": "Sorry, your token expired",
"sort_by": "Sort by",
"sort_by_x": "Sort by __x__",
"sort_projects": "Sort projects",
"source": "Source",
"spell_check": "Spell check",
"sso": "SSO",
@ -2117,6 +2119,10 @@
"toolbar_insert_table": "Insert Table",
"toolbar_numbered_list": "Numbered List",
"toolbar_redo": "Redo",
"toolbar_selected_projects": "Selected projects",
"toolbar_selected_projects_management_actions": "Selected projects management actions",
"toolbar_selected_projects_remove": "Remove selected projects",
"toolbar_selected_projects_restore": "Restore selected projects",
"toolbar_table_insert_size_table": "Insert __size__ table",
"toolbar_table_insert_table_lowercase": "Insert table",
"toolbar_toggle_symbol_palette": "Toggle Symbol Palette",

View file

@ -17,7 +17,7 @@ describe('<ProjectTools />', function () {
screen.getByLabelText('Download')
screen.getByLabelText('Archive')
screen.getByLabelText('Trash')
screen.getByTitle('Tags')
screen.getByLabelText('Tags')
screen.getByRole('button', { name: 'Create new tag' })
})
})