Dashboard improvements (8 Oct 2024) (#11268)

- ⚠️ 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) ℹ️ fixed in #11219
- (2)  backend issue
- (3)  out of scope
- (4)  backend issue
- (5)  out of scope
- (6)  [wontfix]? i think this is intentional, it's not so much slow scrolling and moreso snapped scrolling
- (7)  backend issue
- (8) ℹ️ fixed in #11126
- (9)  out of scope (potentially requires a way to trigger a tooltip on a disabled button)
- (10)  (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)  (will check later) Drag from user space to team should move (and swap ownership)
- (13) ℹ️ fixed in #11219
- (14) ℹ️ fixed somewhere (?)
- (15) ℹ️ fixed somewhere (?)
- (16) Show correct (and up-to-date) description for projects
- (17) ℹ️ fixed in #11219
- (18) ℹ️ 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 51733ee876 (diff-f3e29bffcda342ab6a9dbafc58dde88ce26638eaecda1f17f40ca7e319c90cc8L89)

# Important Notes
None
This commit is contained in:
somebody1234 2024-10-21 20:30:19 +10:00 committed by GitHub
parent fa87a1857a
commit 45ad3a751c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1297 additions and 1232 deletions

View File

@ -8,7 +8,7 @@ import * as remoteBackendPaths from '#/services/remoteBackendPaths'
import * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions' 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' import * as actions from './actions'

View File

@ -72,6 +72,7 @@ test.test('asset panel contents', ({ page }) =>
.do(async () => { .do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions. // `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()
}), }),
) )

View File

@ -29,6 +29,7 @@
"test-dev:unit": "vitest", "test-dev:unit": "vitest",
"test:e2e": "cross-env NODE_ENV=production playwright test", "test:e2e": "cross-env NODE_ENV=production playwright test",
"test-dev:e2e": "cross-env NODE_ENV=production playwright test --ui", "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", "preinstall": "corepack pnpm run generate-metadata",
"postinstall": "playwright install", "postinstall": "playwright install",
"generate-metadata": "node scripts/generateIconMetadata.js" "generate-metadata": "node scripts/generateIconMetadata.js"

View File

@ -216,7 +216,9 @@ export const BUTTON_STYLES = tv({
end: { content: 'flex-row-reverse' }, end: { content: 'flex-row-reverse' },
}, },
showIconOnHover: { 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: { extraClickZone: {
true: { true: {
@ -341,6 +343,7 @@ export const Button = forwardRef(function Button(
const isLoading = loading || implicitlyLoading const isLoading = loading || implicitlyLoading
const isDisabled = props.isDisabled ?? isLoading const isDisabled = props.isDisabled ?? isLoading
const shouldUseVisualTooltip = shouldShowTooltip && isDisabled
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const delay = 350 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 = ( const button = (
<Tag <Tag
// @ts-expect-error ts errors are expected here because we are merging props with different types // @ts-expect-error ts errors are expected here because we are merging props with different types
@ -456,7 +466,11 @@ export const Button = forwardRef(function Button(
> >
{(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => (
<span className={styles.wrapper()}> <span className={styles.wrapper()}>
<span ref={contentRef} className={styles.content({ className: contentClassName })}> <span
ref={contentRef}
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */} {/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */}
{childrenFactory(render)} {childrenFactory(render)}
</span> </span>
@ -471,8 +485,14 @@ export const Button = forwardRef(function Button(
</Tag> </Tag>
) )
return tooltipElement == null ? button : ( return (
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}> tooltipElement == null ? button
: shouldUseVisualTooltip ?
<>
{button}
{visualTooltip}
</>
: <ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
{button} {button}
<ariaComponents.Tooltip <ariaComponents.Tooltip

View File

@ -50,7 +50,7 @@ export function useFormContext<Schema extends types.TSchema>(
invariant(ctx, 'FormContext not found') 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 // eslint-disable-next-line no-restricted-syntax
return ctx.form as unknown as types.UseFormReturn<Schema> return ctx.form as unknown as types.UseFormReturn<Schema>
} }

View File

@ -42,14 +42,17 @@ export function Reset(props: ResetProps): React.JSX.Element {
...buttonProps ...buttonProps
} = props } = props
const { formState } = formContext.useFormContext(props.form) const form = formContext.useFormContext(props.form)
const { formState } = form
return ( return (
<ariaComponents.Button <ariaComponents.Button
onPress={() => {
form.reset()
}}
/* This is safe because we are passing all props to the button */ /* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */ /* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)} {...(buttonProps as any)}
type="reset"
variant={variant} variant={variant}
size={size} size={size}
isDisabled={formState.isSubmitting || !formState.isDirty} isDisabled={formState.isSubmitting || !formState.isDirty}

View File

@ -23,7 +23,7 @@ const DROPDOWN_STYLES = tv({
isFocused: { isFocused: {
true: { true: {
container: 'z-1', 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', optionsContainer: 'grid-rows-1fr',
input: 'z-1', input: 'z-1',
}, },
@ -47,7 +47,7 @@ const DROPDOWN_STYLES = tv({
slots: { slots: {
container: 'absolute left-0 h-full w-full min-w-max', container: 'absolute left-0 h-full w-full min-w-max',
options: 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', optionsSpacing: 'padding relative h-6',
optionsContainer: optionsContainer:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows', 'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',

View File

@ -1,7 +1,13 @@
/** /**
* @file A resizable input that uses a content-editable div. * @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 type { FieldVariantProps } from '#/components/AriaComponents'
import { import {
@ -12,6 +18,7 @@ import {
type FieldStateProps, type FieldStateProps,
type TSchema, type TSchema,
} from '#/components/AriaComponents' } from '#/components/AriaComponents'
import { useAutoFocus } from '#/hooks/autoFocusHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { mergeRefs } from '#/utilities/mergeRefs' import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
@ -43,6 +50,8 @@ export interface ResizableContentEditableInputProps<
VariantProps<typeof CONTENT_EDITABLE_STYLES>, VariantProps<typeof CONTENT_EDITABLE_STYLES>,
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant' 'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
> { > {
/** Defaults to `onInput`. */
readonly mode?: 'onBlur' | 'onInput'
/** /**
* onChange is called when the content of the input changes. * 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. * 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<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>,
) { ) {
const { const {
mode = 'onInput',
placeholder = '', placeholder = '',
description = null, description = null,
name, name,
@ -76,6 +86,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
variant, variant,
variants = CONTENT_EDITABLE_STYLES, variants = CONTENT_EDITABLE_STYLES,
fieldVariants, fieldVariants,
autoFocus = false,
...textFieldProps ...textFieldProps
} = props } = props
@ -100,13 +111,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
defaultValue, defaultValue,
}) })
const { const styles = variants({
base,
description: descriptionClass,
inputContainer,
textArea,
placeholder: placeholderClass,
} = variants({
invalid: fieldState.invalid, invalid: fieldState.invalid,
disabled: isDisabled || formInstance.formState.isSubmitting, disabled: isDisabled || formInstance.formState.isSubmitting,
variant, variant,
@ -114,6 +119,14 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
size, size,
}) })
useAutoFocus({ ref: inputRef, disabled: !autoFocus })
useEffect(() => {
if (inputRef.current) {
inputRef.current.textContent = field.value
}
}, [field.value])
return ( return (
<Form.Field <Form.Field
form={formInstance} form={formInstance}
@ -123,14 +136,14 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
{...textFieldProps} {...textFieldProps}
> >
<div <div
className={base()} className={styles.base()}
onClick={() => { onClick={() => {
inputRef.current?.focus({ preventScroll: true }) inputRef.current?.focus({ preventScroll: true })
}} }}
> >
<div className={inputContainer()}> <div className={styles.inputContainer()}>
<div <div
className={textArea()} className={styles.textArea()}
ref={mergeRefs(inputRef, ref, field.ref)} ref={mergeRefs(inputRef, ref, field.ref)}
contentEditable contentEditable
suppressContentEditableWarning suppressContentEditableWarning
@ -140,19 +153,26 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
spellCheck="false" spellCheck="false"
aria-autocomplete="none" aria-autocomplete="none"
onPaste={onPaste} onPaste={onPaste}
onBlur={field.onBlur} onBlur={(event) => {
onInput={(event) => { if (mode === 'onBlur') {
field.onChange(event.currentTarget.textContent ?? '') field.onChange(event.currentTarget.textContent ?? '')
}
field.onBlur()
}}
onInput={(event) => {
if (mode === 'onInput') {
field.onChange(event.currentTarget.textContent ?? '')
}
}} }}
/> />
<Text className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}> <Text className={styles.placeholder({ class: field.value.length > 0 ? 'hidden' : '' })}>
{placeholder} {placeholder}
</Text> </Text>
</div> </div>
{description != null && ( {description != null && (
<Text slot="description" className={descriptionClass()}> <Text slot="description" className={styles.description()}>
{description} {description}
</Text> </Text>
)} )}

View File

@ -68,7 +68,6 @@ export default function SelectionBrush(props: SelectionBrushProps) {
}, [anchorAnimFactor, anchor]) }, [anchorAnimFactor, anchor])
React.useEffect(() => { React.useEffect(() => {
const target = targetRef.current ?? document.body
const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => { const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => {
if (parent == null) { if (parent == null) {
return true return true
@ -95,7 +94,8 @@ export default function SelectionBrush(props: SelectionBrushProps) {
didMoveWhileDraggingRef.current = false didMoveWhileDraggingRef.current = false
lastMouseEvent.current = event lastMouseEvent.current = event
const newAnchor = { left: event.pageX, top: event.pageY } const newAnchor = { left: event.pageX, top: event.pageY }
anchorRef.current = null anchorRef.current = newAnchor
setAnchor(newAnchor)
setLastSetAnchor(newAnchor) setLastSetAnchor(newAnchor)
setPosition(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('mouseup', onMouseUp)
document.addEventListener('dragstart', onDragStart, { capture: true }) document.addEventListener('dragstart', onDragStart, { capture: true })
document.addEventListener('mousemove', onMouseMove) document.addEventListener('mousemove', onMouseMove)
document.addEventListener('click', onClick, { capture: true }) document.addEventListener('click', onClick, { capture: true })
return () => { return () => {
target.removeEventListener('mousedown', onMouseDown) document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp) document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('dragstart', onDragStart, { capture: true }) document.removeEventListener('dragstart', onDragStart, { capture: true })
document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mousemove', onMouseMove)

View File

@ -2,6 +2,7 @@
import type { Mutable } from 'enso-common/src/utilities/data/object' import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as aria from 'react-aria' import * as aria from 'react-aria'
export { ClearPressResponder } from '@react-aria/interactions'
export type * from '@react-types/shared' export type * from '@react-types/shared'
// @ts-expect-error The conflicting exports are props types ONLY. // @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria' export * from 'react-aria'

View File

@ -1,6 +1,7 @@
/** @file A table row for an arbitrary asset. */ /** @file A table row for an arbitrary asset. */
import * as React from 'react' import * as React from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useStore } from 'zustand' import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg' import BlankIcon from '#/assets/blank.svg'
@ -9,12 +10,7 @@ import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks' import * as setAssetHooks from '#/hooks/setAssetHooks'
import { import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
useDriveStore,
useSetAssetPanelProps,
useSetIsAssetPanelTemporarilyVisible,
useSetSelectedKeys,
} from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
@ -34,7 +30,12 @@ import * as localBackend from '#/services/LocalBackend'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import { Text } from '#/components/AriaComponents' 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 { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks' import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks' import { useToastAndLog } from '#/hooks/toastAndLogHooks'
@ -49,7 +50,6 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set' import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility' import Visibility from '#/utilities/Visibility'
import { useMutation, useQuery } from '@tanstack/react-query'
// ================= // =================
// === Constants === // === Constants ===
@ -133,9 +133,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const [item, setItem] = React.useState(rawItem) const [item, setItem] = React.useState(rawItem)
const driveStore = useDriveStore() const driveStore = useDriveStore()
const queryClient = useQueryClient()
const { user } = useFullUserSession() const { user } = useFullUserSession()
const setSelectedKeys = useSetSelectedKeys() const setSelectedKeys = useSetSelectedKeys()
const setAssetPanelProps = useSetAssetPanelProps()
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) => const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
(visuallySelectedKeys ?? selectedKeys).has(item.key), (visuallySelectedKeys ?? selectedKeys).has(item.key),
) )
@ -151,17 +151,15 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const draggableProps = dragAndDropHooks.useDraggable() const draggableProps = dragAndDropHooks.useDraggable()
const { setModal, unsetModal } = modalProvider.useSetModal() const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const cutAndPaste = useCutAndPaste(category)
const [isDraggedOver, setIsDraggedOver] = React.useState(false) const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const rootRef = React.useRef<HTMLElement | null>(null) const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null) const dragOverTimeoutHandle = React.useRef<number | null>(null)
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus) const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus)
const asset = item.item const asset = item.item
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible) const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(() => assetRowUtils.INITIAL_ROW_STATE,
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
) )
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id) const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
@ -176,12 +174,14 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId> readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
} | null>(null) } | null>(null)
const outerVisibility = visibilities.get(item.key) const isDeleting =
const visibility = useBackendMutationState(backend, 'deleteAsset', {
outerVisibility == null || outerVisibility === Visibility.visible ? predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
insertionVisibility }).length !== 0
: outerVisibility const isRestoring =
const hidden = hiddenRaw || visibility === Visibility.hidden useBackendMutationState(backend, 'undoDeleteAsset', {
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
}).length !== 0
const isCloud = isCloudCategory(category) const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({ const { data: projectState } = useQuery({
@ -194,14 +194,26 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const toastAndLog = useToastAndLog() 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 createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) 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 setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState() const { selectedKeys } = driveStore.getState()
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected)) setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
@ -247,20 +259,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
}, [item.item.id, updateAssetRef]) }, [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( const doDelete = React.useCallback(
(forever = false) => { (forever = false) => {
void doDeleteRaw(item.item, forever) 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) payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
const canPaste = (() => { const canPaste = (() => {
if (!isPayloadMatch) { if (!isPayloadMatch) {
return true return false
} else { } else {
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) { if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
const parentKeys = new Map( 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 } 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 parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
const parent = parentKey == null ? null : nodeMap.current.get(parentKey) const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
return !parent ? true : ( if (!parent) {
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path) 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,41 +321,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
eventListProvider.useAssetEventListener(async (event) => { eventListProvider.useAssetEventListener(async (event) => {
if (state.category.type === 'trash') {
switch (event.type) { 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
}
}
} else {
switch (event.type) {
case AssetEventType.cut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.faded)
}
break
}
case AssetEventType.cancelCut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.visible)
}
break
}
case AssetEventType.move: { case AssetEventType.move: {
if (event.ids.has(item.key)) { if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.visible)
await doMove(event.newParentKey, item.item) await doMove(event.newParentKey, item.item)
} }
break break
@ -378,11 +353,13 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
switch (asset.type) { switch (asset.type) {
case backendModule.AssetType.project: { case backendModule.AssetType.project: {
try { try {
const details = await getProjectDetailsMutation.mutateAsync([ const details = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getProjectDetails', [
asset.id, asset.id,
asset.parentId, asset.parentId,
asset.title, asset.title,
]) ]),
)
if (details.url != null) { if (details.url != null) {
await backend.download(details.url, `${asset.title}.enso-project`) await backend.download(details.url, `${asset.title}.enso-project`)
} else { } else {
@ -396,10 +373,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
case backendModule.AssetType.file: { case backendModule.AssetType.file: {
try { try {
const details = await getFileDetailsMutation.mutateAsync([ const details = await queryClient.fetchQuery(
asset.id, backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title]),
asset.title, )
])
if (details.url != null) { if (details.url != null) {
await backend.download(details.url, asset.title) await backend.download(details.url, asset.title)
} else { } else {
@ -413,7 +389,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
case backendModule.AssetType.datalink: { case backendModule.AssetType.datalink: {
try { try {
const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title]) const value = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]),
)
const fileName = `${asset.title}.datalink` const fileName = `${asset.title}.datalink`
download( download(
URL.createObjectURL( URL.createObjectURL(
@ -450,7 +428,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
case AssetEventType.removeSelf: { case AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`. // This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user.isEnabled) { if (event.id === asset.id && user.isEnabled) {
setInsertionVisibility(Visibility.hidden)
try { try {
await createPermissionMutation.mutateAsync([ await createPermissionMutation.mutateAsync([
{ {
@ -461,7 +438,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
]) ])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) { } catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog(null, error) toastAndLog(null, error)
} }
} }
@ -556,9 +532,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
} }
return oldLabels.length !== labels.length ? return oldLabels.length !== labels.length ? object.merge(oldAsset, { labels }) : oldAsset
object.merge(oldAsset, { labels })
: oldAsset
}) })
break break
} }
@ -572,7 +546,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
return return
} }
} }
}
}, item.initialAssetEvents) }, item.initialAssetEvents)
switch (asset.type) { switch (asset.type) {
@ -658,6 +631,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
<AssetContextMenu <AssetContextMenu
innerProps={innerProps} innerProps={innerProps}
rootDirectoryId={rootDirectoryId} rootDirectoryId={rootDirectoryId}
triggerRef={rootRef}
event={event} event={event}
eventTarget={ eventTarget={
event.target instanceof HTMLElement ? event.target : event.currentTarget event.target instanceof HTMLElement ? event.target : event.currentTarget
@ -742,12 +716,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const ids = payload const ids = payload
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId) .filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
.map((dragItem) => dragItem.key) .map((dragItem) => dragItem.key)
dispatchAssetEvent({ cutAndPaste(
type: AssetEventType.move, directoryKey,
newParentKey: directoryKey, directoryId,
newParentId: directoryId, { backendType: backend.type, ids: new Set(ids), category },
ids: new Set(ids), nodeMap.current,
}) )
} else if (event.dataTransfer.types.includes('Files')) { } else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@ -801,6 +775,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
setRowState, setRowState,
}} }}
rootDirectoryId={rootDirectoryId} rootDirectoryId={rootDirectoryId}
triggerRef={rootRef}
event={{ pageX: 0, pageY: 0 }} event={{ pageX: 0, pageY: 0 }}
eventTarget={null} eventTarget={null}
doCopy={doCopy} doCopy={doCopy}

View File

@ -9,18 +9,19 @@ import CtrlKeyIcon from '#/assets/ctrl_key.svg'
import OptionKeyIcon from '#/assets/option_key.svg' import OptionKeyIcon from '#/assets/option_key.svg'
import ShiftKeyIcon from '#/assets/shift_key.svg' import ShiftKeyIcon from '#/assets/shift_key.svg'
import WindowsKeyIcon from '#/assets/windows_key.svg' import WindowsKeyIcon from '#/assets/windows_key.svg'
import { Text } from '#/components/AriaComponents'
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 SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
import type { DashboardBindingKey } from '#/configurations/inputBindings'
import * as inputBindingsModule from '#/utilities/inputBindings' import { useInputBindings } from '#/providers/InputBindingsProvider'
import * as tailwindMerge from '#/utilities/tailwindMerge' import { useText } from '#/providers/TextProvider'
import {
compareModifiers,
decomposeKeybindString,
toModifierKey,
type Key,
type ModifierKey,
} from '#/utilities/inputBindings'
import { twMerge } from '#/utilities/tailwindMerge'
// ======================== // ========================
// === KeyboardShortcut === // === 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}. */ /** Props for values of {@link MODIFIER_JSX}. */
interface InternalModifierProps { interface InternalModifierProps {
readonly getText: ReturnType<typeof textProvider.useText>['getText'] readonly getText: ReturnType<typeof useText>['getText']
} }
/** Icons for modifier keys (if they exist). */ /** Icons for modifier keys (if they exist). */
const MODIFIER_JSX: Readonly< const MODIFIER_JSX: Readonly<
Record< Record<
detect.Platform, detect.Platform,
Partial< Partial<Record<ModifierKey, (props: InternalModifierProps) => React.ReactNode>>
Record<inputBindingsModule.ModifierKey, (props: InternalModifierProps) => React.ReactNode>
>
> >
> = { > = {
// The names are intentionally not in `camelCase`, as they are case-sensitive. // The names are intentionally not in `camelCase`, as they are case-sensitive.
@ -58,18 +57,18 @@ const MODIFIER_JSX: Readonly<
}, },
[detect.Platform.linux]: { [detect.Platform.linux]: {
Meta: (props) => ( Meta: (props) => (
<aria.Text key="Meta" className="text"> <Text key="Meta" className="text">
{props.getText('superModifier')} {props.getText('superModifier')}
</aria.Text> </Text>
), ),
}, },
[detect.Platform.unknown]: { [detect.Platform.unknown]: {
// Assume the system is Unix-like and calls the key that triggers `event.metaKey` // Assume the system is Unix-like and calls the key that triggers `event.metaKey`
// the "Super" key. // the "Super" key.
Meta: (props) => ( Meta: (props) => (
<aria.Text key="Meta" className="text"> <Text key="Meta" className="text">
{props.getText('superModifier')} {props.getText('superModifier')}
</aria.Text> </Text>
), ),
}, },
[detect.Platform.iPhoneOS]: {}, [detect.Platform.iPhoneOS]: {},
@ -86,9 +85,9 @@ const KEY_CHARACTER: Readonly<Record<string, string>> = {
ArrowLeft: '←', ArrowLeft: '←',
ArrowRight: '→', ArrowRight: '→',
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
} satisfies Partial<Record<inputBindingsModule.Key, string>> } satisfies Partial<Record<Key, string>>
const MODIFIER_TO_TEXT_ID: Readonly<Record<inputBindingsModule.ModifierKey, text.TextId>> = { const MODIFIER_TO_TEXT_ID: Readonly<Record<ModifierKey, text.TextId>> = {
// The names come from a third-party API and cannot be changed. // The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
Ctrl: 'ctrlModifier', Ctrl: 'ctrlModifier',
@ -96,11 +95,11 @@ const MODIFIER_TO_TEXT_ID: Readonly<Record<inputBindingsModule.ModifierKey, text
Meta: 'metaModifier', Meta: 'metaModifier',
Shift: 'shiftModifier', Shift: 'shiftModifier',
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
} satisfies { [K in inputBindingsModule.ModifierKey]: `${Lowercase<K>}Modifier` } } satisfies { [K in ModifierKey]: `${Lowercase<K>}Modifier` }
/** Props for a {@link KeyboardShortcut}, specifying the keyboard action. */ /** Props for a {@link KeyboardShortcut}, specifying the keyboard action. */
export interface KeyboardShortcutActionProps { export interface KeyboardShortcutActionProps {
readonly action: dashboardInputBindings.DashboardBindingKey readonly action: DashboardBindingKey
} }
/** Props for a {@link KeyboardShortcut}, specifying the shortcut string. */ /** 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. */ /** A visual representation of a keyboard shortcut. */
export default function KeyboardShortcut(props: KeyboardShortcutProps) { export default function KeyboardShortcut(props: KeyboardShortcutProps) {
const { getText } = textProvider.useText() const { getText } = useText()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = useInputBindings()
const shortcutString = const shortcutString =
'shortcut' in props ? props.shortcut : inputBindings.metadata[props.action].bindings[0] 'shortcut' in props ? props.shortcut : inputBindings.metadata[props.action].bindings[0]
if (shortcutString == null) { if (shortcutString == null) {
return null return null
} else { } else {
const shortcut = inputBindingsModule.decomposeKeybindString(shortcutString) const shortcut = decomposeKeybindString(shortcutString)
const modifiers = [...shortcut.modifiers] const modifiers = [...shortcut.modifiers].sort(compareModifiers).map(toModifierKey)
.sort(inputBindingsModule.compareModifiers)
.map(inputBindingsModule.toModifierKey)
return ( return (
<div <div
className={tailwindMerge.twMerge( className={twMerge(
'flex items-center', 'flex items-center',
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers', detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers',
)} )}
@ -134,14 +131,10 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
{modifiers.map( {modifiers.map(
(modifier) => (modifier) =>
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? ( MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
<ariaComponents.Text key={modifier}> <Text key={modifier}>{getText(MODIFIER_TO_TEXT_ID[modifier])}</Text>
{getText(MODIFIER_TO_TEXT_ID[modifier])}
</ariaComponents.Text>
), ),
)} )}
<ariaComponents.Text> <Text>{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}</Text>
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
</ariaComponents.Text>
</div> </div>
) )
} }

View File

@ -1,51 +1,48 @@
/** @file Column types and column display modes. */ /** @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 { Column } from '#/components/dashboard/column/columnUtils'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import DocsColumn from '#/components/dashboard/column/DocsColumn' import DocsColumn from '#/components/dashboard/column/DocsColumn'
import LabelsColumn from '#/components/dashboard/column/LabelsColumn' import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn' import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn'
import NameColumn from '#/components/dashboard/column/NameColumn' import NameColumn from '#/components/dashboard/column/NameColumn'
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn' import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn' import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable'
import type * as backendModule from '#/services/Backend' import type { Asset, AssetId, BackendType } from '#/services/Backend'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
// =================== // ===================
// === AssetColumn === // === AssetColumn ===
// =================== // ===================
/** Props for an arbitrary variant of {@link backendModule.Asset}. */ /** Props for an arbitrary variant of {@link Asset}. */
export interface AssetColumnProps { export interface AssetColumnProps {
readonly keyProp: backendModule.AssetId readonly keyProp: AssetId
readonly isOpened: boolean readonly isOpened: boolean
readonly item: assetTreeNode.AnyAssetTreeNode readonly item: AnyAssetTreeNode
readonly backendType: backendModule.BackendType readonly backendType: BackendType
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>> readonly setItem: Dispatch<SetStateAction<AnyAssetTreeNode>>
readonly selected: boolean readonly selected: boolean
readonly setSelected: (selected: boolean) => void readonly setSelected: (selected: boolean) => void
readonly isSoleSelected: boolean readonly isSoleSelected: boolean
readonly state: assetsTable.AssetsTableState readonly state: AssetsTableState
readonly rowState: assetsTable.AssetRowState readonly rowState: AssetRowState
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>> readonly setRowState: Dispatch<SetStateAction<AssetRowState>>
readonly isEditable: boolean readonly isEditable: boolean
} }
/** Props for a {@link AssetColumn}. */ /** Props for a {@link AssetColumn}. */
export interface AssetColumnHeadingProps { export interface AssetColumnHeadingProps {
readonly state: assetsTable.AssetsTableState readonly state: AssetsTableState
} }
/** Metadata describing how to render a column of the table. */ /** Metadata describing how to render a column of the table. */
export interface AssetColumn { export interface AssetColumn {
readonly id: string readonly id: string
readonly className?: string readonly className?: string
readonly heading: (props: AssetColumnHeadingProps) => React.JSX.Element readonly heading: (props: AssetColumnHeadingProps) => JSX.Element
readonly render: (props: AssetColumnProps) => React.JSX.Element readonly render: (props: AssetColumnProps) => JSX.Element
} }
// ======================= // =======================
@ -53,14 +50,12 @@ export interface AssetColumn {
// ======================= // =======================
/** React components for every column. */ /** React components for every column. */
export const COLUMN_RENDERER: Readonly< export const COLUMN_RENDERER: Readonly<Record<Column, (props: AssetColumnProps) => JSX.Element>> = {
Record<columnUtils.Column, (props: AssetColumnProps) => React.JSX.Element> [Column.name]: NameColumn,
> = { [Column.modified]: ModifiedColumn,
[columnUtils.Column.name]: NameColumn, [Column.sharedWith]: SharedWithColumn,
[columnUtils.Column.modified]: ModifiedColumn, [Column.labels]: LabelsColumn,
[columnUtils.Column.sharedWith]: SharedWithColumn, [Column.accessedByProjects]: PlaceholderColumn,
[columnUtils.Column.labels]: LabelsColumn, [Column.accessedData]: PlaceholderColumn,
[columnUtils.Column.accessedByProjects]: PlaceholderColumn, [Column.docs]: DocsColumn,
[columnUtils.Column.accessedData]: PlaceholderColumn,
[columnUtils.Column.docs]: DocsColumn,
} }

View File

@ -10,7 +10,7 @@ import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents' import { Button, DialogTrigger } from '#/components/AriaComponents'
import ContextMenu from '#/components/ContextMenu' import ContextMenu from '#/components/ContextMenu'
import ContextMenus from '#/components/ContextMenus' import ContextMenus from '#/components/ContextMenus'
import type * as column from '#/components/dashboard/column' 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 object from '#/utilities/object'
import * as permissions from '#/utilities/permissions' import * as permissions from '#/utilities/permissions'
import * as uniqueString from '#/utilities/uniqueString'
// ==================== // ====================
// === LabelsColumn === // === LabelsColumn ===
@ -43,7 +42,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const labelsByName = React.useMemo(() => { const labelsByName = React.useMemo(() => {
return new Map(labels?.map((label) => [label.value, label])) return new Map(labels?.map((label) => [label.value, label]))
}, [labels]) }, [labels])
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = permissions.tryFindSelfPermission(user, asset.permissions) const self = permissions.tryFindSelfPermission(user, asset.permissions)
const managesThisAsset = const managesThisAsset =
category.type !== 'trash' && category.type !== 'trash' &&
@ -130,23 +128,10 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
</Label> </Label>
))} ))}
{managesThisAsset && ( {managesThisAsset && (
<ariaComponents.Button <DialogTrigger>
ref={plusButtonRef} <Button variant="ghost" showIconOnHover icon={Plus2Icon} />
size="medium" <ManageLabelsModal backend={backend} item={asset} />
variant="ghost" </DialogTrigger>
showIconOnHover
icon={Plus2Icon}
onPress={() => {
setModal(
<ManageLabelsModal
key={uniqueString.uniqueString()}
backend={backend}
item={asset}
eventTarget={plusButtonRef.current}
/>,
)
}}
/>
)} )}
</div> </div>
) )

View File

@ -2,83 +2,67 @@
import * as React from 'react' import * as React from 'react'
import Plus2Icon from '#/assets/plus2.svg' import Plus2Icon from '#/assets/plus2.svg'
import { Button } from '#/components/AriaComponents'
import * as billingHooks from '#/hooks/billing' import type { AssetColumnProps } from '#/components/dashboard/column'
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 PermissionDisplay from '#/components/dashboard/PermissionDisplay' 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 ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import * as backendModule from '#/services/Backend' import { useSetModal } from '#/providers/ModalProvider'
import { getAssetPermissionId, getAssetPermissionName } from '#/services/Backend'
import * as permissions from '#/utilities/permissions' import { PermissionAction, tryFindSelfPermission } from '#/utilities/permissions'
import * as uniqueString from '#/utilities/uniqueString'
// ======================== // ========================
// === SharedWithColumn === // === SharedWithColumn ===
// ======================== // ========================
/** The type of the `state` prop of a {@link SharedWithColumn}. */ /** The type of the `state` prop of a {@link SharedWithColumn}. */
interface SharedWithColumnStateProp extends Pick<column.AssetColumnProps['state'], 'category'> { interface SharedWithColumnStateProp
readonly setQuery: column.AssetColumnProps['state']['setQuery'] | null extends Pick<AssetColumnProps['state'], 'backend' | 'category'> {
readonly setQuery: AssetColumnProps['state']['setQuery'] | null
} }
/** Props for a {@link SharedWithColumn}. */ /** Props for a {@link SharedWithColumn}. */
interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'item' | 'setItem'> { interface SharedWithColumnPropsInternal extends Pick<AssetColumnProps, 'item'> {
readonly isReadonly?: boolean readonly isReadonly?: boolean
readonly state: SharedWithColumnStateProp readonly state: SharedWithColumnStateProp
} }
/** A column listing the users with which this asset is shared. */ /** A column listing the users with which this asset is shared. */
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) { export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, setItem, state, isReadonly = false } = props const { item, state, isReadonly = false } = props
const { category, setQuery } = state const { backend, category, setQuery } = state
const asset = item.item const asset = useAssetPassiveListenerStrict(
const { user } = authProvider.useFullUserSession() backend.type,
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() item.item.id,
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) item.item.parentId,
category,
)
const { user } = useFullUserSession()
const dispatchAssetEvent = useDispatchAssetEvent()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share') const isUnderPaywall = isFeatureUnderPaywall('share')
const assetPermissions = asset.permissions ?? [] const assetPermissions = asset.permissions ?? []
const { setModal } = modalProvider.useSetModal() const { setModal } = useSetModal()
const self = permissions.tryFindSelfPermission(user, asset.permissions) const self = tryFindSelfPermission(user, asset.permissions)
const plusButtonRef = React.useRef<HTMLButtonElement>(null) const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const managesThisAsset = const managesThisAsset =
!isReadonly && !isReadonly &&
category.type !== 'trash' && category.type !== 'trash' &&
(self?.permission === permissions.PermissionAction.own || (self?.permission === PermissionAction.own || self?.permission === PermissionAction.admin)
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem((oldItem) =>
oldItem.with({
item:
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
}),
)
},
[setItem],
)
return ( return (
<div className="group flex items-center gap-column-items"> <div className="group flex items-center gap-column-items">
{(category.type === 'trash' ? {(category.type === 'trash' ?
assetPermissions.filter( assetPermissions.filter((permission) => permission.permission === PermissionAction.own)
(permission) => permission.permission === permissions.PermissionAction.own,
)
: assetPermissions : assetPermissions
).map((other, idx) => ( ).map((other, idx) => (
<PermissionDisplay <PermissionDisplay
key={backendModule.getAssetPermissionId(other) + idx} key={getAssetPermissionId(other) + idx}
action={other.permission} action={other.permission}
onPress={ onPress={
setQuery == null ? null : ( setQuery == null ? null : (
@ -87,7 +71,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
oldQuery.withToggled( oldQuery.withToggled(
'owners', 'owners',
'negativeOwners', 'negativeOwners',
backendModule.getAssetPermissionName(other), getAssetPermissionName(other),
event.shiftKey, event.shiftKey,
), ),
) )
@ -95,11 +79,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
) )
} }
> >
{backendModule.getAssetPermissionName(other)} {getAssetPermissionName(other)}
</PermissionDisplay> </PermissionDisplay>
))} ))}
{isUnderPaywall && ( {isUnderPaywall && (
<paywall.PaywallDialogButton <PaywallDialogButton
feature="share" feature="share"
variant="icon" variant="icon"
size="medium" size="medium"
@ -108,7 +92,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
/> />
)} )}
{managesThisAsset && !isUnderPaywall && ( {managesThisAsset && !isUnderPaywall && (
<ariaComponents.Button <Button
ref={plusButtonRef} ref={plusButtonRef}
size="medium" size="medium"
variant="ghost" variant="ghost"
@ -117,9 +101,9 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
onPress={() => { onPress={() => {
setModal( setModal(
<ManagePermissionsModal <ManagePermissionsModal
key={uniqueString.uniqueString()} backend={backend}
category={category}
item={asset} item={asset}
setItem={setAsset}
self={self} self={self}
eventTarget={plusButtonRef.current} eventTarget={plusButtonRef.current}
doRemoveSelf={() => { doRemoveSelf={() => {

View File

@ -7,12 +7,10 @@ enum AssetListEventType {
uploadFiles = 'upload-files', uploadFiles = 'upload-files',
newDatalink = 'new-datalink', newDatalink = 'new-datalink',
newSecret = 'new-secret', newSecret = 'new-secret',
insertAssets = 'insert-assets',
duplicateProject = 'duplicate-project', duplicateProject = 'duplicate-project',
closeFolder = 'close-folder', closeFolder = 'close-folder',
copy = 'copy', copy = 'copy',
move = 'move', move = 'move',
willDelete = 'will-delete',
delete = 'delete', delete = 'delete',
emptyTrash = 'empty-trash', emptyTrash = 'empty-trash',
removeSelf = 'remove-self', removeSelf = 'remove-self',

View File

@ -1,7 +1,13 @@
/** @file Events related to changes in the asset list. */ /** @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 * as backend from '#/services/Backend'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
import { isTeamPath, isUserPath } from '#/utilities/permissions'
// ====================== // ======================
// === AssetListEvent === // === AssetListEvent ===
@ -19,12 +25,10 @@ interface AssetListEvents {
readonly uploadFiles: AssetListUploadFilesEvent readonly uploadFiles: AssetListUploadFilesEvent
readonly newSecret: AssetListNewSecretEvent readonly newSecret: AssetListNewSecretEvent
readonly newDatalink: AssetListNewDatalinkEvent readonly newDatalink: AssetListNewDatalinkEvent
readonly insertAssets: AssetListInsertAssetsEvent
readonly duplicateProject: AssetListDuplicateProjectEvent readonly duplicateProject: AssetListDuplicateProjectEvent
readonly closeFolder: AssetListCloseFolderEvent readonly closeFolder: AssetListCloseFolderEvent
readonly copy: AssetListCopyEvent readonly copy: AssetListCopyEvent
readonly move: AssetListMoveEvent readonly move: AssetListMoveEvent
readonly willDelete: AssetListWillDeleteEvent
readonly delete: AssetListDeleteEvent readonly delete: AssetListDeleteEvent
readonly emptyTrash: AssetListEmptyTrashEvent readonly emptyTrash: AssetListEmptyTrashEvent
readonly removeSelf: AssetListRemoveSelfEvent readonly removeSelf: AssetListRemoveSelfEvent
@ -81,13 +85,6 @@ interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.
readonly value: string readonly value: string
} }
/** A signal to insert new assets. The assets themselves need to be created by the caller. */
interface AssetListInsertAssetsEvent extends AssetListBaseEvent<AssetListEventType.insertAssets> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly assets: backend.AnyAsset[]
}
/** A signal to duplicate a project. */ /** A signal to duplicate a project. */
interface AssetListDuplicateProjectEvent interface AssetListDuplicateProjectEvent
extends AssetListBaseEvent<AssetListEventType.duplicateProject> { extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
@ -118,11 +115,6 @@ interface AssetListMoveEvent extends AssetListBaseEvent<AssetListEventType.move>
readonly items: backend.AnyAsset[] readonly items: backend.AnyAsset[]
} }
/** A signal that a file has been deleted. */
interface AssetListWillDeleteEvent extends AssetListBaseEvent<AssetListEventType.willDelete> {
readonly key: backend.AssetId
}
/** /**
* A signal that a file has been deleted. This must not be called before the request is * A signal that a file has been deleted. This must not be called before the request is
* finished. * finished.
@ -141,3 +133,53 @@ interface AssetListRemoveSelfEvent extends AssetListBaseEvent<AssetListEventType
/** Every possible type of asset list event. */ /** Every possible type of asset list event. */
export type AssetListEvent = AssetListEvents[keyof AssetListEvents] export type AssetListEvent = AssetListEvents[keyof AssetListEvents]
/**
* A hook to copy or move assets as appropriate. Assets are moved, except when performing
* a cut and paste between the Team Space and the User Space, in which case the asset is copied.
*/
export function useCutAndPaste(category: Category) {
const transferBetweenCategories = useTransferBetweenCategories(category)
const dispatchAssetListEvent = useDispatchAssetListEvent()
return useEventCallback(
(
newParentKey: backend.DirectoryId,
newParentId: backend.DirectoryId,
pasteData: DrivePastePayload,
nodeMap: ReadonlyMap<backend.AssetId, AnyAssetTreeNode>,
) => {
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,
)
}
},
)
}

View File

@ -1,12 +1,37 @@
/** @file Hooks for interacting with the backend. */ /** @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 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 === // === DefineBackendMethods ===
@ -44,10 +69,6 @@ export type MutationMethod = DefineBackendMethods<
| 'deleteUser' | 'deleteUser'
| 'deleteUserGroup' | 'deleteUserGroup'
| 'duplicateProject' | 'duplicateProject'
// TODO: `get*` are not mutations, but are currently used in some places.
| 'getDatalink'
| 'getFileDetails'
| 'getProjectDetails'
| 'inviteUser' | 'inviteUser'
| 'logEvent' | 'logEvent'
| 'openProject' | 'openProject'
@ -71,55 +92,75 @@ export type MutationMethod = DefineBackendMethods<
// === useBackendQuery === // === useBackendQuery ===
// ======================= // =======================
export function useBackendQuery<Method extends backendQuery.BackendMethods>( export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend, backend: Backend,
method: Method, method: Method,
args: Parameters<Backend[Method]>, args: Parameters<Backend[Method]>,
options?: Omit< options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
'queryFn' | 'queryKey' ): UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>
> & export function backendQueryOptions<Method extends BackendMethods>(
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
backend: Backend | null, backend: Backend | null,
method: Method, method: Method,
args: Parameters<Backend[Method]>, args: Parameters<Backend[Method]>,
options?: Omit< options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
'queryFn' | 'queryKey' ): UseQueryOptions<
> &
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): reactQuery.UseQueryResult<
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Backend[Method]>> | undefined Awaited<ReturnType<Backend[Method]>> | undefined
> >
/** Wrap a backend method call in a React Query. */ /** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends backendQuery.BackendMethods>( export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null, backend: Backend | null,
method: Method, method: Method,
args: Parameters<Backend[Method]>, args: Parameters<Backend[Method]>,
options?: Omit< options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
'queryFn' | 'queryKey'
> &
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) { ) {
return reactQuery.useQuery<Awaited<ReturnType<Backend[Method]>>>({ // @ts-expect-error This call is generic over the presence or absence of `inputData`.
return queryOptions<Awaited<ReturnType<Backend[Method]>>>({
...options, ...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 // 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), queryFn: () => (backend?.[method] as any)?.(...args),
}) })
} }
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<
// eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Backend[Method]>> | undefined
>
/** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
return useQuery(backendQueryOptions(backend, method, args, options))
}
// ========================== // ==========================
// === useBackendMutation === // === useBackendMutation ===
// ========================== // ==========================
const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries') const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
const INVALIDATION_MAP: Partial< const INVALIDATION_MAP: Partial<
Record<MutationMethod, readonly (backendQuery.BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]> Record<MutationMethod, readonly (BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
> = { > = {
createUser: ['usersMe'], createUser: ['usersMe'],
updateUser: ['usersMe'], updateUser: ['usersMe'],
@ -141,7 +182,7 @@ const INVALIDATION_MAP: Partial<
createDirectory: ['listDirectory'], createDirectory: ['listDirectory'],
createSecret: ['listDirectory'], createSecret: ['listDirectory'],
updateSecret: ['listDirectory'], updateSecret: ['listDirectory'],
createDatalink: ['listDirectory'], createDatalink: ['listDirectory', 'getDatalink'],
uploadFile: ['listDirectory'], uploadFile: ['listDirectory'],
copyAsset: ['listDirectory', 'listAssetVersions'], copyAsset: ['listDirectory', 'listAssetVersions'],
deleteAsset: ['listDirectory', 'listAssetVersions'], deleteAsset: ['listDirectory', 'listAssetVersions'],
@ -151,34 +192,29 @@ const INVALIDATION_MAP: Partial<
updateDirectory: ['listDirectory'], updateDirectory: ['listDirectory'],
} }
export function backendMutationOptions<Method extends MutationMethod>( /** The type of the corresponding mutation for the given backend method. */
backend: Backend, export type BackendMutation<Method extends MutationMethod> = Mutation<
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
'mutationFn'
>,
): reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>, Awaited<ReturnType<Backend[Method]>>,
Error, Error,
Parameters<Backend[Method]> Parameters<Backend[Method]>
> >
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>
export function backendMutationOptions<Method extends MutationMethod>( export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null, backend: Backend | null,
method: Method, method: Method,
options?: Omit< options?: Omit<
reactQuery.UseMutationOptions< UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
'mutationFn' 'mutationFn'
>, >,
): reactQuery.UseMutationOptions< ): UseMutationOptions<
Awaited<ReturnType<Backend[Method]>> | undefined, Awaited<ReturnType<Backend[Method]>> | undefined,
Error, Error,
Parameters<Backend[Method]> Parameters<Backend[Method]>
@ -188,24 +224,16 @@ export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null, backend: Backend | null,
method: Method, method: Method,
options?: Omit< options?: Omit<
reactQuery.UseMutationOptions< UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
'mutationFn' 'mutationFn'
>, >,
): reactQuery.UseMutationOptions< ): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>> {
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
> {
return { return {
...options, ...options,
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])], 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 // 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), mutationFn: (args) => (backend?.[method] as any)?.(...args),
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online', networkMode: backend?.type === BackendType.local ? 'always' : 'online',
meta: { meta: {
invalidates: [ invalidates: [
...(options?.meta?.invalidates ?? []), ...(options?.meta?.invalidates ?? []),
@ -223,8 +251,8 @@ export function backendMutationOptions<Method extends MutationMethod>(
// ================================== // ==================================
/** A user group, as well as the users that are a part of the user group. */ /** A user group, as well as the users that are a part of the user group. */
export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo { export interface UserGroupInfoWithUsers extends UserGroupInfo {
readonly users: readonly backendModule.User[] readonly users: readonly User[]
} }
/** A list of user groups, taking into account optimistic state. */ /** A list of user groups, taking into account optimistic state. */
@ -233,12 +261,12 @@ export function useListUserGroupsWithUsers(
): readonly UserGroupInfoWithUsers[] | null { ): readonly UserGroupInfoWithUsers[] | null {
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', []) const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
const listUsersQuery = useBackendQuery(backend, 'listUsers', []) const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
return React.useMemo(() => { return useMemo(() => {
if (listUserGroupsQuery.data == null || listUsersQuery.data == null) { if (listUserGroupsQuery.data == null || listUsersQuery.data == null) {
return null return null
} else { } else {
const result = listUserGroupsQuery.data.map((userGroup) => { 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), user.userGroups?.includes(userGroup.id),
) )
return { ...userGroup, users: usersInGroup } return { ...userGroup, users: usersInGroup }
@ -247,3 +275,96 @@ export function useListUserGroupsWithUsers(
} }
}, [listUserGroupsQuery.data, listUsersQuery.data]) }, [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<readonly AnyAsset<AssetType>[] | 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<Method extends MutationMethod, Result>(
backend: Backend,
method: Method,
options: {
mutationKey?: MutationKey
predicate?: (mutation: BackendMutation<Method>) => boolean
select?: (mutation: BackendMutation<Method>) => Result
} = {},
) {
const { mutationKey, predicate, select } = options
return useMutationState({
filters: {
...backendMutationOptions(backend, method, mutationKey ? { mutationKey } : {}),
predicate: (mutation: BackendMutation<Method>) =>
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<unknown, Error, unknown, unknown>) => Result,
})
}

View File

@ -62,26 +62,21 @@ function useSetProjectAsset() {
parentId: backendModule.DirectoryId, parentId: backendModule.DirectoryId,
transform: (asset: backendModule.ProjectAsset) => backendModule.ProjectAsset, transform: (asset: backendModule.ProjectAsset) => backendModule.ProjectAsset,
) => { ) => {
const listDirectoryQuery = queryClient.getQueryCache().find< const listDirectoryQuery = queryClient
| { .getQueryCache()
parentId: backendModule.DirectoryId .find<readonly backendModule.AnyAsset<backendModule.AssetType>[] | undefined>({
children: readonly backendModule.AnyAsset<backendModule.AssetType>[]
}
| undefined
>({
queryKey: [backendType, 'listDirectory', parentId], queryKey: [backendType, 'listDirectory', parentId],
exact: false, exact: false,
}) })
if (listDirectoryQuery?.state.data) { if (listDirectoryQuery?.state.data) {
listDirectoryQuery.setData({ listDirectoryQuery.setData(
...listDirectoryQuery.state.data, listDirectoryQuery.state.data.map((child) =>
children: listDirectoryQuery.state.data.children.map((child) =>
child.id === assetId && child.type === backendModule.AssetType.project ? child.id === assetId && child.type === backendModule.AssetType.project ?
transform(child) transform(child)
: child, : child,
), ),
}) )
} }
}, },
) )

View File

@ -7,7 +7,6 @@ import * as toast from 'react-toastify'
import * as billingHooks from '#/hooks/billing' import * as billingHooks from '#/hooks/billing'
import * as copyHooks from '#/hooks/copyHooks' import * as copyHooks from '#/hooks/copyHooks'
import * as projectHooks from '#/hooks/projectHooks' import * as projectHooks from '#/hooks/projectHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
@ -56,6 +55,7 @@ export interface AssetContextMenuProps {
readonly hidden?: boolean readonly hidden?: boolean
readonly innerProps: assetRow.AssetRowInnerProps readonly innerProps: assetRow.AssetRowInnerProps
readonly rootDirectoryId: backendModule.DirectoryId readonly rootDirectoryId: backendModule.DirectoryId
readonly triggerRef: React.MutableRefObject<HTMLElement | null>
readonly event: Pick<React.MouseEvent, 'pageX' | 'pageY'> readonly event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
readonly eventTarget: HTMLElement | null readonly eventTarget: HTMLElement | null
readonly doDelete: () => void readonly doDelete: () => void
@ -69,7 +69,7 @@ export interface AssetContextMenuProps {
/** The context menu for an arbitrary {@link backendModule.Asset}. */ /** The context menu for an arbitrary {@link backendModule.Asset}. */
export default function AssetContextMenu(props: AssetContextMenuProps) { 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 { doCopy, doCut, doPaste, doDelete } = props
const { item, setItem, state, setRowState } = innerProps const { item, setItem, state, setRowState } = innerProps
const { backend, category, nodeMap } = state const { backend, category, nodeMap } = state
@ -131,12 +131,21 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const canPaste = const canPaste =
!pasteData || !pasteDataParentKeys || !isCloud ? !pasteData || !pasteDataParentKeys || !isCloud ?
true true
: !Array.from(pasteData.data.ids).some((assetId) => { : Array.from(pasteData.data.ids).every((key) => {
const parentKey = pasteDataParentKeys.get(assetId) const parentKey = pasteDataParentKeys.get(key)
const parent = parentKey == null ? null : nodeMap.current.get(parentKey) const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
return !parent ? true : ( if (!parent) {
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path) 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( const { data } = reactQuery.useQuery(
@ -161,8 +170,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
asset.projectState.openedBy != null && asset.projectState.openedBy != null &&
asset.projectState.openedBy !== user.email asset.projectState.openedBy !== user.email
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const pasteMenuEntry = hasPasteData && canPaste && ( const pasteMenuEntry = hasPasteData && canPaste && (
<ContextMenuEntry <ContextMenuEntry
hidden={hidden} hidden={hidden}
@ -358,7 +365,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
label={getText('editDescriptionShortcut')} label={getText('editDescriptionShortcut')}
doAction={() => { doAction={() => {
setIsAssetPanelTemporarilyVisible(true) setIsAssetPanelTemporarilyVisible(true)
setAssetPanelProps({ backend, item, setItem, spotlightOn: 'description' }) setAssetPanelProps({ backend, item, spotlightOn: 'description' })
}} }}
/> />
)} )}
@ -417,8 +424,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
doAction={() => { doAction={() => {
setModal( setModal(
<ManagePermissionsModal <ManagePermissionsModal
backend={backend}
category={category}
item={asset} item={asset}
setItem={setAsset}
self={self} self={self}
eventTarget={eventTarget} eventTarget={eventTarget}
doRemoveSelf={() => { doRemoveSelf={() => {
@ -441,7 +449,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
action="label" action="label"
doAction={() => { doAction={() => {
setModal( setModal(
<ManageLabelsModal backend={backend} item={asset} eventTarget={eventTarget} />, <ManageLabelsModal backend={backend} item={asset} triggerRef={triggerRef} />,
) )
}} }}
/> />

View File

@ -1,5 +1,5 @@
/** @file A panel containing the description and settings for an asset. */ /** @file A panel containing the description and settings for an asset. */
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import * as z from 'zod' import * as z from 'zod'
@ -53,7 +53,6 @@ LocalStorage.register({
export interface AssetPanelContextProps { export interface AssetPanelContextProps {
readonly backend: Backend | null readonly backend: Backend | null
readonly item: AnyAssetTreeNode | null readonly item: AnyAssetTreeNode | null
readonly setItem: Dispatch<SetStateAction<AnyAssetTreeNode>> | null
readonly spotlightOn?: AssetPropertiesSpotlight readonly spotlightOn?: AssetPropertiesSpotlight
} }
@ -68,7 +67,7 @@ export default function AssetPanel(props: AssetPanelProps) {
const { backendType, category } = props const { backendType, category } = props
const contextPropsRaw = useAssetPanelProps() const contextPropsRaw = useAssetPanelProps()
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
const { backend, item, setItem } = contextProps ?? {} const { backend, item } = contextProps ?? {}
const isReadonly = category.type === 'trash' const isReadonly = category.type === 'trash'
const isCloud = backend?.type === BackendType.remote const isCloud = backend?.type === BackendType.remote
const isVisible = useIsAssetPanelVisible() const isVisible = useIsAssetPanelVisible()
@ -83,11 +82,11 @@ export default function AssetPanel(props: AssetPanelProps) {
if (!isCloud) { if (!isCloud) {
return 'settings' return 'settings'
} else if ( } else if (
(item?.item.type === AssetType.secret || item?.item.type === AssetType.directory) && (item?.type === AssetType.secret || item?.type === AssetType.directory) &&
tabRaw === 'versions' tabRaw === 'versions'
) { ) {
return 'settings' return 'settings'
} else if (item?.item.type !== AssetType.project && tabRaw === 'sessions') { } else if (item?.type !== AssetType.project && tabRaw === 'sessions') {
return 'settings' return 'settings'
} else { } else {
return tabRaw return tabRaw
@ -131,7 +130,7 @@ export default function AssetPanel(props: AssetPanelProps) {
} }
}} }}
> >
{item == null || setItem == null || backend == null ? {item == null || backend == null ?
<div className="grid grow place-items-center text-lg"> <div className="grid grow place-items-center text-lg">
{getText('selectExactlyOneAssetToViewItsDetails')} {getText('selectExactlyOneAssetToViewItsDetails')}
</div> </div>
@ -141,14 +140,12 @@ export default function AssetPanel(props: AssetPanelProps) {
<Tab id="settings" labelId="settings" isActive={tab === 'settings'} icon={null}> <Tab id="settings" labelId="settings" isActive={tab === 'settings'} icon={null}>
{getText('settings')} {getText('settings')}
</Tab> </Tab>
{isCloud && {isCloud && item.type !== AssetType.secret && item.type !== AssetType.directory && (
item.item.type !== AssetType.secret &&
item.item.type !== AssetType.directory && (
<Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}> <Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}>
{getText('versions')} {getText('versions')}
</Tab> </Tab>
)} )}
{isCloud && item.item.type === AssetType.project && ( {isCloud && item.type === AssetType.project && (
<Tab <Tab
id="sessions" id="sessions"
labelId="projectSessions" labelId="projectSessions"
@ -165,7 +162,6 @@ export default function AssetPanel(props: AssetPanelProps) {
backend={backend} backend={backend}
isReadonly={isReadonly} isReadonly={isReadonly}
item={item} item={item}
setItem={setItem}
category={category} category={category}
spotlightOn={contextProps?.spotlightOn} spotlightOn={contextProps?.spotlightOn}
/> />

View File

@ -4,50 +4,62 @@ import * as React from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import PenIcon from '#/assets/pen.svg' import PenIcon from '#/assets/pen.svg'
import { Heading } from '#/components/aria'
import * as datalinkValidator from '#/data/datalinkValidator' import {
Button,
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' ButtonGroup,
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' CopyButton,
Form,
import * as authProvider from '#/providers/AuthProvider' ResizableContentEditableInput,
import * as backendProvider from '#/providers/BackendProvider' Text,
import * as textProvider from '#/providers/TextProvider' } from '#/components/AriaComponents'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn' import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import { DatalinkFormInput } from '#/components/dashboard/DatalinkInput' import { DatalinkFormInput } from '#/components/dashboard/DatalinkInput'
import Label from '#/components/dashboard/Label' import Label from '#/components/dashboard/Label'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import { validateDatalink } from '#/data/datalinkValidator'
import type Backend from '#/services/Backend' import {
import * as backendModule from '#/services/Backend' backendMutationOptions,
import * as localBackendModule from '#/services/LocalBackend' useAssetPassiveListenerStrict,
useBackendQuery,
} from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSpotlight } from '#/hooks/spotlightHooks' import { useSpotlight } from '#/hooks/spotlightHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category' import type { Category } from '#/layouts/CategorySwitcher/Category'
import UpsertSecretModal from '#/modals/UpsertSecretModal' import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useDriveStore, useSetAssetPanelProps } from '#/providers/DriveProvider' import { useDriveStore, useSetAssetPanelProps } from '#/providers/DriveProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode' import { useFeatureFlags } from '#/providers/FeatureFlagsProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import { AssetType, BackendType, Plan, type DatalinkId } from '#/services/Backend'
import { extractTypeAndId } from '#/services/LocalBackend'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
import { normalizePath } from '#/utilities/fileInfo' import { normalizePath } from '#/utilities/fileInfo'
import { mapNonNullish } from '#/utilities/nullable' import { mapNonNullish } from '#/utilities/nullable'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions' import * as permissions from '#/utilities/permissions'
import { tv } from '#/utilities/tailwindVariants'
// ======================= // =======================
// === AssetProperties === // === AssetProperties ===
// ======================= // =======================
const ASSET_PROPERTIES_VARIANTS = tv({
base: '',
slots: {
section: 'pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default',
},
})
/** Possible elements in this screen to spotlight on. */ /** Possible elements in this screen to spotlight on. */
export type AssetPropertiesSpotlight = 'datalink' | 'description' | 'secret' export type AssetPropertiesSpotlight = 'datalink' | 'description' | 'secret'
/** Props for an {@link AssetPropertiesProps}. */ /** Props for an {@link AssetPropertiesProps}. */
export interface AssetPropertiesProps { export interface AssetPropertiesProps {
readonly backend: Backend readonly backend: Backend
readonly item: assetTreeNode.AnyAssetTreeNode readonly item: AnyAssetTreeNode
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
readonly category: Category readonly category: Category
readonly isReadonly?: boolean readonly isReadonly?: boolean
readonly spotlightOn: AssetPropertiesSpotlight | undefined readonly spotlightOn: AssetPropertiesSpotlight | undefined
@ -55,9 +67,15 @@ export interface AssetPropertiesProps {
/** Display and modify the properties of an asset. */ /** Display and modify the properties of an asset. */
export default function AssetProperties(props: AssetPropertiesProps) { export default function AssetProperties(props: AssetPropertiesProps) {
const { backend, item, setItem, category, spotlightOn } = props const { backend, item, category, spotlightOn, isReadonly = false } = props
const { isReadonly = false } = props const styles = ASSET_PROPERTIES_VARIANTS({})
const asset = useAssetPassiveListenerStrict(
backend.type,
item.item.id,
item.item.parentId,
category,
)
const setAssetPanelProps = useSetAssetPanelProps() const setAssetPanelProps = useSetAssetPanelProps()
const closeSpotlight = useEventCallback(() => { const closeSpotlight = useEventCallback(() => {
const assetPanelProps = driveStore.getState().assetPanelProps const assetPanelProps = driveStore.getState().assetPanelProps
@ -67,10 +85,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
setAssetPanelProps(rest) setAssetPanelProps(rest)
} }
}) })
const { user } = authProvider.useFullUserSession() const { user } = useFullUserSession()
const { getText } = textProvider.useText() const isEnterprise = user.plan === Plan.enterprise
const toastAndLog = toastAndLogHooks.useToastAndLog() const { getText } = useText()
const localBackend = backendProvider.useLocalBackend() const localBackend = useLocalBackend()
const [isEditingDescriptionRaw, setIsEditingDescriptionRaw] = React.useState(false) const [isEditingDescriptionRaw, setIsEditingDescriptionRaw] = React.useState(false)
const isEditingDescription = isEditingDescriptionRaw || spotlightOn === 'description' const isEditingDescription = isEditingDescriptionRaw || spotlightOn === 'description'
const setIsEditingDescription = React.useCallback( const setIsEditingDescription = React.useCallback(
@ -87,10 +105,19 @@ export default function AssetProperties(props: AssetPropertiesProps) {
}, },
[closeSpotlight], [closeSpotlight],
) )
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null) const featureFlags = useFeatureFlags()
const [description, setDescription] = React.useState('') const datalinkQuery = useBackendQuery(
const [datalinkValue, setDatalinkValue] = React.useState<NonNullable<unknown> | null>(null) backend,
const [isDatalinkFetched, setIsDatalinkFetched] = React.useState(false) 'getDatalink',
// eslint-disable-next-line no-restricted-syntax
[asset.id as DatalinkId, asset.title],
{
enabled: asset.type === AssetType.datalink,
...(featureFlags.enableAssetsTableBackgroundRefresh ?
{ refetchInterval: featureFlags.assetsTableBackgroundRefreshInterval }
: {}),
},
)
const driveStore = useDriveStore() const driveStore = useDriveStore()
const descriptionRef = React.useRef<HTMLDivElement>(null) const descriptionRef = React.useRef<HTMLDivElement>(null)
const descriptionSpotlight = useSpotlight({ const descriptionSpotlight = useSpotlight({
@ -112,187 +139,167 @@ export default function AssetProperties(props: AssetPropertiesProps) {
}) })
const labels = useBackendQuery(backend, 'listTags', []).data ?? [] const labels = useBackendQuery(backend, 'listTags', []).data ?? []
const self = permissions.tryFindSelfPermission(user, item.item.permissions) const self = permissions.tryFindSelfPermission(user, asset.permissions)
const ownsThisAsset = self?.permission === permissions.PermissionAction.own const ownsThisAsset = self?.permission === permissions.PermissionAction.own
const canEditThisAsset = const canEditThisAsset =
ownsThisAsset || ownsThisAsset ||
self?.permission === permissions.PermissionAction.admin || self?.permission === permissions.PermissionAction.admin ||
self?.permission === permissions.PermissionAction.edit self?.permission === permissions.PermissionAction.edit
const isSecret = item.type === backendModule.AssetType.secret const isSecret = asset.type === AssetType.secret
const isDatalink = item.type === backendModule.AssetType.datalink const isDatalink = asset.type === AssetType.datalink
const isCloud = backend.type === backendModule.BackendType.remote const isCloud = backend.type === BackendType.remote
const pathRaw = const pathRaw =
category.type === 'recent' || category.type === 'trash' ? null category.type === 'recent' || category.type === 'trash' ? null
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}` : isCloud ? `${item.path}${item.type === AssetType.datalink ? '.datalink' : ''}`
: item.item.type === backendModule.AssetType.project ? : asset.type === AssetType.project ?
mapNonNullish(localBackend?.getProjectPath(item.item.id) ?? null, normalizePath) mapNonNullish(localBackend?.getProjectPath(asset.id) ?? null, normalizePath)
: normalizePath(localBackendModule.extractTypeAndId(item.item.id).id) : normalizePath(extractTypeAndId(asset.id).id)
const path = const path =
pathRaw == null ? null pathRaw == null ? null
: isCloud ? encodeURI(pathRaw) : isCloud ? encodeURI(pathRaw)
: pathRaw : pathRaw
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink')) const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink')) const editDescriptionMutation = useMutation(
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset')) // Provide an extra `mutationKey` so that it has its own loading state.
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret')) backendMutationOptions(backend, 'updateAsset', { mutationKey: ['editDescription'] }),
const getDatalink = getDatalinkMutation.mutateAsync
React.useEffect(() => {
setDescription(item.item.description ?? '')
}, [item.item.description])
React.useEffect(() => {
void (async () => {
if (item.item.type === backendModule.AssetType.datalink) {
const value = await getDatalink([item.item.id, item.item.title])
setDatalinkValue(value)
setIsDatalinkFetched(true)
}
})()
}, [backend, item.item, getDatalink])
const doEditDescription = async () => {
setIsEditingDescription(false)
if (description !== item.item.description) {
const oldDescription = item.item.description
setItem((oldItem) => oldItem.with({ item: object.merge(oldItem.item, { description }) }))
try {
await updateAssetMutation.mutateAsync([
item.item.id,
{ parentDirectoryId: null, description },
item.item.title,
])
} catch {
toastAndLog('editDescriptionError')
setItem((oldItem) =>
oldItem.with({ item: object.merge(oldItem.item, { description: oldDescription }) }),
) )
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const displayedDescription =
editDescriptionMutation.variables?.[1].description ?? asset.description
const editDescriptionForm = Form.useForm({
schema: (z) => z.object({ description: z.string() }),
defaultValues: { description: asset.description ?? '' },
onSubmit: async ({ description }) => {
if (description !== asset.description) {
await editDescriptionMutation.mutateAsync([
asset.id,
{ parentDirectoryId: null, description },
asset.title,
])
} }
} setIsEditingDescription(false)
} },
})
const editDatalinkForm = Form.useForm({
schema: (z) => z.object({ datalink: z.custom((x) => validateDatalink(x)) }),
defaultValues: { datalink: datalinkQuery.data },
onSubmit: async ({ datalink }) => {
await createDatalinkMutation.mutateAsync([
{
// The UI to submit this form is only visible if the asset is a datalink.
// eslint-disable-next-line no-restricted-syntax
datalinkId: asset.id as DatalinkId,
name: asset.title,
parentDirectoryId: null,
value: datalink,
},
])
},
})
const editDatalinkFormRef = useSyncRef(editDatalinkForm)
React.useEffect(() => {
editDatalinkFormRef.current.setValue('datalink', datalinkQuery.data)
}, [datalinkQuery.data, editDatalinkFormRef])
return ( return (
<> <>
{descriptionSpotlight.spotlightElement} {descriptionSpotlight.spotlightElement}
{secretSpotlight.spotlightElement} {secretSpotlight.spotlightElement}
{datalinkSpotlight.spotlightElement} {datalinkSpotlight.spotlightElement}
<div <div ref={descriptionRef} className={styles.section()} {...descriptionSpotlight.props}>
ref={descriptionRef} <Heading
className="pointer-events-auto flex flex-col items-start gap-side-panel rounded-default"
{...descriptionSpotlight.props}
>
<aria.Heading
level={2} level={2}
className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug" className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug"
> >
{getText('description')} {getText('description')}
{!isReadonly && ownsThisAsset && !isEditingDescription && ( {!isReadonly && ownsThisAsset && !isEditingDescription && (
<ariaComponents.Button <Button
size="medium" size="medium"
variant="icon" variant="icon"
icon={PenIcon} icon={PenIcon}
loading={editDescriptionMutation.isPending}
onPress={() => { onPress={() => {
setIsEditingDescription(true) setIsEditingDescription(true)
setQueuedDescripion(item.item.description)
}} }}
/> />
)} )}
</aria.Heading> </Heading>
<div <div
data-testid="asset-panel-description" data-testid="asset-panel-description"
className="self-stretch py-side-panel-description-y" className="self-stretch py-side-panel-description-y"
> >
{!isEditingDescription ? {!isEditingDescription ?
<aria.Text className="text">{item.item.description}</aria.Text> <Text>{displayedDescription}</Text>
: <form className="flex flex-col gap-modal pr-4" onSubmit={doEditDescription}> : <Form form={editDescriptionForm} className="flex flex-col gap-modal pr-4">
<textarea <ResizableContentEditableInput
ref={(element) => {
if (element != null && queuedDescription != null) {
element.value = queuedDescription
setQueuedDescripion(null)
}
}}
autoFocus autoFocus
value={description} form={editDescriptionForm}
className="w-full resize-none rounded-default border-0.5 border-primary/20 p-2 outline-2 outline-offset-2 transition-[border-color,outline] focus-within:outline focus-within:outline-offset-0" name="description"
onChange={(event) => { mode="onBlur"
setDescription(event.currentTarget.value)
}}
onKeyDown={(event) => {
event.stopPropagation()
switch (event.key) {
case 'Escape': {
setIsEditingDescription(false)
break
}
case 'Enter': {
if (event.ctrlKey) {
void doEditDescription()
break
}
}
}
}}
/> />
<ariaComponents.ButtonGroup> <ButtonGroup>
<ariaComponents.Button size="medium" variant="outline" onPress={doEditDescription}> <Form.Submit>{getText('update')}</Form.Submit>
{getText('update')} </ButtonGroup>
</ariaComponents.Button> </Form>
</ariaComponents.ButtonGroup>
</form>
} }
</div> </div>
</div> </div>
{isCloud && ( {isCloud && (
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section"> <div className={styles.section()}>
<aria.Heading <Heading
level={2} level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug" className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
> >
{getText('settings')} {getText('settings')}
</aria.Heading> </Heading>
<table> <table>
<tbody> <tbody>
{path != null && ( {path != null && (
<tr data-testid="asset-panel-permissions" className="h-row"> <tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0"> <td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('path')}</aria.Label> <Text>{getText('path')}</Text>
</td> </td>
<td className="w-full p-0"> <td className="w-full p-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ariaComponents.Text className="w-0 grow" truncate="1"> <Text className="w-0 grow" truncate="1">
{decodeURI(path)} {decodeURI(path)}
</ariaComponents.Text> </Text>
<ariaComponents.CopyButton copyText={path} /> <CopyButton copyText={path} />
</div> </div>
</td> </td>
</tr> </tr>
)} )}
{isEnterprise && (
<tr data-testid="asset-panel-permissions" className="h-row"> <tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0"> <td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('sharedWith')}</aria.Label> <Text className="text inline-block">{getText('sharedWith')}</Text>
</td> </td>
<td className="flex w-full gap-1 p-0"> <td className="flex w-full gap-1 p-0">
<SharedWithColumn <SharedWithColumn
isReadonly={isReadonly} isReadonly={isReadonly}
item={item} item={item}
setItem={setItem} state={{ backend, category, setQuery: () => {} }}
state={{ category, setQuery: () => {} }}
/> />
</td> </td>
</tr> </tr>
)}
<tr data-testid="asset-panel-labels" className="h-row"> <tr data-testid="asset-panel-labels" className="h-row">
<td className="text my-auto min-w-side-panel-label p-0"> <td className="text my-auto min-w-side-panel-label p-0">
<aria.Label className="text inline-block">{getText('labels')}</aria.Label> <Text className="text inline-block">{getText('labels')}</Text>
</td> </td>
<td className="flex w-full gap-1 p-0"> <td className="flex w-full gap-1 p-0">
{item.item.labels?.map((value) => { {asset.labels?.map((value) => {
const label = labels.find((otherLabel) => otherLabel.value === value) const label = labels.find((otherLabel) => otherLabel.value === value)
return label == null ? null : ( return (
label != null && (
<Label key={value} active isDisabled color={label.color} onPress={() => {}}> <Label key={value} active isDisabled color={label.color} onPress={() => {}}>
{value} {value}
</Label> </Label>
) )
)
})} })}
</td> </td>
</tr> </tr>
@ -302,83 +309,56 @@ export default function AssetProperties(props: AssetPropertiesProps) {
)} )}
{isSecret && ( {isSecret && (
<div <div ref={secretRef} className={styles.section()} {...secretSpotlight.props}>
ref={secretRef} <Heading
className="pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default"
{...secretSpotlight.props}
>
<aria.Heading
level={2} level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug" className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
> >
{getText('secret')} {getText('secret')}
</aria.Heading> </Heading>
<UpsertSecretModal <UpsertSecretModal
noDialog noDialog
canReset={false} canReset
canCancel={false} canCancel={false}
id={item.item.id} id={asset.id}
name={item.item.title} name={asset.title}
doCreate={async (name, value) => { doCreate={async (name, value) => {
await updateSecretMutation.mutateAsync([item.item.id, { value }, name]) await updateSecretMutation.mutateAsync([asset.id, { value }, name])
}} }}
/> />
</div> </div>
)} )}
{isDatalink && ( {isDatalink && (
<div <div ref={datalinkRef} className={styles.section()} {...datalinkSpotlight.props}>
ref={datalinkRef} <Heading
className="pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default"
{...datalinkSpotlight.props}
>
<aria.Heading
level={2} level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug" className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
> >
{getText('datalink')} {getText('datalink')}
</aria.Heading> </Heading>
{!isDatalinkFetched ? {datalinkQuery.isLoading ?
<div className="grid place-items-center self-stretch"> <div className="grid place-items-center self-stretch">
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} /> <StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
</div> </div>
: <> : <Form form={editDatalinkForm} className="w-full">
<ariaComponents.Form
schema={(z) =>
z.object({
value: z.unknown().refine(datalinkValidator.validateDatalink),
})
}
defaultValues={{ value: datalinkValue }}
className="w-full"
onSubmit={async ({ value }) => {
await createDatalinkMutation.mutateAsync([
{
datalinkId: item.item.id,
name: item.item.title,
parentDirectoryId: null,
value: value,
},
])
}}
>
{({ form }) => (
<>
<DatalinkFormInput <DatalinkFormInput
form={form} form={editDatalinkForm}
name="value" name="datalink"
readOnly={!canEditThisAsset} readOnly={!canEditThisAsset}
dropdownTitle={getText('type')} dropdownTitle={getText('type')}
/> />
{canEditThisAsset && ( {canEditThisAsset && (
<ariaComponents.ButtonGroup> <ButtonGroup>
<ariaComponents.Form.Submit action="update" /> <Form.Submit>{getText('update')}</Form.Submit>
</ariaComponents.ButtonGroup> <Form.Reset
onPress={() => {
editDatalinkForm.reset({ datalink: datalinkQuery.data })
}}
/>
</ButtonGroup>
)} )}
</> </Form>
)}
</ariaComponents.Form>
</>
} }
</div> </div>
)} )}

View File

@ -17,7 +17,7 @@ import * as backendService from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode' import type AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as uniqueString from '#/utilities/uniqueString' import * as uniqueString from 'enso-common/src/utilities/uniqueString'
// ============================== // ==============================
// === AddNewVersionVariables === // === AddNewVersionVariables ===

View File

@ -52,10 +52,10 @@ import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
import { ASSETS_MIME_TYPE } from '#/data/mimeTypes' import { ASSETS_MIME_TYPE } from '#/data/mimeTypes'
import AssetEventType from '#/events/AssetEventType' import AssetEventType from '#/events/AssetEventType'
import type { AssetListEvent } from '#/events/assetListEvent' import { useCutAndPaste, type AssetListEvent } from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType' import AssetListEventType from '#/events/AssetListEventType'
import { useAutoScroll } from '#/hooks/autoScrollHooks' import { useAutoScroll } from '#/hooks/autoScrollHooks'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' import { backendMutationOptions, backendQueryOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useIntersectionRatio } from '#/hooks/intersectionHooks' import { useIntersectionRatio } from '#/hooks/intersectionHooks'
import { useOpenProject } from '#/hooks/projectHooks' import { useOpenProject } from '#/hooks/projectHooks'
@ -66,8 +66,8 @@ import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu' import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
import { import {
canTransferBetweenCategories, canTransferBetweenCategories,
CATEGORY_TO_FILTER_BY,
isLocalCategory, isLocalCategory,
useTransferBetweenCategories,
type Category, type Category,
} from '#/layouts/CategorySwitcher/Category' } from '#/layouts/CategorySwitcher/Category'
import DragModal from '#/modals/DragModal' import DragModal from '#/modals/DragModal'
@ -117,7 +117,6 @@ import {
extractProjectExtension, extractProjectExtension,
fileIsNotProject, fileIsNotProject,
fileIsProject, fileIsProject,
FilterBy,
getAssetPermissionName, getAssetPermissionName,
Path, Path,
Plan, Plan,
@ -136,6 +135,7 @@ import {
import LocalBackend, { extractTypeAndId, newProjectId } from '#/services/LocalBackend' import LocalBackend, { extractTypeAndId, newProjectId } from '#/services/LocalBackend'
import { UUID } from '#/services/ProjectManager' import { UUID } from '#/services/ProjectManager'
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend' import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import type { AssetQueryKey } from '#/utilities/AssetQuery' import type { AssetQueryKey } from '#/utilities/AssetQuery'
import AssetQuery from '#/utilities/AssetQuery' import AssetQuery from '#/utilities/AssetQuery'
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode' import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
@ -147,7 +147,6 @@ import { fileExtension } from '#/utilities/fileInfo'
import type { DetailedRectangle } from '#/utilities/geometry' import type { DetailedRectangle } from '#/utilities/geometry'
import { DEFAULT_HANDLER } from '#/utilities/inputBindings' import { DEFAULT_HANDLER } from '#/utilities/inputBindings'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
import PasteType from '#/utilities/PasteType'
import { import {
canPermissionModifyDirectoryContents, canPermissionModifyDirectoryContents,
PermissionAction, PermissionAction,
@ -160,8 +159,8 @@ import type { SortInfo } from '#/utilities/sorting'
import { SortDirection } from '#/utilities/sorting' import { SortDirection } from '#/utilities/sorting'
import { regexEscape } from '#/utilities/string' import { regexEscape } from '#/utilities/string'
import { twJoin, twMerge } from '#/utilities/tailwindMerge' import { twJoin, twMerge } from '#/utilities/tailwindMerge'
import { uniqueString } from '#/utilities/uniqueString'
import Visibility from '#/utilities/Visibility' import Visibility from '#/utilities/Visibility'
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
// ============================ // ============================
// === Global configuration === // === Global configuration ===
@ -287,21 +286,6 @@ interface DragSelectionInfo {
readonly end: number readonly end: number
} }
// =============================
// === Category to filter by ===
// =============================
const CATEGORY_TO_FILTER_BY: Readonly<Record<Category['type'], FilterBy | null>> = {
cloud: FilterBy.active,
local: FilterBy.active,
recent: null,
trash: FilterBy.trashed,
user: FilterBy.active,
team: FilterBy.active,
// eslint-disable-next-line @typescript-eslint/naming-convention
'local-directory': FilterBy.active,
}
// =================== // ===================
// === AssetsTable === // === AssetsTable ===
// =================== // ===================
@ -335,7 +319,6 @@ export interface AssetsTableState {
/** Data associated with a {@link AssetRow}, used for rendering. */ /** Data associated with a {@link AssetRow}, used for rendering. */
export interface AssetRowState { export interface AssetRowState {
readonly setVisibility: (visibility: Visibility) => void
readonly isEditingName: boolean readonly isEditingName: boolean
readonly temporarilyAddedLabels: ReadonlySet<LabelName> readonly temporarilyAddedLabels: ReadonlySet<LabelName>
readonly temporarilyRemovedLabels: ReadonlySet<LabelName> readonly temporarilyRemovedLabels: ReadonlySet<LabelName>
@ -421,7 +404,6 @@ export default function AssetsTable(props: AssetsTableProps) {
return id return id
}, [category, backend, user, organization, localRootDirectory]) }, [category, backend, user, organization, localRootDirectory])
const rootParentDirectoryId = DirectoryId('')
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId]) const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh') const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh')
@ -436,7 +418,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = useState<DirectoryId[]>(() => []) const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = useState<DirectoryId[]>(() => [])
const expandedDirectoryIds = useMemo( const expandedDirectoryIds = useMemo(
() => privateExpandedDirectoryIds.concat(rootDirectoryId), () => [rootDirectoryId].concat(privateExpandedDirectoryIds),
[privateExpandedDirectoryIds, rootDirectoryId], [privateExpandedDirectoryIds, rootDirectoryId],
) )
@ -452,9 +434,6 @@ export default function AssetsTable(props: AssetsTableProps) {
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret')) const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink')) const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
const uploadFileMutation = useMutation(backendMutationOptions(backend, 'uploadFile')) const uploadFileMutation = useMutation(backendMutationOptions(backend, 'uploadFile'))
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset')) const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset')) const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset')) const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
@ -472,7 +451,6 @@ export default function AssetsTable(props: AssetsTableProps) {
'listDirectory', 'listDirectory',
directoryId, directoryId,
{ {
parentId: directoryId,
labels: null, labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type], filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent', recentProjects: category.type === 'recent',
@ -480,7 +458,7 @@ export default function AssetsTable(props: AssetsTableProps) {
] as const, ] as const,
queryFn: async ({ queryKey: [, , parentId, params] }) => { queryFn: async ({ queryKey: [, , parentId, params] }) => {
try { try {
return { parentId, children: await backend.listDirectory(params, parentId) } return await backend.listDirectory({ ...params, parentId }, parentId)
} catch { } catch {
throw Object.assign(new Error(), { parentId }) throw Object.assign(new Error(), { parentId })
} }
@ -506,13 +484,7 @@ export default function AssetsTable(props: AssetsTableProps) {
], ],
), ),
combine: (results) => { combine: (results) => {
const rootQuery = results.find( const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)]
(directory) =>
directory.data?.parentId === rootDirectory.id ||
// eslint-disable-next-line no-restricted-syntax
(directory.error as unknown as { parentId: string } | null)?.parentId ===
rootDirectory.id,
)
return { return {
rootDirectory: { rootDirectory: {
@ -522,8 +494,8 @@ export default function AssetsTable(props: AssetsTableProps) {
data: rootQuery?.data, data: rootQuery?.data,
}, },
directories: new Map( directories: new Map(
results.map((res) => [ results.map((res, i) => [
res.data?.parentId, expandedDirectoryIds[i],
{ {
isFetching: res.isFetching, isFetching: res.isFetching,
isLoading: res.isLoading, isLoading: res.isLoading,
@ -541,7 +513,7 @@ export default function AssetsTable(props: AssetsTableProps) {
*/ */
type DirectoryQuery = typeof directories.rootDirectory.data type DirectoryQuery = typeof directories.rootDirectory.data
const rootDirectoryContent = directories.rootDirectory.data?.children const rootDirectoryContent = directories.rootDirectory.data
const isLoading = directories.rootDirectory.isLoading && !directories.rootDirectory.isError const isLoading = directories.rootDirectory.isLoading && !directories.rootDirectory.isError
const assetTree = useMemo(() => { const assetTree = useMemo(() => {
@ -553,8 +525,8 @@ export default function AssetsTable(props: AssetsTableProps) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
return AssetTreeNode.fromAsset( return AssetTreeNode.fromAsset(
createRootDirectoryAsset(rootDirectoryId), createRootDirectoryAsset(rootDirectoryId),
rootParentDirectoryId, ROOT_PARENT_DIRECTORY_ID,
rootParentDirectoryId, ROOT_PARENT_DIRECTORY_ID,
-1, -1,
rootPath, rootPath,
null, null,
@ -563,8 +535,8 @@ export default function AssetsTable(props: AssetsTableProps) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
return AssetTreeNode.fromAsset( return AssetTreeNode.fromAsset(
createRootDirectoryAsset(rootDirectoryId), createRootDirectoryAsset(rootDirectoryId),
rootParentDirectoryId, ROOT_PARENT_DIRECTORY_ID,
rootParentDirectoryId, ROOT_PARENT_DIRECTORY_ID,
-1, -1,
rootPath, rootPath,
null, null,
@ -595,7 +567,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (assetIsDirectory(item)) { if (assetIsDirectory(item)) {
const childrenAssetsQuery = directories.directories.get(item.id) const childrenAssetsQuery = directories.directories.get(item.id)
const nestedChildren = childrenAssetsQuery?.data?.children.map((child) => const nestedChildren = childrenAssetsQuery?.data?.map((child) =>
AssetTreeNode.fromAsset( AssetTreeNode.fromAsset(
child, child,
item.id, item.id,
@ -669,8 +641,8 @@ export default function AssetsTable(props: AssetsTableProps) {
return new AssetTreeNode( return new AssetTreeNode(
rootDirectory, rootDirectory,
rootParentDirectoryId, ROOT_PARENT_DIRECTORY_ID,
rootParentDirectoryId, ROOT_PARENT_DIRECTORY_ID,
children, children,
-1, -1,
rootPath, rootPath,
@ -685,7 +657,6 @@ export default function AssetsTable(props: AssetsTableProps) {
directories.rootDirectory.isError, directories.rootDirectory.isError,
directories.directories, directories.directories,
rootDirectory, rootDirectory,
rootParentDirectoryId,
rootDirectoryId, rootDirectoryId,
]) ])
@ -866,6 +837,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const lastSelectedIdsRef = useRef<AssetId | ReadonlySet<AssetId> | null>(null) const lastSelectedIdsRef = useRef<AssetId | ReadonlySet<AssetId> | null>(null)
const headerRowRef = useRef<HTMLTableRowElement>(null) const headerRowRef = useRef<HTMLTableRowElement>(null)
const assetTreeRef = useRef<AnyAssetTreeNode>(assetTree) const assetTreeRef = useRef<AnyAssetTreeNode>(assetTree)
const getPasteData = useEventCallback(() => driveStore.getState().pasteData)
const nodeMapRef = useRef<ReadonlyMap<AssetId, AnyAssetTreeNode>>( const nodeMapRef = useRef<ReadonlyMap<AssetId, AnyAssetTreeNode>>(
new Map<AssetId, AnyAssetTreeNode>(), new Map<AssetId, AnyAssetTreeNode>(),
) )
@ -915,9 +887,13 @@ export default function AssetsTable(props: AssetsTableProps) {
setTargetDirectory(null) setTargetDirectory(null)
} else if (selectedKeys.size === 1) { } else if (selectedKeys.size === 1) {
const [soleKey] = selectedKeys const [soleKey] = selectedKeys
const node = soleKey == null ? null : nodeMapRef.current.get(soleKey) const item = soleKey == null ? null : nodeMapRef.current.get(soleKey)
if (node != null && node.isType(AssetType.directory)) { if (item != null && item.isType(AssetType.directory)) {
setTargetDirectory(node) setTargetDirectory(item)
}
if (item && item.item.id !== driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps({ backend, item })
setIsAssetPanelTemporarilyVisible(false)
} }
} else { } else {
let commonDirectoryKey: AssetId | null = null let commonDirectoryKey: AssetId | null = null
@ -956,7 +932,13 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
} }
}), }),
[driveStore, setTargetDirectory], [
backend,
driveStore,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
setTargetDirectory,
],
) )
useEffect(() => { useEffect(() => {
@ -1146,7 +1128,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (!hidden) { if (!hidden) {
return inputBindings.attach(document.body, 'keydown', { return inputBindings.attach(document.body, 'keydown', {
cancelCut: () => { cancelCut: () => {
const { pasteData } = driveStore.getState() const pasteData = getPasteData()
if (pasteData == null) { if (pasteData == null) {
return false return false
} else { } else {
@ -1157,7 +1139,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}, },
}) })
} }
}, [dispatchAssetEvent, driveStore, hidden, inputBindings, setPasteData]) }, [dispatchAssetEvent, getPasteData, hidden, inputBindings, setPasteData])
useEffect( useEffect(
() => () =>
@ -1281,6 +1263,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const doMove = useEventCallback(async (newParentId: DirectoryId | null, asset: AnyAsset) => { const doMove = useEventCallback(async (newParentId: DirectoryId | null, asset: AnyAsset) => {
try { try {
if (asset.id === driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps(null)
}
await updateAssetMutation.mutateAsync([ await updateAssetMutation.mutateAsync([
asset.id, asset.id,
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, { parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
@ -1292,6 +1277,9 @@ export default function AssetsTable(props: AssetsTableProps) {
}) })
const doDelete = useEventCallback(async (asset: AnyAsset, forever: boolean = false) => { const doDelete = useEventCallback(async (asset: AnyAsset, forever: boolean = false) => {
if (asset.id === driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps(null)
}
if (asset.type === AssetType.directory) { if (asset.type === AssetType.directory) {
dispatchAssetListEvent({ dispatchAssetListEvent({
type: AssetListEventType.closeFolder, type: AssetListEventType.closeFolder,
@ -1302,7 +1290,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}) })
} }
try { try {
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id })
if (asset.type === AssetType.project && backend.type === BackendType.local) { if (asset.type === AssetType.project && backend.type === BackendType.local) {
try { try {
await closeProjectMutation.mutateAsync([asset.id, asset.title]) await closeProjectMutation.mutateAsync([asset.id, asset.title])
@ -1317,6 +1304,9 @@ export default function AssetsTable(props: AssetsTableProps) {
}) })
const doDeleteById = useEventCallback(async (assetId: AssetId, forever: boolean = false) => { const doDeleteById = useEventCallback(async (assetId: AssetId, forever: boolean = false) => {
if (assetId === driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps(null)
}
const asset = nodeMapRef.current.get(assetId)?.item const asset = nodeMapRef.current.get(assetId)?.item
if (asset != null) { if (asset != null) {
@ -1565,10 +1555,9 @@ export default function AssetsTable(props: AssetsTableProps) {
}) })
if (listDirectoryQuery?.state.data) { if (listDirectoryQuery?.state.data) {
listDirectoryQuery.setData({ listDirectoryQuery.setData(
...listDirectoryQuery.state.data, listDirectoryQuery.state.data.filter((child) => child.id !== assetId),
children: listDirectoryQuery.state.data.children.filter((child) => child.id !== assetId), )
})
} }
} }
}) })
@ -1584,10 +1573,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}) })
if (listDirectoryQuery?.state.data) { if (listDirectoryQuery?.state.data) {
listDirectoryQuery.setData({ listDirectoryQuery.setData([...listDirectoryQuery.state.data, ...assets])
...listDirectoryQuery.state.data,
children: [...listDirectoryQuery.state.data.children, ...assets],
})
} }
}, },
) )
@ -1774,8 +1760,14 @@ export default function AssetsTable(props: AssetsTableProps) {
const projectId = newProjectId(UUID(id)) const projectId = newProjectId(UUID(id))
addIdToSelection(projectId) addIdToSelection(projectId)
await getProjectDetailsMutation await queryClient
.mutateAsync([projectId, asset.parentId, asset.title]) .fetchQuery(
backendQueryOptions(backend, 'getProjectDetails', [
projectId,
asset.parentId,
asset.title,
]),
)
.catch((error) => { .catch((error) => {
deleteAsset(projectId) deleteAsset(projectId)
toastAndLog('uploadProjectError', error) toastAndLog('uploadProjectError', error)
@ -2010,10 +2002,6 @@ export default function AssetsTable(props: AssetsTableProps) {
break break
} }
case AssetListEventType.insertAssets: {
insertAssets(event.assets, event.parentId)
break
}
case AssetListEventType.duplicateProject: { case AssetListEventType.duplicateProject: {
const parent = nodeMapRef.current.get(event.parentKey) const parent = nodeMapRef.current.get(event.parentKey)
const siblings = parent?.children ?? [] const siblings = parent?.children ?? []
@ -2068,38 +2056,19 @@ export default function AssetsTable(props: AssetsTableProps) {
break break
} }
case AssetListEventType.willDelete: {
const { selectedKeys } = driveStore.getState()
if (selectedKeys.has(event.key)) {
const newSelectedKeys = new Set(selectedKeys)
newSelectedKeys.delete(event.key)
setSelectedKeys(newSelectedKeys)
}
deleteAsset(event.key)
break
}
case AssetListEventType.copy: { case AssetListEventType.copy: {
insertAssets(event.items, event.newParentId)
for (const item of event.items) { for (const item of event.items) {
void doCopyOnBackend(event.newParentId, item) void doCopyOnBackend(event.newParentId, item)
} }
break break
} }
case AssetListEventType.move: { case AssetListEventType.move: {
deleteAsset(event.key)
insertAssets(event.items, event.newParentId)
for (const item of event.items) { for (const item of event.items) {
void doMove(event.newParentId, item) void doMove(event.newParentId, item)
} }
break break
} }
case AssetListEventType.delete: { case AssetListEventType.delete: {
deleteAsset(event.key)
const asset = nodeMapRef.current.get(event.key)?.item const asset = nodeMapRef.current.get(event.key)?.item
if (asset) { if (asset) {
void doDelete(asset, false) void doDelete(asset, false)
@ -2144,7 +2113,7 @@ export default function AssetsTable(props: AssetsTableProps) {
unsetModal() unsetModal()
const { selectedKeys } = driveStore.getState() const { selectedKeys } = driveStore.getState()
setPasteData({ setPasteData({
type: PasteType.copy, type: 'copy',
data: { backendType: backend.type, category, ids: selectedKeys }, data: { backendType: backend.type, category, ids: selectedKeys },
}) })
}) })
@ -2156,14 +2125,14 @@ export default function AssetsTable(props: AssetsTableProps) {
dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data.ids }) dispatchAssetEvent({ type: AssetEventType.cancelCut, ids: pasteData.data.ids })
} }
setPasteData({ setPasteData({
type: PasteType.move, type: 'move',
data: { backendType: backend.type, category, ids: selectedKeys }, data: { backendType: backend.type, category, ids: selectedKeys },
}) })
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys }) dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys })
setSelectedKeys(EMPTY_SET) setSelectedKeys(EMPTY_SET)
}) })
const transferBetweenCategories = useTransferBetweenCategories(category) const cutAndPaste = useCutAndPaste(category)
const doPaste = useEventCallback((newParentKey: DirectoryId, newParentId: DirectoryId) => { const doPaste = useEventCallback((newParentKey: DirectoryId, newParentId: DirectoryId) => {
unsetModal() unsetModal()
const { pasteData } = driveStore.getState() const { pasteData } = driveStore.getState()
@ -2175,7 +2144,7 @@ export default function AssetsTable(props: AssetsTableProps) {
toast.error('Cannot paste a folder into itself.') toast.error('Cannot paste a folder into itself.')
} else { } else {
doToggleDirectoryExpansion(newParentId, newParentKey, true) doToggleDirectoryExpansion(newParentId, newParentKey, true)
if (pasteData.type === PasteType.copy) { if (pasteData.type === 'copy') {
const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap( const assets = Array.from(pasteData.data.ids, (id) => nodeMapRef.current.get(id)).flatMap(
(asset) => (asset ? [asset.item] : []), (asset) => (asset ? [asset.item] : []),
) )
@ -2186,13 +2155,7 @@ export default function AssetsTable(props: AssetsTableProps) {
newParentKey, newParentKey,
}) })
} else { } else {
transferBetweenCategories( cutAndPaste(newParentKey, newParentId, pasteData.data, nodeMapRef.current)
pasteData.data.category,
category,
pasteData.data.ids,
newParentKey,
newParentId,
)
} }
setPasteData(null) setPasteData(null)
} }
@ -2201,6 +2164,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const doRestore = useEventCallback(async (asset: AnyAsset) => { const doRestore = useEventCallback(async (asset: AnyAsset) => {
try { try {
if (asset.id === driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps(null)
}
await undoDeleteAssetMutation.mutateAsync([asset.id, asset.title]) await undoDeleteAssetMutation.mutateAsync([asset.id, asset.title])
} catch (error) { } catch (error) {
toastAndLog('restoreAssetError', error, asset.title) toastAndLog('restoreAssetError', error, asset.title)
@ -2681,12 +2647,9 @@ export default function AssetsTable(props: AssetsTableProps) {
}) })
if (listDirectoryQuery?.state.data) { if (listDirectoryQuery?.state.data) {
listDirectoryQuery.setData({ listDirectoryQuery.setData(
...listDirectoryQuery.state.data, listDirectoryQuery.state.data.map((child) => (child.id === assetId ? asset : child)),
children: listDirectoryQuery.state.data.children.map((child) => )
child.id === assetId ? asset : child,
),
})
} }
}) })
@ -2938,7 +2901,7 @@ export default function AssetsTable(props: AssetsTableProps) {
{!hidden && ( {!hidden && (
<SelectionBrush <SelectionBrush
targetRef={rootRef} targetRef={rootRef}
margin={8} margin={16}
onDrag={onSelectionDrag} onDrag={onSelectionDrag}
onDragEnd={onSelectionDragEnd} onDragEnd={onSelectionDragEnd}
onDragCancel={onSelectionDragCancel} onDragCancel={onSelectionDragCancel}

View File

@ -4,6 +4,10 @@
*/ */
import * as React from 'react' import * as React from 'react'
import { useStore } from 'zustand'
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import { useDriveStore, useSelectedKeys, useSetSelectedKeys } from '#/providers/DriveProvider' import { useDriveStore, useSelectedKeys, useSetSelectedKeys } from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
@ -31,8 +35,6 @@ import * as backendModule from '#/services/Backend'
import type * as assetTreeNode from '#/utilities/AssetTreeNode' import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as permissions from '#/utilities/permissions' import * as permissions from '#/utilities/permissions'
import { EMPTY_SET } from '#/utilities/set' import { EMPTY_SET } from '#/utilities/set'
import * as uniqueString from '#/utilities/uniqueString'
import { useStore } from 'zustand'
// ================= // =================
// === Constants === // === Constants ===
@ -149,9 +151,9 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
) )
if (category.type === 'trash') { if (category.type === 'trash') {
return selectedKeys.size === 0 ? return (
null selectedKeys.size !== 0 && (
: <ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}> <ContextMenus key={uniqueString()} hidden={hidden} event={event}>
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}> <ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
<ContextMenuEntry <ContextMenuEntry
hidden={hidden} hidden={hidden}
@ -196,11 +198,13 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
{pasteAllMenuEntry} {pasteAllMenuEntry}
</ContextMenu> </ContextMenu>
</ContextMenus> </ContextMenus>
)
)
} else if (category.type === 'recent') { } else if (category.type === 'recent') {
return null return null
} else { } else {
return ( return (
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}> <ContextMenus key={uniqueString()} hidden={hidden} event={event}>
{(selectedKeys.size !== 0 || pasteAllMenuEntry !== false) && ( {(selectedKeys.size !== 0 || pasteAllMenuEntry !== false) && (
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}> <ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
{selectedKeys.size !== 0 && ownsAllSelectedAssets && ( {selectedKeys.size !== 0 && ownsAllSelectedAssets && (

View File

@ -267,9 +267,9 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
'listDirectory', 'listDirectory',
[ [
{ {
parentId: backend.DirectoryId(USERS_DIRECTORY_ID), parentId: USERS_DIRECTORY_ID,
filterBy: backend.FilterBy.active, filterBy: backend.FilterBy.active,
labels: [], labels: null,
recentProjects: false, recentProjects: false,
}, },
'Users', 'Users',
@ -281,9 +281,9 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
'listDirectory', 'listDirectory',
[ [
{ {
parentId: backend.DirectoryId(TEAMS_DIRECTORY_ID), parentId: TEAMS_DIRECTORY_ID,
filterBy: backend.FilterBy.active, filterBy: backend.FilterBy.active,
labels: [], labels: null,
recentProjects: false, recentProjects: false,
}, },
'Teams', 'Teams',

View File

@ -1,4 +1,6 @@
/** @file The categories available in the category switcher. */ /** @file The categories available in the category switcher. */
import { useMutation } from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as z from 'zod' import * as z from 'zod'
import AssetEventType from '#/events/AssetEventType' import AssetEventType from '#/events/AssetEventType'
@ -7,10 +9,14 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider' import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useFullUserSession } from '#/providers/AuthProvider' import { useFullUserSession } from '#/providers/AuthProvider'
import { useBackend, useLocalBackend, useRemoteBackendStrict } from '#/providers/BackendProvider' import { useBackend, useLocalBackend, useRemoteBackendStrict } from '#/providers/BackendProvider'
import type { AssetId, DirectoryId, Path, UserGroupInfo } from '#/services/Backend' import {
FilterBy,
type AssetId,
type DirectoryId,
type Path,
type UserGroupInfo,
} from '#/services/Backend'
import { newDirectoryId } from '#/services/LocalBackend' import { newDirectoryId } from '#/services/LocalBackend'
import { useMutation } from '@tanstack/react-query'
import invariant from 'tiny-invariant'
const PATH_SCHEMA = z.string().refine((s): s is Path => true) const PATH_SCHEMA = z.string().refine((s): s is Path => true)
const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true) const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true)
@ -92,6 +98,17 @@ export const CATEGORY_SCHEMA = z.union([ANY_CLOUD_CATEGORY_SCHEMA, ANY_LOCAL_CAT
/** A category of an arbitrary type. */ /** A category of an arbitrary type. */
export type Category = z.infer<typeof CATEGORY_SCHEMA> export type Category = z.infer<typeof CATEGORY_SCHEMA>
export const CATEGORY_TO_FILTER_BY: Readonly<Record<Category['type'], FilterBy | null>> = {
cloud: FilterBy.active,
local: FilterBy.active,
recent: null,
trash: FilterBy.trashed,
user: FilterBy.active,
team: FilterBy.active,
// eslint-disable-next-line @typescript-eslint/naming-convention
'local-directory': FilterBy.active,
}
/** Whether the category is only accessible from the cloud. */ /** Whether the category is only accessible from the cloud. */
export function isCloudCategory(category: Category): category is AnyCloudCategory { export function isCloudCategory(category: Category): category is AnyCloudCategory {
return ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category).success return ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category).success

View File

@ -51,7 +51,6 @@ import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend' import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery' import type AssetQuery from '#/utilities/AssetQuery'
import PasteType from '#/utilities/PasteType'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
// ================ // ================
@ -209,7 +208,7 @@ export default function DriveBar(props: DriveBarProps) {
const pasteDataStatus = effectivePasteData && ( const pasteDataStatus = effectivePasteData && (
<div className="flex items-center"> <div className="flex items-center">
<Text> <Text>
{effectivePasteData.type === PasteType.copy ? {effectivePasteData.type === 'copy' ?
getText('xItemsCopied', effectivePasteData.data.ids.size) getText('xItemsCopied', effectivePasteData.data.ids.size)
: getText('xItemsCut', effectivePasteData.data.ids.size)} : getText('xItemsCut', effectivePasteData.data.ids.size)}
</Text> </Text>

View File

@ -1,6 +1,8 @@
/** @file A context menu available everywhere in the directory. */ /** @file A context menu available everywhere in the directory. */
import * as React from 'react' import * as React from 'react'
import { useStore } from 'zustand'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
@ -18,7 +20,6 @@ import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useDriveStore } from '#/providers/DriveProvider' import { useDriveStore } from '#/providers/DriveProvider'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import { useStore } from 'zustand'
/** Props for a {@link GlobalContextMenu}. */ /** Props for a {@link GlobalContextMenu}. */
export interface GlobalContextMenuProps { export interface GlobalContextMenuProps {
@ -35,8 +36,8 @@ export interface GlobalContextMenuProps {
/** A context menu available everywhere in the directory. */ /** A context menu available everywhere in the directory. */
export default function GlobalContextMenu(props: GlobalContextMenuProps) { export default function GlobalContextMenu(props: GlobalContextMenuProps) {
const { hidden = false, backend, directoryKey, directoryId } = props const { hidden = false, backend, directoryKey, directoryId, rootDirectoryId } = props
const { rootDirectoryId, doPaste } = props const { doPaste } = props
const { setModal, unsetModal } = modalProvider.useSetModal() const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()

View File

@ -120,6 +120,7 @@ export default function Labels(props: LabelsProps) {
variant="icon" variant="icon"
icon={Trash2Icon} icon={Trash2Icon}
aria-label={getText('delete')} aria-label={getText('delete')}
tooltipPlacement="right"
className="relative flex size-4 text-delete opacity-0 transition-all after:absolute after:-inset-1 after:rounded-button-focus-ring group-has-[[data-focus-visible]]:active group-hover:active" className="relative flex size-4 text-delete opacity-0 transition-all after:absolute after:-inset-1 after:rounded-button-focus-ring group-has-[[data-focus-visible]]:active group-hover:active"
/> />
<ConfirmDeleteModal <ConfirmDeleteModal

View File

@ -5,26 +5,19 @@ import BlankIcon from '#/assets/blank.svg'
import CrossIcon from '#/assets/cross.svg' import CrossIcon from '#/assets/cross.svg'
import Plus2Icon from '#/assets/plus2.svg' import Plus2Icon from '#/assets/plus2.svg'
import ReloadIcon from '#/assets/reload.svg' import ReloadIcon from '#/assets/reload.svg'
import { mergeProps } from '#/components/aria'
import type * as inputBindings from '#/configurations/inputBindings' import { Button, ButtonGroup, DialogTrigger } from '#/components/AriaComponents'
import * as refreshHooks from '#/hooks/refreshHooks'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as inputBindingsManager from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
import type { DashboardBindingKey } from '#/configurations/inputBindings'
import { useRefresh } from '#/hooks/refreshHooks'
import { useStickyTableHeaderOnScroll } from '#/hooks/scrollHooks'
import CaptureKeyboardShortcutModal from '#/modals/CaptureKeyboardShortcutModal' import CaptureKeyboardShortcutModal from '#/modals/CaptureKeyboardShortcutModal'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import * as object from '#/utilities/object' import { useText } from '#/providers/TextProvider'
import { unsafeEntries } from '#/utilities/object'
// ======================================== // ========================================
// === KeyboardShortcutsSettingsSection === // === KeyboardShortcutsSettingsSection ===
@ -32,10 +25,9 @@ import * as object from '#/utilities/object'
/** Settings tab for viewing and editing keyboard shortcuts. */ /** Settings tab for viewing and editing keyboard shortcuts. */
export default function KeyboardShortcutsSettingsSection() { export default function KeyboardShortcutsSettingsSection() {
const [refresh, doRefresh] = refreshHooks.useRefresh() const [refresh, doRefresh] = useRefresh()
const inputBindings = inputBindingsManager.useInputBindings() const inputBindings = useInputBindings()
const { setModal } = modalProvider.useSetModal() const { getText } = useText()
const { getText } = textProvider.useText()
const rootRef = React.useRef<HTMLDivElement>(null) const rootRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null) const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const allShortcuts = React.useMemo(() => { const allShortcuts = React.useMemo(() => {
@ -45,36 +37,36 @@ export default function KeyboardShortcutsSettingsSection() {
return new Set(Object.values(inputBindings.metadata).flatMap((value) => value.bindings)) return new Set(Object.values(inputBindings.metadata).flatMap((value) => value.bindings))
}, [inputBindings.metadata, refresh]) }, [inputBindings.metadata, refresh])
const visibleBindings = React.useMemo( const visibleBindings = React.useMemo(
() => object.unsafeEntries(inputBindings.metadata).filter((kv) => kv[1].rebindable !== false), () => unsafeEntries(inputBindings.metadata).filter((kv) => kv[1].rebindable !== false),
[inputBindings.metadata], [inputBindings.metadata],
) )
const { onScroll } = scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef) const { onScroll } = useStickyTableHeaderOnScroll(rootRef, bodyRef)
return ( return (
<> <>
<ariaComponents.ButtonGroup> <ButtonGroup>
<ariaComponents.DialogTrigger> <DialogTrigger>
<ariaComponents.Button size="medium" variant="outline"> <Button size="medium" variant="outline">
{getText('resetAll')} {getText('resetAll')}
</ariaComponents.Button> </Button>
<ConfirmDeleteModal <ConfirmDeleteModal
actionText={getText('resetAllKeyboardShortcuts')} actionText={getText('resetAllKeyboardShortcuts')}
actionButtonLabel={getText('resetAll')} actionButtonLabel={getText('resetAll')}
doDelete={() => { doDelete={() => {
for (const k in inputBindings.metadata) { for (const k in inputBindings.metadata) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
inputBindings.reset(k as inputBindings.DashboardBindingKey) inputBindings.reset(k as DashboardBindingKey)
} }
doRefresh() doRefresh()
}} }}
/> />
</ariaComponents.DialogTrigger> </DialogTrigger>
</ariaComponents.ButtonGroup> </ButtonGroup>
<FocusArea direction="vertical" focusChildClass="focus-default" focusDefaultClass=""> <FocusArea direction="vertical" focusChildClass="focus-default" focusDefaultClass="">
{(innerProps) => ( {(innerProps) => (
<div <div
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, { {...mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
ref: rootRef, ref: rootRef,
// There is a horizontal scrollbar for some reason without `px-px`. // There is a horizontal scrollbar for some reason without `px-px`.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
@ -126,7 +118,7 @@ export default function KeyboardShortcutsSettingsSection() {
className="inline-flex shrink-0 items-center gap-keyboard-shortcuts-button" className="inline-flex shrink-0 items-center gap-keyboard-shortcuts-button"
> >
<KeyboardShortcut shortcut={binding} /> <KeyboardShortcut shortcut={binding} />
<ariaComponents.Button <Button
variant="ghost" variant="ghost"
size="medium" size="medium"
aria-label={getText('removeShortcut')} aria-label={getText('removeShortcut')}
@ -142,15 +134,15 @@ export default function KeyboardShortcutsSettingsSection() {
))} ))}
<div className="grow" /> <div className="grow" />
<div className="gap-keyboard-shortcuts-buttons flex shrink-0 items-center"> <div className="gap-keyboard-shortcuts-buttons flex shrink-0 items-center">
<ariaComponents.Button <DialogTrigger>
<Button
variant="ghost" variant="ghost"
size="medium" size="medium"
aria-label={getText('addShortcut')} aria-label={getText('addShortcut')}
tooltipPlacement="top left" tooltipPlacement="top left"
icon={Plus2Icon} icon={Plus2Icon}
showIconOnHover showIconOnHover
onPress={() => { />
setModal(
<CaptureKeyboardShortcutModal <CaptureKeyboardShortcutModal
description={`'${info.name}'`} description={`'${info.name}'`}
existingShortcuts={allShortcuts} existingShortcuts={allShortcuts}
@ -158,11 +150,9 @@ export default function KeyboardShortcutsSettingsSection() {
inputBindings.add(action, shortcut) inputBindings.add(action, shortcut)
doRefresh() doRefresh()
}} }}
/>,
)
}}
/> />
<ariaComponents.Button </DialogTrigger>
<Button
variant="ghost" variant="ghost"
size="medium" size="medium"
aria-label={getText('resetShortcut')} aria-label={getText('resetShortcut')}

View File

@ -90,7 +90,7 @@ export default function MembersSettingsSection() {
<table className="table-fixed self-start rounded-rows"> <table className="table-fixed self-start rounded-rows">
<thead> <thead>
<tr className="h-row"> <tr className="h-row">
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"> <th className="min-w-48 max-w-80 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('name')} {getText('name')}
</th> </th>
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"> <th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
@ -101,7 +101,7 @@ export default function MembersSettingsSection() {
<tbody className="select-text"> <tbody className="select-text">
{members.map((member) => ( {members.map((member) => (
<tr key={member.email} className="group h-row rounded-rows-child"> <tr key={member.email} className="group h-row rounded-rows-child">
<td className="max-w-48 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0"> <td className="min-w-48 max-w-80 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
<ariaComponents.Text truncate="1" className="block"> <ariaComponents.Text truncate="1" className="block">
{member.email} {member.email}
</ariaComponents.Text> </ariaComponents.Text>

View File

@ -1,31 +1,29 @@
/** @file A modal for capturing an arbitrary keyboard shortcut. */ /** @file A modal for capturing an arbitrary keyboard shortcut. */
import * as React from 'react' import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import * as detect from 'enso-common/src/detect' import { isOnMacOS } from 'enso-common/src/detect'
import * as modalProvider from '#/providers/ModalProvider' import { ButtonGroup, Dialog, Form, Text } from '#/components/AriaComponents'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import Modal from '#/components/Modal' import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import * as inputBindings from '#/utilities/inputBindings' import {
import * as tailwindMerge from '#/utilities/tailwindMerge' modifierFlagsForEvent,
modifiersForModifierFlags,
normalizedKeyboardSegmentLookup,
} from '#/utilities/inputBindings'
import { twMerge } from '#/utilities/tailwindMerge'
// ============================== // ==============================
// === eventToPartialShortcut === // === eventToPartialShortcut ===
// ============================== // ==============================
const DISALLOWED_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']) const DISALLOWED_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta'])
const DELETE_KEY = detect.isOnMacOS() ? 'Backspace' : 'Delete' const DELETE_KEY = isOnMacOS() ? 'Backspace' : 'Delete'
/** Extracts a partial keyboard shortcut from a {@link KeyboardEvent}. */ /** Extracts a partial keyboard shortcut from a {@link KeyboardEvent}. */
function eventToPartialShortcut(event: KeyboardEvent | React.KeyboardEvent) { function eventToPartialShortcut(event: KeyboardEvent | ReactKeyboardEvent) {
const modifiers = inputBindings const modifiers = modifiersForModifierFlags(modifierFlagsForEvent(event)).join('+')
.modifiersForModifierFlags(inputBindings.modifierFlagsForEvent(event))
.join('+')
// `Tab` and `Shift+Tab` should be reserved for keyboard navigation // `Tab` and `Shift+Tab` should be reserved for keyboard navigation
const key = const key =
( (
@ -35,7 +33,7 @@ function eventToPartialShortcut(event: KeyboardEvent | React.KeyboardEvent) {
null null
: event.key === ' ' ? 'Space' : event.key === ' ' ? 'Space'
: event.key === DELETE_KEY ? 'OsDelete' : event.key === DELETE_KEY ? 'OsDelete'
: inputBindings.normalizedKeyboardSegmentLookup[event.key.toLowerCase()] ?? event.key : normalizedKeyboardSegmentLookup[event.key.toLowerCase()] ?? event.key
return { key, modifiers } return { key, modifiers }
} }
@ -53,10 +51,10 @@ export interface CaptureKeyboardShortcutModalProps {
/** A modal for capturing an arbitrary keyboard shortcut. */ /** A modal for capturing an arbitrary keyboard shortcut. */
export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShortcutModalProps) { export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShortcutModalProps) {
const { description, existingShortcuts, onSubmit } = props const { description, existingShortcuts, onSubmit } = props
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = useSetModal()
const { getText } = textProvider.useText() const { getText } = useText()
const [key, setKey] = React.useState<string | null>(null) const [key, setKey] = useState<string | null>(null)
const [modifiers, setModifiers] = React.useState<string>('') const [modifiers, setModifiers] = useState<string>('')
const shortcut = const shortcut =
key == null ? modifiers key == null ? modifiers
: modifiers === '' ? key : modifiers === '' ? key
@ -65,13 +63,16 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
const canSubmit = key != null && !doesAlreadyExist const canSubmit = key != null && !doesAlreadyExist
return ( return (
<Modal centered className="bg-dim"> <Dialog>
<form <Form
ref={(element) => { ref={(element) => {
element?.focus() element?.focus()
}} }}
tabIndex={-1} tabIndex={-1}
className="pointer-events-auto relative flex w-capture-keyboard-shortcut-modal flex-col items-center gap-modal rounded-default p-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-3xl" method="dialog"
schema={(z) => z.object({})}
className="flex-col items-center"
gap="none"
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Escape' && key === 'Escape') { if (event.key === 'Escape' && key === 'Escape') {
// Ignore. // Ignore.
@ -99,8 +100,7 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
}} }}
onSubmit={(event) => { onSubmit={() => {
event.preventDefault()
if (canSubmit) { if (canSubmit) {
unsetModal() unsetModal()
onSubmit(shortcut) onSubmit(shortcut)
@ -109,34 +109,23 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
> >
<div className="relative">{getText('enterTheNewKeyboardShortcutFor', description)}</div> <div className="relative">{getText('enterTheNewKeyboardShortcutFor', description)}</div>
<div <div
className={tailwindMerge.twMerge( className={twMerge(
'relative flex scale-150 items-center justify-center', 'relative flex scale-150 items-center justify-center',
doesAlreadyExist && 'text-red-600', doesAlreadyExist && 'text-red-600',
)} )}
> >
{shortcut === '' ? {shortcut === '' ?
<aria.Text className="text text-primary/30">{getText('noShortcutEntered')}</aria.Text> <Text>{getText('noShortcutEntered')}</Text>
: <KeyboardShortcut shortcut={shortcut} />} : <KeyboardShortcut shortcut={shortcut} />}
</div> </div>
<aria.Text className="relative text-red-600"> <Text className="relative text-red-600">
{doesAlreadyExist ? 'This shortcut already exists.' : ''} {doesAlreadyExist ? 'This shortcut already exists.' : ''}
</aria.Text> </Text>
<ariaComponents.ButtonGroup> <ButtonGroup>
<ariaComponents.Button <Form.Submit isDisabled={!canSubmit}>{getText('confirm')}</Form.Submit>
variant="submit" <Form.Submit action="cancel" />
isDisabled={!canSubmit} </ButtonGroup>
onPress={() => { </Form>
unsetModal() </Dialog>
onSubmit(shortcut)
}}
>
{getText('confirm')}
</ariaComponents.Button>
<ariaComponents.Button variant="outline" onPress={unsetModal}>
{getText('cancel')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
</form>
</Modal>
) )
} }

View File

@ -3,11 +3,9 @@ import { useEffect, useMemo, useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { Heading, Text } from '#/components/aria' import { ButtonGroup, Checkbox, Form, Input, Popover, Text } from '#/components/AriaComponents'
import { ButtonGroup, Checkbox, Form, Input } from '#/components/AriaComponents'
import ColorPicker from '#/components/ColorPicker' import ColorPicker from '#/components/ColorPicker'
import Label from '#/components/dashboard/Label' import Label from '#/components/dashboard/Label'
import Modal from '#/components/Modal'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useSyncRef } from '#/hooks/syncRefHooks' import { useSyncRef } from '#/hooks/syncRefHooks'
@ -26,8 +24,7 @@ import { regexEscape } from '#/utilities/string'
export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> { export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
readonly backend: Backend readonly backend: Backend
readonly item: Asset readonly item: Asset
/** If this is `null`, this modal will be centered. */ readonly triggerRef?: React.MutableRefObject<HTMLElement | null>
readonly eventTarget: HTMLElement | null
} }
/** /**
@ -38,14 +35,13 @@ export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>( export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
props: ManageLabelsModalProps<Asset>, props: ManageLabelsModalProps<Asset>,
) { ) {
const { backend, item, eventTarget } = props const { backend, item, triggerRef } = props
const { unsetModal } = useSetModal() const { unsetModal } = useSetModal()
const { getText } = useText() const { getText } = useText()
const toastAndLog = useToastAndLog() const toastAndLog = useToastAndLog()
const { data: allLabels } = useBackendQuery(backend, 'listTags', []) const { data: allLabels } = useBackendQuery(backend, 'listTags', [])
const [color, setColor] = useState<LChColor | null>(null) const [color, setColor] = useState<LChColor | null>(null)
const leastUsedColor = useMemo(() => findLeastUsedColor(allLabels ?? []), [allLabels]) const leastUsedColor = useMemo(() => findLeastUsedColor(allLabels ?? []), [allLabels])
const position = useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const createTagMutation = useMutation(backendMutationOptions(backend, 'createTag')) const createTagMutation = useMutation(backendMutationOptions(backend, 'createTag'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
@ -75,7 +71,7 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
const formRef = useSyncRef(form) const formRef = useSyncRef(form)
useEffect(() => { useEffect(() => {
formRef.current.setValue('labels', item.labels ?? []) formRef.current.resetField('labels', { defaultValue: item.labels ?? [] })
}, [formRef, item.labels]) }, [formRef, item.labels])
const query = Form.useWatch({ control: form.control, name: 'name' }) const query = Form.useWatch({ control: form.control, name: 'name' })
@ -89,35 +85,11 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
const canCreateNewLabel = canSelectColor const canCreateNewLabel = canSelectColor
return ( return (
<Modal <Popover size="xsmall" {...(triggerRef ? { triggerRef } : {})}>
centered={eventTarget == null}
className="absolute left top z-1 size-full overflow-hidden bg-dim"
>
<div
tabIndex={-1}
style={
position != null ?
{ left: position.left + window.scrollX, top: position.top + window.scrollY }
: {}
}
className="sticky w-manage-labels-modal"
onClick={(mouseEvent) => {
mouseEvent.stopPropagation()
}}
onContextMenu={(mouseEvent) => {
mouseEvent.stopPropagation()
mouseEvent.preventDefault()
}}
>
<div className="absolute h-full w-full rounded-default bg-selected-frame backdrop-blur-default" />
<Form form={form} className="relative flex flex-col gap-modal rounded-default p-modal"> <Form form={form} className="relative flex flex-col gap-modal rounded-default p-modal">
<Heading <Text.Heading slot="title" level={2} variant="subtitle">
slot="title" {getText('labels')}
level={2} </Text.Heading>
className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x"
>
<Text className="text text-sm font-bold">{getText('labels')}</Text>
</Heading>
<FocusArea direction="horizontal"> <FocusArea direction="horizontal">
{(innerProps) => ( {(innerProps) => (
<ButtonGroup className="relative" {...innerProps}> <ButtonGroup className="relative" {...innerProps}>
@ -141,21 +113,17 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
name="labels" name="labels"
className="max-h-manage-labels-list overflow-auto" className="max-h-manage-labels-list overflow-auto"
onChange={async (values) => { onChange={async (values) => {
await associateTagMutation.mutateAsync([ await associateTagMutation.mutateAsync([item.id, values.map(LabelName), item.title])
item.id,
values.map(LabelName),
item.title,
])
}} }}
{...innerProps} {...innerProps}
> >
<> <>
{(allLabels ?? []) {allLabels
.filter((label) => regex.test(label.value)) ?.filter((label) => regex.test(label.value))
.map((label) => { .map((label) => {
const isActive = labels.includes(label.value) const isActive = labels.includes(label.value)
return ( return (
<Checkbox key={label.id} value={String(label.value)} isSelected={isActive}> <Checkbox key={label.id} value={String(label.value)}>
<Label active={isActive} color={label.color} onPress={() => {}}> <Label active={isActive} color={label.color} onPress={() => {}}>
{label.value} {label.value}
</Label> </Label>
@ -167,7 +135,6 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
)} )}
</FocusArea> </FocusArea>
</Form> </Form>
</div> </Popover>
</Modal>
) )
} }

View File

@ -1,32 +1,41 @@
/** @file A modal with inputs for user email and permission level. */ /** @file A modal with inputs for user email and permission level. */
import * as React from 'react' import { useMemo, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import * as toast from 'react-toastify' import { toast } from 'react-toastify'
import isEmail from 'validator/es/lib/isEmail' import isEmail from 'validator/es/lib/isEmail'
import { backendMutationOptions } from '#/hooks/backendHooks' import { Heading } from '#/components/aria'
import * as billingHooks from '#/hooks/billing' import { Button } from '#/components/AriaComponents'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import Autocomplete from '#/components/Autocomplete' import Autocomplete from '#/components/Autocomplete'
import Permission from '#/components/dashboard/Permission' import Permission from '#/components/dashboard/Permission'
import PermissionSelector from '#/components/dashboard/PermissionSelector' import PermissionSelector from '#/components/dashboard/PermissionSelector'
import Modal from '#/components/Modal' import Modal from '#/components/Modal'
import * as paywall from '#/components/Paywall' import { PaywallAlert } from '#/components/Paywall'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import { backendMutationOptions, useAssetPassiveListenerStrict } from '#/hooks/backendHooks'
import * as backendModule from '#/services/Backend' import { usePaywall } from '#/hooks/billing'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import * as object from '#/utilities/object' import type { Category } from '#/layouts/CategorySwitcher/Category'
import * as permissionsModule from '#/utilities/permissions' import { useFullUserSession } from '#/providers/AuthProvider'
import { useRemoteBackendStrict } from '#/providers/BackendProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
compareAssetPermissions,
EmailAddress,
getAssetPermissionId,
getAssetPermissionName,
isUserGroupPermission,
isUserPermission,
type AnyAsset,
type AssetPermission,
type UserGroupInfo,
type UserInfo,
type UserPermissionIdentifier,
} from '#/services/Backend'
import { PermissionAction } from '#/utilities/permissions'
// ================= // =================
// === Constants === // === Constants ===
@ -43,12 +52,11 @@ const TYPE_SELECTOR_Y_OFFSET_PX = 32
// ============================== // ==============================
/** Props for a {@link ManagePermissionsModal}. */ /** Props for a {@link ManagePermissionsModal}. */
export interface ManagePermissionsModalProps< export interface ManagePermissionsModalProps<Asset extends AnyAsset = AnyAsset> {
Asset extends backendModule.AnyAsset = backendModule.AnyAsset, readonly backend: Backend
> { readonly category: Category
readonly item: Pick<Asset, 'id' | 'permissions' | 'type'> readonly item: Pick<Asset, 'id' | 'parentId' | 'permissions' | 'type'>
readonly setItem: React.Dispatch<React.SetStateAction<Asset>> readonly self: AssetPermission
readonly self: backendModule.AssetPermission
/** /**
* Remove the current user's permissions from this asset. This MUST be a prop because it should * Remove the current user's permissions from this asset. This MUST be a prop because it should
* change the assets list. * change the assets list.
@ -63,17 +71,18 @@ export interface ManagePermissionsModalProps<
* @throws {Error} when the current backend is the local backend, or when the user is offline. * @throws {Error} when the current backend is the local backend, or when the user is offline.
* This should never happen, as this modal should not be accessible in either case. * This should never happen, as this modal should not be accessible in either case.
*/ */
export default function ManagePermissionsModal< export default function ManagePermissionsModal<Asset extends AnyAsset = AnyAsset>(
Asset extends backendModule.AnyAsset = backendModule.AnyAsset, props: ManagePermissionsModalProps<Asset>,
>(props: ManagePermissionsModalProps<Asset>) { ) {
const { item, setItem, self, doRemoveSelf, eventTarget } = props const { backend, category, item: itemRaw, self, doRemoveSelf, eventTarget } = props
const remoteBackend = backendProvider.useRemoteBackendStrict() const item = useAssetPassiveListenerStrict(backend.type, itemRaw.id, itemRaw.parentId, category)
const { user } = authProvider.useFullUserSession() const remoteBackend = useRemoteBackendStrict()
const { unsetModal } = modalProvider.useSetModal() const { user } = useFullUserSession()
const toastAndLog = toastAndLogHooks.useToastAndLog() const { unsetModal } = useSetModal()
const { getText } = textProvider.useText() const toastAndLog = useToastAndLog()
const { getText } = useText()
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('shareFull') const isUnderPaywall = isFeatureUnderPaywall('shareFull')
const listedUsers = useQuery({ const listedUsers = useQuery({
@ -88,27 +97,25 @@ export default function ManagePermissionsModal<
queryFn: () => remoteBackend.listUserGroups(), queryFn: () => remoteBackend.listUserGroups(),
}) })
const [permissions, setPermissions] = React.useState(item.permissions ?? []) const [permissions, setPermissions] = useState(item.permissions ?? [])
const [usersAndUserGroups, setUserAndUserGroups] = React.useState< const [usersAndUserGroups, setUserAndUserGroups] = useState<
readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[] readonly (UserGroupInfo | UserInfo)[]
>([]) >([])
const [email, setEmail] = React.useState<string | null>(null) const [email, setEmail] = useState<string | null>(null)
const [action, setAction] = React.useState(permissionsModule.PermissionAction.view) const [action, setAction] = useState(PermissionAction.view)
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget]) const position = useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const editablePermissions = React.useMemo( const editablePermissions = useMemo(
() => () =>
self.permission === permissionsModule.PermissionAction.own ? self.permission === PermissionAction.own ?
permissions permissions
: permissions.filter( : permissions.filter((permission) => permission.permission !== PermissionAction.own),
(permission) => permission.permission !== permissionsModule.PermissionAction.own,
),
[permissions, self.permission], [permissions, self.permission],
) )
const permissionsHoldersNames = React.useMemo( const permissionsHoldersNames = useMemo(
() => new Set(item.permissions?.map(backendModule.getAssetPermissionName)), () => new Set(item.permissions?.map(getAssetPermissionName)),
[item.permissions], [item.permissions],
) )
const emailsOfUsersWithPermission = React.useMemo( const emailsOfUsersWithPermission = useMemo(
() => () =>
new Set<string>( new Set<string>(
item.permissions?.flatMap((userPermission) => item.permissions?.flatMap((userPermission) =>
@ -117,30 +124,24 @@ export default function ManagePermissionsModal<
), ),
[item.permissions], [item.permissions],
) )
const isOnlyOwner = React.useMemo( const isOnlyOwner = useMemo(
() => () =>
self.permission === permissionsModule.PermissionAction.own && self.permission === PermissionAction.own &&
permissions.every( permissions.every(
(permission) => (permission) =>
permission.permission !== permissionsModule.PermissionAction.own || permission.permission !== PermissionAction.own ||
(backendModule.isUserPermission(permission) && permission.user.userId === user.userId), (isUserPermission(permission) && permission.user.userId === user.userId),
), ),
[user.userId, permissions, self.permission], [user.userId, permissions, self.permission],
) )
const selfId = backendModule.getAssetPermissionId(self) const selfId = getAssetPermissionId(self)
const inviteUserMutation = useMutation(backendMutationOptions(remoteBackend, 'inviteUser')) const inviteUserMutation = useMutation(backendMutationOptions(remoteBackend, 'inviteUser'))
const createPermissionMutation = useMutation( const createPermissionMutation = useMutation(
backendMutationOptions(remoteBackend, 'createPermission'), backendMutationOptions(remoteBackend, 'createPermission'),
) )
React.useEffect(() => { const canAdd = useMemo(
// This is SAFE, as the type of asset is not being changed.
// eslint-disable-next-line no-restricted-syntax
setItem(object.merger({ permissions } as Partial<Asset>))
}, [permissions, setItem])
const canAdd = React.useMemo(
() => [ () => [
...(listedUsers.data ?? []).filter( ...(listedUsers.data ?? []).filter(
(listedUser) => (listedUser) =>
@ -153,7 +154,7 @@ export default function ManagePermissionsModal<
], ],
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups], [emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups],
) )
const willInviteNewUser = React.useMemo(() => { const willInviteNewUser = useMemo(() => {
if (usersAndUserGroups.length !== 0 || email == null || email === '') { if (usersAndUserGroups.length !== 0 || email == null || email === '') {
return false return false
} else { } else {
@ -184,47 +185,44 @@ export default function ManagePermissionsModal<
setUserAndUserGroups([]) setUserAndUserGroups([])
setEmail('') setEmail('')
if (email != null) { if (email != null) {
await inviteUserMutation.mutateAsync([{ userEmail: backendModule.EmailAddress(email) }]) await inviteUserMutation.mutateAsync([{ userEmail: EmailAddress(email) }])
toast.toast.success(getText('inviteSuccess', email)) toast.success(getText('inviteSuccess', email))
} }
} catch (error) { } catch (error) {
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)') toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
} }
} else { } else {
setUserAndUserGroups([]) setUserAndUserGroups([])
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>( const addedPermissions = usersAndUserGroups.map<AssetPermission>((newUserOrUserGroup) =>
(newUserOrUserGroup) =>
'userId' in newUserOrUserGroup ? 'userId' in newUserOrUserGroup ?
{ user: newUserOrUserGroup, permission: action } { user: newUserOrUserGroup, permission: action }
: { userGroup: newUserOrUserGroup, permission: action }, : { userGroup: newUserOrUserGroup, permission: action },
) )
const addedUsersIds = new Set( const addedUsersIds = new Set(
addedPermissions.flatMap((permission) => addedPermissions.flatMap((permission) =>
backendModule.isUserPermission(permission) ? [permission.user.userId] : [], isUserPermission(permission) ? [permission.user.userId] : [],
), ),
) )
const addedUserGroupsIds = new Set( const addedUserGroupsIds = new Set(
addedPermissions.flatMap((permission) => addedPermissions.flatMap((permission) =>
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : [], isUserGroupPermission(permission) ? [permission.userGroup.id] : [],
), ),
) )
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) => const isPermissionNotBeingOverwritten = (permission: AssetPermission) =>
backendModule.isUserPermission(permission) ? isUserPermission(permission) ?
!addedUsersIds.has(permission.user.userId) !addedUsersIds.has(permission.user.userId)
: !addedUserGroupsIds.has(permission.userGroup.id) : !addedUserGroupsIds.has(permission.userGroup.id)
try { try {
setPermissions((oldPermissions) => setPermissions((oldPermissions) =>
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort( [...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
backendModule.compareAssetPermissions, compareAssetPermissions,
), ),
) )
await createPermissionMutation.mutateAsync([ await createPermissionMutation.mutateAsync([
{ {
actorsIds: addedPermissions.map((permission) => actorsIds: addedPermissions.map((permission) =>
backendModule.isUserPermission(permission) ? isUserPermission(permission) ? permission.user.userId : permission.userGroup.id,
permission.user.userId
: permission.userGroup.id,
), ),
resourceId: item.id, resourceId: item.id,
action: action, action: action,
@ -233,7 +231,7 @@ export default function ManagePermissionsModal<
} catch (error) { } catch (error) {
setPermissions((oldPermissions) => setPermissions((oldPermissions) =>
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort( [...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
backendModule.compareAssetPermissions, compareAssetPermissions,
), ),
) )
toastAndLog('setPermissionsError', error) toastAndLog('setPermissionsError', error)
@ -241,18 +239,16 @@ export default function ManagePermissionsModal<
} }
} }
const doDelete = async (permissionId: backendModule.UserPermissionIdentifier) => { const doDelete = async (permissionId: UserPermissionIdentifier) => {
if (selfId === permissionId) { if (selfId === permissionId) {
doRemoveSelf() doRemoveSelf()
} else { } else {
const oldPermission = permissions.find( const oldPermission = permissions.find(
(permission) => backendModule.getAssetPermissionId(permission) === permissionId, (permission) => getAssetPermissionId(permission) === permissionId,
) )
try { try {
setPermissions((oldPermissions) => setPermissions((oldPermissions) =>
oldPermissions.filter( oldPermissions.filter((permission) => getAssetPermissionId(permission) !== permissionId),
(permission) => backendModule.getAssetPermissionId(permission) !== permissionId,
),
) )
await createPermissionMutation.mutateAsync([ await createPermissionMutation.mutateAsync([
{ {
@ -264,7 +260,7 @@ export default function ManagePermissionsModal<
} catch (error) { } catch (error) {
if (oldPermission != null) { if (oldPermission != null) {
setPermissions((oldPermissions) => setPermissions((oldPermissions) =>
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions), [...oldPermissions, oldPermission].sort(compareAssetPermissions),
) )
} }
toastAndLog('setPermissionsError', error) toastAndLog('setPermissionsError', error)
@ -298,9 +294,9 @@ export default function ManagePermissionsModal<
> >
<div className="relative flex flex-col gap-modal rounded-default p-modal"> <div className="relative flex flex-col gap-modal rounded-default p-modal">
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x"> <div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<aria.Heading level={2} className="text text-sm font-bold"> <Heading level={2} className="text text-sm font-bold">
{getText('invite')} {getText('invite')}
</aria.Heading> </Heading>
{/* Space reserved for other tabs. */} {/* Space reserved for other tabs. */}
</div> </div>
<FocusArea direction="horizontal"> <FocusArea direction="horizontal">
@ -319,7 +315,7 @@ export default function ManagePermissionsModal<
isDisabled={willInviteNewUser} isDisabled={willInviteNewUser}
selfPermission={self.permission} selfPermission={self.permission}
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX} typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
action={permissionsModule.PermissionAction.view} action={PermissionAction.view}
assetType={item.type} assetType={item.type}
onChange={setAction} onChange={setAction}
/> />
@ -366,7 +362,7 @@ export default function ManagePermissionsModal<
</Autocomplete> </Autocomplete>
</div> </div>
</div> </div>
<ariaComponents.Button <Button
size="medium" size="medium"
variant="submit" variant="submit"
isDisabled={ isDisabled={
@ -378,16 +374,13 @@ export default function ManagePermissionsModal<
onPress={doSubmit} onPress={doSubmit}
> >
{willInviteNewUser ? getText('invite') : getText('share')} {willInviteNewUser ? getText('invite') : getText('share')}
</ariaComponents.Button> </Button>
</form> </form>
)} )}
</FocusArea> </FocusArea>
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input"> <div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
{editablePermissions.map((permission) => ( {editablePermissions.map((permission) => (
<div <div key={getAssetPermissionName(permission)} className="flex h-row items-center">
key={backendModule.getAssetPermissionName(permission)}
className="flex h-row items-center"
>
<Permission <Permission
backend={remoteBackend} backend={remoteBackend}
asset={item} asset={item}
@ -395,12 +388,12 @@ export default function ManagePermissionsModal<
isOnlyOwner={isOnlyOwner} isOnlyOwner={isOnlyOwner}
permission={permission} permission={permission}
setPermission={(newPermission) => { setPermission={(newPermission) => {
const permissionId = backendModule.getAssetPermissionId(newPermission) const permissionId = getAssetPermissionId(newPermission)
setPermissions((oldPermissions) => setPermissions((oldPermissions) =>
oldPermissions.map((oldPermission) => oldPermissions.map((oldPermission) =>
backendModule.getAssetPermissionId(oldPermission) === permissionId ? getAssetPermissionId(oldPermission) === permissionId ? newPermission : (
newPermission oldPermission
: oldPermission, ),
), ),
) )
if (selfId === permissionId) { if (selfId === permissionId) {
@ -423,7 +416,7 @@ export default function ManagePermissionsModal<
</div> </div>
{isUnderPaywall && ( {isUnderPaywall && (
<paywall.PaywallAlert feature="shareFull" label={getText('shareFullPaywallMessage')} /> <PaywallAlert feature="shareFull" label={getText('shareFullPaywallMessage')} />
)} )}
</div> </div>
</div> </div>

View File

@ -10,7 +10,7 @@ import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import { Button, ButtonGroup } from '#/components/AriaComponents'
import Modal from '#/components/Modal' import Modal from '#/components/Modal'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
@ -110,18 +110,14 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
</div> </div>
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError> <aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
</aria.TextField> </aria.TextField>
<ariaComponents.ButtonGroup className="relative"> <ButtonGroup className="relative">
<ariaComponents.Button <Button variant="submit" isDisabled={!canSubmit} onPress={eventModule.submitForm}>
variant="submit"
isDisabled={!canSubmit}
onPress={eventModule.submitForm}
>
{getText('create')} {getText('create')}
</ariaComponents.Button> </Button>
<ariaComponents.Button variant="outline" onPress={unsetModal}> <Button variant="outline" onPress={unsetModal}>
{getText('cancel')} {getText('cancel')}
</ariaComponents.Button> </Button>
</ariaComponents.ButtonGroup> </ButtonGroup>
</form> </form>
</Modal> </Modal>
) )

View File

@ -59,6 +59,7 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
const content = ( const content = (
<Form form={form} testId="upsert-secret-modal" gap="none" className="w-full"> <Form form={form} testId="upsert-secret-modal" gap="none" className="w-full">
{isNameEditable && (
<Input <Input
form={form} form={form}
name="name" name="name"
@ -72,6 +73,7 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
variants={CLASSIC_INPUT_STYLES} variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES} fieldVariants={CLASSIC_FIELD_STYLES}
/> />
)}
<Input <Input
form={form} form={form}
name="value" name="value"

View File

@ -162,6 +162,7 @@ function DashboardInner(props: DashboardProps) {
setCategoryRaw(newCategory) setCategoryRaw(newCategory)
setStoreCategory(newCategory) setStoreCategory(newCategory)
}) })
const backend = backendProvider.useBackend(category)
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
const page = usePage() const page = usePage()
@ -275,11 +276,9 @@ function DashboardInner(props: DashboardProps) {
if (asset != null && self != null) { if (asset != null && self != null) {
setModal( setModal(
<ManagePermissionsModal <ManagePermissionsModal
backend={backend}
category={category}
item={asset} item={asset}
setItem={(updater) => {
const nextAsset = updater instanceof Function ? updater(asset) : updater
assetManagementApiRef.current?.setAsset(asset.id, nextAsset)
}}
self={self} self={self}
doRemoveSelf={() => { doRemoveSelf={() => {
doRemoveSelf(selectedProject) doRemoveSelf(selectedProject)

View File

@ -91,6 +91,8 @@ export default function DriveProvider(props: ProjectsProviderProps) {
targetDirectory: null, targetDirectory: null,
selectedKeys: EMPTY_SET, selectedKeys: EMPTY_SET,
visuallySelectedKeys: null, visuallySelectedKeys: null,
suggestions: EMPTY_ARRAY,
assetPanelProps: null,
}) })
} }
}, },

View File

@ -505,11 +505,14 @@ export default class RemoteBackend extends Backend {
return await this.throw(response, 'listRootFolderBackendError') return await this.throw(response, 'listRootFolderBackendError')
} }
} else { } else {
return (await response.json()).assets const ret = (await response.json()).assets
.map((asset) => .map((asset) =>
object.merge(asset, { object.merge(asset, {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
type: asset.id.match(/^(.+?)-/)?.[1] as backend.AssetType, type: asset.id.match(/^(.+?)-/)?.[1] as backend.AssetType,
// `Users` and `Teams` folders are virtual, so their children incorrectly have
// the organization root id as their parent id.
parentId: query.parentId ?? asset.parentId,
}), }),
) )
.map((asset) => .map((asset) =>
@ -518,6 +521,7 @@ export default class RemoteBackend extends Backend {
}), }),
) )
.map((asset) => this.dynamicAssetUser(asset)) .map((asset) => this.dynamicAssetUser(asset))
return ret
} }
} }

View File

@ -1,5 +1,6 @@
/** @file Paths used by the `RemoteBackend`. */ /** @file Paths used by the `RemoteBackend`. */
import type * as backend from '#/services/Backend' import type * as backend from '#/services/Backend'
import { newtypeConstructor, type Newtype } from 'enso-common/src/utilities/data/newtype'
// ============= // =============
// === Paths === // === Paths ===
@ -187,7 +188,12 @@ export function getCheckoutSessionPath(checkoutSessionId: backend.CheckoutSessio
// === IDs === // === IDs ===
// =========== // ===========
/** Unique identifier for a directory. */
type DirectoryId = Newtype<string, 'DirectoryId'>
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-redeclare
const DirectoryId = newtypeConstructor<DirectoryId>()
export const ROOT_PARENT_DIRECTORY_ID = DirectoryId('')
/** The ID of the directory containing the home directories of all users. */ /** The ID of the directory containing the home directories of all users. */
export const USERS_DIRECTORY_ID = 'directory-0000000000000000000000users' export const USERS_DIRECTORY_ID = DirectoryId('directory-0000000000000000000000users')
/** The ID of the directory containing home directories of all teams. */ /** The ID of the directory containing home directories of all teams. */
export const TEAMS_DIRECTORY_ID = 'directory-0000000000000000000000teams' export const TEAMS_DIRECTORY_ID = DirectoryId('directory-0000000000000000000000teams')

View File

@ -1,15 +0,0 @@
/** @file The type of operation that should be triggered on paste. */
// =================
// === PasteType ===
// =================
/** The type of operation that should be triggered on paste. */
enum PasteType {
copy = 'copy',
move = 'move',
}
// This is REQUIRED, as `export default enum` is invalid syntax.
// eslint-disable-next-line no-restricted-syntax
export default PasteType

View File

@ -3,7 +3,7 @@ import type * as React from 'react'
import type * as backend from '#/services/Backend' import type * as backend from '#/services/Backend'
import * as uniqueString from '#/utilities/uniqueString' import * as uniqueString from 'enso-common/src/utilities/uniqueString'
// =========================== // ===========================
// === setDragImageToBlank === // === setDragImageToBlank ===

View File

@ -1,9 +1,7 @@
/** @file Types related to pasting. */ /** @file Types related to pasting. */
import type PasteType from '#/utilities/PasteType'
// ================= /** The type of operation that should be triggered on paste. */
// === PasteData === export type PasteType = 'copy' | 'move'
// =================
/** All information required to paste assets. */ /** All information required to paste assets. */
export interface PasteData<T> { export interface PasteData<T> {

View File

@ -1,5 +1,14 @@
/** @file Utilities related to the `react-query` library. */ /** @file Utilities related to the `react-query` library. */
import type { DefinedInitialDataOptions } from '@tanstack/react-query' import {
matchQuery,
useQueryClient,
type DefaultError,
type DefinedInitialDataOptions,
type Query,
type QueryFilters,
type QueryKey,
} from '@tanstack/react-query'
import { useSyncExternalStore } from 'react'
export const STATIC_QUERY_OPTIONS = { export const STATIC_QUERY_OPTIONS = {
meta: { persist: false }, meta: { persist: false },
@ -10,3 +19,31 @@ export const STATIC_QUERY_OPTIONS = {
refetchOnReconnect: false, refetchOnReconnect: false,
refetchIntervalInBackground: false, refetchIntervalInBackground: false,
} as const satisfies Partial<DefinedInitialDataOptions> } as const satisfies Partial<DefinedInitialDataOptions>
/** Reactively listen to a subset of filters, rather tha just one. */
export function useCachedQueries<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
>(filters: QueryFilters) {
const queryClient = useQueryClient()
const queryCache = queryClient.getQueryCache()
return useSyncExternalStore(
(onChange) =>
queryCache.subscribe((changes) => {
if (changes.type !== 'added' && changes.type !== 'removed' && changes.type !== 'updated')
return
if (!matchQuery(filters, changes.query)) return
onChange()
}),
() =>
// eslint-disable-next-line no-restricted-syntax
queryCache.findAll(filters) as unknown as readonly Query<
TQueryFnData,
TError,
TData,
TQueryKey
>[],
)
}

View File

@ -1,3 +0,0 @@
/** @file A function that generates a unique string. */
export * from 'enso-common/src/utilities/uniqueString'

View File

@ -9,4 +9,3 @@
src: url('/font-dejavu/DejaVuSansMono-Bold.ttf'); src: url('/font-dejavu/DejaVuSansMono-Bold.ttf');
font-weight: 700; font-weight: 700;
} }

View File

@ -51,4 +51,3 @@
src: url('/font-enso/Enso-Black.ttf'); src: url('/font-enso/Enso-Black.ttf');
font-weight: 900; font-weight: 900;
} }

View File

@ -2,4 +2,3 @@
font-family: 'M PLUS 1'; font-family: 'M PLUS 1';
src: url('/font-mplus1/MPLUS1[wght].ttf'); src: url('/font-mplus1/MPLUS1[wght].ttf');
} }