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

View file

@ -446,6 +446,7 @@
"files_cannot_include_invalid_characters": "", "files_cannot_include_invalid_characters": "",
"files_selected": "", "files_selected": "",
"fill_in_our_quick_survey": "", "fill_in_our_quick_survey": "",
"filter_projects": "",
"find_out_more": "", "find_out_more": "",
"find_out_more_about_institution_login": "", "find_out_more_about_institution_login": "",
"find_out_more_about_the_file_outline": "", "find_out_more_about_the_file_outline": "",
@ -1298,6 +1299,7 @@
"sorry_your_table_cant_be_displayed_at_the_moment": "", "sorry_your_table_cant_be_displayed_at_the_moment": "",
"sort_by": "", "sort_by": "",
"sort_by_x": "", "sort_by_x": "",
"sort_projects": "",
"source": "", "source": "",
"spell_check": "", "spell_check": "",
"sso": "", "sso": "",
@ -1499,6 +1501,10 @@
"toolbar_insert_table": "", "toolbar_insert_table": "",
"toolbar_numbered_list": "", "toolbar_numbered_list": "",
"toolbar_redo": "", "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_size_table": "",
"toolbar_table_insert_table_lowercase": "", "toolbar_table_insert_table_lowercase": "",
"toolbar_toggle_symbol_palette": "", "toolbar_toggle_symbol_palette": "",

View file

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

View file

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

View file

@ -1,6 +1,10 @@
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { GroupPlanSubscription } from '../../../../../../types/project/dashboard/subscription' 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< type GroupPlanProps = Pick<
GroupPlanSubscription, GroupPlanSubscription,
@ -33,8 +37,15 @@ function GroupPlan({
return ( return (
<> <>
<span className="current-plan-label visible-xs">{currentPlanLabel}</span> <span
<Tooltip className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
{currentPlanLabel}
</span>
<OLTooltip
description={ description={
subscription.teamName != null subscription.teamName != null
? t('group_plan_with_name_tooltip', { ? t('group_plan_with_name_tooltip', {
@ -46,10 +57,25 @@ function GroupPlan({
id="group-plan" id="group-plan"
overlayProps={{ placement: 'bottom' }} overlayProps={{ placement: 'bottom' }}
> >
<a href={featuresPageURL} className="current-plan-label hidden-xs"> <a
{currentPlanLabel} <span className="info-badge" /> 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> </a>
</Tooltip> </OLTooltip>
</> </>
) )
} }

View file

@ -1,6 +1,10 @@
import { useTranslation, Trans } from 'react-i18next' import { useTranslation, Trans } from 'react-i18next'
import { IndividualPlanSubscription } from '../../../../../../types/project/dashboard/subscription' 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< type IndividualPlanProps = Pick<
IndividualPlanSubscription, IndividualPlanSubscription,
@ -32,16 +36,35 @@ function IndividualPlan({
return ( return (
<> <>
<span className="current-plan-label visible-xs">{currentPlanLabel}</span> <span
<Tooltip className={classnames(
'current-plan-label',
bsVersion({ bs5: 'd-md-none', bs3: 'visible-xs' })
)}
>
{currentPlanLabel}
</span>
<OLTooltip
description={t('plan_tooltip', { plan: plan.name })} description={t('plan_tooltip', { plan: plan.name })}
id="individual-plan" id="individual-plan"
overlayProps={{ placement: 'bottom' }} overlayProps={{ placement: 'bottom' }}
> >
<a href={featuresPageURL} className="current-plan-label hidden-xs"> <a
{currentPlanLabel} <span className="info-badge" /> 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> </a>
</Tooltip> </OLTooltip>
</> </>
) )
} }

View file

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

View file

@ -1,41 +1,67 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' 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 Icon from '../../../../shared/components/icon'
import { import {
Filter, Filter,
UNCATEGORIZED_KEY, UNCATEGORIZED_KEY,
useProjectListContext, useProjectListContext,
} from '../../context/project-list-context' } 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 ProjectsFilterMenu from '../projects-filter-menu'
import TagsList from '../tags-list' import TagsList from '../tags-list'
import MenuItemButton from './menu-item-button' import MenuItemButton from './menu-item-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
type ItemProps = { type ItemProps = {
filter: Filter filter: Filter
text: string text: string
onClick: () => void onClick?: () => void
} }
export function Item({ filter, text, onClick }: ItemProps) { export function Item({ filter, text, onClick }: ItemProps) {
const { selectFilter } = useProjectListContext() const { selectFilter } = useProjectListContext()
const handleClick = () => { const handleClick = () => {
selectFilter(filter) selectFilter(filter)
onClick() onClick?.()
} }
return ( return (
<ProjectsFilterMenu filter={filter}> <ProjectsFilterMenu filter={filter}>
{isActive => ( {isActive => (
<MenuItemButton <BootstrapVersionSwitcher
onClick={handleClick} bs3={
className="projects-types-menu-item" <MenuItemButton
> onClick={handleClick}
{isActive ? ( className="projects-types-menu-item"
<Icon type="check" className="menu-item-button-icon" /> >
) : null} {isActive ? (
<span className="menu-item-button-text">{text}</span> <Icon type="check" className="menu-item-button-icon" />
</MenuItemButton> ) : 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> </ProjectsFilterMenu>
) )
@ -72,37 +98,84 @@ function ProjectsDropdown() {
}, [filter, tags, selectedTagId, t]) }, [filter, tags, selectedTagId, t])
return ( return (
<Dropdown <BootstrapVersionSwitcher
id="projects-types-dropdown" bs3={
open={isOpened} <BS3Dropdown
onToggle={open => setIsOpened(open)} id="projects-types-dropdown"
> open={isOpened}
<Dropdown.Toggle bsSize="large" noCaret className="ps-0 btn-transparent"> onToggle={open => setIsOpened(open)}
<span className="text-truncate me-1">{title}</span> >
<Icon type="angle-down" /> <BS3Dropdown.Toggle
</Dropdown.Toggle> bsSize="large"
<Dropdown.Menu className="projects-dropdown-menu"> noCaret
<Item filter="all" text={t('all_projects')} onClick={handleClose} /> className="ps-0 btn-transparent"
<Item filter="owned" text={t('your_projects')} onClick={handleClose} /> >
<Item <span className="text-truncate me-1">{title}</span>
filter="shared" <Icon type="angle-down" />
text={t('shared_with_you')} </BS3Dropdown.Toggle>
onClick={handleClose} <BS3Dropdown.Menu className="projects-dropdown-menu">
/> <Item filter="all" text={t('all_projects')} onClick={handleClose} />
<Item <Item
filter="archived" filter="owned"
text={t('archived_projects')} text={t('your_projects')}
onClick={handleClose} onClick={handleClose}
/> />
<Item <Item
filter="trashed" filter="shared"
text={t('trashed_projects')} text={t('shared_with_you')}
onClick={handleClose} onClick={handleClose}
/> />
<MenuItem header>{t('tags')}:</MenuItem> <Item
<TagsList onTagClick={handleClose} onEditClick={handleClose} /> filter="archived"
</Dropdown.Menu> text={t('archived_projects')}
</Dropdown> onClick={handleClose}
/>
<Item
filter="trashed"
text={t('trashed_projects')}
onClick={handleClose}
/>
<BS3MenuItem header>{t('tags')}:</BS3MenuItem>
<TagsList onTagClick={handleClose} onEditClick={handleClose} />
</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,22 +1,47 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' 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 Icon from '../../../../shared/components/icon'
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 { useProjectListContext } from '../../context/project-list-context' import { useProjectListContext } from '../../context/project-list-context'
import { Sort } from '../../../../../../types/project/dashboard/api' import { Sort } from '../../../../../../types/project/dashboard/api'
import MenuItemButton from './menu-item-button' 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) { function Item({ onClick, text, iconType, screenReaderText }: SortBtnProps) {
return ( return (
<MenuItemButton onClick={onClick} className="projects-sort-menu-item"> <BootstrapVersionSwitcher
{iconType ? ( bs3={
<Icon type={iconType} className="menu-item-button-icon" /> <MenuItemButton onClick={onClick} className="projects-sort-menu-item">
) : null} {iconType ? (
<span className="menu-item-button-text">{text}</span> <Icon type={iconType} className="menu-item-button-icon" />
<span className="sr-only">{screenReaderText}</span> ) : null}
</MenuItemButton> <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,45 +64,94 @@ function SortByDropdown() {
setIsOpened(false) setIsOpened(false)
handleSort(by) handleSort(by)
} }
const handleClickBS5 = (by: Sort['by']) => {
setTitle(sortByTranslations.current[by])
handleSort(by)
}
useEffect(() => { useEffect(() => {
setTitle(sortByTranslations.current[sort.by]) setTitle(sortByTranslations.current[sort.by])
}, [sort.by]) }, [sort.by])
return ( return (
<Dropdown <BootstrapVersionSwitcher
id="projects-sort-dropdown" bs3={
className="projects-sort-dropdown" <BS3Dropdown
pullRight id="projects-sort-dropdown"
open={isOpened} className="projects-sort-dropdown"
onToggle={open => setIsOpened(open)} pullRight
> open={isOpened}
<Dropdown.Toggle bsSize="small" noCaret className="pe-0 btn-transparent"> onToggle={open => setIsOpened(open)}
<span className="text-truncate me-1">{title}</span> >
<Icon type="angle-down" /> <BS3Dropdown.Toggle
</Dropdown.Toggle> bsSize="small"
<Dropdown.Menu className="projects-dropdown-menu"> noCaret
<MenuItem header>{t('sort_by')}:</MenuItem> className="pe-0 btn-transparent"
<ItemWithContent >
column="title" <span className="text-truncate me-1">{title}</span>
text={t('title')} <Icon type="angle-down" />
sort={sort} </BS3Dropdown.Toggle>
onClick={() => handleClick('title')} <BS3Dropdown.Menu className="projects-dropdown-menu">
/> <BS3MenuItem header>{t('sort_by')}:</BS3MenuItem>
<ItemWithContent <ItemWithContent
column="owner" column="title"
text={t('owner')} text={t('title')}
sort={sort} sort={sort}
onClick={() => handleClick('owner')} onClick={() => handleClick('title')}
/> />
<ItemWithContent <ItemWithContent
column="lastUpdated" column="owner"
text={t('last_modified')} text={t('owner')}
sort={sort} sort={sort}
onClick={() => handleClick('lastUpdated')} onClick={() => handleClick('owner')}
/> />
</Dropdown.Menu> <ItemWithContent
</Dropdown> column="lastUpdated"
text={t('last_modified')}
sort={sort}
onClick={() => handleClick('lastUpdated')}
/>
</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 OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button' 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 OLFormControl from '@/features/ui/components/ol/ol-form-control'
import OLForm from '@/features/ui/components/ol/ol-form' import OLForm from '@/features/ui/components/ol/ol-form'
import Notification from '@/shared/components/notification'
type CreateTagModalProps = { type CreateTagModalProps = {
id: string id: string
@ -104,10 +104,10 @@ export default function CreateTagModal({
</OLFormGroup> </OLFormGroup>
</OLForm> </OLForm>
{validationError && ( {validationError && (
<OLNotification type="error" content={validationError} /> <Notification type="error" content={validationError} />
)} )}
{isError && ( {isError && (
<OLNotification <Notification
type="error" type="error"
content={t('generic_something_went_wrong')} content={t('generic_something_went_wrong')}
/> />

View file

@ -11,7 +11,7 @@ import OLModal, {
OLModalTitle, OLModalTitle,
} from '@/features/ui/components/ol/ol-modal' } from '@/features/ui/components/ol/ol-modal'
import OLButton from '@/features/ui/components/ol/ol-button' 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 = { type DeleteTagModalProps = {
id: string id: string
@ -56,7 +56,7 @@ export default function DeleteTagModal({
<li>{tag.name}</li> <li>{tag.name}</li>
</ul> </ul>
{isError && ( {isError && (
<OLNotification <Notification
type="error" type="error"
content={t('generic_something_went_wrong')} 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 OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label' import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLButton from '@/features/ui/components/ol/ol-button' 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 = { type EditTagModalProps = {
id: string id: string
@ -113,12 +113,12 @@ export function EditTagModal({ id, tag, onEdit, onClose }: EditTagModalProps) {
</OLFormGroup> </OLFormGroup>
</OLForm> </OLForm>
{validationError && ( {validationError && (
<OLNotification type="error" content={validationError} /> <Notification content={validationError} type="error" />
)} )}
{isError && ( {isError && (
<OLNotification <Notification
type="error"
content={t('generic_something_went_wrong')} content={t('generic_something_went_wrong')}
type="error"
/> />
)} )}
</OLModalBody> </OLModalBody>

View file

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

View file

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

View file

@ -3,6 +3,8 @@ import Icon from '../../../../../shared/components/icon'
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' 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 = { type LinkSharingIconProps = {
prependSpace: boolean prependSpace: boolean
@ -26,10 +28,21 @@ function LinkSharingIcon({
{/* OverlayTrigger won't fire unless icon is wrapped in a span */} {/* OverlayTrigger won't fire unless icon is wrapped in a span */}
<span className={className}> <span className={className}>
{prependSpace ? ' ' : ''} {prependSpace ? ' ' : ''}
<Icon <BootstrapVersionSwitcher
type="link" bs3={
className="small" <Icon
accessibilityLabel={t('link_sharing')} type="link"
className="small"
accessibilityLabel={t('link_sharing')}
/>
}
bs5={
<MaterialIcon
type="link"
className="align-text-bottom"
accessibilityLabel={t('link_sharing')}
/>
}
/> />
</span> </span>
</OLTooltip> </OLTooltip>
@ -48,14 +61,8 @@ export default function OwnerCell({ project }: OwnerCellProps) {
return ( return (
<> <>
{ownerName === 'You' ? t('you') : ownerName} {ownerName === 'You' ? t('you') : ownerName}
{project.source === 'token' ? ( {project.source === 'token' && (
<LinkSharingIcon <LinkSharingIcon project={project} prependSpace={!!project.owner} />
className="hidden-xs"
project={project}
prependSpace={!!project.owner}
/>
) : (
''
)} )}
</> </>
) )

View file

@ -1,12 +1,13 @@
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 OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import Tooltip from '../../../../../../shared/components/tooltip' import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
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 { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import { bsVersion } from '@/features/utils/bootstrap-5'
function ArchiveProjectsButton() { function ArchiveProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } = const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
@ -40,19 +41,23 @@ function ArchiveProjectsButton() {
return ( return (
<> <>
<Tooltip <OLTooltip
id="tooltip-archive-projects" id="tooltip-archive-projects"
description={text} description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
> >
<button <OLIconButton
className="btn btn-secondary"
aria-label={text}
onClick={handleOpenModal} onClick={handleOpenModal}
> variant="secondary"
<Icon type="inbox" /> accessibilityLabel={text}
</button> icon={
</Tooltip> bsVersion({
bs5: 'inbox',
bs3: 'inbox',
}) as string
}
/>
</OLTooltip>
<ArchiveProjectModal <ArchiveProjectModal
projects={selectedProjects} projects={selectedProjects}
actionHandler={handleArchiveProject} actionHandler={handleArchiveProject}

View file

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

View file

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Button } from 'react-bootstrap' import OLButton from '@/features/ui/components/ol/ol-button'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
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'
@ -37,9 +37,9 @@ function DeleteProjectsButton() {
return ( return (
<> <>
{hasDeletableProjectsSelected && !hasLeavableProjectsSelected && ( {hasDeletableProjectsSelected && !hasLeavableProjectsSelected && (
<Button bsStyle={null} className="btn-danger" onClick={handleOpenModal}> <OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')} {t('delete')}
</Button> </OLButton>
)} )}
<DeleteProjectModal <DeleteProjectModal
projects={selectedProjects} projects={selectedProjects}

View file

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

View file

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

View file

@ -1,25 +1,54 @@
import { memo } from 'react' import { memo } from 'react'
import { Dropdown } from 'react-bootstrap' import { Dropdown as BS3Dropdown } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown' import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
import CopyProjectMenuItem from '../menu-items/copy-project-menu-item' import CopyProjectMenuItem from '../menu-items/copy-project-menu-item'
import RenameProjectMenuItem from '../menu-items/rename-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() { function ProjectToolsMoreDropdownButton() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<ControlledDropdown id="project-tools-more-dropdown"> <BootstrapVersionSwitcher
<Dropdown.Toggle bsStyle={null} className="btn-secondary"> bs3={
{t('more')} <ControlledDropdown id="project-tools-more-dropdown">
</Dropdown.Toggle> <BS3Dropdown.Toggle bsStyle={null} className="btn-secondary">
<Dropdown.Menu {t('more')}
className="dropdown-menu-right" </BS3Dropdown.Toggle>
data-testid="project-tools-more-dropdown-menu" <BS3Dropdown.Menu
> className="dropdown-menu-right"
<RenameProjectMenuItem /> data-testid="project-tools-more-dropdown-menu"
<CopyProjectMenuItem /> >
</Dropdown.Menu> <RenameProjectMenuItem />
</ControlledDropdown> <CopyProjectMenuItem />
</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 { sortBy } from 'lodash'
import { memo, useCallback } from 'react' 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 { useTranslation } from 'react-i18next'
import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown' import ControlledDropdown from '../../../../../../shared/components/controlled-dropdown'
import Icon from '../../../../../../shared/components/icon' import Icon from '../../../../../../shared/components/icon'
@ -9,6 +9,15 @@ import { useProjectListContext } from '../../../../context/project-list-context'
import useTag from '../../../../hooks/use-tag' import useTag from '../../../../hooks/use-tag'
import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api' import { addProjectsToTag, removeProjectsFromTag } from '../../../../util/api'
import { getTagColor } from '../../../../util/tag' 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() { function TagsDropdown() {
const { const {
@ -84,65 +93,126 @@ function TagsDropdown() {
return ( return (
<> <>
<ControlledDropdown id="tags"> <BootstrapVersionSwitcher
<Dropdown.Toggle bs3={
bsStyle={null} <ControlledDropdown id="tags">
className="btn-secondary" <BS3Dropdown.Toggle
title={t('tags')} bsStyle={null}
aria-label={t('tags')} className="btn-secondary"
> aria-label={t('tags')}
<MaterialIcon type="label" style={{ verticalAlign: 'sub' }} /> >
</Dropdown.Toggle> <MaterialIcon type="label" style={{ verticalAlign: 'sub' }} />
<Dropdown.Menu className="dropdown-menu-right"> </BS3Dropdown.Toggle>
<li className="dropdown-header" role="heading" aria-level={3}> <BS3Dropdown.Menu className="dropdown-menu-right">
{t('add_to_tag')} <li className="dropdown-header" role="heading" aria-level={3}>
</li> {t('add_to_tag')}
{sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => { </li>
return ( {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => {
<li key={tag._id}> return (
<li key={tag._id}>
<Button
className="tag-dropdown-button"
onClick={e =>
containsAllSelectedProjects(tag)
? handleRemoveTagFromSelectedProjects(e, tag._id)
: handleAddTagToSelectedProjects(e, tag._id)
}
aria-label={t('add_or_remove_project_from_tag', {
tagName: tag.name,
})}
>
<Icon
type={
containsAllSelectedProjects(tag)
? 'check-square-o'
: containsSomeSelectedProjects(tag)
? 'minus-square-o'
: 'square-o'
}
className="tag-checkbox"
/>{' '}
<span
className="tag-dot"
style={{
backgroundColor: getTagColor(tag),
}}
/>{' '}
{tag.name}
</Button>
</li>
)
})}
<li className="divider" />
<li>
<Button <Button
className="tag-dropdown-button" className="tag-dropdown-button"
onClick={e => onClick={handleOpenCreateTagModal}
containsAllSelectedProjects(tag)
? handleRemoveTagFromSelectedProjects(e, tag._id)
: handleAddTagToSelectedProjects(e, tag._id)
}
aria-label={t('add_or_remove_project_from_tag', {
tagName: tag.name,
})}
> >
<Icon {t('create_new_tag')}
type={
containsAllSelectedProjects(tag)
? 'check-square-o'
: containsSomeSelectedProjects(tag)
? 'minus-square-o'
: 'square-o'
}
className="tag-checkbox"
/>{' '}
<span
className="tag-dot"
style={{
backgroundColor: getTagColor(tag),
}}
/>{' '}
{tag.name}
</Button> </Button>
</li> </li>
) </BS3Dropdown.Menu>
})} </ControlledDropdown>
<li className="divider" /> }
<li> bs5={
<Button <Dropdown align="end" autoClose="outside">
className="tag-dropdown-button" <DropdownToggle
onClick={handleOpenCreateTagModal} id="project-tools-more-dropdown"
variant="secondary"
aria-label={t('tags')}
> >
{t('create_new_tag')} <MaterialIcon type="label" className="align-text-top" />
</Button> </DropdownToggle>
</li> <DropdownMenu
</Dropdown.Menu> flip={false}
</ControlledDropdown> 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" /> <CreateTagModal id="toolbar-create-tag-modal" />
</> </>
) )

View file

@ -1,12 +1,13 @@
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 OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import Tooltip from '../../../../../../shared/components/tooltip' import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
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 { Project } from '../../../../../../../../types/project/dashboard/api' import { Project } from '../../../../../../../../types/project/dashboard/api'
import { bsVersion } from '@/features/utils/bootstrap-5'
function TrashProjectsButton() { function TrashProjectsButton() {
const { selectedProjects, toggleSelectedProject, updateProjectViewData } = const { selectedProjects, toggleSelectedProject, updateProjectViewData } =
@ -40,19 +41,23 @@ function TrashProjectsButton() {
return ( return (
<> <>
<Tooltip <OLTooltip
id="tooltip-trash-projects" id="tooltip-trash-projects"
description={text} description={text}
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }} overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
> >
<button <OLIconButton
className="btn btn-secondary"
aria-label={text}
onClick={handleOpenModal} onClick={handleOpenModal}
> variant="secondary"
<Icon type="trash" /> accessibilityLabel={text}
</button> icon={
</Tooltip> bsVersion({
bs5: 'delete',
bs3: 'trash',
}) as string
}
/>
</OLTooltip>
<TrashProjectModal <TrashProjectModal
projects={selectedProjects} projects={selectedProjects}
actionHandler={handleTrashProject} actionHandler={handleTrashProject}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 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'
@ -64,6 +64,9 @@ function CopyProjectMenuItem() {
return ( return (
<> <>
<OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('make_a_copy')}
</OlDropdownMenuItem>
<CloneProjectModal <CloneProjectModal
show={showModal} show={showModal}
handleHide={handleCloseModal} handleHide={handleCloseModal}
@ -72,7 +75,6 @@ function CopyProjectMenuItem() {
projectName={selectedProjects[0].name} projectName={selectedProjects[0].name}
projectTags={projectTags} projectTags={projectTags}
/> />
<MenuItem onClick={handleOpenModal}>{t('make_a_copy')}</MenuItem>
</> </>
) )
} }

View file

@ -1,8 +1,8 @@
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { MenuItem } 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 useIsMounted from '../../../../../../shared/hooks/use-is-mounted' 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' import RenameProjectModal from '../../../modals/rename-project-modal'
function RenameProjectMenuItem() { function RenameProjectMenuItem() {
@ -34,7 +34,9 @@ function RenameProjectMenuItem() {
return ( return (
<> <>
<MenuItem onClick={handleOpenModal}>{t('rename')}</MenuItem> <OlDropdownMenuItem onClick={handleOpenModal} as="button" tabIndex={-1}>
{t('rename')}
</OlDropdownMenuItem>
<RenameProjectModal <RenameProjectModal
handleCloseModal={handleCloseModal} handleCloseModal={handleCloseModal}
showModal={showModal} showModal={showModal}

View file

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

View file

@ -11,10 +11,12 @@ import {
import useTag from '../hooks/use-tag' import useTag from '../hooks/use-tag'
import { sortBy } from 'lodash' import { sortBy } from 'lodash'
import { Tag } from '../../../../../app/src/Features/Tags/types' 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 = { type TagsListProps = {
onTagClick: () => void onTagClick?: () => void
onEditClick: () => void onEditClick?: () => void
} }
function TagsList({ onTagClick, onEditClick }: TagsListProps) { function TagsList({ onTagClick, onEditClick }: TagsListProps) {
@ -32,76 +34,151 @@ function TagsList({ onTagClick, onEditClick }: TagsListProps) {
const handleClick = (e: React.MouseEvent, tag: Tag) => { const handleClick = (e: React.MouseEvent, tag: Tag) => {
handleSelectTag(e, tag._id) handleSelectTag(e, tag._id)
onTagClick() onTagClick?.()
} }
return ( return (
<> <>
{sortBy(tags, ['name']).map((tag, index) => ( <BootstrapVersionSwitcher
<MenuItemButton bs3={
key={index} <>
onClick={e => handleClick(e as unknown as React.MouseEvent, tag)} {sortBy(tags, ['name']).map((tag, index) => (
className="projects-types-menu-item projects-types-menu-tag-item" <MenuItemButton
afterNode={ key={index}
<Button onClick={e =>
onClick={e => { handleClick(e as unknown as React.MouseEvent, tag)
e.stopPropagation() }
handleManageTag(e, tag._id) className="projects-types-menu-item projects-types-menu-tag-item"
onEditClick() afterNode={
}} <Button
className="btn-transparent edit-btn me-2" onClick={e => {
bsStyle={null} e.stopPropagation()
> handleManageTag(e, tag._id)
<Icon type="pencil" fw /> onEditClick?.()
</Button> }}
} className="btn-transparent edit-btn me-2"
> bsStyle={null}
<span className="tag-item menu-item-button-text"> >
{selectedTagId === tag._id ? ( <Icon type="pencil" fw />
<Icon type="check" className="menu-item-button-icon" /> </Button>
) : null} }
<span >
className="me-2" <span className="tag-item menu-item-button-text">
style={{ {selectedTagId === tag._id ? (
color: getTagColor(tag), <Icon type="check" className="menu-item-button-icon" />
) : null}
<span
className="me-2"
style={{
color: getTagColor(tag),
}}
>
<MaterialIcon
type="label"
style={{ verticalAlign: 'sub' }}
/>
</span>
<span>
{tag.name}{' '}
<span className="subdued">
{' '}
({tag.project_ids?.length})
</span>
</span>
</span>
</MenuItemButton>
))}
<MenuItemButton
className="untagged projects-types-menu-item"
onClick={() => {
selectTag(UNCATEGORIZED_KEY)
onTagClick?.()
}} }}
> >
<MaterialIcon type="label" style={{ verticalAlign: 'sub' }} /> {selectedTagId === UNCATEGORIZED_KEY ? (
</span> <Icon type="check" className="menu-item-button-icon" />
<span> ) : null}
{tag.name}{' '} <span className="tag-item menu-item-button-text">
<span className="subdued"> ({tag.project_ids?.length})</span> {t('uncategorized')}&nbsp;
</span> <span className="subdued">({untaggedProjectsCount})</span>
</span> </span>
</MenuItemButton> </MenuItemButton>
))} <MenuItemButton
<MenuItemButton onClick={() => {
className="untagged projects-types-menu-item" openCreateTagModal()
onClick={() => { onTagClick?.()
selectTag(UNCATEGORIZED_KEY) }}
onTagClick() className="projects-types-menu-item"
}} >
> <span className="tag-item menu-item-button-text">
{selectedTagId === UNCATEGORIZED_KEY ? ( <Icon type="plus" className="me-2" />
<Icon type="check" className="menu-item-button-icon" /> <span>{t('new_tag')}</span>
) : null} </span>
<span className="tag-item menu-item-button-text"> </MenuItemButton>
{t('uncategorized')}&nbsp; </>
<span className="subdued">({untaggedProjectsCount})</span> }
</span> bs5={
</MenuItemButton> <>
<MenuItemButton {sortBy(tags, ['name']).map((tag, index) => (
onClick={() => { <li role="none" className="position-relative" key={index}>
openCreateTagModal() <DropdownItem
onTagClick() as="button"
}} tabIndex={-1}
className="projects-types-menu-item" onClick={e =>
> handleClick(e as unknown as React.MouseEvent, tag)
<span className="tag-item menu-item-button-text"> }
<Icon type="plus" className="me-2" /> leadingIcon={
<span>{t('new_tag')}</span> <span style={{ color: getTagColor(tag) }}>
</span> <MaterialIcon type="label" className="align-text-top" />
</MenuItemButton> </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 /> <CreateTagModal id="create-tag-modal-dropdown" disableCustomColor />
<ManageTagModal id="manage-tag-modal-dropdown" /> <ManageTagModal id="manage-tag-modal-dropdown" />
</> </>

View file

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

View file

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

View file

@ -1,3 +1,4 @@
import { forwardRef } from 'react'
import { Button as BS5Button, Spinner } from 'react-bootstrap-5' import { Button as BS5Button, Spinner } from 'react-bootstrap-5'
import type { ButtonProps } from '@/features/ui/components/types/button-props' import type { ButtonProps } from '@/features/ui/components/types/button-props'
import classNames from 'classnames' import classNames from 'classnames'
@ -10,52 +11,71 @@ const sizeClasses = new Map<ButtonProps['size'], string>([
['large', 'btn-lg'], ['large', 'btn-lg'],
]) ])
export default function Button({ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
children, (
className, {
leadingIcon, children,
isLoading = false, className,
loadingLabel, leadingIcon,
size = 'default', isLoading = false,
trailingIcon, loadingLabel,
variant = 'primary', size = 'default',
...props trailingIcon,
}: ButtonProps) { variant = 'primary',
const { t } = useTranslation() ...props
},
ref
) => {
const { t } = useTranslation()
const sizeClass = sizeClasses.get(size) const sizeClass = sizeClasses.get(size)
const buttonClassName = classNames('d-inline-grid', sizeClass, className, { const buttonClassName = classNames('d-inline-grid', sizeClass, className, {
'button-loading': isLoading, 'button-loading': isLoading,
}) })
const loadingSpinnerClassName = const loadingSpinnerClassName =
size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small' size === 'large' ? 'loading-spinner-large' : 'loading-spinner-small'
const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small' const materialIconClassName = size === 'large' ? 'icon-large' : 'icon-small'
return ( return (
<BS5Button className={buttonClassName} variant={variant} {...props}> <BS5Button
{isLoading && ( className={buttonClassName}
<span className="spinner-container"> variant={variant}
<Spinner {...props}
animation="border" ref={ref}
aria-hidden="true" >
as="span" {isLoading && (
className={loadingSpinnerClassName} <span className="spinner-container">
role="status" <Spinner
/> animation="border"
<span className="visually-hidden"> aria-hidden="true"
{loadingLabel ?? t('loading')} as="span"
className={loadingSpinnerClassName}
role="status"
/>
<span className="visually-hidden">
{loadingLabel ?? t('loading')}
</span>
</span> </span>
)}
<span className="button-content" aria-hidden={isLoading}>
{leadingIcon && (
<MaterialIcon
type={leadingIcon}
className={materialIconClassName}
/>
)}
{children}
{trailingIcon && (
<MaterialIcon
type={trailingIcon}
className={materialIconClassName}
/>
)}
</span> </span>
)} </BS5Button>
<span className="button-content" aria-hidden={isLoading}> )
{leadingIcon && ( }
<MaterialIcon type={leadingIcon} className={materialIconClassName} /> )
)} Button.displayName = 'Button'
{children}
{trailingIcon && ( export default Button
<MaterialIcon type={trailingIcon} className={materialIconClassName} />
)}
</span>
</BS5Button>
)
}

View file

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

View file

@ -1,28 +1,33 @@
import { forwardRef } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon' import MaterialIcon from '@/shared/components/material-icon'
import Button from './button' import Button from './button'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props' import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
export default function IconButton({ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
accessibilityLabel, (
icon, { accessibilityLabel, icon, isLoading = false, size = 'default', ...props },
isLoading = false, ref
size = 'default', ) => {
...props const iconButtonClassName = `icon-button-${size}`
}: IconButtonProps) { const iconSizeClassName = size === 'large' ? 'icon-large' : 'icon-small'
const iconButtonClassName = `icon-button-${size}` const materialIconClassName = classNames(iconSizeClassName, {
const iconSizeClassName = size === 'large' ? 'icon-large' : 'icon-small' 'button-content-hidden': isLoading,
const materialIconClassName = classNames(iconSizeClassName, { })
'button-content-hidden': isLoading,
})
return ( return (
<Button className={iconButtonClassName} isLoading={isLoading} {...props}> <Button
<MaterialIcon className={iconButtonClassName}
accessibilityLabel={accessibilityLabel} isLoading={isLoading}
className={materialIconClassName} aria-label={accessibilityLabel}
type={icon} {...props}
/> ref={ref}
</Button> >
) <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 { 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' import { callFnsInSequence } from '@/utils/functions'
type OverlayProps = Omit< type OverlayProps = Omit<OverlayTriggerProps, 'overlay' | 'children'>
React.ComponentProps<typeof OverlayTrigger>,
'overlay' | 'children'
>
type UpdatingTooltipProps = { type UpdatingTooltipProps = {
popper: { popper: {
@ -34,7 +36,7 @@ export type TooltipProps = {
description: React.ReactNode description: React.ReactNode
id: string id: string
overlayProps?: OverlayProps overlayProps?: OverlayProps
tooltipProps?: React.ComponentProps<typeof BSTooltip> tooltipProps?: BSTooltipProps
hidden?: boolean hidden?: boolean
children: React.ReactElement 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 { bs3ButtonProps, BS3ButtonSize } from './ol-button'
import { Button as BS3Button } from 'react-bootstrap' import { Button as BS3Button } from 'react-bootstrap'
import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props' import type { IconButtonProps } from '@/features/ui/components/types/icon-button-props'
import BootstrapVersionSwitcher from '../bootstrap-5/bootstrap-version-switcher' import BootstrapVersionSwitcher from '../bootstrap-5/bootstrap-version-switcher'
import Icon, { IconProps } from '@/shared/components/icon' import Icon, { IconProps } from '@/shared/components/icon'
import IconButton from '../bootstrap-5/icon-button' import IconButton from '../bootstrap-5/icon-button'
import { callFnsInSequence } from '@/utils/functions'
export type OLIconButtonProps = IconButtonProps & { export type OLIconButtonProps = IconButtonProps & {
bs3Props?: { bs3Props?: {
@ -11,28 +13,44 @@ export type OLIconButtonProps = IconButtonProps & {
fw?: IconProps['fw'] fw?: IconProps['fw']
className?: string className?: string
bsSize?: BS3ButtonSize 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>(
const { bs3Props, ...rest } = props (props, ref) => {
const { bs3Props, ...rest } = props
const { fw, loading, ...bs3Rest } = bs3Props || {} const { fw, loading, ...bs3Rest } = bs3Props || {}
return ( // BS3 OverlayTrigger automatically provides 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' event handlers
<BootstrapVersionSwitcher const bs3FinalProps = {
bs3={ 'aria-label': rest.accessibilityLabel,
<BS3Button {...bs3ButtonProps(rest)} {...bs3Rest}> ...bs3ButtonProps(rest),
{loading || ( ...bs3Rest,
<Icon onMouseOver: callFnsInSequence(bs3Props?.onMouseOver, rest.onMouseOver),
type={rest.icon} onMouseOut: callFnsInSequence(bs3Props?.onMouseOut, rest.onMouseOut),
fw={fw} onFocus: callFnsInSequence(bs3Props?.onFocus, rest.onFocus),
accessibilityLabel={rest.accessibilityLabel} onBlur: callFnsInSequence(bs3Props?.onBlur, rest.onBlur),
/> }
)}
</BS3Button> // BS3 tooltip relies on the 'onMouseOver', 'onMouseOut', 'onFocus', 'onBlur' props
} // BS5 tooltip relies on the ref
bs5={<IconButton {...rest} />} return (
/> <BootstrapVersionSwitcher
) bs3={
} <BS3Button {...bs3FinalProps}>
{loading || <Icon type={rest.icon} fw={fw} />}
</BS3Button>
}
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 = { export type ButtonProps = {
children?: ReactNode children?: ReactNode
@ -11,7 +11,11 @@ export type ButtonProps = {
rel?: string rel?: string
isLoading?: boolean isLoading?: boolean
loadingLabel?: string 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' size?: 'small' | 'default' | 'large'
trailingIcon?: string trailingIcon?: string
type?: 'button' | 'reset' | 'submit' type?: 'button' | 'reset' | 'submit'

View file

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

View file

@ -1,6 +1,6 @@
export function callFnsInSequence< export function callFnsInSequence<
Args, Args extends Array<any>,
Fn extends ((...args: Args[]) => void) | void, Fn extends ((...args: Args) => void) | void,
>(...fns: Fn[]) { >(...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, DropdownMenu,
DropdownItem, DropdownItem,
DropdownDivider, DropdownDivider,
DropdownHeader,
} from '@/features/ui/components/bootstrap-5/dropdown-menu' } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import type { Meta } from '@storybook/react' 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) => { export const Danger = (args: Args) => {
return ( return (
<DropdownMenu show> <DropdownMenu show>

View file

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

View file

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

View file

@ -1,4 +1,5 @@
@import 'button'; @import 'button';
@import 'button-group';
@import 'dropdown-menu'; @import 'dropdown-menu';
@import 'split-button'; @import 'split-button';
@import 'notifications'; @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 { .copy-button {
text-decoration: none; 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 // description text should look disabled when the dropdown item is disabled
.dropdown-item.disabled .dropdown-item-description, .dropdown-item.disabled .dropdown-item-description,
.dropdown-item[aria-disabled='true'] .dropdown-item-description { .dropdown-item[aria-disabled='true'] .dropdown-item-description {

View file

@ -164,6 +164,65 @@
padding: var(--spacing-08) var(--spacing-06); 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 { ul.project-list-filters {
margin: var(--spacing-05) calc(-1 * var(--spacing-06)); 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 */ /* stylelint-disable selector-class-pattern */
.project-list-upload-project-modal-uppy-dashboard .uppy-Root { .project-list-upload-project-modal-uppy-dashboard .uppy-Root {
.uppy-Dashboard-AddFiles-title { .uppy-Dashboard-AddFiles-title {
@ -747,7 +818,7 @@ form.project-search {
margin: 3px; // it's centered, no matching spacing variable margin: 3px; // it's centered, no matching spacing variable
font-weight: bold; font-weight: bold;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(md) {
margin: 5px; // it's centered, no matching spacing variable margin: 5px; // it's centered, no matching spacing variable
} }
} }
@ -757,7 +828,7 @@ form.project-search {
margin: 3px; margin: 3px;
font-weight: bold; font-weight: bold;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(md) {
margin: 5px; margin: 5px;
} }
} }
@ -769,7 +840,7 @@ form.project-search {
font-weight: bold; font-weight: bold;
} }
@include media-breakpoint-down(sm) { @include media-breakpoint-down(md) {
height: 32px; height: 32px;
width: 32px; width: 32px;
margin: var(--spacing-08); margin: var(--spacing-08);

View file

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

View file

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