Fix dashboard tests (#9050)

Fixes issues that were causing CI to fail.

# Important Notes
None
This commit is contained in:
somebody1234 2024-02-19 21:47:48 +10:00 committed by GitHub
parent a25d716932
commit 50d5f32570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 246 additions and 242 deletions

View File

@ -589,6 +589,17 @@ export function getAssetRowLeftPx(locator: test.Locator) {
return locator.evaluate(el => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0) return locator.evaluate(el => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0)
} }
// ===================================
// === expect functions for themes ===
// ===================================
/** A test assertion to confirm that the element has the class `selected`. */
export async function expectClassSelected(locator: test.Locator) {
await test.test.step('Expect `selected`', async () => {
await test.expect(locator).toHaveClass(/(?:^| )selected(?: |$)/)
})
}
// ============================ // ============================
// === expectPlaceholderRow === // === expectPlaceholderRow ===
// ============================ // ============================

View File

@ -137,9 +137,14 @@ test.test('cut (keyboard)', async ({ page }) => {
await actions.locateNewFolderIcon(page).click() await actions.locateNewFolderIcon(page).click()
await assetRows.nth(0).click() await assetRows.nth(0).click()
await actions.press(page, 'Mod+X') await actions.press(page, 'Mod+X')
test // This action is not a builtin `expect` action, so it needs to be manually retried.
.expect(await assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity))) await test
.toBeLessThan(1) .expect(async () => {
test
.expect(await assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
.toBeLessThan(1)
})
.toPass()
}) })
test.test('duplicate', async ({ page }) => { test.test('duplicate', async ({ page }) => {

View File

@ -18,6 +18,7 @@ export default test.defineConfig({
toHaveScreenshot: { threshold: 0 }, toHaveScreenshot: { threshold: 0 },
timeout: 30_000, timeout: 30_000,
}, },
timeout: 30_000,
use: { use: {
baseURL: 'http://localhost:8080', baseURL: 'http://localhost:8080',
launchOptions: { launchOptions: {

View File

@ -56,14 +56,14 @@ export default function DragModal(props: DragModalProps) {
unsetModal() unsetModal()
} }
// Update position (non-FF) // Update position (non-FF)
document.addEventListener('drag', onDrag) document.addEventListener('drag', onDrag, { capture: true })
// Update position (FF) // Update position (FF)
document.addEventListener('dragover', onDrag) document.addEventListener('dragover', onDrag, { capture: true })
document.addEventListener('dragend', onDragEnd) document.addEventListener('dragend', onDragEnd, { capture: true })
return () => { return () => {
document.removeEventListener('drag', onDrag) document.removeEventListener('drag', onDrag, { capture: true })
document.removeEventListener('dragover', onDrag) document.removeEventListener('dragover', onDrag, { capture: true })
document.removeEventListener('dragend', onDragEnd) document.removeEventListener('dragend', onDragEnd, { capture: true })
} }
// `doCleanup` is a callback, not a dependency. // `doCleanup` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -39,7 +39,7 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */ * This should never happen. */
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, setItem, selected, state, rowState, setRowState } = props const { item, setItem, selected, state, rowState, setRowState } = props
const { numberOfSelectedItems, assetEvents, dispatchAssetListEvent, nodeMap } = state const { selectedKeys, assetEvents, dispatchAssetListEvent, nodeMap } = state
const { doToggleDirectoryExpansion } = state const { doToggleDirectoryExpansion } = state
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
@ -137,7 +137,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
onClick={event => { onClick={event => {
if ( if (
eventModule.isSingleClick(event) && eventModule.isSingleClick(event) &&
((selected && numberOfSelectedItems === 1) || ((selected && selectedKeys.current.size === 1) ||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
) { ) {
event.stopPropagation() event.stopPropagation()

View File

@ -42,7 +42,7 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */ * This should never happen. */
export default function ProjectNameColumn(props: ProjectNameColumnProps) { export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const { item, setItem, selected, rowState, setRowState, state } = props const { item, setItem, selected, rowState, setRowState, state } = props
const { numberOfSelectedItems, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state const { selectedKeys, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { nodeMap, doOpenManually, doOpenIde, doCloseIde } = state const { nodeMap, doOpenManually, doOpenIde, doCloseIde } = state
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
@ -277,7 +277,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} else if ( } else if (
!isRunning && !isRunning &&
eventModule.isSingleClick(event) && eventModule.isSingleClick(event) &&
((selected && numberOfSelectedItems === 1) || ((selected && selectedKeys.current.size === 1) ||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
) { ) {
setRowState(object.merger({ isEditingName: true })) setRowState(object.merger({ isEditingName: true }))

View File

@ -73,12 +73,12 @@ export interface AssetNewFolderEvent extends AssetBaseEvent<AssetEventType.newFo
/** A signal to upload files. */ /** A signal to upload files. */
export interface AssetUploadFilesEvent extends AssetBaseEvent<AssetEventType.uploadFiles> { export interface AssetUploadFilesEvent extends AssetBaseEvent<AssetEventType.uploadFiles> {
readonly files: Map<backendModule.AssetId, File> readonly files: ReadonlyMap<backendModule.AssetId, File>
} }
/** A signal to update files with new versions. */ /** A signal to update files with new versions. */
export interface AssetUpdateFilesEvent extends AssetBaseEvent<AssetEventType.updateFiles> { export interface AssetUpdateFilesEvent extends AssetBaseEvent<AssetEventType.updateFiles> {
readonly files: Map<backendModule.AssetId, File> readonly files: ReadonlyMap<backendModule.AssetId, File>
} }
/** A signal to create a Data Link. */ /** A signal to create a Data Link. */
@ -112,41 +112,41 @@ export interface AssetCancelOpeningAllProjectsEvent
/** A signal that multiple assets should be copied. `ids` are the `Id`s of the newly created /** A signal that multiple assets should be copied. `ids` are the `Id`s of the newly created
* placeholder items. */ * placeholder items. */
export interface AssetCopyEvent extends AssetBaseEvent<AssetEventType.copy> { export interface AssetCopyEvent extends AssetBaseEvent<AssetEventType.copy> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
readonly newParentKey: backendModule.AssetId readonly newParentKey: backendModule.AssetId
readonly newParentId: backendModule.DirectoryId readonly newParentId: backendModule.DirectoryId
} }
/** A signal to cut multiple assets. */ /** A signal to cut multiple assets. */
export interface AssetCutEvent extends AssetBaseEvent<AssetEventType.cut> { export interface AssetCutEvent extends AssetBaseEvent<AssetEventType.cut> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
} }
/** A signal that a cut operation has been cancelled. */ /** A signal that a cut operation has been cancelled. */
export interface AssetCancelCutEvent extends AssetBaseEvent<AssetEventType.cancelCut> { export interface AssetCancelCutEvent extends AssetBaseEvent<AssetEventType.cancelCut> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
} }
/** A signal to move multiple assets. */ /** A signal to move multiple assets. */
export interface AssetMoveEvent extends AssetBaseEvent<AssetEventType.move> { export interface AssetMoveEvent extends AssetBaseEvent<AssetEventType.move> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
readonly newParentKey: backendModule.AssetId readonly newParentKey: backendModule.AssetId
readonly newParentId: backendModule.DirectoryId readonly newParentId: backendModule.DirectoryId
} }
/** A signal to delete assets. */ /** A signal to delete assets. */
export interface AssetDeleteEvent extends AssetBaseEvent<AssetEventType.delete> { export interface AssetDeleteEvent extends AssetBaseEvent<AssetEventType.delete> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
} }
/** A signal to restore assets from trash. */ /** A signal to restore assets from trash. */
export interface AssetRestoreEvent extends AssetBaseEvent<AssetEventType.restore> { export interface AssetRestoreEvent extends AssetBaseEvent<AssetEventType.restore> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
} }
/** A signal to download assets. */ /** A signal to download assets. */
export interface AssetDownloadEvent extends AssetBaseEvent<AssetEventType.download> { export interface AssetDownloadEvent extends AssetBaseEvent<AssetEventType.download> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
} }
/** A signal to download the currently selected assets. */ /** A signal to download the currently selected assets. */
@ -161,26 +161,26 @@ export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.remo
/** A signal to temporarily add labels to the selected assetss. */ /** A signal to temporarily add labels to the selected assetss. */
export interface AssetTemporarilyAddLabelsEvent export interface AssetTemporarilyAddLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> { extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName> readonly labelNames: ReadonlySet<backendModule.LabelName>
} }
/** A signal to temporarily remove labels from the selected assetss. */ /** A signal to temporarily remove labels from the selected assetss. */
export interface AssetTemporarilyRemoveLabelsEvent export interface AssetTemporarilyRemoveLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> { extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName> readonly labelNames: ReadonlySet<backendModule.LabelName>
} }
/** A signal to add labels to the selected assetss. */ /** A signal to add labels to the selected assetss. */
export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> { export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName> readonly labelNames: ReadonlySet<backendModule.LabelName>
} }
/** A signal to remove labels from the selected assetss. */ /** A signal to remove labels from the selected assetss. */
export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> { export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> {
readonly ids: Set<backendModule.AssetId> readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName> readonly labelNames: ReadonlySet<backendModule.LabelName>
} }

View File

@ -250,7 +250,7 @@ const CATEGORY_TO_FILTER_BY: Readonly<Record<Category, backendModule.FilterBy |
/** State passed through from a {@link AssetsTable} to every cell. */ /** State passed through from a {@link AssetsTable} to every cell. */
export interface AssetsTableState { export interface AssetsTableState {
readonly numberOfSelectedItems: number readonly selectedKeys: React.MutableRefObject<ReadonlySet<backendModule.AssetId>>
readonly visibilities: ReadonlyMap<backendModule.AssetId, Visibility> readonly visibilities: ReadonlyMap<backendModule.AssetId, Visibility>
readonly category: Category readonly category: Category
readonly labels: Map<backendModule.LabelName, backendModule.Label> readonly labels: Map<backendModule.LabelName, backendModule.Label>
@ -352,9 +352,12 @@ export default function AssetsTable(props: AssetsTableProps) {
const [extraColumns, setExtraColumns] = React.useState(() => new Set<columnUtils.ExtraColumn>()) const [extraColumns, setExtraColumns] = React.useState(() => new Set<columnUtils.ExtraColumn>())
const [sortColumn, setSortColumn] = React.useState<columnUtils.SortableColumn | null>(null) const [sortColumn, setSortColumn] = React.useState<columnUtils.SortableColumn | null>(null)
const [sortDirection, setSortDirection] = React.useState<SortDirection | null>(null) const [sortDirection, setSortDirection] = React.useState<SortDirection | null>(null)
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<backendModule.AssetId>()) const [selectedKeys, setSelectedKeysRaw] = React.useState<ReadonlySet<backendModule.AssetId>>(
() => new Set()
)
const selectedKeysRef = React.useRef(selectedKeys)
const [pasteData, setPasteData] = React.useState<pasteDataModule.PasteData< const [pasteData, setPasteData] = React.useState<pasteDataModule.PasteData<
Set<backendModule.AssetId> ReadonlySet<backendModule.AssetId>
> | null>(null) > | null>(null)
const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([]) const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName) const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName)
@ -381,9 +384,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const scrollContainerRef = React.useRef<HTMLDivElement>(null) const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const headerRowRef = React.useRef<HTMLTableRowElement>(null) const headerRowRef = React.useRef<HTMLTableRowElement>(null)
const assetTreeRef = React.useRef<AssetTreeNode>(assetTree) const assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
const pasteDataRef = React.useRef<pasteDataModule.PasteData<Set<backendModule.AssetId>> | null>( const pasteDataRef = React.useRef<pasteDataModule.PasteData<
null ReadonlySet<backendModule.AssetId>
) > | null>(null)
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>( const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>(
new Map<backendModule.AssetId, AssetTreeNode>() new Map<backendModule.AssetId, AssetTreeNode>()
) )
@ -575,17 +578,17 @@ export default function AssetsTable(props: AssetsTableProps) {
if (category === Category.trash) { if (category === Category.trash) {
setCanDownloadFiles(false) setCanDownloadFiles(false)
} else if (!isCloud) { } else if (!isCloud) {
setCanDownloadFiles(selectedKeys.size !== 0) setCanDownloadFiles(selectedKeysRef.current.size !== 0)
} else { } else {
setCanDownloadFiles( setCanDownloadFiles(
selectedKeys.size !== 0 && selectedKeysRef.current.size !== 0 &&
Array.from(selectedKeys).every(key => { Array.from(selectedKeysRef.current).every(key => {
const node = nodeMapRef.current.get(key) const node = nodeMapRef.current.get(key)
return node?.item.type === backendModule.AssetType.file return node?.item.type === backendModule.AssetType.file
}) })
) )
} }
}, [category, selectedKeys, isCloud, /* should never change */ setCanDownloadFiles]) }, [category, isCloud, /* should never change */ setCanDownloadFiles])
React.useEffect(() => { React.useEffect(() => {
const nodeToSuggestion = ( const nodeToSuggestion = (
@ -792,10 +795,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (pasteDataRef.current == null) { if (pasteDataRef.current == null) {
return false return false
} else { } else {
dispatchAssetEvent({ dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteDataRef.current.data })
type: AssetEventType.cancelCut,
ids: pasteDataRef.current.data,
})
setPasteData(null) setPasteData(null)
return return
} }
@ -833,6 +833,18 @@ export default function AssetsTable(props: AssetsTableProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProjectName]) }, [initialProjectName])
const setSelectedKeys = React.useCallback(
(newSelectedKeys: ReadonlySet<backendModule.AssetId>) => {
selectedKeysRef.current = newSelectedKeys
setSelectedKeysRaw(newSelectedKeys)
},
[]
)
const clearSelectedKeys = React.useCallback(() => {
setSelectedKeys(new Set())
}, [/* should never change */ setSelectedKeys])
const overwriteNodes = React.useCallback( const overwriteNodes = React.useCallback(
(newAssets: backendModule.AnyAsset[]) => { (newAssets: backendModule.AnyAsset[]) => {
// This is required, otherwise we are using an outdated // This is required, otherwise we are using an outdated
@ -1057,10 +1069,10 @@ export default function AssetsTable(props: AssetsTableProps) {
}, [extraColumns, initialized, /* should never change */ localStorage]) }, [extraColumns, initialized, /* should never change */ localStorage])
React.useEffect(() => { React.useEffect(() => {
if (selectedKeys.size !== 1) { if (selectedKeysRef.current.size !== 1) {
setAssetPanelProps(null) setAssetPanelProps(null)
} }
}, [selectedKeys.size, /* should never change */ setAssetPanelProps]) }, [selectedKeysRef.current.size, /* should never change */ setAssetPanelProps])
const directoryListAbortControllersRef = React.useRef( const directoryListAbortControllersRef = React.useRef(
new Map<backendModule.DirectoryId, AbortController>() new Map<backendModule.DirectoryId, AbortController>()
@ -1474,12 +1486,10 @@ export default function AssetsTable(props: AssetsTableProps) {
break break
} }
case AssetListEventType.willDelete: { case AssetListEventType.willDelete: {
if (selectedKeys.has(event.key)) { if (selectedKeysRef.current.has(event.key)) {
setSelectedKeys(oldSelectedKeys => { const newSelectedKeys = new Set(selectedKeysRef.current)
const newSelectedKeys = new Set(oldSelectedKeys) newSelectedKeys.delete(event.key)
newSelectedKeys.delete(event.key) setSelectedKeys(newSelectedKeys)
return newSelectedKeys
})
} }
break break
} }
@ -1548,34 +1558,20 @@ export default function AssetsTable(props: AssetsTableProps) {
const doCopy = React.useCallback(() => { const doCopy = React.useCallback(() => {
unsetModal() unsetModal()
setSelectedKeys(oldSelectedKeys => { setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
queueMicrotask(() => {
setPasteData({ type: PasteType.copy, data: oldSelectedKeys })
})
return oldSelectedKeys
})
}, [/* should never change */ unsetModal]) }, [/* should never change */ unsetModal])
const doCut = React.useCallback(() => { const doCut = React.useCallback(() => {
unsetModal() unsetModal()
setSelectedKeys(oldSelectedKeys => { if (pasteData != null) {
queueMicrotask(() => { dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data })
if (pasteData != null) { }
dispatchAssetEvent({ setPasteData({ type: PasteType.move, data: selectedKeysRef.current })
type: AssetEventType.cancelCut, dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeysRef.current })
ids: pasteData.data, setSelectedKeys(new Set())
})
}
setPasteData({ type: PasteType.move, data: oldSelectedKeys })
dispatchAssetEvent({
type: AssetEventType.cut,
ids: oldSelectedKeys,
})
})
return new Set()
})
}, [ }, [
pasteData, pasteData,
/* should never change */ setSelectedKeys,
/* should never change */ unsetModal, /* should never change */ unsetModal,
/* should never change */ dispatchAssetEvent, /* should never change */ dispatchAssetEvent,
]) ])
@ -1626,9 +1622,9 @@ export default function AssetsTable(props: AssetsTableProps) {
category={category} category={category}
pasteData={pasteData} pasteData={pasteData}
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
clearSelectedKeys={clearSelectedKeys}
nodeMapRef={nodeMapRef} nodeMapRef={nodeMapRef}
event={{ pageX: 0, pageY: 0 }} event={{ pageX: 0, pageY: 0 }}
setSelectedKeys={setSelectedKeys}
dispatchAssetEvent={dispatchAssetEvent} dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent} dispatchAssetListEvent={dispatchAssetListEvent}
doCopy={doCopy} doCopy={doCopy}
@ -1638,11 +1634,12 @@ export default function AssetsTable(props: AssetsTableProps) {
), ),
[ [
category, category,
pasteData,
selectedKeys, selectedKeys,
pasteData,
doCopy, doCopy,
doCut, doCut,
doPaste, doPaste,
/* should never change */ clearSelectedKeys,
/* should never change */ dispatchAssetEvent, /* should never change */ dispatchAssetEvent,
/* should never change */ dispatchAssetListEvent, /* should never change */ dispatchAssetListEvent,
] ]
@ -1660,7 +1657,7 @@ export default function AssetsTable(props: AssetsTableProps) {
// The type MUST be here to trigger excess property errors at typecheck time. // The type MUST be here to trigger excess property errors at typecheck time.
(): AssetsTableState => ({ (): AssetsTableState => ({
visibilities, visibilities,
numberOfSelectedItems: selectedKeys.size, selectedKeys: selectedKeysRef,
category, category,
labels: allLabels, labels: allLabels,
deletedLabelNames, deletedLabelNames,
@ -1688,7 +1685,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}), }),
[ [
visibilities, visibilities,
selectedKeys.size,
category, category,
allLabels, allLabels,
deletedLabelNames, deletedLabelNames,
@ -1755,7 +1751,7 @@ export default function AssetsTable(props: AssetsTableProps) {
shortcutManagerModule.MouseAction.selectAdditionalRange, shortcutManagerModule.MouseAction.selectAdditionalRange,
event event
) && ) &&
selectedKeys.size !== 0 selectedKeysRef.current.size !== 0
) { ) {
setSelectedKeys(new Set()) setSelectedKeys(new Set())
} }
@ -1764,7 +1760,7 @@ export default function AssetsTable(props: AssetsTableProps) {
return () => { return () => {
document.removeEventListener('click', onDocumentClick) document.removeEventListener('click', onDocumentClick)
} }
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcutManager]) }, [shortcutManager, /* should never change */ setSelectedKeys])
React.useEffect(() => { React.useEffect(() => {
if (isLoading) { if (isLoading) {
@ -1809,24 +1805,20 @@ export default function AssetsTable(props: AssetsTableProps) {
event event
) )
) { ) {
setSelectedKeys( setSelectedKeys(new Set([...selectedKeysRef.current, ...getNewlySelectedKeys()]))
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
)
} else if ( } else if (
shortcutManager.matchesMouseAction( shortcutManager.matchesMouseAction(
shortcutManagerModule.MouseAction.selectAdditional, shortcutManagerModule.MouseAction.selectAdditional,
event event
) )
) { ) {
setSelectedKeys(oldSelectedItems => { const newSelectedKeys = new Set(selectedKeysRef.current)
const newItems = new Set(oldSelectedItems) if (selectedKeysRef.current.has(key)) {
if (oldSelectedItems.has(key)) { newSelectedKeys.delete(key)
newItems.delete(key) } else {
} else { newSelectedKeys.add(key)
newItems.add(key) }
} setSelectedKeys(newSelectedKeys)
return newItems
})
} else { } else {
setSelectedKeys(new Set([key])) setSelectedKeys(new Set([key]))
} }
@ -1882,10 +1874,10 @@ export default function AssetsTable(props: AssetsTableProps) {
hidden={visibilities.get(item.key) === Visibility.hidden} hidden={visibilities.get(item.key) === Visibility.hidden}
selected={isSelected} selected={isSelected}
setSelected={selected => { setSelected={selected => {
setSelectedKeys(oldSelectedKeys => set.withPresence(oldSelectedKeys, key, selected)) setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
}} }}
isSoleSelectedItem={isSoleSelectedItem} isSoleSelectedItem={isSoleSelectedItem}
allowContextMenu={selectedKeys.size === 0 || !isSelected || isSoleSelectedItem} allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelectedItem}
onClick={onRowClick} onClick={onRowClick}
onContextMenu={(_innerProps, event) => { onContextMenu={(_innerProps, event) => {
if (!isSelected) { if (!isSelected) {
@ -1896,106 +1888,56 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
}} }}
onDragStart={event => { onDragStart={event => {
if (!selectedKeys.has(key)) { let newSelectedKeys = selectedKeysRef.current
if (!selectedKeysRef.current.has(key)) {
setPreviouslySelectedKey(key) setPreviouslySelectedKey(key)
setSelectedKeys(new Set([key])) newSelectedKeys = new Set([key])
setSelectedKeys(newSelectedKeys)
} }
setSelectedKeys(oldSelectedKeys => { const nodes = assetTree
const nodes = assetTree .preorderTraversal()
.preorderTraversal() .filter(node => newSelectedKeys.has(node.key))
.filter(node => oldSelectedKeys.has(node.key)) const payload: drag.AssetRowsDragPayload = nodes.map(node => ({
const payload: drag.AssetRowsDragPayload = nodes.map(node => ({ key: node.key,
key: node.key, asset: node.item,
asset: node.item, }))
})) drag.setDragImageToBlank(event)
drag.setDragImageToBlank(event) drag.ASSET_ROWS.bind(event, payload)
drag.ASSET_ROWS.bind(event, payload) setModal(
queueMicrotask(() => { <DragModal
setModal( event={event}
<DragModal className="flex flex-col rounded-2xl bg-frame-selected backdrop-blur-3xl"
event={event} doCleanup={() => {
className="flex flex-col rounded-2xl bg-frame-selected backdrop-blur-3xl" drag.ASSET_ROWS.unbind(payload)
doCleanup={() => { }}
drag.ASSET_ROWS.unbind(payload) >
}} {nodes.map(node => (
> <NameColumn
{nodes.map(node => ( key={node.key}
<NameColumn keyProp={node.key}
key={node.key} item={node.with({ depth: 0 })}
keyProp={node.key} state={state}
item={node.with({ depth: 0 })} // Default states.
state={state} isSoleSelectedItem={false}
// Default states. selected={false}
isSoleSelectedItem={false} rowState={assetRowUtils.INITIAL_ROW_STATE}
selected={false} // The drag placeholder cannot be interacted with.
rowState={assetRowUtils.INITIAL_ROW_STATE} setSelected={() => {}}
// The drag placeholder cannot be interacted with. setItem={() => {}}
setSelected={() => {}} setRowState={() => {}}
setItem={() => {}} />
setRowState={() => {}} ))}
/> </DragModal>
))} )
</DragModal>
)
})
return oldSelectedKeys
})
}} }}
onDragOver={event => { onDragOver={event => {
setSelectedKeys(oldSelectedKeys => { const payload = drag.LABELS.lookup(event)
const payload = drag.LABELS.lookup(event) if (payload != null) {
if (payload != null) { event.preventDefault()
event.preventDefault() event.stopPropagation()
event.stopPropagation() const ids = new Set(
const ids = oldSelectedKeys.has(key) ? oldSelectedKeys : new Set([key]) selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key]
// Expand ids to include ids of children as well. )
for (const node of assetTree.preorderTraversal()) {
if (ids.has(node.key) && node.children != null) {
for (const child of node.children) {
ids.add(child.key)
}
}
}
let labelsPresent = 0
for (const selectedKey of ids) {
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
if (labels != null) {
for (const label of labels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type: shouldAdd
? AssetEventType.temporarilyAddLabels
: AssetEventType.temporarilyRemoveLabels,
ids,
labelNames: payload,
})
})
}
return oldSelectedKeys
})
}}
onDragEnd={() => {
setSelectedKeys(oldSelectedKeys => {
window.setTimeout(() => {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: oldSelectedKeys,
labelNames: set.EMPTY,
})
})
return oldSelectedKeys
})
}}
onDrop={event => {
setSelectedKeys(oldSelectedKeys => {
const ids = oldSelectedKeys.has(key) ? new Set(oldSelectedKeys) : new Set([key])
// Expand ids to include ids of children as well. // Expand ids to include ids of children as well.
for (const node of assetTree.preorderTraversal()) { for (const node of assetTree.preorderTraversal()) {
if (ids.has(node.key) && node.children != null) { if (ids.has(node.key) && node.children != null) {
@ -2004,41 +1946,75 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
} }
} }
const payload = drag.LABELS.lookup(event) let labelsPresent = 0
if (payload != null) { for (const selectedKey of ids) {
event.preventDefault() const labels = nodeMapRef.current.get(selectedKey)?.item.labels
event.stopPropagation() if (labels != null) {
let labelsPresent = 0 for (const label of labels) {
for (const selectedKey of ids) { if (payload.has(label)) {
const labels = nodeMapRef.current.get(selectedKey)?.item.labels labelsPresent += 1
if (labels != null) {
for (const label of labels) {
if (payload.has(label)) {
labelsPresent += 1
}
} }
} }
} }
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type: shouldAdd ? AssetEventType.addLabels : AssetEventType.removeLabels,
ids,
labelNames: payload,
})
})
} else {
window.setTimeout(() => {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids,
labelNames: set.EMPTY,
})
})
} }
return oldSelectedKeys const shouldAdd = labelsPresent * 2 < ids.size * payload.size
window.setTimeout(() => {
dispatchAssetEvent({
type: shouldAdd
? AssetEventType.temporarilyAddLabels
: AssetEventType.temporarilyRemoveLabels,
ids,
labelNames: payload,
})
})
}
}}
onDragEnd={() => {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeysRef.current,
labelNames: set.EMPTY,
}) })
}} }}
onDrop={event => {
const ids = new Set(selectedKeysRef.current.has(key) ? selectedKeysRef.current : [key])
// Expand ids to include ids of descendants as well.
for (const node of assetTree.preorderTraversal()) {
if (ids.has(node.key) && node.children != null) {
for (const child of node.children) {
ids.add(child.key)
}
}
}
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
let labelsPresent = 0
for (const selectedKey of ids) {
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
if (labels != null) {
for (const label of labels) {
if (payload.has(label)) {
labelsPresent += 1
}
}
}
}
const shouldAdd = labelsPresent * 2 < ids.size * payload.size
dispatchAssetEvent({
type: shouldAdd ? AssetEventType.addLabels : AssetEventType.removeLabels,
ids,
labelNames: payload,
})
} else {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids,
labelNames: set.EMPTY,
})
}
}}
/> />
) )
}) })
@ -2057,7 +2033,7 @@ export default function AssetsTable(props: AssetsTableProps) {
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
nodeMapRef={nodeMapRef} nodeMapRef={nodeMapRef}
event={event} event={event}
setSelectedKeys={setSelectedKeys} clearSelectedKeys={clearSelectedKeys}
dispatchAssetEvent={dispatchAssetEvent} dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent} dispatchAssetListEvent={dispatchAssetListEvent}
doCopy={doCopy} doCopy={doCopy}
@ -2073,15 +2049,10 @@ export default function AssetsTable(props: AssetsTableProps) {
event.relatedTarget instanceof Node && event.relatedTarget instanceof Node &&
!event.currentTarget.contains(event.relatedTarget) !event.currentTarget.contains(event.relatedTarget)
) { ) {
setSelectedKeys(oldSelectedKeys => { dispatchAssetEvent({
window.setTimeout(() => { type: AssetEventType.temporarilyAddLabels,
dispatchAssetEvent({ ids: selectedKeysRef.current,
type: AssetEventType.temporarilyAddLabels, labelNames: set.EMPTY,
ids: oldSelectedKeys,
labelNames: set.EMPTY,
})
})
return oldSelectedKeys
}) })
} }
}} }}
@ -2100,6 +2071,9 @@ export default function AssetsTable(props: AssetsTableProps) {
<div <div
className="grow" className="grow"
onClick={() => {
setSelectedKeys(new Set())
}}
onDragEnter={onDragOver} onDragEnter={onDragOver}
onDragOver={onDragOver} onDragOver={onDragOver}
onDrop={event => { onDrop={event => {

View File

@ -43,9 +43,9 @@ const pluralize = string.makePluralize('item', 'items')
export interface AssetsTableContextMenuProps { export interface AssetsTableContextMenuProps {
readonly hidden?: boolean readonly hidden?: boolean
readonly category: Category readonly category: Category
readonly pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>> | null readonly pasteData: pasteDataModule.PasteData<ReadonlySet<backendModule.AssetId>> | null
readonly selectedKeys: Set<backendModule.AssetId> readonly selectedKeys: ReadonlySet<backendModule.AssetId>
readonly setSelectedKeys: (items: Set<backendModule.AssetId>) => void readonly clearSelectedKeys: () => void
readonly nodeMapRef: React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>> readonly nodeMapRef: React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>
readonly event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'> readonly event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'>
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
@ -61,7 +61,7 @@ export interface AssetsTableContextMenuProps {
/** A context menu for an `AssetsTable`, when no row is selected, or multiple rows /** A context menu for an `AssetsTable`, when no row is selected, or multiple rows
* are selected. */ * are selected. */
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) { export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
const { category, pasteData, selectedKeys, setSelectedKeys, nodeMapRef, event } = props const { category, pasteData, selectedKeys, clearSelectedKeys, nodeMapRef, event } = props
const { dispatchAssetEvent, dispatchAssetListEvent, hidden = false } = props const { dispatchAssetEvent, dispatchAssetListEvent, hidden = false } = props
const { doCopy, doCut, doPaste } = props const { doCopy, doCut, doPaste } = props
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
@ -91,20 +91,14 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
const doDeleteAll = () => { const doDeleteAll = () => {
if (isCloud) { if (isCloud) {
unsetModal() unsetModal()
dispatchAssetEvent({ dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
type: AssetEventType.delete,
ids: selectedKeys,
})
} else { } else {
setModal( setModal(
<ConfirmDeleteModal <ConfirmDeleteModal
description={`${selectedKeys.size} selected ${pluralized}`} description={`${selectedKeys.size} selected ${pluralized}`}
doDelete={() => { doDelete={() => {
setSelectedKeys(new Set()) clearSelectedKeys()
dispatchAssetEvent({ dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
type: AssetEventType.delete,
ids: selectedKeys,
})
}} }}
/> />
) )

View File

@ -9,7 +9,7 @@ import * as jsonSchema from '#/utilities/jsonSchema'
// ============= // =============
fc.test.prop({ fc.test.prop({
value: fc.fc.anything(), value: fc.fc.anything({ withNullPrototype: true }),
})('converting between constant value and schema', ({ value }) => { })('converting between constant value and schema', ({ value }) => {
const schema = jsonSchema.constantValueToSchema(value) const schema = jsonSchema.constantValueToSchema(value)
if (schema != null) { if (schema != null) {
@ -25,6 +25,25 @@ fc.test.prop({
} }
}) })
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
v.test.each([{ value: JSON.parse('{"__proto__":{}}') }])(
'converting between constant value and schema',
({ value }) => {
const schema = jsonSchema.constantValueToSchema(value)
if (schema != null) {
const extractedValue = jsonSchema.constantValue({}, schema)[0]
v.expect(
extractedValue,
`\`${JSON.stringify(value)}\` should round trip to schema and back`
).toEqual(value)
v.expect(
jsonSchema.isMatch({}, schema, value),
`\`${JSON.stringify(value)}\` should match its converted schema`
).toBe(true)
}
}
)
const STRING_SCHEMA = { type: 'string' } as const const STRING_SCHEMA = { type: 'string' } as const
fc.test.prop({ value: fc.fc.string() })('string schema', ({ value }) => { fc.test.prop({ value: fc.fc.string() })('string schema', ({ value }) => {
const constSchema = { const: value, type: 'string' } const constSchema = { const: value, type: 'string' }

View File

@ -56,7 +56,7 @@ export function constantValueToSchema(value: unknown): object | null {
result = null result = null
break break
} }
properties[key] = schema Object.defineProperty(properties, key, { value: schema, enumerable: true })
} }
} }
break break
@ -144,7 +144,7 @@ function constantValueHelper(
result = [] result = []
break break
} else { } else {
object[key] = value[0] ?? null Object.defineProperty(object, key, { value: value[0] ?? null, enumerable: true })
} }
} }
break break

View File

@ -23,7 +23,7 @@ export function setPresence<T>(set: Set<T>, value: T, presence: boolean) {
* *
* This is an immutable version of {@link setPresence}, so it returns a new set if the old set * This is an immutable version of {@link setPresence}, so it returns a new set if the old set
* would have been mutated, and returns the original set if it would not have been mutated. */ * would have been mutated, and returns the original set if it would not have been mutated. */
export function withPresence<T>(set: Set<T>, value: T, presence: boolean) { export function withPresence<T>(set: ReadonlySet<T>, value: T, presence: boolean) {
if (presence === set.has(value)) { if (presence === set.has(value)) {
return set return set
} else { } else {