Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 102 additions & 26 deletions src/components/map-projects/Controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import Menu from '@mui/material/Menu';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import CircularProgress from '@mui/material/CircularProgress'
import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import AutoMatchIcon from '@mui/icons-material/MotionPhotosAutoOutlined';
import DownloadIcon from '@mui/icons-material/Download';
import TimelineIcon from '@mui/icons-material/Timeline';
import SettingsIcon from '@mui/icons-material/Settings';
Expand All @@ -20,7 +22,9 @@ import JSONIcon from '@mui/icons-material/DataObject';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { copyToClipboard } from '../../common/utils'
import RepoIcon from '../repos/RepoIcon'
import { splitSecondaryActionsByVisibility } from './controlsLayout'
import { getToolbarLayout } from './controlsLayout'

const SHRINKABLE_MIN_WIDTH = 48

const IkonButton = ({title, icon, onClick, color, disabled, id}) => {
return (
Expand All @@ -42,7 +46,7 @@ const IkonButton = ({title, icon, onClick, color, disabled, id}) => {
)
}

const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving, onImport, importResponse, onDownloadImportReport, onProjectLogsClick, isProjectsLogOpen, configure, setConfigure, isCoreUser, loadingMatches, onCopyClick}) => {
const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving, onImport, importResponse, onDownloadImportReport, onProjectLogsClick, isProjectsLogOpen, configure, setConfigure, isCoreUser, loadingMatches, onCopyClick, autoMatch}) => {
const { t } = useTranslation();
const toolbarRef = React.useRef(null)
const overflowButtonRef = React.useRef(null)
Expand All @@ -56,20 +60,44 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
const hasPersistentOverflowActions = Boolean(project?.id)

React.useEffect(() => {
const node = toolbarRef.current
const node = toolbarRef.current?.parentElement
if (!node) return undefined
const sibling = toolbarRef.current.previousElementSibling

const getOuterWidth = element => {
const style = window.getComputedStyle(element)
return element.getBoundingClientRect().width + (parseFloat(style.marginLeft) || 0) + (parseFloat(style.marginRight) || 0)
}

const updateToolbarWidth = () => {
const nextWidth = node.getBoundingClientRect?.().width
setToolbarWidth(nextWidth || Number.POSITIVE_INFINITY)
const headerWidth = node.getBoundingClientRect?.().width
if (!headerWidth) {
setToolbarWidth(Number.POSITIVE_INFINITY)
return
}
const reservedWidth = sibling ?
Array.from(sibling.children).reduce((sum, child) => sum + (child.dataset.shrinkable ? SHRINKABLE_MIN_WIDTH : getOuterWidth(child)), 0) :
0
setToolbarWidth(headerWidth - reservedWidth)
}

updateToolbarWidth()

if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateToolbarWidth)
observer.observe(node)
return () => observer.disconnect()
const resizeObserver = new ResizeObserver(updateToolbarWidth)
const observeChildren = () => {
resizeObserver.disconnect()
resizeObserver.observe(node)
Array.from(sibling?.children || []).forEach(child => resizeObserver.observe(child))
}
observeChildren()
const mutationObserver = typeof MutationObserver !== 'undefined' && sibling ?
new MutationObserver(() => { observeChildren(); updateToolbarWidth() }) : null
mutationObserver?.observe(sibling, {childList: true})
return () => {
resizeObserver.disconnect()
mutationObserver?.disconnect()
}
}

window.addEventListener('resize', updateToolbarWidth)
Expand Down Expand Up @@ -110,14 +138,24 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
}
}

const { overflowActionKeys, visibleActionKeys } = splitSecondaryActionsByVisibility({
toolbarWidth,
hasOverflowItems: hasPersistentOverflowActions
})
const hasActions = Boolean(file?.name)
const { autoMatch: autoMatchMode, saveInMenu, overflowActionKeys, visibleActionKeys } = hasActions ?
getToolbarLayout({
toolbarWidth,
hasOverflowItems: hasPersistentOverflowActions,
hasAutoMatch: Boolean(autoMatch)
}) :
{autoMatch: 'full', saveInMenu: false, overflowActionKeys: [], visibleActionKeys: []}

const visibleSecondaryActions = visibleActionKeys.map(key => actionsByKey[key])
const overflowSecondaryActions = overflowActionKeys.map(key => actionsByKey[key])
const hasOverflowActions = hasPersistentOverflowActions || overflowSecondaryActions.length > 0
const autoMatchInMenu = Boolean(autoMatch) && autoMatchMode === 'menu'
const hasOverflowActions = hasActions && (hasPersistentOverflowActions || overflowSecondaryActions.length > 0 || saveInMenu || autoMatchInMenu)
const overflowMenuActions = [
...(autoMatchInMenu ? [{key: 'autoMatch', icon: <AutoMatchIcon />, title: autoMatch.label, disabled: autoMatch.disabled || autoMatch.loading, onClick: autoMatch.onClick}] : []),
...overflowSecondaryActions,
...(saveInMenu ? [actionsByKey.save] : [])
]

const closeDownloadMenu = () => setDownloadAnchorEl(null)
const closeOverflowMenu = () => setOverflowAnchorEl(null)
Expand All @@ -134,8 +172,43 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
}

return (
<span ref={toolbarRef} style={{display: 'flex', flex: '1 1 auto', flexDirection: 'column', alignItems: 'flex-end', maxWidth: '100%', minWidth: 0}}>
<div style={{display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'flex-end', alignContent: 'flex-end', width: '100%', minWidth: 0}}>
<span ref={toolbarRef} style={{display: 'flex', flex: '0 0 auto', flexDirection: 'column', alignItems: 'flex-end', minWidth: 0}}>
<div style={{display: 'flex', flexWrap: 'nowrap', alignItems: 'center', justifyContent: 'flex-end', minWidth: 0}}>
{
autoMatch && !autoMatchInMenu && (autoMatchMode === 'compact' ?
<Tooltip title={autoMatch.label} placement='bottom'>
<span>
<IconButton
aria-label={autoMatch.label}
color='primary'
size='small'
sx={{margin: '2px 5px'}}
onClick={autoMatch.onClick}
disabled={autoMatch.disabled || autoMatch.loading}
>
{autoMatch.loading ? <CircularProgress size={20} /> : <AutoMatchIcon />}
</IconButton>
</span>
</Tooltip> :
<Button
variant='contained'
size='small'
sx={{textTransform: 'none', margin: '5px', whiteSpace: 'nowrap', flexShrink: 0}}
endIcon={<DoubleArrowIcon />}
startIcon={<AutoMatchIcon />}
disabled={autoMatch.disabled}
onClick={autoMatch.onClick}
loading={autoMatch.loading}
loadingPosition='start'
>
{autoMatch.label}
</Button>
)
}
{
autoMatch && !autoMatchInMenu && hasActions &&
<Divider flexItem orientation='vertical' sx={{mx: 0.5, my: 0.75}} />
}
{
visibleSecondaryActions.map(action => (
<IkonButton
Expand All @@ -153,17 +226,20 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
visibleSecondaryActions.length > 0 &&
<Divider flexItem orientation='vertical' sx={{mx: 0.5, my: 0.75}} />
}
<IkonButton
color={actionsByKey.save.color}
onClick={actionsByKey.save.onClick}
title={actionsByKey.save.title}
disabled={actionsByKey.save.disabled}
icon={actionsByKey.save.icon}
/>
{
hasActions && !saveInMenu &&
<IkonButton
color={actionsByKey.save.color}
onClick={actionsByKey.save.onClick}
title={actionsByKey.save.title}
disabled={actionsByKey.save.disabled}
icon={actionsByKey.save.icon}
/>
}
{
hasOverflowActions &&
<>
<Divider flexItem orientation='vertical' sx={{mx: 0.5, my: 0.75}} />
{!saveInMenu && <Divider flexItem orientation='vertical' sx={{mx: 0.5, my: 0.75}} />}
<Tooltip title={t('common.more_actions')} placement='bottom'>
<span>
<IconButton
Expand All @@ -187,7 +263,7 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
</div>
{
(lastSavedText || isSaving) &&
<div style={{fontSize: '11px', color: 'rgba(0, 0, 0, 0.6)', textAlign: 'right', marginTop: '-4px'}}>
<div style={{fontSize: '11px', color: 'rgba(0, 0, 0, 0.6)', textAlign: 'right', marginTop: '-4px', whiteSpace: 'nowrap'}}>
{
isSaving ? t('map_project.saving') :
t('map_project.last_saved', {time: lastSavedText})
Expand Down Expand Up @@ -266,15 +342,15 @@ const Controls = ({project, onDownload, onSave, onDelete, owner, file, isSaving,
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{
overflowSecondaryActions.map(action => (
overflowMenuActions.map(action => (
<MenuItem key={action.key} onClick={onOverflowActionClick(action)} disabled={action.disabled}>
<ListItemIcon>{action.icon}</ListItemIcon>
<ListItemText>{action.title}</ListItemText>
</MenuItem>
))
}
{
overflowSecondaryActions.length > 0 && project?.id &&
overflowMenuActions.length > 0 && project?.id &&
<Divider />
}
{
Expand Down
22 changes: 4 additions & 18 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,8 @@ import Snackbar from '@mui/material/Snackbar';
import { DataGrid, GRID_CHECKBOX_SELECTION_COL_DEF } from '@mui/x-data-grid';


import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import DoneIcon from '@mui/icons-material/Done';
import CloseIcon from '@mui/icons-material/Close';
import AutoMatchIcon from '@mui/icons-material/MotionPhotosAutoOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import AssistantIcon from '@mui/icons-material/Assistant';
import PendingIcon from '@mui/icons-material/HourglassBottom';
Expand Down Expand Up @@ -6345,30 +6343,17 @@ const MapProject = () => {
<div className='col-xs-12 padding-0' style={{backgroundColor: isConfigFormInline ? WHITE : SURFACE_COLORS.main, marginLeft: '-5px', paddingBottom: '0px', paddingLeft: '0px', paddingTop: '0px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
{
!isConfigFormInline &&
<span style={{display: 'flex', alignItems: 'center'}}>
<span style={{display: 'flex', alignItems: 'center', flex: '1 1 0', minWidth: 0}}>
<MapProjectBreadcrumb ownerType={params.ownerType} owner={params.owner} />
{
name &&
<span style={{fontWeight: 'bold', fontSize: '18px', maxWidth: isSplitView ? '300px' : '500px', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', marginRight: '8px'}}>
<span title={name} data-shrinkable='true' style={{fontWeight: 'bold', fontSize: '18px', flex: '0 1 auto', minWidth: '40px', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', marginRight: '8px'}}>
{name}
</span>
}
<span style={{marginRight: '8px'}}>
<MapperQuotaChip />
</span>
<Button
variant='contained'
size='small'
sx={{textTransform: 'none', margin: '5px'}}
endIcon={<DoubleArrowIcon />}
startIcon={<AutoMatchIcon />}
disabled={!file}
onClick={onGetCandidates}
loading={loadingMatches}
loadingPosition="start"
>
{getCandidatesButtonLabel()}
</Button>
{
scispacyCandidatesStartedAt &&
<Button
Expand Down Expand Up @@ -6445,8 +6430,9 @@ const MapProject = () => {
</span>
}
{
file?.name &&
(file?.name || !isConfigFormInline) &&
<Controls
autoMatch={isConfigFormInline ? undefined : {label: getCandidatesButtonLabel(), onClick: onGetCandidates, disabled: !file, loading: Boolean(loadingMatches)}}
isCoreUser={isCoreUser}
project={project}
onDownload={onDownloadClick}
Expand Down
32 changes: 32 additions & 0 deletions src/components/map-projects/__tests__/controlsLayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'

import {
getToolbarLayout,
getVisibleSecondaryActionCount,
splitSecondaryActionsByVisibility
} from '../controlsLayout.js'
Expand Down Expand Up @@ -48,3 +49,34 @@ test('controlsLayout may skip directly to a single visible secondary action when
}
)
})

test('getToolbarLayout shows AutoMatch in full when width is unconstrained', () => {
assert.deepEqual(
getToolbarLayout({ toolbarWidth: Number.POSITIVE_INFINITY, hasOverflowItems: true, hasAutoMatch: true }),
{ autoMatch: 'full', saveInMenu: false, overflowActionKeys: [], visibleActionKeys: ['settings', 'timeline', 'download'] }
)
})

test('getToolbarLayout compacts AutoMatch before collapsing secondary actions', () => {
const layout = getToolbarLayout({ toolbarWidth: 400, hasOverflowItems: true, hasAutoMatch: true })
assert.equal(layout.autoMatch, 'compact')
assert.deepEqual(layout.overflowActionKeys, [])
})

test('getToolbarLayout moves Save and then AutoMatch into the menu as space runs out', () => {
assert.deepEqual(
getToolbarLayout({ toolbarWidth: 160, hasOverflowItems: true, hasAutoMatch: true }),
{ autoMatch: 'compact', saveInMenu: true, overflowActionKeys: ['settings', 'timeline', 'download'], visibleActionKeys: [] }
)
assert.equal(getToolbarLayout({ toolbarWidth: 80, hasOverflowItems: true, hasAutoMatch: true }).autoMatch, 'menu')
assert.equal(getToolbarLayout({ toolbarWidth: 80, hasOverflowItems: true, hasAutoMatch: false }).autoMatch, 'none')
})

test('getToolbarLayout falls back to the narrowest layout when the budget is zero or negative', () => {
for (const toolbarWidth of [0, -1]) {
assert.deepEqual(
getToolbarLayout({ toolbarWidth, hasOverflowItems: true, hasAutoMatch: true }),
{ autoMatch: 'menu', saveInMenu: true, overflowActionKeys: ['settings', 'timeline', 'download'], visibleActionKeys: [] }
)
}
})
39 changes: 39 additions & 0 deletions src/components/map-projects/controlsLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const SECONDARY_ACTION_KEYS = ['settings', 'timeline', 'download']
const ACTION_BUTTON_WIDTH = 46
const TIER_DIVIDER_WIDTH = 12
const COLLAPSE_BUFFER_WIDTH = 40
const AUTO_MATCH_FULL_WIDTH = 200

const getRequiredToolbarWidth = (visibleSecondaryCount, hasOverflowItems) => {
let requiredWidth = ACTION_BUTTON_WIDTH // Save
Expand Down Expand Up @@ -43,3 +44,41 @@ export const splitSecondaryActionsByVisibility = ({ toolbarWidth, hasOverflowIte
visibleActionKeys: SECONDARY_ACTION_KEYS.slice(SECONDARY_ACTION_KEYS.length - visibleCount)
}
}

const getLayoutStates = hasAutoMatch => {
const compact = hasAutoMatch ? 'compact' : 'none'
const states = []
if (hasAutoMatch) states.push({ autoMatch: 'full', secondaryCount: SECONDARY_ACTION_KEYS.length, save: true })
for (let count = SECONDARY_ACTION_KEYS.length; count >= 0; count -= 1) {
states.push({ autoMatch: compact, secondaryCount: count, save: true })
}
states.push({ autoMatch: compact, secondaryCount: 0, save: false })
if (hasAutoMatch) states.push({ autoMatch: 'menu', secondaryCount: 0, save: false })
return states
}

const getStateWidth = (state, hasOverflowItems) => {
const usesOverflow = hasOverflowItems || state.secondaryCount < SECONDARY_ACTION_KEYS.length || !state.save || state.autoMatch === 'menu'
let width = 0
if (state.autoMatch === 'full') width += AUTO_MATCH_FULL_WIDTH + TIER_DIVIDER_WIDTH
if (state.autoMatch === 'compact') width += ACTION_BUTTON_WIDTH + TIER_DIVIDER_WIDTH
if (state.secondaryCount > 0) width += (state.secondaryCount * ACTION_BUTTON_WIDTH) + TIER_DIVIDER_WIDTH
if (state.save) width += ACTION_BUTTON_WIDTH
if (usesOverflow) width += ACTION_BUTTON_WIDTH + TIER_DIVIDER_WIDTH
return width
}

export const getToolbarLayout = ({ toolbarWidth, hasOverflowItems, hasAutoMatch }) => {
const states = getLayoutStates(hasAutoMatch)
let state = states[0]
if (Number.isFinite(toolbarWidth)) {
const effectiveToolbarWidth = Math.max(0, toolbarWidth - COLLAPSE_BUFFER_WIDTH)
Comment thread
snyaggarwal marked this conversation as resolved.
Resolved
state = states.find(candidate => effectiveToolbarWidth >= getStateWidth(candidate, hasOverflowItems)) || states[states.length - 1]
}
return {
autoMatch: state.autoMatch,
saveInMenu: !state.save,
overflowActionKeys: SECONDARY_ACTION_KEYS.slice(0, SECONDARY_ACTION_KEYS.length - state.secondaryCount),
visibleActionKeys: SECONDARY_ACTION_KEYS.slice(SECONDARY_ACTION_KEYS.length - state.secondaryCount)
}
}
Loading