From 45ad3a751c9f7352d47d9a1d1150e8848a22e281 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 21 Oct 2024 20:30:19 +1000 Subject: [PATCH] Dashboard improvements (8 Oct 2024) (#11268) - :warning: Follow up to #11219. MUST NOT be merged in before that PR. - Changes: - Add optional overlay to `Popover`s - Add and use `useAssetPassiveListener` everywhere to get reactive updates to asset state even outside Asset Panel - `setItem` has been removed in favor of simply waiting for invalidations - Migrate more `Modal`s to `Popover`s - Migrate more inputs to `Form`s - Periodically refetch Datalink in Asset Panel - Show optimistic state for asset description (adding this because it is trivial to add) - Remove usages of `get*` as mutations throughout the entire codebase - replace with `fetchQuery` - Fixes most of rest of https://github.com/enso-org/cloud-v2/issues/1529 - (1) :information_source: fixed in #11219 - (2) :x: backend issue - (3) :x: out of scope - (4) :x: backend issue - (5) :x: out of scope - (6) :x: [wontfix]? i think this is intentional, it's not so much slow scrolling and moreso snapped scrolling - (7) :x: backend issue - (8) :information_source: fixed in #11126 - (9) :x: out of scope (potentially requires a way to trigger a tooltip on a disabled button) - (10) :x: (will check later) Make sure you are not able to open a project opened by another user: cmd + click is not always working. - (11) Drag from team space to user space should copy asset - (12) :x: (will check later) Drag from user space to team should move (and swap ownership) - (13) :information_source: fixed in #11219 - (14) :information_source: fixed somewhere (?) - (15) :information_source: fixed somewhere (?) - (16) Show correct (and up-to-date) description for projects - (17) :information_source: fixed in #11219 - (18) :information_source: fixed in #11219 - Fix https://github.com/enso-org/cloud-v2/issues/1535 - Completely remove optimistic UI for "copy asset" - Fix https://github.com/enso-org/cloud-v2/issues/1541 - Make selection brush work again - Unintentionally regressed in https://github.com/enso-org/enso/commit/51733ee87677fb47e7a123c98660bc72abe1a9fd#diff-f3e29bffcda342ab6a9dbafc58dde88ce26638eaecda1f17f40ca7e319c90cc8L89 # Important Notes None --- app/gui/e2e/dashboard/api.ts | 2 +- app/gui/e2e/dashboard/assetPanel.spec.ts | 3 +- app/gui/package.json | 1 + .../AriaComponents/Button/Button.tsx | 30 +- .../Form/components/FormProvider.tsx | 4 +- .../AriaComponents/Form/components/Reset.tsx | 7 +- .../Inputs/Dropdown/Dropdown.tsx | 4 +- .../ResizableContentEditableInput.tsx | 50 +- .../dashboard/components/SelectionBrush.tsx | 8 +- app/gui/src/dashboard/components/aria.tsx | 1 + .../components/dashboard/AssetRow.tsx | 531 +++++++++--------- .../components/dashboard/KeyboardShortcut.tsx | 65 +-- .../dashboard/components/dashboard/column.ts | 53 +- .../dashboard/column/LabelsColumn.tsx | 25 +- .../dashboard/column/SharedWithColumn.tsx | 90 ++- .../dashboard/events/AssetListEventType.ts | 2 - .../src/dashboard/events/assetListEvent.ts | 72 ++- app/gui/src/dashboard/hooks/backendHooks.ts | 251 ++++++--- app/gui/src/dashboard/hooks/projectHooks.ts | 23 +- .../dashboard/layouts/AssetContextMenu.tsx | 30 +- app/gui/src/dashboard/layouts/AssetPanel.tsx | 26 +- .../src/dashboard/layouts/AssetProperties.tsx | 370 ++++++------ .../layouts/AssetVersions/AssetVersions.tsx | 2 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 173 +++--- .../layouts/AssetsTableContextMenu.tsx | 16 +- .../dashboard/layouts/CategorySwitcher.tsx | 8 +- .../layouts/CategorySwitcher/Category.ts | 23 +- app/gui/src/dashboard/layouts/DriveBar.tsx | 3 +- .../dashboard/layouts/GlobalContextMenu.tsx | 7 +- app/gui/src/dashboard/layouts/Labels.tsx | 1 + .../KeyboardShortcutsSettingsSection.tsx | 92 ++- .../Settings/MembersSettingsSection.tsx | 4 +- .../modals/CaptureKeyboardShortcutModal.tsx | 83 ++- .../dashboard/modals/ManageLabelsModal.tsx | 141 ++--- .../modals/ManagePermissionsModal.tsx | 189 +++---- .../dashboard/modals/NewUserGroupModal.tsx | 18 +- .../dashboard/modals/UpsertSecretModal.tsx | 28 +- .../dashboard/pages/dashboard/Dashboard.tsx | 7 +- .../src/dashboard/providers/DriveProvider.tsx | 2 + .../src/dashboard/services/RemoteBackend.ts | 6 +- .../dashboard/services/remoteBackendPaths.ts | 10 +- app/gui/src/dashboard/utilities/PasteType.ts | 15 - app/gui/src/dashboard/utilities/drag.ts | 2 +- app/gui/src/dashboard/utilities/pasteData.ts | 6 +- app/gui/src/dashboard/utilities/reactQuery.ts | 39 +- .../src/dashboard/utilities/uniqueString.ts | 3 - .../src/project-view/assets/font-dejavu.css | 1 - app/gui/src/project-view/assets/font-enso.css | 1 - .../src/project-view/assets/font-mplus1.css | 1 - 49 files changed, 1297 insertions(+), 1232 deletions(-) delete mode 100644 app/gui/src/dashboard/utilities/PasteType.ts delete mode 100644 app/gui/src/dashboard/utilities/uniqueString.ts diff --git a/app/gui/e2e/dashboard/api.ts b/app/gui/e2e/dashboard/api.ts index ab5f5694cc6..fc74db93a90 100644 --- a/app/gui/e2e/dashboard/api.ts +++ b/app/gui/e2e/dashboard/api.ts @@ -8,7 +8,7 @@ import * as remoteBackendPaths from '#/services/remoteBackendPaths' import * as dateTime from '#/utilities/dateTime' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' -import * as uniqueString from '#/utilities/uniqueString' +import * as uniqueString from 'enso-common/src/utilities/uniqueString' import * as actions from './actions' diff --git a/app/gui/e2e/dashboard/assetPanel.spec.ts b/app/gui/e2e/dashboard/assetPanel.spec.ts index 84b471c1339..896d73d7cb9 100644 --- a/app/gui/e2e/dashboard/assetPanel.spec.ts +++ b/app/gui/e2e/dashboard/assetPanel.spec.ts @@ -72,6 +72,7 @@ test.test('asset panel contents', ({ page }) => .do(async () => { await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) // `getByText` is required so that this assertion works if there are multiple permissions. - await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() + // This is not visible; "Shared with" should only be visible on the Enterprise plan. + // await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() }), ) diff --git a/app/gui/package.json b/app/gui/package.json index 739458be8dd..2400590773a 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -29,6 +29,7 @@ "test-dev:unit": "vitest", "test:e2e": "cross-env NODE_ENV=production playwright test", "test-dev:e2e": "cross-env NODE_ENV=production playwright test --ui", + "test-dev-dashboard:e2e": "cross-env NODE_ENV=production playwright test ./e2e/dashboard/ --ui", "preinstall": "corepack pnpm run generate-metadata", "postinstall": "playwright install", "generate-metadata": "node scripts/generateIconMetadata.js" diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index 2fc504a6ee4..0b387579b4d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -216,7 +216,9 @@ export const BUTTON_STYLES = tv({ end: { content: 'flex-row-reverse' }, }, showIconOnHover: { - true: { icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' }, + true: { + icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 disabled:opacity-0 aria-disabled:opacity-0 disabled:group-hover:opacity-50 aria-disabled:group-hover:opacity-50', + }, }, extraClickZone: { true: { @@ -341,6 +343,7 @@ export const Button = forwardRef(function Button( const isLoading = loading || implicitlyLoading const isDisabled = props.isDisabled ?? isLoading + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled React.useLayoutEffect(() => { const delay = 350 @@ -436,6 +439,13 @@ export const Button = forwardRef(function Button( } } + const { tooltip: visualTooltip, targetProps } = ariaComponents.useVisualTooltip({ + targetRef: contentRef, + children: tooltipElement, + isDisabled: !shouldUseVisualTooltip, + ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), + }) + const button = ( {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( - + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */} {childrenFactory(render)} @@ -471,8 +485,14 @@ export const Button = forwardRef(function Button( ) - return tooltipElement == null ? button : ( - + return ( + tooltipElement == null ? button + : shouldUseVisualTooltip ? + <> + {button} + {visualTooltip} + + : {button} - ) + ) }) diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/FormProvider.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/FormProvider.tsx index a6f9eb9245d..baa9679aed0 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/FormProvider.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/FormProvider.tsx @@ -29,7 +29,7 @@ export function FormProvider( const { children, form } = props return ( - // eslint-disable-next-line no-restricted-syntax,@typescript-eslint/no-explicit-any + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any }}> {children} @@ -50,7 +50,7 @@ export function useFormContext( invariant(ctx, 'FormContext not found') - // This is safe, as it's we pass the value transparently and it's typed outside + // This is safe, as we pass the value transparently and it is typed outside // eslint-disable-next-line no-restricted-syntax return ctx.form as unknown as types.UseFormReturn } diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx index 1ebeeb88d12..9224748b912 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx @@ -42,14 +42,17 @@ export function Reset(props: ResetProps): React.JSX.Element { ...buttonProps } = props - const { formState } = formContext.useFormContext(props.form) + const form = formContext.useFormContext(props.form) + const { formState } = form return ( { + form.reset() + }} /* This is safe because we are passing all props to the button */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */ {...(buttonProps as any)} - type="reset" variant={variant} size={size} isDisabled={formState.isSubmitting || !formState.isDirty} diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx index 873399c62ba..ed0fb714c65 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Dropdown/Dropdown.tsx @@ -23,7 +23,7 @@ const DROPDOWN_STYLES = tv({ isFocused: { true: { container: 'z-1', - options: 'before:h-full before:shadow-soft', + options: 'before:h-full before:shadow-soft before:bg-frame before:backdrop-blur-md', optionsContainer: 'grid-rows-1fr', input: 'z-1', }, @@ -47,7 +47,7 @@ const DROPDOWN_STYLES = tv({ slots: { container: 'absolute left-0 h-full w-full min-w-max', options: - 'relative backdrop-blur-md before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors', + 'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors', optionsSpacing: 'padding relative h-6', optionsContainer: 'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows', diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx index 7cf417c0d6b..6fa951df8a9 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/ResizableInput/ResizableContentEditableInput.tsx @@ -1,7 +1,13 @@ /** * @file A resizable input that uses a content-editable div. */ -import { useRef, type ClipboardEvent, type ForwardedRef, type HTMLAttributes } from 'react' +import { + useEffect, + useRef, + type ClipboardEvent, + type ForwardedRef, + type HTMLAttributes, +} from 'react' import type { FieldVariantProps } from '#/components/AriaComponents' import { @@ -12,6 +18,7 @@ import { type FieldStateProps, type TSchema, } from '#/components/AriaComponents' +import { useAutoFocus } from '#/hooks/autoFocusHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { mergeRefs } from '#/utilities/mergeRefs' import { forwardRef } from '#/utilities/react' @@ -43,6 +50,8 @@ export interface ResizableContentEditableInputProps< VariantProps, 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' > { + /** Defaults to `onInput`. */ + readonly mode?: 'onBlur' | 'onInput' /** * onChange is called when the content of the input changes. * There is no way to prevent the change, so the value is always the new value. @@ -65,6 +74,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten ref: ForwardedRef, ) { const { + mode = 'onInput', placeholder = '', description = null, name, @@ -76,6 +86,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten variant, variants = CONTENT_EDITABLE_STYLES, fieldVariants, + autoFocus = false, ...textFieldProps } = props @@ -100,13 +111,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten defaultValue, }) - const { - base, - description: descriptionClass, - inputContainer, - textArea, - placeholder: placeholderClass, - } = variants({ + const styles = variants({ invalid: fieldState.invalid, disabled: isDisabled || formInstance.formState.isSubmitting, variant, @@ -114,6 +119,14 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten size, }) + useAutoFocus({ ref: inputRef, disabled: !autoFocus }) + + useEffect(() => { + if (inputRef.current) { + inputRef.current.textContent = field.value + } + }, [field.value]) + return (
{ inputRef.current?.focus({ preventScroll: true }) }} > -
+
{ + if (mode === 'onBlur') { + field.onChange(event.currentTarget.textContent ?? '') + } + field.onBlur() + }} onInput={(event) => { - field.onChange(event.currentTarget.textContent ?? '') + if (mode === 'onInput') { + field.onChange(event.currentTarget.textContent ?? '') + } }} /> - 0 ? 'hidden' : '' })}> + 0 ? 'hidden' : '' })}> {placeholder}
{description != null && ( - + {description} )} diff --git a/app/gui/src/dashboard/components/SelectionBrush.tsx b/app/gui/src/dashboard/components/SelectionBrush.tsx index 146abca7abf..3314c01ccad 100644 --- a/app/gui/src/dashboard/components/SelectionBrush.tsx +++ b/app/gui/src/dashboard/components/SelectionBrush.tsx @@ -68,7 +68,6 @@ export default function SelectionBrush(props: SelectionBrushProps) { }, [anchorAnimFactor, anchor]) React.useEffect(() => { - const target = targetRef.current ?? document.body const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => { if (parent == null) { return true @@ -95,7 +94,8 @@ export default function SelectionBrush(props: SelectionBrushProps) { didMoveWhileDraggingRef.current = false lastMouseEvent.current = event const newAnchor = { left: event.pageX, top: event.pageY } - anchorRef.current = null + anchorRef.current = newAnchor + setAnchor(newAnchor) setLastSetAnchor(newAnchor) setPosition(newAnchor) } @@ -150,13 +150,13 @@ export default function SelectionBrush(props: SelectionBrushProps) { } } - target.addEventListener('mousedown', onMouseDown) + document.addEventListener('mousedown', onMouseDown) document.addEventListener('mouseup', onMouseUp) document.addEventListener('dragstart', onDragStart, { capture: true }) document.addEventListener('mousemove', onMouseMove) document.addEventListener('click', onClick, { capture: true }) return () => { - target.removeEventListener('mousedown', onMouseDown) + document.removeEventListener('mousedown', onMouseDown) document.removeEventListener('mouseup', onMouseUp) document.removeEventListener('dragstart', onDragStart, { capture: true }) document.removeEventListener('mousemove', onMouseMove) diff --git a/app/gui/src/dashboard/components/aria.tsx b/app/gui/src/dashboard/components/aria.tsx index 5f019d08bda..8eb69b3f246 100644 --- a/app/gui/src/dashboard/components/aria.tsx +++ b/app/gui/src/dashboard/components/aria.tsx @@ -2,6 +2,7 @@ import type { Mutable } from 'enso-common/src/utilities/data/object' import * as aria from 'react-aria' +export { ClearPressResponder } from '@react-aria/interactions' export type * from '@react-types/shared' // @ts-expect-error The conflicting exports are props types ONLY. export * from 'react-aria' diff --git a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx index 3b08d83f41c..099779de379 100644 --- a/app/gui/src/dashboard/components/dashboard/AssetRow.tsx +++ b/app/gui/src/dashboard/components/dashboard/AssetRow.tsx @@ -1,6 +1,7 @@ /** @file A table row for an arbitrary asset. */ import * as React from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useStore } from 'zustand' import BlankIcon from '#/assets/blank.svg' @@ -9,12 +10,7 @@ import * as dragAndDropHooks from '#/hooks/dragAndDropHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as setAssetHooks from '#/hooks/setAssetHooks' -import { - useDriveStore, - useSetAssetPanelProps, - useSetIsAssetPanelTemporarilyVisible, - useSetSelectedKeys, -} from '#/providers/DriveProvider' +import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider' import * as modalProvider from '#/providers/ModalProvider' import * as textProvider from '#/providers/TextProvider' @@ -34,7 +30,12 @@ import * as localBackend from '#/services/LocalBackend' import * as backendModule from '#/services/Backend' import { Text } from '#/components/AriaComponents' -import { backendMutationOptions } from '#/hooks/backendHooks' +import { useCutAndPaste } from '#/events/assetListEvent' +import { + backendMutationOptions, + backendQueryOptions, + useBackendMutationState, +} from '#/hooks/backendHooks' import { createGetProjectDetailsQuery } from '#/hooks/projectHooks' import { useSyncRef } from '#/hooks/syncRefHooks' import { useToastAndLog } from '#/hooks/toastAndLogHooks' @@ -49,7 +50,6 @@ import * as permissions from '#/utilities/permissions' import * as set from '#/utilities/set' import * as tailwindMerge from '#/utilities/tailwindMerge' import Visibility from '#/utilities/Visibility' -import { useMutation, useQuery } from '@tanstack/react-query' // ================= // === Constants === @@ -133,9 +133,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { const [item, setItem] = React.useState(rawItem) const driveStore = useDriveStore() + const queryClient = useQueryClient() const { user } = useFullUserSession() const setSelectedKeys = useSetSelectedKeys() - const setAssetPanelProps = useSetAssetPanelProps() const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) => (visuallySelectedKeys ?? selectedKeys).has(item.key), ) @@ -151,17 +151,15 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { const draggableProps = dragAndDropHooks.useDraggable() const { setModal, unsetModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() - const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() + const cutAndPaste = useCutAndPaste(category) const [isDraggedOver, setIsDraggedOver] = React.useState(false) const rootRef = React.useRef(null) const dragOverTimeoutHandle = React.useRef(null) - const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible() const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus) const asset = item.item - const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible) - const [innerRowState, setRowState] = React.useState(() => - object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }), + const [innerRowState, setRowState] = React.useState( + assetRowUtils.INITIAL_ROW_STATE, ) const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id) @@ -176,12 +174,14 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { readonly parentKeys: Map } | null>(null) - const outerVisibility = visibilities.get(item.key) - const visibility = - outerVisibility == null || outerVisibility === Visibility.visible ? - insertionVisibility - : outerVisibility - const hidden = hiddenRaw || visibility === Visibility.hidden + const isDeleting = + useBackendMutationState(backend, 'deleteAsset', { + predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, + }).length !== 0 + const isRestoring = + useBackendMutationState(backend, 'undoDeleteAsset', { + predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, + }).length !== 0 const isCloud = isCloudCategory(category) const { data: projectState } = useQuery({ @@ -194,14 +194,26 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { const toastAndLog = useToastAndLog() - const getProjectDetailsMutation = useMutation( - backendMutationOptions(backend, 'getProjectDetails'), - ) - const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails')) - const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink')) const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) + const outerVisibility = visibilities.get(item.key) + const insertionVisibility = useStore(driveStore, (driveState) => + driveState.pasteData?.type === 'move' && driveState.pasteData.data.ids.has(item.key) ? + Visibility.faded + : Visibility.visible, + ) + const createPermissionVariables = createPermissionMutation.variables?.[0] + const isRemovingSelf = + createPermissionVariables != null && + createPermissionVariables.action == null && + createPermissionVariables.actorsIds[0] === user.userId + const visibility = + isRemovingSelf ? Visibility.hidden + : outerVisibility === Visibility.visible ? insertionVisibility + : outerVisibility ?? insertionVisibility + const hidden = isDeleting || isRestoring || hiddenRaw || visibility === Visibility.hidden + const setSelected = useEventCallback((newSelected: boolean) => { const { selectedKeys } = driveStore.getState() setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected)) @@ -247,20 +259,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { } }, [item.item.id, updateAssetRef]) - React.useEffect(() => { - if (isSoleSelected && item.item.id !== driveStore.getState().assetPanelProps?.item?.item.id) { - setAssetPanelProps({ backend, item, setItem }) - setIsAssetPanelTemporarilyVisible(false) - } - }, [ - item, - isSoleSelected, - backend, - setAssetPanelProps, - setIsAssetPanelTemporarilyVisible, - driveStore, - ]) - const doDelete = React.useCallback( (forever = false) => { void doDeleteRaw(item.item, forever) @@ -285,7 +283,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { payload != null && payload.every((innerItem) => innerItem.key !== directoryKey) const canPaste = (() => { if (!isPayloadMatch) { - return true + return false } else { if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) { const parentKeys = new Map( @@ -296,12 +294,21 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { ) nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys } } - return !payload.some((payloadItem) => { + return payload.every((payloadItem) => { const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key) const parent = parentKey == null ? null : nodeMap.current.get(parentKey) - return !parent ? true : ( - permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path) + if (!parent) { + return false + } else if (permissions.isTeamPath(parent.path)) { + return true + } else { + // Assume user path; check permissions + const permission = permissions.tryFindSelfPermission(user, item.item.permissions) + return ( + permission != null && + permissions.canPermissionModifyDirectoryContents(permission.permission) ) + } }) } })() @@ -314,263 +321,229 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { } eventListProvider.useAssetEventListener(async (event) => { - if (state.category.type === 'trash') { - switch (event.type) { - case AssetEventType.deleteForever: { - if (event.ids.has(item.key)) { - doDelete(true) - } - break - } - case AssetEventType.restore: { - if (event.ids.has(item.key)) { - await doRestore(item.item) - } - break - } - default: { - break + switch (event.type) { + case AssetEventType.move: { + if (event.ids.has(item.key)) { + await doMove(event.newParentKey, item.item) } + break } - } else { - switch (event.type) { - case AssetEventType.cut: { - if (event.ids.has(item.key)) { - setInsertionVisibility(Visibility.faded) - } - break + case AssetEventType.delete: { + if (event.ids.has(item.key)) { + doDelete(false) } - case AssetEventType.cancelCut: { - if (event.ids.has(item.key)) { - setInsertionVisibility(Visibility.visible) - } - break + break + } + case AssetEventType.deleteForever: { + if (event.ids.has(item.key)) { + doDelete(true) } - case AssetEventType.move: { - if (event.ids.has(item.key)) { - setInsertionVisibility(Visibility.visible) - await doMove(event.newParentKey, item.item) - } - break + break + } + case AssetEventType.restore: { + if (event.ids.has(item.key)) { + await doRestore(item.item) } - case AssetEventType.delete: { - if (event.ids.has(item.key)) { - doDelete(false) - } - break - } - case AssetEventType.deleteForever: { - if (event.ids.has(item.key)) { - doDelete(true) - } - break - } - case AssetEventType.restore: { - if (event.ids.has(item.key)) { - await doRestore(item.item) - } - break - } - case AssetEventType.download: - case AssetEventType.downloadSelected: { - if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) { - if (isCloud) { - switch (asset.type) { - case backendModule.AssetType.project: { - try { - const details = await getProjectDetailsMutation.mutateAsync([ + break + } + case AssetEventType.download: + case AssetEventType.downloadSelected: { + if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) { + if (isCloud) { + switch (asset.type) { + case backendModule.AssetType.project: { + try { + const details = await queryClient.fetchQuery( + backendQueryOptions(backend, 'getProjectDetails', [ asset.id, asset.parentId, asset.title, - ]) - if (details.url != null) { - await backend.download(details.url, `${asset.title}.enso-project`) - } else { - const error: unknown = getText('projectHasNoSourceFilesPhrase') - toastAndLog('downloadProjectError', error, asset.title) - } - } catch (error) { + ]), + ) + if (details.url != null) { + await backend.download(details.url, `${asset.title}.enso-project`) + } else { + const error: unknown = getText('projectHasNoSourceFilesPhrase') toastAndLog('downloadProjectError', error, asset.title) } - break + } catch (error) { + toastAndLog('downloadProjectError', error, asset.title) } - case backendModule.AssetType.file: { - try { - const details = await getFileDetailsMutation.mutateAsync([ - asset.id, - asset.title, - ]) - if (details.url != null) { - await backend.download(details.url, asset.title) - } else { - const error: unknown = getText('fileNotFoundPhrase') - toastAndLog('downloadFileError', error, asset.title) - } - } catch (error) { + break + } + case backendModule.AssetType.file: { + try { + const details = await queryClient.fetchQuery( + backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title]), + ) + if (details.url != null) { + await backend.download(details.url, asset.title) + } else { + const error: unknown = getText('fileNotFoundPhrase') toastAndLog('downloadFileError', error, asset.title) } - break - } - case backendModule.AssetType.datalink: { - try { - const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title]) - const fileName = `${asset.title}.datalink` - download( - URL.createObjectURL( - new File([JSON.stringify(value)], fileName, { - type: 'application/json+x-enso-data-link', - }), - ), - fileName, - ) - } catch (error) { - toastAndLog('downloadDatalinkError', error, asset.title) - } - break - } - default: { - toastAndLog('downloadInvalidTypeError') - break + } catch (error) { + toastAndLog('downloadFileError', error, asset.title) } + break } - } else { - if (asset.type === backendModule.AssetType.project) { - const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id - const uuid = localBackend.extractTypeAndId(asset.id).id - const queryString = new URLSearchParams({ projectsDirectory }).toString() - await backend.download( - `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, - `${asset.title}.enso-project`, - ) + case backendModule.AssetType.datalink: { + try { + const value = await queryClient.fetchQuery( + backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]), + ) + const fileName = `${asset.title}.datalink` + download( + URL.createObjectURL( + new File([JSON.stringify(value)], fileName, { + type: 'application/json+x-enso-data-link', + }), + ), + fileName, + ) + } catch (error) { + toastAndLog('downloadDatalinkError', error, asset.title) + } + break + } + default: { + toastAndLog('downloadInvalidTypeError') + break } } - } - break - } - case AssetEventType.removeSelf: { - // This is not triggered from the asset list, so it uses `item.id` instead of `key`. - if (event.id === asset.id && user.isEnabled) { - setInsertionVisibility(Visibility.hidden) - try { - await createPermissionMutation.mutateAsync([ - { - action: null, - resourceId: asset.id, - actorsIds: [user.userId], - }, - ]) - dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) - } catch (error) { - setInsertionVisibility(Visibility.visible) - toastAndLog(null, error) + } else { + if (asset.type === backendModule.AssetType.project) { + const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id + const uuid = localBackend.extractTypeAndId(asset.id).id + const queryString = new URLSearchParams({ projectsDirectory }).toString() + await backend.download( + `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, + `${asset.title}.enso-project`, + ) } } - break } - case AssetEventType.temporarilyAddLabels: { - const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === labels && - oldRowState.temporarilyRemovedLabels === set.EMPTY_SET - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: labels, - temporarilyRemovedLabels: set.EMPTY_SET, - }), - ) - break - } - case AssetEventType.temporarilyRemoveLabels: { - const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === set.EMPTY_SET && - oldRowState.temporarilyRemovedLabels === labels - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: set.EMPTY_SET, - temporarilyRemovedLabels: labels, - }), - ) - break - } - case AssetEventType.addLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(item.key) && - (labels == null || [...event.labelNames].some((label) => !labels.includes(label))) - ) { - const newLabels = [ - ...(labels ?? []), - ...[...event.labelNames].filter((label) => labels?.includes(label) !== true), - ] - setAsset(object.merger({ labels: newLabels })) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - setAsset(object.merger({ labels })) - toastAndLog(null, error) - } + break + } + case AssetEventType.removeSelf: { + // This is not triggered from the asset list, so it uses `item.id` instead of `key`. + if (event.id === asset.id && user.isEnabled) { + try { + await createPermissionMutation.mutateAsync([ + { + action: null, + resourceId: asset.id, + actorsIds: [user.userId], + }, + ]) + dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) + } catch (error) { + toastAndLog(null, error) } - break } - case AssetEventType.removeLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(item.key) && - labels != null && - [...event.labelNames].some((label) => labels.includes(label)) - ) { - const newLabels = labels.filter((label) => !event.labelNames.has(label)) - setAsset(object.merger({ labels: newLabels })) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - setAsset(object.merger({ labels })) - toastAndLog(null, error) - } + break + } + case AssetEventType.temporarilyAddLabels: { + const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET + setRowState((oldRowState) => + ( + oldRowState.temporarilyAddedLabels === labels && + oldRowState.temporarilyRemovedLabels === set.EMPTY_SET + ) ? + oldRowState + : object.merge(oldRowState, { + temporarilyAddedLabels: labels, + temporarilyRemovedLabels: set.EMPTY_SET, + }), + ) + break + } + case AssetEventType.temporarilyRemoveLabels: { + const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET + setRowState((oldRowState) => + ( + oldRowState.temporarilyAddedLabels === set.EMPTY_SET && + oldRowState.temporarilyRemovedLabels === labels + ) ? + oldRowState + : object.merge(oldRowState, { + temporarilyAddedLabels: set.EMPTY_SET, + temporarilyRemovedLabels: labels, + }), + ) + break + } + case AssetEventType.addLabels: { + setRowState((oldRowState) => + oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? + oldRowState + : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), + ) + const labels = asset.labels + if ( + event.ids.has(item.key) && + (labels == null || [...event.labelNames].some((label) => !labels.includes(label))) + ) { + const newLabels = [ + ...(labels ?? []), + ...[...event.labelNames].filter((label) => labels?.includes(label) !== true), + ] + setAsset(object.merger({ labels: newLabels })) + try { + await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) + } catch (error) { + setAsset(object.merger({ labels })) + toastAndLog(null, error) } - break } - case AssetEventType.deleteLabel: { - setAsset((oldAsset) => { - const oldLabels = oldAsset.labels ?? [] - const labels: backendModule.LabelName[] = [] + break + } + case AssetEventType.removeLabels: { + setRowState((oldRowState) => + oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? + oldRowState + : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), + ) + const labels = asset.labels + if ( + event.ids.has(item.key) && + labels != null && + [...event.labelNames].some((label) => labels.includes(label)) + ) { + const newLabels = labels.filter((label) => !event.labelNames.has(label)) + setAsset(object.merger({ labels: newLabels })) + try { + await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) + } catch (error) { + setAsset(object.merger({ labels })) + toastAndLog(null, error) + } + } + break + } + case AssetEventType.deleteLabel: { + setAsset((oldAsset) => { + const oldLabels = oldAsset.labels ?? [] + const labels: backendModule.LabelName[] = [] - for (const label of oldLabels) { - if (label !== event.labelName) { - labels.push(label) - } + for (const label of oldLabels) { + if (label !== event.labelName) { + labels.push(label) } - - return oldLabels.length !== labels.length ? - object.merge(oldAsset, { labels }) - : oldAsset - }) - break - } - case AssetEventType.setItem: { - if (asset.id === event.id) { - setAsset(event.valueOrUpdater) } - break - } - default: { - return + + return oldLabels.length !== labels.length ? object.merge(oldAsset, { labels }) : oldAsset + }) + break + } + case AssetEventType.setItem: { + if (asset.id === event.id) { + setAsset(event.valueOrUpdater) } + break + } + default: { + return } } }, item.initialAssetEvents) @@ -658,6 +631,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { payloadItem.asset.parentId !== directoryId) .map((dragItem) => dragItem.key) - dispatchAssetEvent({ - type: AssetEventType.move, - newParentKey: directoryKey, - newParentId: directoryId, - ids: new Set(ids), - }) + cutAndPaste( + directoryKey, + directoryId, + { backendType: backend.type, ids: new Set(ids), category }, + nodeMap.current, + ) } else if (event.dataTransfer.types.includes('Files')) { event.preventDefault() event.stopPropagation() @@ -801,6 +775,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { setRowState, }} rootDirectoryId={rootDirectoryId} + triggerRef={rootRef} event={{ pageX: 0, pageY: 0 }} eventTarget={null} doCopy={doCopy} diff --git a/app/gui/src/dashboard/components/dashboard/KeyboardShortcut.tsx b/app/gui/src/dashboard/components/dashboard/KeyboardShortcut.tsx index d5763a82139..807614828cf 100644 --- a/app/gui/src/dashboard/components/dashboard/KeyboardShortcut.tsx +++ b/app/gui/src/dashboard/components/dashboard/KeyboardShortcut.tsx @@ -9,18 +9,19 @@ import CtrlKeyIcon from '#/assets/ctrl_key.svg' import OptionKeyIcon from '#/assets/option_key.svg' import ShiftKeyIcon from '#/assets/shift_key.svg' import WindowsKeyIcon from '#/assets/windows_key.svg' - -import type * as dashboardInputBindings from '#/configurations/inputBindings' - -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' -import * as textProvider from '#/providers/TextProvider' - -import * as aria from '#/components/aria' -import * as ariaComponents from '#/components/AriaComponents' +import { Text } from '#/components/AriaComponents' import SvgMask from '#/components/SvgMask' - -import * as inputBindingsModule from '#/utilities/inputBindings' -import * as tailwindMerge from '#/utilities/tailwindMerge' +import type { DashboardBindingKey } from '#/configurations/inputBindings' +import { useInputBindings } from '#/providers/InputBindingsProvider' +import { useText } from '#/providers/TextProvider' +import { + compareModifiers, + decomposeKeybindString, + toModifierKey, + type Key, + type ModifierKey, +} from '#/utilities/inputBindings' +import { twMerge } from '#/utilities/tailwindMerge' // ======================== // === KeyboardShortcut === @@ -33,16 +34,14 @@ const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX, marginTop: '0.1c /** Props for values of {@link MODIFIER_JSX}. */ interface InternalModifierProps { - readonly getText: ReturnType['getText'] + readonly getText: ReturnType['getText'] } /** Icons for modifier keys (if they exist). */ const MODIFIER_JSX: Readonly< Record< detect.Platform, - Partial< - Record React.ReactNode> - > + Partial React.ReactNode>> > > = { // The names are intentionally not in `camelCase`, as they are case-sensitive. @@ -58,18 +57,18 @@ const MODIFIER_JSX: Readonly< }, [detect.Platform.linux]: { Meta: (props) => ( - + {props.getText('superModifier')} - + ), }, [detect.Platform.unknown]: { // Assume the system is Unix-like and calls the key that triggers `event.metaKey` // the "Super" key. Meta: (props) => ( - + {props.getText('superModifier')} - + ), }, [detect.Platform.iPhoneOS]: {}, @@ -86,9 +85,9 @@ const KEY_CHARACTER: Readonly> = { ArrowLeft: '←', ArrowRight: '→', /* eslint-enable @typescript-eslint/naming-convention */ -} satisfies Partial> +} satisfies Partial> -const MODIFIER_TO_TEXT_ID: Readonly> = { +const MODIFIER_TO_TEXT_ID: Readonly> = { // The names come from a third-party API and cannot be changed. /* eslint-disable @typescript-eslint/naming-convention */ Ctrl: 'ctrlModifier', @@ -96,11 +95,11 @@ const MODIFIER_TO_TEXT_ID: Readonly}Modifier` } +} satisfies { [K in ModifierKey]: `${Lowercase}Modifier` } /** Props for a {@link KeyboardShortcut}, specifying the keyboard action. */ export interface KeyboardShortcutActionProps { - readonly action: dashboardInputBindings.DashboardBindingKey + readonly action: DashboardBindingKey } /** Props for a {@link KeyboardShortcut}, specifying the shortcut string. */ @@ -113,20 +112,18 @@ export type KeyboardShortcutProps = KeyboardShortcutActionProps | KeyboardShortc /** A visual representation of a keyboard shortcut. */ export default function KeyboardShortcut(props: KeyboardShortcutProps) { - const { getText } = textProvider.useText() - const inputBindings = inputBindingsProvider.useInputBindings() + const { getText } = useText() + const inputBindings = useInputBindings() const shortcutString = 'shortcut' in props ? props.shortcut : inputBindings.metadata[props.action].bindings[0] if (shortcutString == null) { return null } else { - const shortcut = inputBindingsModule.decomposeKeybindString(shortcutString) - const modifiers = [...shortcut.modifiers] - .sort(inputBindingsModule.compareModifiers) - .map(inputBindingsModule.toModifierKey) + const shortcut = decomposeKeybindString(shortcutString) + const modifiers = [...shortcut.modifiers].sort(compareModifiers).map(toModifierKey) return (
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? ( - - {getText(MODIFIER_TO_TEXT_ID[modifier])} - + {getText(MODIFIER_TO_TEXT_ID[modifier])} ), )} - - {shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key} - + {shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
) } diff --git a/app/gui/src/dashboard/components/dashboard/column.ts b/app/gui/src/dashboard/components/dashboard/column.ts index 219534dac4d..b2f18b3447f 100644 --- a/app/gui/src/dashboard/components/dashboard/column.ts +++ b/app/gui/src/dashboard/components/dashboard/column.ts @@ -1,51 +1,48 @@ /** @file Column types and column display modes. */ -import type * as React from 'react' +import type { Dispatch, JSX, SetStateAction } from 'react' -import type * as assetsTable from '#/layouts/AssetsTable' - -import * as columnUtils from '#/components/dashboard/column/columnUtils' +import { Column } from '#/components/dashboard/column/columnUtils' import DocsColumn from '#/components/dashboard/column/DocsColumn' import LabelsColumn from '#/components/dashboard/column/LabelsColumn' import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn' import NameColumn from '#/components/dashboard/column/NameColumn' import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn' import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn' - -import type * as backendModule from '#/services/Backend' - -import type * as assetTreeNode from '#/utilities/AssetTreeNode' +import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable' +import type { Asset, AssetId, BackendType } from '#/services/Backend' +import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode' // =================== // === AssetColumn === // =================== -/** Props for an arbitrary variant of {@link backendModule.Asset}. */ +/** Props for an arbitrary variant of {@link Asset}. */ export interface AssetColumnProps { - readonly keyProp: backendModule.AssetId + readonly keyProp: AssetId readonly isOpened: boolean - readonly item: assetTreeNode.AnyAssetTreeNode - readonly backendType: backendModule.BackendType - readonly setItem: React.Dispatch> + readonly item: AnyAssetTreeNode + readonly backendType: BackendType + readonly setItem: Dispatch> readonly selected: boolean readonly setSelected: (selected: boolean) => void readonly isSoleSelected: boolean - readonly state: assetsTable.AssetsTableState - readonly rowState: assetsTable.AssetRowState - readonly setRowState: React.Dispatch> + readonly state: AssetsTableState + readonly rowState: AssetRowState + readonly setRowState: Dispatch> readonly isEditable: boolean } /** Props for a {@link AssetColumn}. */ export interface AssetColumnHeadingProps { - readonly state: assetsTable.AssetsTableState + readonly state: AssetsTableState } /** Metadata describing how to render a column of the table. */ export interface AssetColumn { readonly id: string readonly className?: string - readonly heading: (props: AssetColumnHeadingProps) => React.JSX.Element - readonly render: (props: AssetColumnProps) => React.JSX.Element + readonly heading: (props: AssetColumnHeadingProps) => JSX.Element + readonly render: (props: AssetColumnProps) => JSX.Element } // ======================= @@ -53,14 +50,12 @@ export interface AssetColumn { // ======================= /** React components for every column. */ -export const COLUMN_RENDERER: Readonly< - Record React.JSX.Element> -> = { - [columnUtils.Column.name]: NameColumn, - [columnUtils.Column.modified]: ModifiedColumn, - [columnUtils.Column.sharedWith]: SharedWithColumn, - [columnUtils.Column.labels]: LabelsColumn, - [columnUtils.Column.accessedByProjects]: PlaceholderColumn, - [columnUtils.Column.accessedData]: PlaceholderColumn, - [columnUtils.Column.docs]: DocsColumn, +export const COLUMN_RENDERER: Readonly JSX.Element>> = { + [Column.name]: NameColumn, + [Column.modified]: ModifiedColumn, + [Column.sharedWith]: SharedWithColumn, + [Column.labels]: LabelsColumn, + [Column.accessedByProjects]: PlaceholderColumn, + [Column.accessedData]: PlaceholderColumn, + [Column.docs]: DocsColumn, } diff --git a/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx index 0daf47745fc..2277354d3e8 100644 --- a/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx @@ -10,7 +10,7 @@ import * as authProvider from '#/providers/AuthProvider' import * as modalProvider from '#/providers/ModalProvider' import * as textProvider from '#/providers/TextProvider' -import * as ariaComponents from '#/components/AriaComponents' +import { Button, DialogTrigger } from '#/components/AriaComponents' import ContextMenu from '#/components/ContextMenu' import ContextMenus from '#/components/ContextMenus' import type * as column from '#/components/dashboard/column' @@ -23,7 +23,6 @@ import * as backendModule from '#/services/Backend' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' -import * as uniqueString from '#/utilities/uniqueString' // ==================== // === LabelsColumn === @@ -43,7 +42,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) { const labelsByName = React.useMemo(() => { return new Map(labels?.map((label) => [label.value, label])) }, [labels]) - const plusButtonRef = React.useRef(null) const self = permissions.tryFindSelfPermission(user, asset.permissions) const managesThisAsset = category.type !== 'trash' && @@ -130,23 +128,10 @@ export default function LabelsColumn(props: column.AssetColumnProps) { ))} {managesThisAsset && ( - { - setModal( - , - ) - }} - /> + +
) diff --git a/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx index f50b0eead9d..4654dc5b0e1 100644 --- a/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/SharedWithColumn.tsx @@ -2,83 +2,67 @@ import * as React from 'react' import Plus2Icon from '#/assets/plus2.svg' - -import * as billingHooks from '#/hooks/billing' - -import * as authProvider from '#/providers/AuthProvider' -import * as modalProvider from '#/providers/ModalProvider' - -import AssetEventType from '#/events/AssetEventType' - -import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' - -import * as ariaComponents from '#/components/AriaComponents' -import type * as column from '#/components/dashboard/column' +import { Button } from '#/components/AriaComponents' +import type { AssetColumnProps } from '#/components/dashboard/column' import PermissionDisplay from '#/components/dashboard/PermissionDisplay' -import * as paywall from '#/components/Paywall' - +import { PaywallDialogButton } from '#/components/Paywall' +import AssetEventType from '#/events/AssetEventType' +import { useAssetPassiveListenerStrict } from '#/hooks/backendHooks' +import { usePaywall } from '#/hooks/billing' +import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider' import ManagePermissionsModal from '#/modals/ManagePermissionsModal' - -import * as backendModule from '#/services/Backend' - -import * as permissions from '#/utilities/permissions' -import * as uniqueString from '#/utilities/uniqueString' +import { useFullUserSession } from '#/providers/AuthProvider' +import { useSetModal } from '#/providers/ModalProvider' +import { getAssetPermissionId, getAssetPermissionName } from '#/services/Backend' +import { PermissionAction, tryFindSelfPermission } from '#/utilities/permissions' // ======================== // === SharedWithColumn === // ======================== /** The type of the `state` prop of a {@link SharedWithColumn}. */ -interface SharedWithColumnStateProp extends Pick { - readonly setQuery: column.AssetColumnProps['state']['setQuery'] | null +interface SharedWithColumnStateProp + extends Pick { + readonly setQuery: AssetColumnProps['state']['setQuery'] | null } /** Props for a {@link SharedWithColumn}. */ -interface SharedWithColumnPropsInternal extends Pick { +interface SharedWithColumnPropsInternal extends Pick { readonly isReadonly?: boolean readonly state: SharedWithColumnStateProp } /** A column listing the users with which this asset is shared. */ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { - const { item, setItem, state, isReadonly = false } = props - const { category, setQuery } = state - const asset = item.item - const { user } = authProvider.useFullUserSession() - const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() - const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) + const { item, state, isReadonly = false } = props + const { backend, category, setQuery } = state + const asset = useAssetPassiveListenerStrict( + backend.type, + item.item.id, + item.item.parentId, + category, + ) + const { user } = useFullUserSession() + const dispatchAssetEvent = useDispatchAssetEvent() + const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan }) const isUnderPaywall = isFeatureUnderPaywall('share') const assetPermissions = asset.permissions ?? [] - const { setModal } = modalProvider.useSetModal() - const self = permissions.tryFindSelfPermission(user, asset.permissions) + const { setModal } = useSetModal() + const self = tryFindSelfPermission(user, asset.permissions) const plusButtonRef = React.useRef(null) const managesThisAsset = !isReadonly && category.type !== 'trash' && - (self?.permission === permissions.PermissionAction.own || - self?.permission === permissions.PermissionAction.admin) - const setAsset = React.useCallback( - (valueOrUpdater: React.SetStateAction) => { - setItem((oldItem) => - oldItem.with({ - item: - typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item), - }), - ) - }, - [setItem], - ) + (self?.permission === PermissionAction.own || self?.permission === PermissionAction.admin) return (
{(category.type === 'trash' ? - assetPermissions.filter( - (permission) => permission.permission === permissions.PermissionAction.own, - ) + assetPermissions.filter((permission) => permission.permission === PermissionAction.own) : assetPermissions ).map((other, idx) => ( - {backendModule.getAssetPermissionName(other)} + {getAssetPermissionName(other)} ))} {isUnderPaywall && ( - )} {managesThisAsset && !isUnderPaywall && ( - { setModal( { diff --git a/app/gui/src/dashboard/events/AssetListEventType.ts b/app/gui/src/dashboard/events/AssetListEventType.ts index fc075ceff97..9f245727866 100644 --- a/app/gui/src/dashboard/events/AssetListEventType.ts +++ b/app/gui/src/dashboard/events/AssetListEventType.ts @@ -7,12 +7,10 @@ enum AssetListEventType { uploadFiles = 'upload-files', newDatalink = 'new-datalink', newSecret = 'new-secret', - insertAssets = 'insert-assets', duplicateProject = 'duplicate-project', closeFolder = 'close-folder', copy = 'copy', move = 'move', - willDelete = 'will-delete', delete = 'delete', emptyTrash = 'empty-trash', removeSelf = 'remove-self', diff --git a/app/gui/src/dashboard/events/assetListEvent.ts b/app/gui/src/dashboard/events/assetListEvent.ts index 8df141a9431..0b807490be4 100644 --- a/app/gui/src/dashboard/events/assetListEvent.ts +++ b/app/gui/src/dashboard/events/assetListEvent.ts @@ -1,7 +1,13 @@ /** @file Events related to changes in the asset list. */ -import type AssetListEventType from '#/events/AssetListEventType' +import AssetListEventType from '#/events/AssetListEventType' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useDispatchAssetListEvent } from '#/layouts/AssetsTable/EventListProvider' +import { useTransferBetweenCategories, type Category } from '#/layouts/CategorySwitcher/Category' +import type { DrivePastePayload } from '#/providers/DriveProvider' import type * as backend from '#/services/Backend' +import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode' +import { isTeamPath, isUserPath } from '#/utilities/permissions' // ====================== // === AssetListEvent === @@ -19,12 +25,10 @@ interface AssetListEvents { readonly uploadFiles: AssetListUploadFilesEvent readonly newSecret: AssetListNewSecretEvent readonly newDatalink: AssetListNewDatalinkEvent - readonly insertAssets: AssetListInsertAssetsEvent readonly duplicateProject: AssetListDuplicateProjectEvent readonly closeFolder: AssetListCloseFolderEvent readonly copy: AssetListCopyEvent readonly move: AssetListMoveEvent - readonly willDelete: AssetListWillDeleteEvent readonly delete: AssetListDeleteEvent readonly emptyTrash: AssetListEmptyTrashEvent readonly removeSelf: AssetListRemoveSelfEvent @@ -81,13 +85,6 @@ interface AssetListNewSecretEvent extends AssetListBaseEvent { - readonly parentKey: backend.DirectoryId - readonly parentId: backend.DirectoryId - readonly assets: backend.AnyAsset[] -} - /** A signal to duplicate a project. */ interface AssetListDuplicateProjectEvent extends AssetListBaseEvent { @@ -118,11 +115,6 @@ interface AssetListMoveEvent extends AssetListBaseEvent readonly items: backend.AnyAsset[] } -/** A signal that a file has been deleted. */ -interface AssetListWillDeleteEvent extends AssetListBaseEvent { - readonly key: backend.AssetId -} - /** * A signal that a file has been deleted. This must not be called before the request is * finished. @@ -141,3 +133,53 @@ interface AssetListRemoveSelfEvent extends AssetListBaseEvent, + ) => { + const ids = Array.from(pasteData.ids) + const nodes = ids.flatMap((id) => { + const item = nodeMap.get(id) + return item == null ? [] : [item] + }) + const newParent = nodeMap.get(newParentKey) + const isMovingToUserSpace = newParent?.path != null && isUserPath(newParent.path) + const teamToUserItems = + isMovingToUserSpace ? + nodes.filter((node) => isTeamPath(node.path)).map((otherItem) => otherItem.item) + : [] + const nonTeamToUserIds = + isMovingToUserSpace ? + nodes.filter((node) => !isTeamPath(node.path)).map((otherItem) => otherItem.item.id) + : ids + if (teamToUserItems.length !== 0) { + dispatchAssetListEvent({ + type: AssetListEventType.copy, + newParentKey, + newParentId, + items: teamToUserItems, + }) + } + if (nonTeamToUserIds.length !== 0) { + transferBetweenCategories( + pasteData.category, + category, + pasteData.ids, + newParentKey, + newParentId, + ) + } + }, + ) +} diff --git a/app/gui/src/dashboard/hooks/backendHooks.ts b/app/gui/src/dashboard/hooks/backendHooks.ts index 9a4ecef4bc4..39600458936 100644 --- a/app/gui/src/dashboard/hooks/backendHooks.ts +++ b/app/gui/src/dashboard/hooks/backendHooks.ts @@ -1,12 +1,37 @@ /** @file Hooks for interacting with the backend. */ -import * as React from 'react' +import { useMemo } from 'react' -import * as reactQuery from '@tanstack/react-query' +import { + queryOptions, + useMutationState, + useQuery, + type Mutation, + type MutationKey, + type UseMutationOptions, + type UseQueryOptions, + type UseQueryResult, +} from '@tanstack/react-query' +import invariant from 'tiny-invariant' -import * as backendQuery from 'enso-common/src/backendQuery' +import { + backendQueryOptions as backendQueryOptionsBase, + type BackendMethods, +} from 'enso-common/src/backendQuery' +import { CATEGORY_TO_FILTER_BY, type Category } from '#/layouts/CategorySwitcher/Category' import type Backend from '#/services/Backend' -import * as backendModule from '#/services/Backend' +import { + AssetType, + BackendType, + type AnyAsset, + type AssetId, + type DirectoryAsset, + type DirectoryId, + type User, + type UserGroupInfo, +} from '#/services/Backend' +import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths' +import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime' // ============================ // === DefineBackendMethods === @@ -44,10 +69,6 @@ export type MutationMethod = DefineBackendMethods< | 'deleteUser' | 'deleteUserGroup' | 'duplicateProject' - // TODO: `get*` are not mutations, but are currently used in some places. - | 'getDatalink' - | 'getFileDetails' - | 'getProjectDetails' | 'inviteUser' | 'logEvent' | 'openProject' @@ -71,55 +92,75 @@ export type MutationMethod = DefineBackendMethods< // === useBackendQuery === // ======================= -export function useBackendQuery( +export function backendQueryOptions( backend: Backend, method: Method, args: Parameters, - options?: Omit< - reactQuery.UseQueryOptions>>, - 'queryFn' | 'queryKey' - > & - Partial>>, 'queryKey'>>, -): reactQuery.UseQueryResult>> -export function useBackendQuery( + options?: Omit>>, 'queryFn' | 'queryKey'> & + Partial>>, 'queryKey'>>, +): UseQueryOptions>> +export function backendQueryOptions( backend: Backend | null, method: Method, args: Parameters, - options?: Omit< - reactQuery.UseQueryOptions>>, - 'queryFn' | 'queryKey' - > & - Partial>>, 'queryKey'>>, -): reactQuery.UseQueryResult< + options?: Omit>>, 'queryFn' | 'queryKey'> & + Partial>>, 'queryKey'>>, +): UseQueryOptions< // eslint-disable-next-line no-restricted-syntax Awaited> | undefined > /** Wrap a backend method call in a React Query. */ -export function useBackendQuery( +export function backendQueryOptions( backend: Backend | null, method: Method, args: Parameters, - options?: Omit< - reactQuery.UseQueryOptions>>, - 'queryFn' | 'queryKey' - > & - Partial>>, 'queryKey'>>, + options?: Omit>>, 'queryFn' | 'queryKey'> & + Partial>>, 'queryKey'>>, ) { - return reactQuery.useQuery>>({ + // @ts-expect-error This call is generic over the presence or absence of `inputData`. + return queryOptions>>({ ...options, - ...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey), + ...backendQueryOptionsBase(backend, method, args, options?.queryKey), // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return queryFn: () => (backend?.[method] as any)?.(...args), }) } +export function useBackendQuery( + backend: Backend, + method: Method, + args: Parameters, + options?: Omit>>, 'queryFn' | 'queryKey'> & + Partial>>, 'queryKey'>>, +): UseQueryResult>> +export function useBackendQuery( + backend: Backend | null, + method: Method, + args: Parameters, + options?: Omit>>, 'queryFn' | 'queryKey'> & + Partial>>, 'queryKey'>>, +): UseQueryResult< + // eslint-disable-next-line no-restricted-syntax + Awaited> | undefined +> +/** Wrap a backend method call in a React Query. */ +export function useBackendQuery( + backend: Backend | null, + method: Method, + args: Parameters, + options?: Omit>>, 'queryFn' | 'queryKey'> & + Partial>>, 'queryKey'>>, +) { + return useQuery(backendQueryOptions(backend, method, args, options)) +} + // ========================== // === useBackendMutation === // ========================== const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries') const INVALIDATION_MAP: Partial< - Record + Record > = { createUser: ['usersMe'], updateUser: ['usersMe'], @@ -141,7 +182,7 @@ const INVALIDATION_MAP: Partial< createDirectory: ['listDirectory'], createSecret: ['listDirectory'], updateSecret: ['listDirectory'], - createDatalink: ['listDirectory'], + createDatalink: ['listDirectory', 'getDatalink'], uploadFile: ['listDirectory'], copyAsset: ['listDirectory', 'listAssetVersions'], deleteAsset: ['listDirectory', 'listAssetVersions'], @@ -151,34 +192,29 @@ const INVALIDATION_MAP: Partial< updateDirectory: ['listDirectory'], } -export function backendMutationOptions( - backend: Backend, - method: Method, - options?: Omit< - reactQuery.UseMutationOptions< - Awaited>, - Error, - Parameters - >, - 'mutationFn' - >, -): reactQuery.UseMutationOptions< +/** The type of the corresponding mutation for the given backend method. */ +export type BackendMutation = Mutation< Awaited>, Error, Parameters > + +export function backendMutationOptions( + backend: Backend, + method: Method, + options?: Omit< + UseMutationOptions>, Error, Parameters>, + 'mutationFn' + >, +): UseMutationOptions>, Error, Parameters> export function backendMutationOptions( backend: Backend | null, method: Method, options?: Omit< - reactQuery.UseMutationOptions< - Awaited>, - Error, - Parameters - >, + UseMutationOptions>, Error, Parameters>, 'mutationFn' >, -): reactQuery.UseMutationOptions< +): UseMutationOptions< Awaited> | undefined, Error, Parameters @@ -188,24 +224,16 @@ export function backendMutationOptions( backend: Backend | null, method: Method, options?: Omit< - reactQuery.UseMutationOptions< - Awaited>, - Error, - Parameters - >, + UseMutationOptions>, Error, Parameters>, 'mutationFn' >, -): reactQuery.UseMutationOptions< - Awaited>, - Error, - Parameters -> { +): UseMutationOptions>, Error, Parameters> { return { ...options, mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])], // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return mutationFn: (args) => (backend?.[method] as any)?.(...args), - networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online', + networkMode: backend?.type === BackendType.local ? 'always' : 'online', meta: { invalidates: [ ...(options?.meta?.invalidates ?? []), @@ -223,8 +251,8 @@ export function backendMutationOptions( // ================================== /** A user group, as well as the users that are a part of the user group. */ -export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo { - readonly users: readonly backendModule.User[] +export interface UserGroupInfoWithUsers extends UserGroupInfo { + readonly users: readonly User[] } /** A list of user groups, taking into account optimistic state. */ @@ -233,12 +261,12 @@ export function useListUserGroupsWithUsers( ): readonly UserGroupInfoWithUsers[] | null { const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', []) const listUsersQuery = useBackendQuery(backend, 'listUsers', []) - return React.useMemo(() => { + return useMemo(() => { if (listUserGroupsQuery.data == null || listUsersQuery.data == null) { return null } else { const result = listUserGroupsQuery.data.map((userGroup) => { - const usersInGroup: readonly backendModule.User[] = listUsersQuery.data.filter((user) => + const usersInGroup: readonly User[] = listUsersQuery.data.filter((user) => user.userGroups?.includes(userGroup.id), ) return { ...userGroup, users: usersInGroup } @@ -247,3 +275,96 @@ export function useListUserGroupsWithUsers( } }, [listUserGroupsQuery.data, listUsersQuery.data]) } + +/** Data for a specific asset. */ +export function useAssetPassiveListener( + backendType: BackendType, + assetId: AssetId | null | undefined, + parentId: DirectoryId | null | undefined, + category: Category, +) { + const listDirectoryQuery = useQuery[] | undefined>({ + queryKey: [ + backendType, + 'listDirectory', + parentId, + { + labels: null, + filterBy: CATEGORY_TO_FILTER_BY[category.type], + recentProjects: category.type === 'recent', + }, + ], + initialData: undefined, + }) + const asset = listDirectoryQuery.data?.find((child) => child.id === assetId) + if (asset || !assetId || !parentId) { + return asset + } + switch (assetId) { + case USERS_DIRECTORY_ID: { + return { + id: assetId, + parentId, + type: AssetType.directory, + projectState: null, + title: 'Users', + description: '', + modifiedAt: toRfc3339(new Date()), + permissions: [], + labels: [], + } satisfies DirectoryAsset + } + case TEAMS_DIRECTORY_ID: { + return { + id: assetId, + parentId, + type: AssetType.directory, + projectState: null, + title: 'Teams', + description: '', + modifiedAt: toRfc3339(new Date()), + permissions: [], + labels: [], + } satisfies DirectoryAsset + } + default: { + return + } + } +} + +/** Data for a specific asset. */ +export function useAssetPassiveListenerStrict( + backendType: BackendType, + assetId: AssetId | null | undefined, + parentId: DirectoryId | null | undefined, + category: Category, +) { + const asset = useAssetPassiveListener(backendType, assetId, parentId, category) + invariant(asset, 'Asset not found') + return asset +} + +/** Return matching in-flight mutations */ +export function useBackendMutationState( + backend: Backend, + method: Method, + options: { + mutationKey?: MutationKey + predicate?: (mutation: BackendMutation) => boolean + select?: (mutation: BackendMutation) => Result + } = {}, +) { + const { mutationKey, predicate, select } = options + return useMutationState({ + filters: { + ...backendMutationOptions(backend, method, mutationKey ? { mutationKey } : {}), + predicate: (mutation: BackendMutation) => + mutation.state.status === 'pending' && (predicate?.(mutation) ?? true), + }, + // This is UNSAFE when the `Result` parameter is explicitly specified in the + // generic parameter list. + // eslint-disable-next-line no-restricted-syntax + select: select as (mutation: Mutation) => Result, + }) +} diff --git a/app/gui/src/dashboard/hooks/projectHooks.ts b/app/gui/src/dashboard/hooks/projectHooks.ts index 388a7d3dc38..40a2769831d 100644 --- a/app/gui/src/dashboard/hooks/projectHooks.ts +++ b/app/gui/src/dashboard/hooks/projectHooks.ts @@ -62,26 +62,21 @@ function useSetProjectAsset() { parentId: backendModule.DirectoryId, transform: (asset: backendModule.ProjectAsset) => backendModule.ProjectAsset, ) => { - const listDirectoryQuery = queryClient.getQueryCache().find< - | { - parentId: backendModule.DirectoryId - children: readonly backendModule.AnyAsset[] - } - | undefined - >({ - queryKey: [backendType, 'listDirectory', parentId], - exact: false, - }) + const listDirectoryQuery = queryClient + .getQueryCache() + .find[] | undefined>({ + queryKey: [backendType, 'listDirectory', parentId], + exact: false, + }) if (listDirectoryQuery?.state.data) { - listDirectoryQuery.setData({ - ...listDirectoryQuery.state.data, - children: listDirectoryQuery.state.data.children.map((child) => + listDirectoryQuery.setData( + listDirectoryQuery.state.data.map((child) => child.id === assetId && child.type === backendModule.AssetType.project ? transform(child) : child, ), - }) + ) } }, ) diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx index eff19837123..abfe20669f5 100644 --- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx @@ -7,7 +7,6 @@ import * as toast from 'react-toastify' import * as billingHooks from '#/hooks/billing' import * as copyHooks from '#/hooks/copyHooks' import * as projectHooks from '#/hooks/projectHooks' -import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as authProvider from '#/providers/AuthProvider' @@ -56,6 +55,7 @@ export interface AssetContextMenuProps { readonly hidden?: boolean readonly innerProps: assetRow.AssetRowInnerProps readonly rootDirectoryId: backendModule.DirectoryId + readonly triggerRef: React.MutableRefObject readonly event: Pick readonly eventTarget: HTMLElement | null readonly doDelete: () => void @@ -69,7 +69,7 @@ export interface AssetContextMenuProps { /** The context menu for an arbitrary {@link backendModule.Asset}. */ export default function AssetContextMenu(props: AssetContextMenuProps) { - const { innerProps, rootDirectoryId, event, eventTarget, hidden = false } = props + const { innerProps, rootDirectoryId, event, eventTarget, hidden = false, triggerRef } = props const { doCopy, doCut, doPaste, doDelete } = props const { item, setItem, state, setRowState } = innerProps const { backend, category, nodeMap } = state @@ -131,12 +131,21 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { const canPaste = !pasteData || !pasteDataParentKeys || !isCloud ? true - : !Array.from(pasteData.data.ids).some((assetId) => { - const parentKey = pasteDataParentKeys.get(assetId) + : Array.from(pasteData.data.ids).every((key) => { + const parentKey = pasteDataParentKeys.get(key) const parent = parentKey == null ? null : nodeMap.current.get(parentKey) - return !parent ? true : ( - permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path) + if (!parent) { + return false + } else if (permissions.isTeamPath(parent.path)) { + return true + } else { + // Assume user path; check permissions + const permission = permissions.tryFindSelfPermission(user, item.item.permissions) + return ( + permission != null && + permissions.canPermissionModifyDirectoryContents(permission.permission) ) + } }) const { data } = reactQuery.useQuery( @@ -161,8 +170,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { asset.projectState.openedBy != null && asset.projectState.openedBy !== user.email - const setAsset = setAssetHooks.useSetAsset(asset, setItem) - const pasteMenuEntry = hasPasteData && canPaste && (