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)
}
// ===================================
// === 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 ===
// ============================

View File

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

View File

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

View File

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

View File

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

View File

@ -42,7 +42,7 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {}
* This should never happen. */
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
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 toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
@ -277,7 +277,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} else if (
!isRunning &&
eventModule.isSingleClick(event) &&
((selected && numberOfSelectedItems === 1) ||
((selected && selectedKeys.current.size === 1) ||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
) {
setRowState(object.merger({ isEditingName: true }))

View File

@ -73,12 +73,12 @@ export interface AssetNewFolderEvent extends AssetBaseEvent<AssetEventType.newFo
/** A signal to upload files. */
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. */
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. */
@ -112,41 +112,41 @@ export interface AssetCancelOpeningAllProjectsEvent
/** A signal that multiple assets should be copied. `ids` are the `Id`s of the newly created
* placeholder items. */
export interface AssetCopyEvent extends AssetBaseEvent<AssetEventType.copy> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
readonly newParentKey: backendModule.AssetId
readonly newParentId: backendModule.DirectoryId
}
/** A signal to cut multiple assets. */
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. */
export interface AssetCancelCutEvent extends AssetBaseEvent<AssetEventType.cancelCut> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
}
/** A signal to move multiple assets. */
export interface AssetMoveEvent extends AssetBaseEvent<AssetEventType.move> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
readonly newParentKey: backendModule.AssetId
readonly newParentId: backendModule.DirectoryId
}
/** A signal to delete assets. */
export interface AssetDeleteEvent extends AssetBaseEvent<AssetEventType.delete> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
}
/** A signal to restore assets from trash. */
export interface AssetRestoreEvent extends AssetBaseEvent<AssetEventType.restore> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
}
/** A signal to download assets. */
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. */
@ -161,26 +161,26 @@ export interface AssetRemoveSelfEvent extends AssetBaseEvent<AssetEventType.remo
/** A signal to temporarily add labels to the selected assetss. */
export interface AssetTemporarilyAddLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyAddLabels> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
}
/** A signal to temporarily remove labels from the selected assetss. */
export interface AssetTemporarilyRemoveLabelsEvent
extends AssetBaseEvent<AssetEventType.temporarilyRemoveLabels> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
}
/** A signal to add labels to the selected assetss. */
export interface AssetAddLabelsEvent extends AssetBaseEvent<AssetEventType.addLabels> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
readonly labelNames: ReadonlySet<backendModule.LabelName>
}
/** A signal to remove labels from the selected assetss. */
export interface AssetRemoveLabelsEvent extends AssetBaseEvent<AssetEventType.removeLabels> {
readonly ids: Set<backendModule.AssetId>
readonly ids: ReadonlySet<backendModule.AssetId>
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. */
export interface AssetsTableState {
readonly numberOfSelectedItems: number
readonly selectedKeys: React.MutableRefObject<ReadonlySet<backendModule.AssetId>>
readonly visibilities: ReadonlyMap<backendModule.AssetId, Visibility>
readonly category: Category
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 [sortColumn, setSortColumn] = React.useState<columnUtils.SortableColumn | 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<
Set<backendModule.AssetId>
ReadonlySet<backendModule.AssetId>
> | null>(null)
const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName)
@ -381,9 +384,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
const assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
const pasteDataRef = React.useRef<pasteDataModule.PasteData<Set<backendModule.AssetId>> | null>(
null
)
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
ReadonlySet<backendModule.AssetId>
> | null>(null)
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>(
new Map<backendModule.AssetId, AssetTreeNode>()
)
@ -575,17 +578,17 @@ export default function AssetsTable(props: AssetsTableProps) {
if (category === Category.trash) {
setCanDownloadFiles(false)
} else if (!isCloud) {
setCanDownloadFiles(selectedKeys.size !== 0)
setCanDownloadFiles(selectedKeysRef.current.size !== 0)
} else {
setCanDownloadFiles(
selectedKeys.size !== 0 &&
Array.from(selectedKeys).every(key => {
selectedKeysRef.current.size !== 0 &&
Array.from(selectedKeysRef.current).every(key => {
const node = nodeMapRef.current.get(key)
return node?.item.type === backendModule.AssetType.file
})
)
}
}, [category, selectedKeys, isCloud, /* should never change */ setCanDownloadFiles])
}, [category, isCloud, /* should never change */ setCanDownloadFiles])
React.useEffect(() => {
const nodeToSuggestion = (
@ -792,10 +795,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (pasteDataRef.current == null) {
return false
} else {
dispatchAssetEvent({
type: AssetEventType.cancelCut,
ids: pasteDataRef.current.data,
})
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteDataRef.current.data })
setPasteData(null)
return
}
@ -833,6 +833,18 @@ export default function AssetsTable(props: AssetsTableProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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(
(newAssets: backendModule.AnyAsset[]) => {
// 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])
React.useEffect(() => {
if (selectedKeys.size !== 1) {
if (selectedKeysRef.current.size !== 1) {
setAssetPanelProps(null)
}
}, [selectedKeys.size, /* should never change */ setAssetPanelProps])
}, [selectedKeysRef.current.size, /* should never change */ setAssetPanelProps])
const directoryListAbortControllersRef = React.useRef(
new Map<backendModule.DirectoryId, AbortController>()
@ -1474,12 +1486,10 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case AssetListEventType.willDelete: {
if (selectedKeys.has(event.key)) {
setSelectedKeys(oldSelectedKeys => {
const newSelectedKeys = new Set(oldSelectedKeys)
newSelectedKeys.delete(event.key)
return newSelectedKeys
})
if (selectedKeysRef.current.has(event.key)) {
const newSelectedKeys = new Set(selectedKeysRef.current)
newSelectedKeys.delete(event.key)
setSelectedKeys(newSelectedKeys)
}
break
}
@ -1548,34 +1558,20 @@ export default function AssetsTable(props: AssetsTableProps) {
const doCopy = React.useCallback(() => {
unsetModal()
setSelectedKeys(oldSelectedKeys => {
queueMicrotask(() => {
setPasteData({ type: PasteType.copy, data: oldSelectedKeys })
})
return oldSelectedKeys
})
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
}, [/* should never change */ unsetModal])
const doCut = React.useCallback(() => {
unsetModal()
setSelectedKeys(oldSelectedKeys => {
queueMicrotask(() => {
if (pasteData != null) {
dispatchAssetEvent({
type: AssetEventType.cancelCut,
ids: pasteData.data,
})
}
setPasteData({ type: PasteType.move, data: oldSelectedKeys })
dispatchAssetEvent({
type: AssetEventType.cut,
ids: oldSelectedKeys,
})
})
return new Set()
})
if (pasteData != null) {
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data })
}
setPasteData({ type: PasteType.move, data: selectedKeysRef.current })
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeysRef.current })
setSelectedKeys(new Set())
}, [
pasteData,
/* should never change */ setSelectedKeys,
/* should never change */ unsetModal,
/* should never change */ dispatchAssetEvent,
])
@ -1626,9 +1622,9 @@ export default function AssetsTable(props: AssetsTableProps) {
category={category}
pasteData={pasteData}
selectedKeys={selectedKeys}
clearSelectedKeys={clearSelectedKeys}
nodeMapRef={nodeMapRef}
event={{ pageX: 0, pageY: 0 }}
setSelectedKeys={setSelectedKeys}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
doCopy={doCopy}
@ -1638,11 +1634,12 @@ export default function AssetsTable(props: AssetsTableProps) {
),
[
category,
pasteData,
selectedKeys,
pasteData,
doCopy,
doCut,
doPaste,
/* should never change */ clearSelectedKeys,
/* should never change */ dispatchAssetEvent,
/* 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.
(): AssetsTableState => ({
visibilities,
numberOfSelectedItems: selectedKeys.size,
selectedKeys: selectedKeysRef,
category,
labels: allLabels,
deletedLabelNames,
@ -1688,7 +1685,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}),
[
visibilities,
selectedKeys.size,
category,
allLabels,
deletedLabelNames,
@ -1755,7 +1751,7 @@ export default function AssetsTable(props: AssetsTableProps) {
shortcutManagerModule.MouseAction.selectAdditionalRange,
event
) &&
selectedKeys.size !== 0
selectedKeysRef.current.size !== 0
) {
setSelectedKeys(new Set())
}
@ -1764,7 +1760,7 @@ export default function AssetsTable(props: AssetsTableProps) {
return () => {
document.removeEventListener('click', onDocumentClick)
}
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcutManager])
}, [shortcutManager, /* should never change */ setSelectedKeys])
React.useEffect(() => {
if (isLoading) {
@ -1809,24 +1805,20 @@ export default function AssetsTable(props: AssetsTableProps) {
event
)
) {
setSelectedKeys(
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
)
setSelectedKeys(new Set([...selectedKeysRef.current, ...getNewlySelectedKeys()]))
} else if (
shortcutManager.matchesMouseAction(
shortcutManagerModule.MouseAction.selectAdditional,
event
)
) {
setSelectedKeys(oldSelectedItems => {
const newItems = new Set(oldSelectedItems)
if (oldSelectedItems.has(key)) {
newItems.delete(key)
} else {
newItems.add(key)
}
return newItems
})
const newSelectedKeys = new Set(selectedKeysRef.current)
if (selectedKeysRef.current.has(key)) {
newSelectedKeys.delete(key)
} else {
newSelectedKeys.add(key)
}
setSelectedKeys(newSelectedKeys)
} else {
setSelectedKeys(new Set([key]))
}
@ -1882,10 +1874,10 @@ export default function AssetsTable(props: AssetsTableProps) {
hidden={visibilities.get(item.key) === Visibility.hidden}
selected={isSelected}
setSelected={selected => {
setSelectedKeys(oldSelectedKeys => set.withPresence(oldSelectedKeys, key, selected))
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
}}
isSoleSelectedItem={isSoleSelectedItem}
allowContextMenu={selectedKeys.size === 0 || !isSelected || isSoleSelectedItem}
allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelectedItem}
onClick={onRowClick}
onContextMenu={(_innerProps, event) => {
if (!isSelected) {
@ -1896,106 +1888,56 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}}
onDragStart={event => {
if (!selectedKeys.has(key)) {
let newSelectedKeys = selectedKeysRef.current
if (!selectedKeysRef.current.has(key)) {
setPreviouslySelectedKey(key)
setSelectedKeys(new Set([key]))
newSelectedKeys = new Set([key])
setSelectedKeys(newSelectedKeys)
}
setSelectedKeys(oldSelectedKeys => {
const nodes = assetTree
.preorderTraversal()
.filter(node => oldSelectedKeys.has(node.key))
const payload: drag.AssetRowsDragPayload = nodes.map(node => ({
key: node.key,
asset: node.item,
}))
drag.setDragImageToBlank(event)
drag.ASSET_ROWS.bind(event, payload)
queueMicrotask(() => {
setModal(
<DragModal
event={event}
className="flex flex-col rounded-2xl bg-frame-selected backdrop-blur-3xl"
doCleanup={() => {
drag.ASSET_ROWS.unbind(payload)
}}
>
{nodes.map(node => (
<NameColumn
key={node.key}
keyProp={node.key}
item={node.with({ depth: 0 })}
state={state}
// Default states.
isSoleSelectedItem={false}
selected={false}
rowState={assetRowUtils.INITIAL_ROW_STATE}
// The drag placeholder cannot be interacted with.
setSelected={() => {}}
setItem={() => {}}
setRowState={() => {}}
/>
))}
</DragModal>
)
})
return oldSelectedKeys
})
const nodes = assetTree
.preorderTraversal()
.filter(node => newSelectedKeys.has(node.key))
const payload: drag.AssetRowsDragPayload = nodes.map(node => ({
key: node.key,
asset: node.item,
}))
drag.setDragImageToBlank(event)
drag.ASSET_ROWS.bind(event, payload)
setModal(
<DragModal
event={event}
className="flex flex-col rounded-2xl bg-frame-selected backdrop-blur-3xl"
doCleanup={() => {
drag.ASSET_ROWS.unbind(payload)
}}
>
{nodes.map(node => (
<NameColumn
key={node.key}
keyProp={node.key}
item={node.with({ depth: 0 })}
state={state}
// Default states.
isSoleSelectedItem={false}
selected={false}
rowState={assetRowUtils.INITIAL_ROW_STATE}
// The drag placeholder cannot be interacted with.
setSelected={() => {}}
setItem={() => {}}
setRowState={() => {}}
/>
))}
</DragModal>
)
}}
onDragOver={event => {
setSelectedKeys(oldSelectedKeys => {
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
const ids = oldSelectedKeys.has(key) ? oldSelectedKeys : new Set([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])
const payload = drag.LABELS.lookup(event)
if (payload != null) {
event.preventDefault()
event.stopPropagation()
const ids = new Set(
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) {
@ -2004,41 +1946,75 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}
}
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
}
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.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}
nodeMapRef={nodeMapRef}
event={event}
setSelectedKeys={setSelectedKeys}
clearSelectedKeys={clearSelectedKeys}
dispatchAssetEvent={dispatchAssetEvent}
dispatchAssetListEvent={dispatchAssetListEvent}
doCopy={doCopy}
@ -2073,15 +2049,10 @@ export default function AssetsTable(props: AssetsTableProps) {
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(event.relatedTarget)
) {
setSelectedKeys(oldSelectedKeys => {
window.setTimeout(() => {
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: oldSelectedKeys,
labelNames: set.EMPTY,
})
})
return oldSelectedKeys
dispatchAssetEvent({
type: AssetEventType.temporarilyAddLabels,
ids: selectedKeysRef.current,
labelNames: set.EMPTY,
})
}
}}
@ -2100,6 +2071,9 @@ export default function AssetsTable(props: AssetsTableProps) {
<div
className="grow"
onClick={() => {
setSelectedKeys(new Set())
}}
onDragEnter={onDragOver}
onDragOver={onDragOver}
onDrop={event => {

View File

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

View File

@ -9,7 +9,7 @@ import * as jsonSchema from '#/utilities/jsonSchema'
// =============
fc.test.prop({
value: fc.fc.anything(),
value: fc.fc.anything({ withNullPrototype: true }),
})('converting between constant value and schema', ({ value }) => {
const schema = jsonSchema.constantValueToSchema(value)
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
fc.test.prop({ value: fc.fc.string() })('string schema', ({ value }) => {
const constSchema = { const: value, type: 'string' }

View File

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