mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 20:31:45 +03:00
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:
parent
fa87a1857a
commit
45ad3a751c
@ -8,7 +8,7 @@ import * as remoteBackendPaths from '#/services/remoteBackendPaths'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
|
@ -72,6 +72,7 @@ test.test('asset panel contents', ({ page }) =>
|
||||
.do(async () => {
|
||||
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
|
||||
// `getByText` is required so that this assertion works if there are multiple permissions.
|
||||
await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
|
||||
// This is not visible; "Shared with" should only be visible on the Enterprise plan.
|
||||
// await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
|
||||
}),
|
||||
)
|
||||
|
@ -29,6 +29,7 @@
|
||||
"test-dev:unit": "vitest",
|
||||
"test:e2e": "cross-env NODE_ENV=production playwright test",
|
||||
"test-dev:e2e": "cross-env NODE_ENV=production playwright test --ui",
|
||||
"test-dev-dashboard:e2e": "cross-env NODE_ENV=production playwright test ./e2e/dashboard/ --ui",
|
||||
"preinstall": "corepack pnpm run generate-metadata",
|
||||
"postinstall": "playwright install",
|
||||
"generate-metadata": "node scripts/generateIconMetadata.js"
|
||||
|
@ -216,7 +216,9 @@ export const BUTTON_STYLES = tv({
|
||||
end: { content: 'flex-row-reverse' },
|
||||
},
|
||||
showIconOnHover: {
|
||||
true: { icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' },
|
||||
true: {
|
||||
icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 disabled:opacity-0 aria-disabled:opacity-0 disabled:group-hover:opacity-50 aria-disabled:group-hover:opacity-50',
|
||||
},
|
||||
},
|
||||
extraClickZone: {
|
||||
true: {
|
||||
@ -341,6 +343,7 @@ export const Button = forwardRef(function Button(
|
||||
|
||||
const isLoading = loading || implicitlyLoading
|
||||
const isDisabled = props.isDisabled ?? isLoading
|
||||
const shouldUseVisualTooltip = shouldShowTooltip && isDisabled
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const delay = 350
|
||||
@ -436,6 +439,13 @@ export const Button = forwardRef(function Button(
|
||||
}
|
||||
}
|
||||
|
||||
const { tooltip: visualTooltip, targetProps } = ariaComponents.useVisualTooltip({
|
||||
targetRef: contentRef,
|
||||
children: tooltipElement,
|
||||
isDisabled: !shouldUseVisualTooltip,
|
||||
...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }),
|
||||
})
|
||||
|
||||
const button = (
|
||||
<Tag
|
||||
// @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) => (
|
||||
<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 */}
|
||||
{childrenFactory(render)}
|
||||
</span>
|
||||
@ -471,8 +485,14 @@ export const Button = forwardRef(function Button(
|
||||
</Tag>
|
||||
)
|
||||
|
||||
return tooltipElement == null ? button : (
|
||||
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
|
||||
return (
|
||||
tooltipElement == null ? button
|
||||
: shouldUseVisualTooltip ?
|
||||
<>
|
||||
{button}
|
||||
{visualTooltip}
|
||||
</>
|
||||
: <ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
|
||||
{button}
|
||||
|
||||
<ariaComponents.Tooltip
|
||||
@ -481,5 +501,5 @@ export const Button = forwardRef(function Button(
|
||||
{tooltipElement}
|
||||
</ariaComponents.Tooltip>
|
||||
</ariaComponents.TooltipTrigger>
|
||||
)
|
||||
)
|
||||
})
|
||||
|
@ -29,7 +29,7 @@ export function FormProvider<Schema extends types.TSchema>(
|
||||
const { children, form } = props
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line no-restricted-syntax,@typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any
|
||||
<FormContext.Provider value={{ form: form as types.UseFormReturn<any> }}>
|
||||
{children}
|
||||
</FormContext.Provider>
|
||||
@ -50,7 +50,7 @@ export function useFormContext<Schema extends types.TSchema>(
|
||||
|
||||
invariant(ctx, 'FormContext not found')
|
||||
|
||||
// This is safe, as it's we pass the value transparently and it's typed outside
|
||||
// This is safe, as we pass the value transparently and it is typed outside
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return ctx.form as unknown as types.UseFormReturn<Schema>
|
||||
}
|
||||
|
@ -42,14 +42,17 @@ export function Reset(props: ResetProps): React.JSX.Element {
|
||||
...buttonProps
|
||||
} = props
|
||||
|
||||
const { formState } = formContext.useFormContext(props.form)
|
||||
const form = formContext.useFormContext(props.form)
|
||||
const { formState } = form
|
||||
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
onPress={() => {
|
||||
form.reset()
|
||||
}}
|
||||
/* This is safe because we are passing all props to the button */
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
|
||||
{...(buttonProps as any)}
|
||||
type="reset"
|
||||
variant={variant}
|
||||
size={size}
|
||||
isDisabled={formState.isSubmitting || !formState.isDirty}
|
||||
|
@ -23,7 +23,7 @@ const DROPDOWN_STYLES = tv({
|
||||
isFocused: {
|
||||
true: {
|
||||
container: 'z-1',
|
||||
options: 'before:h-full before:shadow-soft',
|
||||
options: 'before:h-full before:shadow-soft before:bg-frame before:backdrop-blur-md',
|
||||
optionsContainer: 'grid-rows-1fr',
|
||||
input: 'z-1',
|
||||
},
|
||||
@ -47,7 +47,7 @@ const DROPDOWN_STYLES = tv({
|
||||
slots: {
|
||||
container: 'absolute left-0 h-full w-full min-w-max',
|
||||
options:
|
||||
'relative backdrop-blur-md before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
|
||||
optionsSpacing: 'padding relative h-6',
|
||||
optionsContainer:
|
||||
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
|
||||
|
@ -1,7 +1,13 @@
|
||||
/**
|
||||
* @file A resizable input that uses a content-editable div.
|
||||
*/
|
||||
import { useRef, type ClipboardEvent, type ForwardedRef, type HTMLAttributes } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ClipboardEvent,
|
||||
type ForwardedRef,
|
||||
type HTMLAttributes,
|
||||
} from 'react'
|
||||
|
||||
import type { FieldVariantProps } from '#/components/AriaComponents'
|
||||
import {
|
||||
@ -12,6 +18,7 @@ import {
|
||||
type FieldStateProps,
|
||||
type TSchema,
|
||||
} from '#/components/AriaComponents'
|
||||
import { useAutoFocus } from '#/hooks/autoFocusHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
@ -43,6 +50,8 @@ export interface ResizableContentEditableInputProps<
|
||||
VariantProps<typeof CONTENT_EDITABLE_STYLES>,
|
||||
'disabled' | 'invalid' | 'rounded' | 'size' | 'variant'
|
||||
> {
|
||||
/** Defaults to `onInput`. */
|
||||
readonly mode?: 'onBlur' | 'onInput'
|
||||
/**
|
||||
* onChange is called when the content of the input changes.
|
||||
* There is no way to prevent the change, so the value is always the new value.
|
||||
@ -65,6 +74,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const {
|
||||
mode = 'onInput',
|
||||
placeholder = '',
|
||||
description = null,
|
||||
name,
|
||||
@ -76,6 +86,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
|
||||
variant,
|
||||
variants = CONTENT_EDITABLE_STYLES,
|
||||
fieldVariants,
|
||||
autoFocus = false,
|
||||
...textFieldProps
|
||||
} = props
|
||||
|
||||
@ -100,13 +111,7 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
|
||||
defaultValue,
|
||||
})
|
||||
|
||||
const {
|
||||
base,
|
||||
description: descriptionClass,
|
||||
inputContainer,
|
||||
textArea,
|
||||
placeholder: placeholderClass,
|
||||
} = variants({
|
||||
const styles = variants({
|
||||
invalid: fieldState.invalid,
|
||||
disabled: isDisabled || formInstance.formState.isSubmitting,
|
||||
variant,
|
||||
@ -114,6 +119,14 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
|
||||
size,
|
||||
})
|
||||
|
||||
useAutoFocus({ ref: inputRef, disabled: !autoFocus })
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.textContent = field.value
|
||||
}
|
||||
}, [field.value])
|
||||
|
||||
return (
|
||||
<Form.Field
|
||||
form={formInstance}
|
||||
@ -123,14 +136,14 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
|
||||
{...textFieldProps}
|
||||
>
|
||||
<div
|
||||
className={base()}
|
||||
className={styles.base()}
|
||||
onClick={() => {
|
||||
inputRef.current?.focus({ preventScroll: true })
|
||||
}}
|
||||
>
|
||||
<div className={inputContainer()}>
|
||||
<div className={styles.inputContainer()}>
|
||||
<div
|
||||
className={textArea()}
|
||||
className={styles.textArea()}
|
||||
ref={mergeRefs(inputRef, ref, field.ref)}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
@ -140,19 +153,26 @@ export const ResizableContentEditableInput = forwardRef(function ResizableConten
|
||||
spellCheck="false"
|
||||
aria-autocomplete="none"
|
||||
onPaste={onPaste}
|
||||
onBlur={field.onBlur}
|
||||
onBlur={(event) => {
|
||||
if (mode === 'onBlur') {
|
||||
field.onChange(event.currentTarget.textContent ?? '')
|
||||
}
|
||||
field.onBlur()
|
||||
}}
|
||||
onInput={(event) => {
|
||||
field.onChange(event.currentTarget.textContent ?? '')
|
||||
if (mode === 'onInput') {
|
||||
field.onChange(event.currentTarget.textContent ?? '')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text className={placeholderClass({ class: field.value.length > 0 ? 'hidden' : '' })}>
|
||||
<Text className={styles.placeholder({ class: field.value.length > 0 ? 'hidden' : '' })}>
|
||||
{placeholder}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{description != null && (
|
||||
<Text slot="description" className={descriptionClass()}>
|
||||
<Text slot="description" className={styles.description()}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
@ -68,7 +68,6 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
}, [anchorAnimFactor, anchor])
|
||||
|
||||
React.useEffect(() => {
|
||||
const target = targetRef.current ?? document.body
|
||||
const isEventInBounds = (event: MouseEvent, parent?: HTMLElement | null) => {
|
||||
if (parent == null) {
|
||||
return true
|
||||
@ -95,7 +94,8 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
didMoveWhileDraggingRef.current = false
|
||||
lastMouseEvent.current = event
|
||||
const newAnchor = { left: event.pageX, top: event.pageY }
|
||||
anchorRef.current = null
|
||||
anchorRef.current = newAnchor
|
||||
setAnchor(newAnchor)
|
||||
setLastSetAnchor(newAnchor)
|
||||
setPosition(newAnchor)
|
||||
}
|
||||
@ -150,13 +150,13 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
||||
}
|
||||
}
|
||||
|
||||
target.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('dragstart', onDragStart, { capture: true })
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('click', onClick, { capture: true })
|
||||
return () => {
|
||||
target.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('dragstart', onDragStart, { capture: true })
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
|
@ -2,6 +2,7 @@
|
||||
import type { Mutable } from 'enso-common/src/utilities/data/object'
|
||||
import * as aria from 'react-aria'
|
||||
|
||||
export { ClearPressResponder } from '@react-aria/interactions'
|
||||
export type * from '@react-types/shared'
|
||||
// @ts-expect-error The conflicting exports are props types ONLY.
|
||||
export * from 'react-aria'
|
||||
|
@ -1,6 +1,7 @@
|
||||
/** @file A table row for an arbitrary asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import BlankIcon from '#/assets/blank.svg'
|
||||
@ -9,12 +10,7 @@ import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
|
||||
import {
|
||||
useDriveStore,
|
||||
useSetAssetPanelProps,
|
||||
useSetIsAssetPanelTemporarilyVisible,
|
||||
useSetSelectedKeys,
|
||||
} from '#/providers/DriveProvider'
|
||||
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -34,7 +30,12 @@ import * as localBackend from '#/services/LocalBackend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { Text } from '#/components/AriaComponents'
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import { useCutAndPaste } from '#/events/assetListEvent'
|
||||
import {
|
||||
backendMutationOptions,
|
||||
backendQueryOptions,
|
||||
useBackendMutationState,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
@ -49,7 +50,6 @@ import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -133,9 +133,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const driveStore = useDriveStore()
|
||||
const queryClient = useQueryClient()
|
||||
const { user } = useFullUserSession()
|
||||
const setSelectedKeys = useSetSelectedKeys()
|
||||
const setAssetPanelProps = useSetAssetPanelProps()
|
||||
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
|
||||
(visuallySelectedKeys ?? selectedKeys).has(item.key),
|
||||
)
|
||||
@ -151,17 +151,15 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
const draggableProps = dragAndDropHooks.useDraggable()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const cutAndPaste = useCutAndPaste(category)
|
||||
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
||||
const rootRef = React.useRef<HTMLElement | null>(null)
|
||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
|
||||
const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus)
|
||||
const asset = item.item
|
||||
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
||||
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
||||
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
|
||||
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
|
||||
assetRowUtils.INITIAL_ROW_STATE,
|
||||
)
|
||||
|
||||
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
|
||||
@ -176,12 +174,14 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
|
||||
} | null>(null)
|
||||
|
||||
const outerVisibility = visibilities.get(item.key)
|
||||
const visibility =
|
||||
outerVisibility == null || outerVisibility === Visibility.visible ?
|
||||
insertionVisibility
|
||||
: outerVisibility
|
||||
const hidden = hiddenRaw || visibility === Visibility.hidden
|
||||
const isDeleting =
|
||||
useBackendMutationState(backend, 'deleteAsset', {
|
||||
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
|
||||
}).length !== 0
|
||||
const isRestoring =
|
||||
useBackendMutationState(backend, 'undoDeleteAsset', {
|
||||
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
|
||||
}).length !== 0
|
||||
const isCloud = isCloudCategory(category)
|
||||
|
||||
const { data: projectState } = useQuery({
|
||||
@ -194,14 +194,26 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
|
||||
const toastAndLog = useToastAndLog()
|
||||
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
|
||||
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
|
||||
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
|
||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||
|
||||
const outerVisibility = visibilities.get(item.key)
|
||||
const insertionVisibility = useStore(driveStore, (driveState) =>
|
||||
driveState.pasteData?.type === 'move' && driveState.pasteData.data.ids.has(item.key) ?
|
||||
Visibility.faded
|
||||
: Visibility.visible,
|
||||
)
|
||||
const createPermissionVariables = createPermissionMutation.variables?.[0]
|
||||
const isRemovingSelf =
|
||||
createPermissionVariables != null &&
|
||||
createPermissionVariables.action == null &&
|
||||
createPermissionVariables.actorsIds[0] === user.userId
|
||||
const visibility =
|
||||
isRemovingSelf ? Visibility.hidden
|
||||
: outerVisibility === Visibility.visible ? insertionVisibility
|
||||
: outerVisibility ?? insertionVisibility
|
||||
const hidden = isDeleting || isRestoring || hiddenRaw || visibility === Visibility.hidden
|
||||
|
||||
const setSelected = useEventCallback((newSelected: boolean) => {
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
|
||||
@ -247,20 +259,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
}, [item.item.id, updateAssetRef])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSoleSelected && item.item.id !== driveStore.getState().assetPanelProps?.item?.item.id) {
|
||||
setAssetPanelProps({ backend, item, setItem })
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
}, [
|
||||
item,
|
||||
isSoleSelected,
|
||||
backend,
|
||||
setAssetPanelProps,
|
||||
setIsAssetPanelTemporarilyVisible,
|
||||
driveStore,
|
||||
])
|
||||
|
||||
const doDelete = React.useCallback(
|
||||
(forever = false) => {
|
||||
void doDeleteRaw(item.item, forever)
|
||||
@ -285,7 +283,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
|
||||
const canPaste = (() => {
|
||||
if (!isPayloadMatch) {
|
||||
return true
|
||||
return false
|
||||
} else {
|
||||
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
|
||||
const parentKeys = new Map(
|
||||
@ -296,12 +294,21 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
)
|
||||
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
|
||||
}
|
||||
return !payload.some((payloadItem) => {
|
||||
return payload.every((payloadItem) => {
|
||||
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
|
||||
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
|
||||
return !parent ? true : (
|
||||
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
|
||||
if (!parent) {
|
||||
return false
|
||||
} else if (permissions.isTeamPath(parent.path)) {
|
||||
return true
|
||||
} else {
|
||||
// Assume user path; check permissions
|
||||
const permission = permissions.tryFindSelfPermission(user, item.item.permissions)
|
||||
return (
|
||||
permission != null &&
|
||||
permissions.canPermissionModifyDirectoryContents(permission.permission)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})()
|
||||
@ -314,263 +321,229 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
|
||||
eventListProvider.useAssetEventListener(async (event) => {
|
||||
if (state.category.type === 'trash') {
|
||||
switch (event.type) {
|
||||
case AssetEventType.deleteForever: {
|
||||
if (event.ids.has(item.key)) {
|
||||
doDelete(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.restore: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doRestore(item.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
switch (event.type) {
|
||||
case AssetEventType.move: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doMove(event.newParentKey, item.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
switch (event.type) {
|
||||
case AssetEventType.cut: {
|
||||
if (event.ids.has(item.key)) {
|
||||
setInsertionVisibility(Visibility.faded)
|
||||
}
|
||||
break
|
||||
case AssetEventType.delete: {
|
||||
if (event.ids.has(item.key)) {
|
||||
doDelete(false)
|
||||
}
|
||||
case AssetEventType.cancelCut: {
|
||||
if (event.ids.has(item.key)) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
}
|
||||
break
|
||||
break
|
||||
}
|
||||
case AssetEventType.deleteForever: {
|
||||
if (event.ids.has(item.key)) {
|
||||
doDelete(true)
|
||||
}
|
||||
case AssetEventType.move: {
|
||||
if (event.ids.has(item.key)) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
await doMove(event.newParentKey, item.item)
|
||||
}
|
||||
break
|
||||
break
|
||||
}
|
||||
case AssetEventType.restore: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doRestore(item.item)
|
||||
}
|
||||
case AssetEventType.delete: {
|
||||
if (event.ids.has(item.key)) {
|
||||
doDelete(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.deleteForever: {
|
||||
if (event.ids.has(item.key)) {
|
||||
doDelete(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.restore: {
|
||||
if (event.ids.has(item.key)) {
|
||||
await doRestore(item.item)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected: {
|
||||
if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) {
|
||||
if (isCloud) {
|
||||
switch (asset.type) {
|
||||
case backendModule.AssetType.project: {
|
||||
try {
|
||||
const details = await getProjectDetailsMutation.mutateAsync([
|
||||
break
|
||||
}
|
||||
case AssetEventType.download:
|
||||
case AssetEventType.downloadSelected: {
|
||||
if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) {
|
||||
if (isCloud) {
|
||||
switch (asset.type) {
|
||||
case backendModule.AssetType.project: {
|
||||
try {
|
||||
const details = await queryClient.fetchQuery(
|
||||
backendQueryOptions(backend, 'getProjectDetails', [
|
||||
asset.id,
|
||||
asset.parentId,
|
||||
asset.title,
|
||||
])
|
||||
if (details.url != null) {
|
||||
await backend.download(details.url, `${asset.title}.enso-project`)
|
||||
} else {
|
||||
const error: unknown = getText('projectHasNoSourceFilesPhrase')
|
||||
toastAndLog('downloadProjectError', error, asset.title)
|
||||
}
|
||||
} catch (error) {
|
||||
]),
|
||||
)
|
||||
if (details.url != null) {
|
||||
await backend.download(details.url, `${asset.title}.enso-project`)
|
||||
} else {
|
||||
const error: unknown = getText('projectHasNoSourceFilesPhrase')
|
||||
toastAndLog('downloadProjectError', error, asset.title)
|
||||
}
|
||||
break
|
||||
} catch (error) {
|
||||
toastAndLog('downloadProjectError', error, asset.title)
|
||||
}
|
||||
case backendModule.AssetType.file: {
|
||||
try {
|
||||
const details = await getFileDetailsMutation.mutateAsync([
|
||||
asset.id,
|
||||
asset.title,
|
||||
])
|
||||
if (details.url != null) {
|
||||
await backend.download(details.url, asset.title)
|
||||
} else {
|
||||
const error: unknown = getText('fileNotFoundPhrase')
|
||||
toastAndLog('downloadFileError', error, asset.title)
|
||||
}
|
||||
} catch (error) {
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.file: {
|
||||
try {
|
||||
const details = await queryClient.fetchQuery(
|
||||
backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title]),
|
||||
)
|
||||
if (details.url != null) {
|
||||
await backend.download(details.url, asset.title)
|
||||
} else {
|
||||
const error: unknown = getText('fileNotFoundPhrase')
|
||||
toastAndLog('downloadFileError', error, asset.title)
|
||||
}
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.datalink: {
|
||||
try {
|
||||
const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title])
|
||||
const fileName = `${asset.title}.datalink`
|
||||
download(
|
||||
URL.createObjectURL(
|
||||
new File([JSON.stringify(value)], fileName, {
|
||||
type: 'application/json+x-enso-data-link',
|
||||
}),
|
||||
),
|
||||
fileName,
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('downloadDatalinkError', error, asset.title)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
toastAndLog('downloadInvalidTypeError')
|
||||
break
|
||||
} catch (error) {
|
||||
toastAndLog('downloadFileError', error, asset.title)
|
||||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (asset.type === backendModule.AssetType.project) {
|
||||
const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id
|
||||
const uuid = localBackend.extractTypeAndId(asset.id).id
|
||||
const queryString = new URLSearchParams({ projectsDirectory }).toString()
|
||||
await backend.download(
|
||||
`./api/project-manager/projects/${uuid}/enso-project?${queryString}`,
|
||||
`${asset.title}.enso-project`,
|
||||
)
|
||||
case backendModule.AssetType.datalink: {
|
||||
try {
|
||||
const value = await queryClient.fetchQuery(
|
||||
backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]),
|
||||
)
|
||||
const fileName = `${asset.title}.datalink`
|
||||
download(
|
||||
URL.createObjectURL(
|
||||
new File([JSON.stringify(value)], fileName, {
|
||||
type: 'application/json+x-enso-data-link',
|
||||
}),
|
||||
),
|
||||
fileName,
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('downloadDatalinkError', error, asset.title)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
toastAndLog('downloadInvalidTypeError')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.removeSelf: {
|
||||
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
|
||||
if (event.id === asset.id && user.isEnabled) {
|
||||
setInsertionVisibility(Visibility.hidden)
|
||||
try {
|
||||
await createPermissionMutation.mutateAsync([
|
||||
{
|
||||
action: null,
|
||||
resourceId: asset.id,
|
||||
actorsIds: [user.userId],
|
||||
},
|
||||
])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
setInsertionVisibility(Visibility.visible)
|
||||
toastAndLog(null, error)
|
||||
} else {
|
||||
if (asset.type === backendModule.AssetType.project) {
|
||||
const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id
|
||||
const uuid = localBackend.extractTypeAndId(asset.id).id
|
||||
const queryString = new URLSearchParams({ projectsDirectory }).toString()
|
||||
await backend.download(
|
||||
`./api/project-manager/projects/${uuid}/enso-project?${queryString}`,
|
||||
`${asset.title}.enso-project`,
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.temporarilyAddLabels: {
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
|
||||
setRowState((oldRowState) =>
|
||||
(
|
||||
oldRowState.temporarilyAddedLabels === labels &&
|
||||
oldRowState.temporarilyRemovedLabels === set.EMPTY_SET
|
||||
) ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, {
|
||||
temporarilyAddedLabels: labels,
|
||||
temporarilyRemovedLabels: set.EMPTY_SET,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case AssetEventType.temporarilyRemoveLabels: {
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
|
||||
setRowState((oldRowState) =>
|
||||
(
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET &&
|
||||
oldRowState.temporarilyRemovedLabels === labels
|
||||
) ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, {
|
||||
temporarilyAddedLabels: set.EMPTY_SET,
|
||||
temporarilyRemovedLabels: labels,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case AssetEventType.addLabels: {
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
const labels = asset.labels
|
||||
if (
|
||||
event.ids.has(item.key) &&
|
||||
(labels == null || [...event.labelNames].some((label) => !labels.includes(label)))
|
||||
) {
|
||||
const newLabels = [
|
||||
...(labels ?? []),
|
||||
...[...event.labelNames].filter((label) => labels?.includes(label) !== true),
|
||||
]
|
||||
setAsset(object.merger({ labels: newLabels }))
|
||||
try {
|
||||
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
|
||||
} catch (error) {
|
||||
setAsset(object.merger({ labels }))
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.removeSelf: {
|
||||
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
|
||||
if (event.id === asset.id && user.isEnabled) {
|
||||
try {
|
||||
await createPermissionMutation.mutateAsync([
|
||||
{
|
||||
action: null,
|
||||
resourceId: asset.id,
|
||||
actorsIds: [user.userId],
|
||||
},
|
||||
])
|
||||
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.removeLabels: {
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
const labels = asset.labels
|
||||
if (
|
||||
event.ids.has(item.key) &&
|
||||
labels != null &&
|
||||
[...event.labelNames].some((label) => labels.includes(label))
|
||||
) {
|
||||
const newLabels = labels.filter((label) => !event.labelNames.has(label))
|
||||
setAsset(object.merger({ labels: newLabels }))
|
||||
try {
|
||||
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
|
||||
} catch (error) {
|
||||
setAsset(object.merger({ labels }))
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.temporarilyAddLabels: {
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
|
||||
setRowState((oldRowState) =>
|
||||
(
|
||||
oldRowState.temporarilyAddedLabels === labels &&
|
||||
oldRowState.temporarilyRemovedLabels === set.EMPTY_SET
|
||||
) ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, {
|
||||
temporarilyAddedLabels: labels,
|
||||
temporarilyRemovedLabels: set.EMPTY_SET,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case AssetEventType.temporarilyRemoveLabels: {
|
||||
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
|
||||
setRowState((oldRowState) =>
|
||||
(
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET &&
|
||||
oldRowState.temporarilyRemovedLabels === labels
|
||||
) ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, {
|
||||
temporarilyAddedLabels: set.EMPTY_SET,
|
||||
temporarilyRemovedLabels: labels,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case AssetEventType.addLabels: {
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
const labels = asset.labels
|
||||
if (
|
||||
event.ids.has(item.key) &&
|
||||
(labels == null || [...event.labelNames].some((label) => !labels.includes(label)))
|
||||
) {
|
||||
const newLabels = [
|
||||
...(labels ?? []),
|
||||
...[...event.labelNames].filter((label) => labels?.includes(label) !== true),
|
||||
]
|
||||
setAsset(object.merger({ labels: newLabels }))
|
||||
try {
|
||||
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
|
||||
} catch (error) {
|
||||
setAsset(object.merger({ labels }))
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.deleteLabel: {
|
||||
setAsset((oldAsset) => {
|
||||
const oldLabels = oldAsset.labels ?? []
|
||||
const labels: backendModule.LabelName[] = []
|
||||
break
|
||||
}
|
||||
case AssetEventType.removeLabels: {
|
||||
setRowState((oldRowState) =>
|
||||
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
|
||||
oldRowState
|
||||
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
|
||||
)
|
||||
const labels = asset.labels
|
||||
if (
|
||||
event.ids.has(item.key) &&
|
||||
labels != null &&
|
||||
[...event.labelNames].some((label) => labels.includes(label))
|
||||
) {
|
||||
const newLabels = labels.filter((label) => !event.labelNames.has(label))
|
||||
setAsset(object.merger({ labels: newLabels }))
|
||||
try {
|
||||
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
|
||||
} catch (error) {
|
||||
setAsset(object.merger({ labels }))
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetEventType.deleteLabel: {
|
||||
setAsset((oldAsset) => {
|
||||
const oldLabels = oldAsset.labels ?? []
|
||||
const labels: backendModule.LabelName[] = []
|
||||
|
||||
for (const label of oldLabels) {
|
||||
if (label !== event.labelName) {
|
||||
labels.push(label)
|
||||
}
|
||||
for (const label of oldLabels) {
|
||||
if (label !== event.labelName) {
|
||||
labels.push(label)
|
||||
}
|
||||
|
||||
return oldLabels.length !== labels.length ?
|
||||
object.merge(oldAsset, { labels })
|
||||
: oldAsset
|
||||
})
|
||||
break
|
||||
}
|
||||
case AssetEventType.setItem: {
|
||||
if (asset.id === event.id) {
|
||||
setAsset(event.valueOrUpdater)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
return
|
||||
|
||||
return oldLabels.length !== labels.length ? object.merge(oldAsset, { labels }) : oldAsset
|
||||
})
|
||||
break
|
||||
}
|
||||
case AssetEventType.setItem: {
|
||||
if (asset.id === event.id) {
|
||||
setAsset(event.valueOrUpdater)
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
return
|
||||
}
|
||||
}
|
||||
}, item.initialAssetEvents)
|
||||
@ -658,6 +631,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
<AssetContextMenu
|
||||
innerProps={innerProps}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
triggerRef={rootRef}
|
||||
event={event}
|
||||
eventTarget={
|
||||
event.target instanceof HTMLElement ? event.target : event.currentTarget
|
||||
@ -742,12 +716,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
const ids = payload
|
||||
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
|
||||
.map((dragItem) => dragItem.key)
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.move,
|
||||
newParentKey: directoryKey,
|
||||
newParentId: directoryId,
|
||||
ids: new Set(ids),
|
||||
})
|
||||
cutAndPaste(
|
||||
directoryKey,
|
||||
directoryId,
|
||||
{ backendType: backend.type, ids: new Set(ids), category },
|
||||
nodeMap.current,
|
||||
)
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@ -801,6 +775,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
|
||||
setRowState,
|
||||
}}
|
||||
rootDirectoryId={rootDirectoryId}
|
||||
triggerRef={rootRef}
|
||||
event={{ pageX: 0, pageY: 0 }}
|
||||
eventTarget={null}
|
||||
doCopy={doCopy}
|
||||
|
@ -9,18 +9,19 @@ import CtrlKeyIcon from '#/assets/ctrl_key.svg'
|
||||
import OptionKeyIcon from '#/assets/option_key.svg'
|
||||
import ShiftKeyIcon from '#/assets/shift_key.svg'
|
||||
import WindowsKeyIcon from '#/assets/windows_key.svg'
|
||||
|
||||
import type * as dashboardInputBindings from '#/configurations/inputBindings'
|
||||
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Text } from '#/components/AriaComponents'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as inputBindingsModule from '#/utilities/inputBindings'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import type { DashboardBindingKey } from '#/configurations/inputBindings'
|
||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import {
|
||||
compareModifiers,
|
||||
decomposeKeybindString,
|
||||
toModifierKey,
|
||||
type Key,
|
||||
type ModifierKey,
|
||||
} from '#/utilities/inputBindings'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
// ========================
|
||||
// === KeyboardShortcut ===
|
||||
@ -33,16 +34,14 @@ const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX, marginTop: '0.1c
|
||||
|
||||
/** Props for values of {@link MODIFIER_JSX}. */
|
||||
interface InternalModifierProps {
|
||||
readonly getText: ReturnType<typeof textProvider.useText>['getText']
|
||||
readonly getText: ReturnType<typeof useText>['getText']
|
||||
}
|
||||
|
||||
/** Icons for modifier keys (if they exist). */
|
||||
const MODIFIER_JSX: Readonly<
|
||||
Record<
|
||||
detect.Platform,
|
||||
Partial<
|
||||
Record<inputBindingsModule.ModifierKey, (props: InternalModifierProps) => React.ReactNode>
|
||||
>
|
||||
Partial<Record<ModifierKey, (props: InternalModifierProps) => React.ReactNode>>
|
||||
>
|
||||
> = {
|
||||
// The names are intentionally not in `camelCase`, as they are case-sensitive.
|
||||
@ -58,18 +57,18 @@ const MODIFIER_JSX: Readonly<
|
||||
},
|
||||
[detect.Platform.linux]: {
|
||||
Meta: (props) => (
|
||||
<aria.Text key="Meta" className="text">
|
||||
<Text key="Meta" className="text">
|
||||
{props.getText('superModifier')}
|
||||
</aria.Text>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
[detect.Platform.unknown]: {
|
||||
// Assume the system is Unix-like and calls the key that triggers `event.metaKey`
|
||||
// the "Super" key.
|
||||
Meta: (props) => (
|
||||
<aria.Text key="Meta" className="text">
|
||||
<Text key="Meta" className="text">
|
||||
{props.getText('superModifier')}
|
||||
</aria.Text>
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
[detect.Platform.iPhoneOS]: {},
|
||||
@ -86,9 +85,9 @@ const KEY_CHARACTER: Readonly<Record<string, string>> = {
|
||||
ArrowLeft: '←',
|
||||
ArrowRight: '→',
|
||||
/* 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.
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
Ctrl: 'ctrlModifier',
|
||||
@ -96,11 +95,11 @@ const MODIFIER_TO_TEXT_ID: Readonly<Record<inputBindingsModule.ModifierKey, text
|
||||
Meta: 'metaModifier',
|
||||
Shift: 'shiftModifier',
|
||||
/* 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. */
|
||||
export interface KeyboardShortcutActionProps {
|
||||
readonly action: dashboardInputBindings.DashboardBindingKey
|
||||
readonly action: DashboardBindingKey
|
||||
}
|
||||
|
||||
/** Props for a {@link KeyboardShortcut}, specifying the shortcut string. */
|
||||
@ -113,20 +112,18 @@ export type KeyboardShortcutProps = KeyboardShortcutActionProps | KeyboardShortc
|
||||
|
||||
/** A visual representation of a keyboard shortcut. */
|
||||
export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const { getText } = useText()
|
||||
const inputBindings = useInputBindings()
|
||||
const shortcutString =
|
||||
'shortcut' in props ? props.shortcut : inputBindings.metadata[props.action].bindings[0]
|
||||
if (shortcutString == null) {
|
||||
return null
|
||||
} else {
|
||||
const shortcut = inputBindingsModule.decomposeKeybindString(shortcutString)
|
||||
const modifiers = [...shortcut.modifiers]
|
||||
.sort(inputBindingsModule.compareModifiers)
|
||||
.map(inputBindingsModule.toModifierKey)
|
||||
const shortcut = decomposeKeybindString(shortcutString)
|
||||
const modifiers = [...shortcut.modifiers].sort(compareModifiers).map(toModifierKey)
|
||||
return (
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
className={twMerge(
|
||||
'flex items-center',
|
||||
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers',
|
||||
)}
|
||||
@ -134,14 +131,10 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
{modifiers.map(
|
||||
(modifier) =>
|
||||
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
|
||||
<ariaComponents.Text key={modifier}>
|
||||
{getText(MODIFIER_TO_TEXT_ID[modifier])}
|
||||
</ariaComponents.Text>
|
||||
<Text key={modifier}>{getText(MODIFIER_TO_TEXT_ID[modifier])}</Text>
|
||||
),
|
||||
)}
|
||||
<ariaComponents.Text>
|
||||
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
|
||||
</ariaComponents.Text>
|
||||
<Text>{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,51 +1,48 @@
|
||||
/** @file Column types and column display modes. */
|
||||
import type * as React from 'react'
|
||||
import type { Dispatch, JSX, SetStateAction } from 'react'
|
||||
|
||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import { Column } from '#/components/dashboard/column/columnUtils'
|
||||
import DocsColumn from '#/components/dashboard/column/DocsColumn'
|
||||
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
|
||||
import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn'
|
||||
import NameColumn from '#/components/dashboard/column/NameColumn'
|
||||
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
|
||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable'
|
||||
import type { Asset, AssetId, BackendType } from '#/services/Backend'
|
||||
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
|
||||
|
||||
// ===================
|
||||
// === AssetColumn ===
|
||||
// ===================
|
||||
|
||||
/** Props for an arbitrary variant of {@link backendModule.Asset}. */
|
||||
/** Props for an arbitrary variant of {@link Asset}. */
|
||||
export interface AssetColumnProps {
|
||||
readonly keyProp: backendModule.AssetId
|
||||
readonly keyProp: AssetId
|
||||
readonly isOpened: boolean
|
||||
readonly item: assetTreeNode.AnyAssetTreeNode
|
||||
readonly backendType: backendModule.BackendType
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
|
||||
readonly item: AnyAssetTreeNode
|
||||
readonly backendType: BackendType
|
||||
readonly setItem: Dispatch<SetStateAction<AnyAssetTreeNode>>
|
||||
readonly selected: boolean
|
||||
readonly setSelected: (selected: boolean) => void
|
||||
readonly isSoleSelected: boolean
|
||||
readonly state: assetsTable.AssetsTableState
|
||||
readonly rowState: assetsTable.AssetRowState
|
||||
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
|
||||
readonly state: AssetsTableState
|
||||
readonly rowState: AssetRowState
|
||||
readonly setRowState: Dispatch<SetStateAction<AssetRowState>>
|
||||
readonly isEditable: boolean
|
||||
}
|
||||
|
||||
/** Props for a {@link AssetColumn}. */
|
||||
export interface AssetColumnHeadingProps {
|
||||
readonly state: assetsTable.AssetsTableState
|
||||
readonly state: AssetsTableState
|
||||
}
|
||||
|
||||
/** Metadata describing how to render a column of the table. */
|
||||
export interface AssetColumn {
|
||||
readonly id: string
|
||||
readonly className?: string
|
||||
readonly heading: (props: AssetColumnHeadingProps) => React.JSX.Element
|
||||
readonly render: (props: AssetColumnProps) => React.JSX.Element
|
||||
readonly heading: (props: AssetColumnHeadingProps) => JSX.Element
|
||||
readonly render: (props: AssetColumnProps) => JSX.Element
|
||||
}
|
||||
|
||||
// =======================
|
||||
@ -53,14 +50,12 @@ export interface AssetColumn {
|
||||
// =======================
|
||||
|
||||
/** React components for every column. */
|
||||
export const COLUMN_RENDERER: Readonly<
|
||||
Record<columnUtils.Column, (props: AssetColumnProps) => React.JSX.Element>
|
||||
> = {
|
||||
[columnUtils.Column.name]: NameColumn,
|
||||
[columnUtils.Column.modified]: ModifiedColumn,
|
||||
[columnUtils.Column.sharedWith]: SharedWithColumn,
|
||||
[columnUtils.Column.labels]: LabelsColumn,
|
||||
[columnUtils.Column.accessedByProjects]: PlaceholderColumn,
|
||||
[columnUtils.Column.accessedData]: PlaceholderColumn,
|
||||
[columnUtils.Column.docs]: DocsColumn,
|
||||
export const COLUMN_RENDERER: Readonly<Record<Column, (props: AssetColumnProps) => JSX.Element>> = {
|
||||
[Column.name]: NameColumn,
|
||||
[Column.modified]: ModifiedColumn,
|
||||
[Column.sharedWith]: SharedWithColumn,
|
||||
[Column.labels]: LabelsColumn,
|
||||
[Column.accessedByProjects]: PlaceholderColumn,
|
||||
[Column.accessedData]: PlaceholderColumn,
|
||||
[Column.docs]: DocsColumn,
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Button, DialogTrigger } from '#/components/AriaComponents'
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
@ -23,7 +23,6 @@ import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// ====================
|
||||
// === LabelsColumn ===
|
||||
@ -43,7 +42,6 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
const labelsByName = React.useMemo(() => {
|
||||
return new Map(labels?.map((label) => [label.value, label]))
|
||||
}, [labels])
|
||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const self = permissions.tryFindSelfPermission(user, asset.permissions)
|
||||
const managesThisAsset =
|
||||
category.type !== 'trash' &&
|
||||
@ -130,23 +128,10 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
</Label>
|
||||
))}
|
||||
{managesThisAsset && (
|
||||
<ariaComponents.Button
|
||||
ref={plusButtonRef}
|
||||
size="medium"
|
||||
variant="ghost"
|
||||
showIconOnHover
|
||||
icon={Plus2Icon}
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManageLabelsModal
|
||||
key={uniqueString.uniqueString()}
|
||||
backend={backend}
|
||||
item={asset}
|
||||
eventTarget={plusButtonRef.current}
|
||||
/>,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<DialogTrigger>
|
||||
<Button variant="ghost" showIconOnHover icon={Plus2Icon} />
|
||||
<ManageLabelsModal backend={backend} item={asset} />
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
@ -2,83 +2,67 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import Plus2Icon from '#/assets/plus2.svg'
|
||||
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import { Button } from '#/components/AriaComponents'
|
||||
import type { AssetColumnProps } from '#/components/dashboard/column'
|
||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
|
||||
import { PaywallDialogButton } from '#/components/Paywall'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import { useAssetPassiveListenerStrict } from '#/hooks/backendHooks'
|
||||
import { usePaywall } from '#/hooks/billing'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { getAssetPermissionId, getAssetPermissionName } from '#/services/Backend'
|
||||
import { PermissionAction, tryFindSelfPermission } from '#/utilities/permissions'
|
||||
|
||||
// ========================
|
||||
// === SharedWithColumn ===
|
||||
// ========================
|
||||
|
||||
/** The type of the `state` prop of a {@link SharedWithColumn}. */
|
||||
interface SharedWithColumnStateProp extends Pick<column.AssetColumnProps['state'], 'category'> {
|
||||
readonly setQuery: column.AssetColumnProps['state']['setQuery'] | null
|
||||
interface SharedWithColumnStateProp
|
||||
extends Pick<AssetColumnProps['state'], 'backend' | 'category'> {
|
||||
readonly setQuery: AssetColumnProps['state']['setQuery'] | null
|
||||
}
|
||||
|
||||
/** Props for a {@link SharedWithColumn}. */
|
||||
interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'item' | 'setItem'> {
|
||||
interface SharedWithColumnPropsInternal extends Pick<AssetColumnProps, 'item'> {
|
||||
readonly isReadonly?: boolean
|
||||
readonly state: SharedWithColumnStateProp
|
||||
}
|
||||
|
||||
/** A column listing the users with which this asset is shared. */
|
||||
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
const { item, setItem, state, isReadonly = false } = props
|
||||
const { category, setQuery } = state
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const { item, state, isReadonly = false } = props
|
||||
const { backend, category, setQuery } = state
|
||||
const asset = useAssetPassiveListenerStrict(
|
||||
backend.type,
|
||||
item.item.id,
|
||||
item.item.parentId,
|
||||
category,
|
||||
)
|
||||
const { user } = useFullUserSession()
|
||||
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('share')
|
||||
const assetPermissions = asset.permissions ?? []
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const self = permissions.tryFindSelfPermission(user, asset.permissions)
|
||||
const { setModal } = useSetModal()
|
||||
const self = tryFindSelfPermission(user, asset.permissions)
|
||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const managesThisAsset =
|
||||
!isReadonly &&
|
||||
category.type !== 'trash' &&
|
||||
(self?.permission === permissions.PermissionAction.own ||
|
||||
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],
|
||||
)
|
||||
(self?.permission === PermissionAction.own || self?.permission === PermissionAction.admin)
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-column-items">
|
||||
{(category.type === 'trash' ?
|
||||
assetPermissions.filter(
|
||||
(permission) => permission.permission === permissions.PermissionAction.own,
|
||||
)
|
||||
assetPermissions.filter((permission) => permission.permission === PermissionAction.own)
|
||||
: assetPermissions
|
||||
).map((other, idx) => (
|
||||
<PermissionDisplay
|
||||
key={backendModule.getAssetPermissionId(other) + idx}
|
||||
key={getAssetPermissionId(other) + idx}
|
||||
action={other.permission}
|
||||
onPress={
|
||||
setQuery == null ? null : (
|
||||
@ -87,7 +71,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
oldQuery.withToggled(
|
||||
'owners',
|
||||
'negativeOwners',
|
||||
backendModule.getAssetPermissionName(other),
|
||||
getAssetPermissionName(other),
|
||||
event.shiftKey,
|
||||
),
|
||||
)
|
||||
@ -95,11 +79,11 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
)
|
||||
}
|
||||
>
|
||||
{backendModule.getAssetPermissionName(other)}
|
||||
{getAssetPermissionName(other)}
|
||||
</PermissionDisplay>
|
||||
))}
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallDialogButton
|
||||
<PaywallDialogButton
|
||||
feature="share"
|
||||
variant="icon"
|
||||
size="medium"
|
||||
@ -108,7 +92,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
/>
|
||||
)}
|
||||
{managesThisAsset && !isUnderPaywall && (
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
ref={plusButtonRef}
|
||||
size="medium"
|
||||
variant="ghost"
|
||||
@ -117,9 +101,9 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
key={uniqueString.uniqueString()}
|
||||
backend={backend}
|
||||
category={category}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
eventTarget={plusButtonRef.current}
|
||||
doRemoveSelf={() => {
|
||||
|
@ -7,12 +7,10 @@ enum AssetListEventType {
|
||||
uploadFiles = 'upload-files',
|
||||
newDatalink = 'new-datalink',
|
||||
newSecret = 'new-secret',
|
||||
insertAssets = 'insert-assets',
|
||||
duplicateProject = 'duplicate-project',
|
||||
closeFolder = 'close-folder',
|
||||
copy = 'copy',
|
||||
move = 'move',
|
||||
willDelete = 'will-delete',
|
||||
delete = 'delete',
|
||||
emptyTrash = 'empty-trash',
|
||||
removeSelf = 'remove-self',
|
||||
|
@ -1,7 +1,13 @@
|
||||
/** @file Events related to changes in the asset list. */
|
||||
import type AssetListEventType from '#/events/AssetListEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useDispatchAssetListEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import { useTransferBetweenCategories, type Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import type { DrivePastePayload } from '#/providers/DriveProvider'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
|
||||
import { isTeamPath, isUserPath } from '#/utilities/permissions'
|
||||
|
||||
// ======================
|
||||
// === AssetListEvent ===
|
||||
@ -19,12 +25,10 @@ interface AssetListEvents {
|
||||
readonly uploadFiles: AssetListUploadFilesEvent
|
||||
readonly newSecret: AssetListNewSecretEvent
|
||||
readonly newDatalink: AssetListNewDatalinkEvent
|
||||
readonly insertAssets: AssetListInsertAssetsEvent
|
||||
readonly duplicateProject: AssetListDuplicateProjectEvent
|
||||
readonly closeFolder: AssetListCloseFolderEvent
|
||||
readonly copy: AssetListCopyEvent
|
||||
readonly move: AssetListMoveEvent
|
||||
readonly willDelete: AssetListWillDeleteEvent
|
||||
readonly delete: AssetListDeleteEvent
|
||||
readonly emptyTrash: AssetListEmptyTrashEvent
|
||||
readonly removeSelf: AssetListRemoveSelfEvent
|
||||
@ -81,13 +85,6 @@ interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.
|
||||
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. */
|
||||
interface AssetListDuplicateProjectEvent
|
||||
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
|
||||
@ -118,11 +115,6 @@ interface AssetListMoveEvent extends AssetListBaseEvent<AssetListEventType.move>
|
||||
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
|
||||
* finished.
|
||||
@ -141,3 +133,53 @@ interface AssetListRemoveSelfEvent extends AssetListBaseEvent<AssetListEventType
|
||||
|
||||
/** Every possible type of asset list event. */
|
||||
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,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,37 @@
|
||||
/** @file Hooks for interacting with the backend. */
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import {
|
||||
queryOptions,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
type Mutation,
|
||||
type MutationKey,
|
||||
type UseMutationOptions,
|
||||
type UseQueryOptions,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import * as backendQuery from 'enso-common/src/backendQuery'
|
||||
import {
|
||||
backendQueryOptions as backendQueryOptionsBase,
|
||||
type BackendMethods,
|
||||
} from 'enso-common/src/backendQuery'
|
||||
|
||||
import { CATEGORY_TO_FILTER_BY, type Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import {
|
||||
AssetType,
|
||||
BackendType,
|
||||
type AnyAsset,
|
||||
type AssetId,
|
||||
type DirectoryAsset,
|
||||
type DirectoryId,
|
||||
type User,
|
||||
type UserGroupInfo,
|
||||
} from '#/services/Backend'
|
||||
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||
import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime'
|
||||
|
||||
// ============================
|
||||
// === DefineBackendMethods ===
|
||||
@ -44,10 +69,6 @@ export type MutationMethod = DefineBackendMethods<
|
||||
| 'deleteUser'
|
||||
| 'deleteUserGroup'
|
||||
| 'duplicateProject'
|
||||
// TODO: `get*` are not mutations, but are currently used in some places.
|
||||
| 'getDatalink'
|
||||
| 'getFileDetails'
|
||||
| 'getProjectDetails'
|
||||
| 'inviteUser'
|
||||
| 'logEvent'
|
||||
| 'openProject'
|
||||
@ -71,55 +92,75 @@ export type MutationMethod = DefineBackendMethods<
|
||||
// === useBackendQuery ===
|
||||
// =======================
|
||||
|
||||
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
export function backendQueryOptions<Method extends BackendMethods>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
|
||||
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>
|
||||
export function backendQueryOptions<Method extends BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): reactQuery.UseQueryResult<
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): UseQueryOptions<
|
||||
// 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 backendQuery.BackendMethods>(
|
||||
export function backendQueryOptions<Method extends BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
|
||||
Partial<Pick<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,
|
||||
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
|
||||
...backendQueryOptionsBase(backend, method, args, options?.queryKey),
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
queryFn: () => (backend?.[method] as any)?.(...args),
|
||||
})
|
||||
}
|
||||
|
||||
export function useBackendQuery<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 ===
|
||||
// ==========================
|
||||
|
||||
const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
|
||||
const INVALIDATION_MAP: Partial<
|
||||
Record<MutationMethod, readonly (backendQuery.BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
|
||||
Record<MutationMethod, readonly (BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
|
||||
> = {
|
||||
createUser: ['usersMe'],
|
||||
updateUser: ['usersMe'],
|
||||
@ -141,7 +182,7 @@ const INVALIDATION_MAP: Partial<
|
||||
createDirectory: ['listDirectory'],
|
||||
createSecret: ['listDirectory'],
|
||||
updateSecret: ['listDirectory'],
|
||||
createDatalink: ['listDirectory'],
|
||||
createDatalink: ['listDirectory', 'getDatalink'],
|
||||
uploadFile: ['listDirectory'],
|
||||
copyAsset: ['listDirectory', 'listAssetVersions'],
|
||||
deleteAsset: ['listDirectory', 'listAssetVersions'],
|
||||
@ -151,34 +192,29 @@ const INVALIDATION_MAP: Partial<
|
||||
updateDirectory: ['listDirectory'],
|
||||
}
|
||||
|
||||
export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): reactQuery.UseMutationOptions<
|
||||
/** The type of the corresponding mutation for the given backend method. */
|
||||
export type BackendMutation<Method extends MutationMethod> = Mutation<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
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>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>,
|
||||
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): reactQuery.UseMutationOptions<
|
||||
): UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>> | undefined,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
@ -188,24 +224,16 @@ export function backendMutationOptions<Method extends MutationMethod>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
options?: Omit<
|
||||
reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
>,
|
||||
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
|
||||
'mutationFn'
|
||||
>,
|
||||
): reactQuery.UseMutationOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Parameters<Backend[Method]>
|
||||
> {
|
||||
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>> {
|
||||
return {
|
||||
...options,
|
||||
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
mutationFn: (args) => (backend?.[method] as any)?.(...args),
|
||||
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online',
|
||||
networkMode: backend?.type === BackendType.local ? 'always' : 'online',
|
||||
meta: {
|
||||
invalidates: [
|
||||
...(options?.meta?.invalidates ?? []),
|
||||
@ -223,8 +251,8 @@ export function backendMutationOptions<Method extends MutationMethod>(
|
||||
// ==================================
|
||||
|
||||
/** A user group, as well as the users that are a part of the user group. */
|
||||
export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
|
||||
readonly users: readonly backendModule.User[]
|
||||
export interface UserGroupInfoWithUsers extends UserGroupInfo {
|
||||
readonly users: readonly User[]
|
||||
}
|
||||
|
||||
/** A list of user groups, taking into account optimistic state. */
|
||||
@ -233,12 +261,12 @@ export function useListUserGroupsWithUsers(
|
||||
): readonly UserGroupInfoWithUsers[] | null {
|
||||
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
|
||||
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
|
||||
return React.useMemo(() => {
|
||||
return useMemo(() => {
|
||||
if (listUserGroupsQuery.data == null || listUsersQuery.data == null) {
|
||||
return null
|
||||
} else {
|
||||
const result = listUserGroupsQuery.data.map((userGroup) => {
|
||||
const usersInGroup: readonly backendModule.User[] = listUsersQuery.data.filter((user) =>
|
||||
const usersInGroup: readonly User[] = listUsersQuery.data.filter((user) =>
|
||||
user.userGroups?.includes(userGroup.id),
|
||||
)
|
||||
return { ...userGroup, users: usersInGroup }
|
||||
@ -247,3 +275,96 @@ export function useListUserGroupsWithUsers(
|
||||
}
|
||||
}, [listUserGroupsQuery.data, listUsersQuery.data])
|
||||
}
|
||||
|
||||
/** Data for a specific asset. */
|
||||
export function useAssetPassiveListener(
|
||||
backendType: BackendType,
|
||||
assetId: AssetId | null | undefined,
|
||||
parentId: DirectoryId | null | undefined,
|
||||
category: Category,
|
||||
) {
|
||||
const listDirectoryQuery = useQuery<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,
|
||||
})
|
||||
}
|
||||
|
@ -62,26 +62,21 @@ function useSetProjectAsset() {
|
||||
parentId: backendModule.DirectoryId,
|
||||
transform: (asset: backendModule.ProjectAsset) => backendModule.ProjectAsset,
|
||||
) => {
|
||||
const listDirectoryQuery = queryClient.getQueryCache().find<
|
||||
| {
|
||||
parentId: backendModule.DirectoryId
|
||||
children: readonly backendModule.AnyAsset<backendModule.AssetType>[]
|
||||
}
|
||||
| undefined
|
||||
>({
|
||||
queryKey: [backendType, 'listDirectory', parentId],
|
||||
exact: false,
|
||||
})
|
||||
const listDirectoryQuery = queryClient
|
||||
.getQueryCache()
|
||||
.find<readonly backendModule.AnyAsset<backendModule.AssetType>[] | undefined>({
|
||||
queryKey: [backendType, 'listDirectory', parentId],
|
||||
exact: false,
|
||||
})
|
||||
|
||||
if (listDirectoryQuery?.state.data) {
|
||||
listDirectoryQuery.setData({
|
||||
...listDirectoryQuery.state.data,
|
||||
children: listDirectoryQuery.state.data.children.map((child) =>
|
||||
listDirectoryQuery.setData(
|
||||
listDirectoryQuery.state.data.map((child) =>
|
||||
child.id === assetId && child.type === backendModule.AssetType.project ?
|
||||
transform(child)
|
||||
: child,
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -7,7 +7,6 @@ import * as toast from 'react-toastify'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
import * as copyHooks from '#/hooks/copyHooks'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -56,6 +55,7 @@ export interface AssetContextMenuProps {
|
||||
readonly hidden?: boolean
|
||||
readonly innerProps: assetRow.AssetRowInnerProps
|
||||
readonly rootDirectoryId: backendModule.DirectoryId
|
||||
readonly triggerRef: React.MutableRefObject<HTMLElement | null>
|
||||
readonly event: Pick<React.MouseEvent, 'pageX' | 'pageY'>
|
||||
readonly eventTarget: HTMLElement | null
|
||||
readonly doDelete: () => void
|
||||
@ -69,7 +69,7 @@ export interface AssetContextMenuProps {
|
||||
|
||||
/** The context menu for an arbitrary {@link backendModule.Asset}. */
|
||||
export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const { innerProps, rootDirectoryId, event, eventTarget, hidden = false } = props
|
||||
const { innerProps, rootDirectoryId, event, eventTarget, hidden = false, triggerRef } = props
|
||||
const { doCopy, doCut, doPaste, doDelete } = props
|
||||
const { item, setItem, state, setRowState } = innerProps
|
||||
const { backend, category, nodeMap } = state
|
||||
@ -131,12 +131,21 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const canPaste =
|
||||
!pasteData || !pasteDataParentKeys || !isCloud ?
|
||||
true
|
||||
: !Array.from(pasteData.data.ids).some((assetId) => {
|
||||
const parentKey = pasteDataParentKeys.get(assetId)
|
||||
: Array.from(pasteData.data.ids).every((key) => {
|
||||
const parentKey = pasteDataParentKeys.get(key)
|
||||
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
|
||||
return !parent ? true : (
|
||||
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
|
||||
if (!parent) {
|
||||
return false
|
||||
} else if (permissions.isTeamPath(parent.path)) {
|
||||
return true
|
||||
} else {
|
||||
// Assume user path; check permissions
|
||||
const permission = permissions.tryFindSelfPermission(user, item.item.permissions)
|
||||
return (
|
||||
permission != null &&
|
||||
permissions.canPermissionModifyDirectoryContents(permission.permission)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const { data } = reactQuery.useQuery(
|
||||
@ -161,8 +170,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
asset.projectState.openedBy != null &&
|
||||
asset.projectState.openedBy !== user.email
|
||||
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
const pasteMenuEntry = hasPasteData && canPaste && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
@ -358,7 +365,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
label={getText('editDescriptionShortcut')}
|
||||
doAction={() => {
|
||||
setIsAssetPanelTemporarilyVisible(true)
|
||||
setAssetPanelProps({ backend, item, setItem, spotlightOn: 'description' })
|
||||
setAssetPanelProps({ backend, item, spotlightOn: 'description' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@ -417,8 +424,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
backend={backend}
|
||||
category={category}
|
||||
item={asset}
|
||||
setItem={setAsset}
|
||||
self={self}
|
||||
eventTarget={eventTarget}
|
||||
doRemoveSelf={() => {
|
||||
@ -441,7 +449,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
action="label"
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManageLabelsModal backend={backend} item={asset} eventTarget={eventTarget} />,
|
||||
<ManageLabelsModal backend={backend} item={asset} triggerRef={triggerRef} />,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
@ -1,5 +1,5 @@
|
||||
/** @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'
|
||||
|
||||
@ -53,7 +53,6 @@ LocalStorage.register({
|
||||
export interface AssetPanelContextProps {
|
||||
readonly backend: Backend | null
|
||||
readonly item: AnyAssetTreeNode | null
|
||||
readonly setItem: Dispatch<SetStateAction<AnyAssetTreeNode>> | null
|
||||
readonly spotlightOn?: AssetPropertiesSpotlight
|
||||
}
|
||||
|
||||
@ -68,7 +67,7 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
const { backendType, category } = props
|
||||
const contextPropsRaw = useAssetPanelProps()
|
||||
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
|
||||
const { backend, item, setItem } = contextProps ?? {}
|
||||
const { backend, item } = contextProps ?? {}
|
||||
const isReadonly = category.type === 'trash'
|
||||
const isCloud = backend?.type === BackendType.remote
|
||||
const isVisible = useIsAssetPanelVisible()
|
||||
@ -83,11 +82,11 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
if (!isCloud) {
|
||||
return 'settings'
|
||||
} else if (
|
||||
(item?.item.type === AssetType.secret || item?.item.type === AssetType.directory) &&
|
||||
(item?.type === AssetType.secret || item?.type === AssetType.directory) &&
|
||||
tabRaw === 'versions'
|
||||
) {
|
||||
return 'settings'
|
||||
} else if (item?.item.type !== AssetType.project && tabRaw === 'sessions') {
|
||||
} else if (item?.type !== AssetType.project && tabRaw === 'sessions') {
|
||||
return 'settings'
|
||||
} else {
|
||||
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">
|
||||
{getText('selectExactlyOneAssetToViewItsDetails')}
|
||||
</div>
|
||||
@ -141,14 +140,12 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
<Tab id="settings" labelId="settings" isActive={tab === 'settings'} icon={null}>
|
||||
{getText('settings')}
|
||||
</Tab>
|
||||
{isCloud &&
|
||||
item.item.type !== AssetType.secret &&
|
||||
item.item.type !== AssetType.directory && (
|
||||
<Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}>
|
||||
{getText('versions')}
|
||||
</Tab>
|
||||
)}
|
||||
{isCloud && item.item.type === AssetType.project && (
|
||||
{isCloud && item.type !== AssetType.secret && item.type !== AssetType.directory && (
|
||||
<Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}>
|
||||
{getText('versions')}
|
||||
</Tab>
|
||||
)}
|
||||
{isCloud && item.type === AssetType.project && (
|
||||
<Tab
|
||||
id="sessions"
|
||||
labelId="projectSessions"
|
||||
@ -165,7 +162,6 @@ export default function AssetPanel(props: AssetPanelProps) {
|
||||
backend={backend}
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
category={category}
|
||||
spotlightOn={contextProps?.spotlightOn}
|
||||
/>
|
||||
|
@ -4,50 +4,62 @@ import * as React from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import PenIcon from '#/assets/pen.svg'
|
||||
|
||||
import * as datalinkValidator from '#/data/datalinkValidator'
|
||||
|
||||
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Heading } from '#/components/aria'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CopyButton,
|
||||
Form,
|
||||
ResizableContentEditableInput,
|
||||
Text,
|
||||
} from '#/components/AriaComponents'
|
||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
import { DatalinkFormInput } from '#/components/dashboard/DatalinkInput'
|
||||
import Label from '#/components/dashboard/Label'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackendModule from '#/services/LocalBackend'
|
||||
|
||||
import { validateDatalink } from '#/data/datalinkValidator'
|
||||
import {
|
||||
backendMutationOptions,
|
||||
useAssetPassiveListenerStrict,
|
||||
useBackendQuery,
|
||||
} from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useSpotlight } from '#/hooks/spotlightHooks'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import { useLocalBackend } from '#/providers/BackendProvider'
|
||||
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 { mapNonNullish } from '#/utilities/nullable'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
|
||||
// =======================
|
||||
// === 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. */
|
||||
export type AssetPropertiesSpotlight = 'datalink' | 'description' | 'secret'
|
||||
|
||||
/** Props for an {@link AssetPropertiesProps}. */
|
||||
export interface AssetPropertiesProps {
|
||||
readonly backend: Backend
|
||||
readonly item: assetTreeNode.AnyAssetTreeNode
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AnyAssetTreeNode>>
|
||||
readonly item: AnyAssetTreeNode
|
||||
readonly category: Category
|
||||
readonly isReadonly?: boolean
|
||||
readonly spotlightOn: AssetPropertiesSpotlight | undefined
|
||||
@ -55,9 +67,15 @@ export interface AssetPropertiesProps {
|
||||
|
||||
/** Display and modify the properties of an asset. */
|
||||
export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const { backend, item, setItem, category, spotlightOn } = props
|
||||
const { isReadonly = false } = props
|
||||
const { backend, item, category, spotlightOn, isReadonly = false } = props
|
||||
const styles = ASSET_PROPERTIES_VARIANTS({})
|
||||
|
||||
const asset = useAssetPassiveListenerStrict(
|
||||
backend.type,
|
||||
item.item.id,
|
||||
item.item.parentId,
|
||||
category,
|
||||
)
|
||||
const setAssetPanelProps = useSetAssetPanelProps()
|
||||
const closeSpotlight = useEventCallback(() => {
|
||||
const assetPanelProps = driveStore.getState().assetPanelProps
|
||||
@ -67,10 +85,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
setAssetPanelProps(rest)
|
||||
}
|
||||
})
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const { user } = useFullUserSession()
|
||||
const isEnterprise = user.plan === Plan.enterprise
|
||||
const { getText } = useText()
|
||||
const localBackend = useLocalBackend()
|
||||
const [isEditingDescriptionRaw, setIsEditingDescriptionRaw] = React.useState(false)
|
||||
const isEditingDescription = isEditingDescriptionRaw || spotlightOn === 'description'
|
||||
const setIsEditingDescription = React.useCallback(
|
||||
@ -87,10 +105,19 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
},
|
||||
[closeSpotlight],
|
||||
)
|
||||
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
|
||||
const [description, setDescription] = React.useState('')
|
||||
const [datalinkValue, setDatalinkValue] = React.useState<NonNullable<unknown> | null>(null)
|
||||
const [isDatalinkFetched, setIsDatalinkFetched] = React.useState(false)
|
||||
const featureFlags = useFeatureFlags()
|
||||
const datalinkQuery = useBackendQuery(
|
||||
backend,
|
||||
'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 descriptionRef = React.useRef<HTMLDivElement>(null)
|
||||
const descriptionSpotlight = useSpotlight({
|
||||
@ -112,187 +139,167 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
})
|
||||
|
||||
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 canEditThisAsset =
|
||||
ownsThisAsset ||
|
||||
self?.permission === permissions.PermissionAction.admin ||
|
||||
self?.permission === permissions.PermissionAction.edit
|
||||
const isSecret = item.type === backendModule.AssetType.secret
|
||||
const isDatalink = item.type === backendModule.AssetType.datalink
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isSecret = asset.type === AssetType.secret
|
||||
const isDatalink = asset.type === AssetType.datalink
|
||||
const isCloud = backend.type === BackendType.remote
|
||||
const pathRaw =
|
||||
category.type === 'recent' || category.type === 'trash' ? null
|
||||
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
|
||||
: item.item.type === backendModule.AssetType.project ?
|
||||
mapNonNullish(localBackend?.getProjectPath(item.item.id) ?? null, normalizePath)
|
||||
: normalizePath(localBackendModule.extractTypeAndId(item.item.id).id)
|
||||
: isCloud ? `${item.path}${item.type === AssetType.datalink ? '.datalink' : ''}`
|
||||
: asset.type === AssetType.project ?
|
||||
mapNonNullish(localBackend?.getProjectPath(asset.id) ?? null, normalizePath)
|
||||
: normalizePath(extractTypeAndId(asset.id).id)
|
||||
const path =
|
||||
pathRaw == null ? null
|
||||
: isCloud ? encodeURI(pathRaw)
|
||||
: pathRaw
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
|
||||
const updateAssetMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
|
||||
const editDescriptionMutation = useMutation(
|
||||
// Provide an extra `mutationKey` so that it has its own loading state.
|
||||
backendMutationOptions(backend, 'updateAsset', { mutationKey: ['editDescription'] }),
|
||||
)
|
||||
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||
const getDatalink = getDatalinkMutation.mutateAsync
|
||||
const displayedDescription =
|
||||
editDescriptionMutation.variables?.[1].description ?? asset.description
|
||||
|
||||
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,
|
||||
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 },
|
||||
item.item.title,
|
||||
asset.title,
|
||||
])
|
||||
} catch {
|
||||
toastAndLog('editDescriptionError')
|
||||
setItem((oldItem) =>
|
||||
oldItem.with({ item: object.merge(oldItem.item, { description: oldDescription }) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
{descriptionSpotlight.spotlightElement}
|
||||
{secretSpotlight.spotlightElement}
|
||||
{datalinkSpotlight.spotlightElement}
|
||||
<div
|
||||
ref={descriptionRef}
|
||||
className="pointer-events-auto flex flex-col items-start gap-side-panel rounded-default"
|
||||
{...descriptionSpotlight.props}
|
||||
>
|
||||
<aria.Heading
|
||||
<div ref={descriptionRef} className={styles.section()} {...descriptionSpotlight.props}>
|
||||
<Heading
|
||||
level={2}
|
||||
className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug"
|
||||
>
|
||||
{getText('description')}
|
||||
{!isReadonly && ownsThisAsset && !isEditingDescription && (
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
icon={PenIcon}
|
||||
loading={editDescriptionMutation.isPending}
|
||||
onPress={() => {
|
||||
setIsEditingDescription(true)
|
||||
setQueuedDescripion(item.item.description)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aria.Heading>
|
||||
</Heading>
|
||||
<div
|
||||
data-testid="asset-panel-description"
|
||||
className="self-stretch py-side-panel-description-y"
|
||||
>
|
||||
{!isEditingDescription ?
|
||||
<aria.Text className="text">{item.item.description}</aria.Text>
|
||||
: <form className="flex flex-col gap-modal pr-4" onSubmit={doEditDescription}>
|
||||
<textarea
|
||||
ref={(element) => {
|
||||
if (element != null && queuedDescription != null) {
|
||||
element.value = queuedDescription
|
||||
setQueuedDescripion(null)
|
||||
}
|
||||
}}
|
||||
<Text>{displayedDescription}</Text>
|
||||
: <Form form={editDescriptionForm} className="flex flex-col gap-modal pr-4">
|
||||
<ResizableContentEditableInput
|
||||
autoFocus
|
||||
value={description}
|
||||
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"
|
||||
onChange={(event) => {
|
||||
setDescription(event.currentTarget.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
setIsEditingDescription(false)
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
if (event.ctrlKey) {
|
||||
void doEditDescription()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
form={editDescriptionForm}
|
||||
name="description"
|
||||
mode="onBlur"
|
||||
/>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.Button size="medium" variant="outline" onPress={doEditDescription}>
|
||||
{getText('update')}
|
||||
</ariaComponents.Button>
|
||||
</ariaComponents.ButtonGroup>
|
||||
</form>
|
||||
<ButtonGroup>
|
||||
<Form.Submit>{getText('update')}</Form.Submit>
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{isCloud && (
|
||||
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
|
||||
<aria.Heading
|
||||
<div className={styles.section()}>
|
||||
<Heading
|
||||
level={2}
|
||||
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
|
||||
>
|
||||
{getText('settings')}
|
||||
</aria.Heading>
|
||||
</Heading>
|
||||
<table>
|
||||
<tbody>
|
||||
{path != null && (
|
||||
<tr data-testid="asset-panel-permissions" className="h-row">
|
||||
<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 className="w-full p-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ariaComponents.Text className="w-0 grow" truncate="1">
|
||||
<Text className="w-0 grow" truncate="1">
|
||||
{decodeURI(path)}
|
||||
</ariaComponents.Text>
|
||||
<ariaComponents.CopyButton copyText={path} />
|
||||
</Text>
|
||||
<CopyButton copyText={path} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr data-testid="asset-panel-permissions" className="h-row">
|
||||
<td className="text my-auto min-w-side-panel-label p-0">
|
||||
<aria.Label className="text inline-block">{getText('sharedWith')}</aria.Label>
|
||||
</td>
|
||||
<td className="flex w-full gap-1 p-0">
|
||||
<SharedWithColumn
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
state={{ category, setQuery: () => {} }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{isEnterprise && (
|
||||
<tr data-testid="asset-panel-permissions" className="h-row">
|
||||
<td className="text my-auto min-w-side-panel-label p-0">
|
||||
<Text className="text inline-block">{getText('sharedWith')}</Text>
|
||||
</td>
|
||||
<td className="flex w-full gap-1 p-0">
|
||||
<SharedWithColumn
|
||||
isReadonly={isReadonly}
|
||||
item={item}
|
||||
state={{ backend, category, setQuery: () => {} }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr data-testid="asset-panel-labels" className="h-row">
|
||||
<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 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)
|
||||
return label == null ? null : (
|
||||
return (
|
||||
label != null && (
|
||||
<Label key={value} active isDisabled color={label.color} onPress={() => {}}>
|
||||
{value}
|
||||
</Label>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
@ -302,83 +309,56 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
)}
|
||||
|
||||
{isSecret && (
|
||||
<div
|
||||
ref={secretRef}
|
||||
className="pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default"
|
||||
{...secretSpotlight.props}
|
||||
>
|
||||
<aria.Heading
|
||||
<div ref={secretRef} className={styles.section()} {...secretSpotlight.props}>
|
||||
<Heading
|
||||
level={2}
|
||||
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
|
||||
>
|
||||
{getText('secret')}
|
||||
</aria.Heading>
|
||||
</Heading>
|
||||
<UpsertSecretModal
|
||||
noDialog
|
||||
canReset={false}
|
||||
canReset
|
||||
canCancel={false}
|
||||
id={item.item.id}
|
||||
name={item.item.title}
|
||||
id={asset.id}
|
||||
name={asset.title}
|
||||
doCreate={async (name, value) => {
|
||||
await updateSecretMutation.mutateAsync([item.item.id, { value }, name])
|
||||
await updateSecretMutation.mutateAsync([asset.id, { value }, name])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDatalink && (
|
||||
<div
|
||||
ref={datalinkRef}
|
||||
className="pointer-events-auto flex flex-col items-start gap-side-panel-section rounded-default"
|
||||
{...datalinkSpotlight.props}
|
||||
>
|
||||
<aria.Heading
|
||||
<div ref={datalinkRef} className={styles.section()} {...datalinkSpotlight.props}>
|
||||
<Heading
|
||||
level={2}
|
||||
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
|
||||
>
|
||||
{getText('datalink')}
|
||||
</aria.Heading>
|
||||
{!isDatalinkFetched ?
|
||||
</Heading>
|
||||
{datalinkQuery.isLoading ?
|
||||
<div className="grid place-items-center self-stretch">
|
||||
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
|
||||
</div>
|
||||
: <>
|
||||
<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
|
||||
form={form}
|
||||
name="value"
|
||||
readOnly={!canEditThisAsset}
|
||||
dropdownTitle={getText('type')}
|
||||
/>
|
||||
{canEditThisAsset && (
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.Form.Submit action="update" />
|
||||
</ariaComponents.ButtonGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
</>
|
||||
: <Form form={editDatalinkForm} className="w-full">
|
||||
<DatalinkFormInput
|
||||
form={editDatalinkForm}
|
||||
name="datalink"
|
||||
readOnly={!canEditThisAsset}
|
||||
dropdownTitle={getText('type')}
|
||||
/>
|
||||
{canEditThisAsset && (
|
||||
<ButtonGroup>
|
||||
<Form.Submit>{getText('update')}</Form.Submit>
|
||||
<Form.Reset
|
||||
onPress={() => {
|
||||
editDatalinkForm.reset({ datalink: datalinkQuery.data })
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</Form>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
@ -17,7 +17,7 @@ import * as backendService from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
|
||||
|
||||
// ==============================
|
||||
// === AddNewVersionVariables ===
|
||||
|
@ -52,10 +52,10 @@ import FocusArea from '#/components/styled/FocusArea'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import { ASSETS_MIME_TYPE } from '#/data/mimeTypes'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type { AssetListEvent } from '#/events/assetListEvent'
|
||||
import { useCutAndPaste, type AssetListEvent } from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import { useAutoScroll } from '#/hooks/autoScrollHooks'
|
||||
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import { backendMutationOptions, backendQueryOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useIntersectionRatio } from '#/hooks/intersectionHooks'
|
||||
import { useOpenProject } from '#/hooks/projectHooks'
|
||||
@ -66,8 +66,8 @@ import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
|
||||
import {
|
||||
canTransferBetweenCategories,
|
||||
CATEGORY_TO_FILTER_BY,
|
||||
isLocalCategory,
|
||||
useTransferBetweenCategories,
|
||||
type Category,
|
||||
} from '#/layouts/CategorySwitcher/Category'
|
||||
import DragModal from '#/modals/DragModal'
|
||||
@ -117,7 +117,6 @@ import {
|
||||
extractProjectExtension,
|
||||
fileIsNotProject,
|
||||
fileIsProject,
|
||||
FilterBy,
|
||||
getAssetPermissionName,
|
||||
Path,
|
||||
Plan,
|
||||
@ -136,6 +135,7 @@ import {
|
||||
import LocalBackend, { extractTypeAndId, newProjectId } from '#/services/LocalBackend'
|
||||
import { UUID } from '#/services/ProjectManager'
|
||||
import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend'
|
||||
import { ROOT_PARENT_DIRECTORY_ID } from '#/services/remoteBackendPaths'
|
||||
import type { AssetQueryKey } from '#/utilities/AssetQuery'
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode'
|
||||
@ -147,7 +147,6 @@ import { fileExtension } from '#/utilities/fileInfo'
|
||||
import type { DetailedRectangle } from '#/utilities/geometry'
|
||||
import { DEFAULT_HANDLER } from '#/utilities/inputBindings'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import PasteType from '#/utilities/PasteType'
|
||||
import {
|
||||
canPermissionModifyDirectoryContents,
|
||||
PermissionAction,
|
||||
@ -160,8 +159,8 @@ import type { SortInfo } from '#/utilities/sorting'
|
||||
import { SortDirection } from '#/utilities/sorting'
|
||||
import { regexEscape } from '#/utilities/string'
|
||||
import { twJoin, twMerge } from '#/utilities/tailwindMerge'
|
||||
import { uniqueString } from '#/utilities/uniqueString'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
@ -287,21 +286,6 @@ interface DragSelectionInfo {
|
||||
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 ===
|
||||
// ===================
|
||||
@ -335,7 +319,6 @@ export interface AssetsTableState {
|
||||
|
||||
/** Data associated with a {@link AssetRow}, used for rendering. */
|
||||
export interface AssetRowState {
|
||||
readonly setVisibility: (visibility: Visibility) => void
|
||||
readonly isEditingName: boolean
|
||||
readonly temporarilyAddedLabels: ReadonlySet<LabelName>
|
||||
readonly temporarilyRemovedLabels: ReadonlySet<LabelName>
|
||||
@ -421,7 +404,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
return id
|
||||
}, [category, backend, user, organization, localRootDirectory])
|
||||
|
||||
const rootParentDirectoryId = DirectoryId('')
|
||||
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
|
||||
|
||||
const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh')
|
||||
@ -436,7 +418,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = useState<DirectoryId[]>(() => [])
|
||||
|
||||
const expandedDirectoryIds = useMemo(
|
||||
() => privateExpandedDirectoryIds.concat(rootDirectoryId),
|
||||
() => [rootDirectoryId].concat(privateExpandedDirectoryIds),
|
||||
[privateExpandedDirectoryIds, rootDirectoryId],
|
||||
)
|
||||
|
||||
@ -452,9 +434,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret'))
|
||||
const createDatalinkMutation = useMutation(backendMutationOptions(backend, 'createDatalink'))
|
||||
const uploadFileMutation = useMutation(backendMutationOptions(backend, 'uploadFile'))
|
||||
const getProjectDetailsMutation = useMutation(
|
||||
backendMutationOptions(backend, 'getProjectDetails'),
|
||||
)
|
||||
const copyAssetMutation = useMutation(backendMutationOptions(backend, 'copyAsset'))
|
||||
const deleteAssetMutation = useMutation(backendMutationOptions(backend, 'deleteAsset'))
|
||||
const undoDeleteAssetMutation = useMutation(backendMutationOptions(backend, 'undoDeleteAsset'))
|
||||
@ -472,7 +451,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
'listDirectory',
|
||||
directoryId,
|
||||
{
|
||||
parentId: directoryId,
|
||||
labels: null,
|
||||
filterBy: CATEGORY_TO_FILTER_BY[category.type],
|
||||
recentProjects: category.type === 'recent',
|
||||
@ -480,7 +458,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
] as const,
|
||||
queryFn: async ({ queryKey: [, , parentId, params] }) => {
|
||||
try {
|
||||
return { parentId, children: await backend.listDirectory(params, parentId) }
|
||||
return await backend.listDirectory({ ...params, parentId }, parentId)
|
||||
} catch {
|
||||
throw Object.assign(new Error(), { parentId })
|
||||
}
|
||||
@ -506,13 +484,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
],
|
||||
),
|
||||
combine: (results) => {
|
||||
const rootQuery = results.find(
|
||||
(directory) =>
|
||||
directory.data?.parentId === rootDirectory.id ||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(directory.error as unknown as { parentId: string } | null)?.parentId ===
|
||||
rootDirectory.id,
|
||||
)
|
||||
const rootQuery = results[expandedDirectoryIds.indexOf(rootDirectory.id)]
|
||||
|
||||
return {
|
||||
rootDirectory: {
|
||||
@ -522,8 +494,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
data: rootQuery?.data,
|
||||
},
|
||||
directories: new Map(
|
||||
results.map((res) => [
|
||||
res.data?.parentId,
|
||||
results.map((res, i) => [
|
||||
expandedDirectoryIds[i],
|
||||
{
|
||||
isFetching: res.isFetching,
|
||||
isLoading: res.isLoading,
|
||||
@ -541,7 +513,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
*/
|
||||
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 assetTree = useMemo(() => {
|
||||
@ -553,8 +525,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return AssetTreeNode.fromAsset(
|
||||
createRootDirectoryAsset(rootDirectoryId),
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
ROOT_PARENT_DIRECTORY_ID,
|
||||
ROOT_PARENT_DIRECTORY_ID,
|
||||
-1,
|
||||
rootPath,
|
||||
null,
|
||||
@ -563,8 +535,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return AssetTreeNode.fromAsset(
|
||||
createRootDirectoryAsset(rootDirectoryId),
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
ROOT_PARENT_DIRECTORY_ID,
|
||||
ROOT_PARENT_DIRECTORY_ID,
|
||||
-1,
|
||||
rootPath,
|
||||
null,
|
||||
@ -595,7 +567,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (assetIsDirectory(item)) {
|
||||
const childrenAssetsQuery = directories.directories.get(item.id)
|
||||
|
||||
const nestedChildren = childrenAssetsQuery?.data?.children.map((child) =>
|
||||
const nestedChildren = childrenAssetsQuery?.data?.map((child) =>
|
||||
AssetTreeNode.fromAsset(
|
||||
child,
|
||||
item.id,
|
||||
@ -669,8 +641,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
return new AssetTreeNode(
|
||||
rootDirectory,
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
ROOT_PARENT_DIRECTORY_ID,
|
||||
ROOT_PARENT_DIRECTORY_ID,
|
||||
children,
|
||||
-1,
|
||||
rootPath,
|
||||
@ -685,7 +657,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
directories.rootDirectory.isError,
|
||||
directories.directories,
|
||||
rootDirectory,
|
||||
rootParentDirectoryId,
|
||||
rootDirectoryId,
|
||||
])
|
||||
|
||||
@ -866,6 +837,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const lastSelectedIdsRef = useRef<AssetId | ReadonlySet<AssetId> | null>(null)
|
||||
const headerRowRef = useRef<HTMLTableRowElement>(null)
|
||||
const assetTreeRef = useRef<AnyAssetTreeNode>(assetTree)
|
||||
const getPasteData = useEventCallback(() => driveStore.getState().pasteData)
|
||||
const nodeMapRef = useRef<ReadonlyMap<AssetId, AnyAssetTreeNode>>(
|
||||
new Map<AssetId, AnyAssetTreeNode>(),
|
||||
)
|
||||
@ -915,9 +887,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setTargetDirectory(null)
|
||||
} else if (selectedKeys.size === 1) {
|
||||
const [soleKey] = selectedKeys
|
||||
const node = soleKey == null ? null : nodeMapRef.current.get(soleKey)
|
||||
if (node != null && node.isType(AssetType.directory)) {
|
||||
setTargetDirectory(node)
|
||||
const item = soleKey == null ? null : nodeMapRef.current.get(soleKey)
|
||||
if (item != null && item.isType(AssetType.directory)) {
|
||||
setTargetDirectory(item)
|
||||
}
|
||||
if (item && item.item.id !== driveStore.getState().assetPanelProps?.item?.item.id) {
|
||||
setAssetPanelProps({ backend, item })
|
||||
setIsAssetPanelTemporarilyVisible(false)
|
||||
}
|
||||
} else {
|
||||
let commonDirectoryKey: AssetId | null = null
|
||||
@ -956,7 +932,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
}),
|
||||
[driveStore, setTargetDirectory],
|
||||
[
|
||||
backend,
|
||||
driveStore,
|
||||
setAssetPanelProps,
|
||||
setIsAssetPanelTemporarilyVisible,
|
||||
setTargetDirectory,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -1146,7 +1128,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (!hidden) {
|
||||
return inputBindings.attach(document.body, 'keydown', {
|
||||
cancelCut: () => {
|
||||
const { pasteData } = driveStore.getState()
|
||||
const pasteData = getPasteData()
|
||||
if (pasteData == null) {
|
||||
return false
|
||||
} else {
|
||||
@ -1157,7 +1139,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [dispatchAssetEvent, driveStore, hidden, inputBindings, setPasteData])
|
||||
}, [dispatchAssetEvent, getPasteData, hidden, inputBindings, setPasteData])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@ -1281,6 +1263,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const doMove = useEventCallback(async (newParentId: DirectoryId | null, asset: AnyAsset) => {
|
||||
try {
|
||||
if (asset.id === driveStore.getState().assetPanelProps?.item?.item.id) {
|
||||
setAssetPanelProps(null)
|
||||
}
|
||||
await updateAssetMutation.mutateAsync([
|
||||
asset.id,
|
||||
{ parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
|
||||
@ -1292,6 +1277,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
|
||||
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) {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.closeFolder,
|
||||
@ -1302,7 +1290,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
}
|
||||
try {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id })
|
||||
if (asset.type === AssetType.project && backend.type === BackendType.local) {
|
||||
try {
|
||||
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) => {
|
||||
if (assetId === driveStore.getState().assetPanelProps?.item?.item.id) {
|
||||
setAssetPanelProps(null)
|
||||
}
|
||||
const asset = nodeMapRef.current.get(assetId)?.item
|
||||
|
||||
if (asset != null) {
|
||||
@ -1565,10 +1555,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
|
||||
if (listDirectoryQuery?.state.data) {
|
||||
listDirectoryQuery.setData({
|
||||
...listDirectoryQuery.state.data,
|
||||
children: listDirectoryQuery.state.data.children.filter((child) => child.id !== assetId),
|
||||
})
|
||||
listDirectoryQuery.setData(
|
||||
listDirectoryQuery.state.data.filter((child) => child.id !== assetId),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -1584,10 +1573,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
|
||||
if (listDirectoryQuery?.state.data) {
|
||||
listDirectoryQuery.setData({
|
||||
...listDirectoryQuery.state.data,
|
||||
children: [...listDirectoryQuery.state.data.children, ...assets],
|
||||
})
|
||||
listDirectoryQuery.setData([...listDirectoryQuery.state.data, ...assets])
|
||||
}
|
||||
},
|
||||
)
|
||||
@ -1774,8 +1760,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const projectId = newProjectId(UUID(id))
|
||||
addIdToSelection(projectId)
|
||||
|
||||
await getProjectDetailsMutation
|
||||
.mutateAsync([projectId, asset.parentId, asset.title])
|
||||
await queryClient
|
||||
.fetchQuery(
|
||||
backendQueryOptions(backend, 'getProjectDetails', [
|
||||
projectId,
|
||||
asset.parentId,
|
||||
asset.title,
|
||||
]),
|
||||
)
|
||||
.catch((error) => {
|
||||
deleteAsset(projectId)
|
||||
toastAndLog('uploadProjectError', error)
|
||||
@ -2010,10 +2002,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
break
|
||||
}
|
||||
case AssetListEventType.insertAssets: {
|
||||
insertAssets(event.assets, event.parentId)
|
||||
break
|
||||
}
|
||||
case AssetListEventType.duplicateProject: {
|
||||
const parent = nodeMapRef.current.get(event.parentKey)
|
||||
const siblings = parent?.children ?? []
|
||||
@ -2068,38 +2056,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
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: {
|
||||
insertAssets(event.items, event.newParentId)
|
||||
|
||||
for (const item of event.items) {
|
||||
void doCopyOnBackend(event.newParentId, item)
|
||||
}
|
||||
break
|
||||
}
|
||||
case AssetListEventType.move: {
|
||||
deleteAsset(event.key)
|
||||
insertAssets(event.items, event.newParentId)
|
||||
|
||||
for (const item of event.items) {
|
||||
void doMove(event.newParentId, item)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case AssetListEventType.delete: {
|
||||
deleteAsset(event.key)
|
||||
const asset = nodeMapRef.current.get(event.key)?.item
|
||||
if (asset) {
|
||||
void doDelete(asset, false)
|
||||
@ -2144,7 +2113,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
unsetModal()
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setPasteData({
|
||||
type: PasteType.copy,
|
||||
type: 'copy',
|
||||
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 })
|
||||
}
|
||||
setPasteData({
|
||||
type: PasteType.move,
|
||||
type: 'move',
|
||||
data: { backendType: backend.type, category, ids: selectedKeys },
|
||||
})
|
||||
dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys })
|
||||
setSelectedKeys(EMPTY_SET)
|
||||
})
|
||||
|
||||
const transferBetweenCategories = useTransferBetweenCategories(category)
|
||||
const cutAndPaste = useCutAndPaste(category)
|
||||
const doPaste = useEventCallback((newParentKey: DirectoryId, newParentId: DirectoryId) => {
|
||||
unsetModal()
|
||||
const { pasteData } = driveStore.getState()
|
||||
@ -2175,7 +2144,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
toast.error('Cannot paste a folder into itself.')
|
||||
} else {
|
||||
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(
|
||||
(asset) => (asset ? [asset.item] : []),
|
||||
)
|
||||
@ -2186,13 +2155,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
newParentKey,
|
||||
})
|
||||
} else {
|
||||
transferBetweenCategories(
|
||||
pasteData.data.category,
|
||||
category,
|
||||
pasteData.data.ids,
|
||||
newParentKey,
|
||||
newParentId,
|
||||
)
|
||||
cutAndPaste(newParentKey, newParentId, pasteData.data, nodeMapRef.current)
|
||||
}
|
||||
setPasteData(null)
|
||||
}
|
||||
@ -2201,6 +2164,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const doRestore = useEventCallback(async (asset: AnyAsset) => {
|
||||
try {
|
||||
if (asset.id === driveStore.getState().assetPanelProps?.item?.item.id) {
|
||||
setAssetPanelProps(null)
|
||||
}
|
||||
await undoDeleteAssetMutation.mutateAsync([asset.id, asset.title])
|
||||
} catch (error) {
|
||||
toastAndLog('restoreAssetError', error, asset.title)
|
||||
@ -2681,12 +2647,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
})
|
||||
|
||||
if (listDirectoryQuery?.state.data) {
|
||||
listDirectoryQuery.setData({
|
||||
...listDirectoryQuery.state.data,
|
||||
children: listDirectoryQuery.state.data.children.map((child) =>
|
||||
child.id === assetId ? asset : child,
|
||||
),
|
||||
})
|
||||
listDirectoryQuery.setData(
|
||||
listDirectoryQuery.state.data.map((child) => (child.id === assetId ? asset : child)),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@ -2938,7 +2901,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
{!hidden && (
|
||||
<SelectionBrush
|
||||
targetRef={rootRef}
|
||||
margin={8}
|
||||
margin={16}
|
||||
onDrag={onSelectionDrag}
|
||||
onDragEnd={onSelectionDragEnd}
|
||||
onDragCancel={onSelectionDragCancel}
|
||||
|
@ -4,6 +4,10 @@
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import { useDriveStore, useSelectedKeys, useSetSelectedKeys } from '#/providers/DriveProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
@ -31,8 +35,6 @@ import * as backendModule from '#/services/Backend'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import { EMPTY_SET } from '#/utilities/set'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -149,9 +151,9 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
)
|
||||
|
||||
if (category.type === 'trash') {
|
||||
return selectedKeys.size === 0 ?
|
||||
null
|
||||
: <ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
||||
return (
|
||||
selectedKeys.size !== 0 && (
|
||||
<ContextMenus key={uniqueString()} hidden={hidden} event={event}>
|
||||
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
@ -196,11 +198,13 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
{pasteAllMenuEntry}
|
||||
</ContextMenu>
|
||||
</ContextMenus>
|
||||
)
|
||||
)
|
||||
} else if (category.type === 'recent') {
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
||||
<ContextMenus key={uniqueString()} hidden={hidden} event={event}>
|
||||
{(selectedKeys.size !== 0 || pasteAllMenuEntry !== false) && (
|
||||
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
|
||||
{selectedKeys.size !== 0 && ownsAllSelectedAssets && (
|
||||
|
@ -267,9 +267,9 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
'listDirectory',
|
||||
[
|
||||
{
|
||||
parentId: backend.DirectoryId(USERS_DIRECTORY_ID),
|
||||
parentId: USERS_DIRECTORY_ID,
|
||||
filterBy: backend.FilterBy.active,
|
||||
labels: [],
|
||||
labels: null,
|
||||
recentProjects: false,
|
||||
},
|
||||
'Users',
|
||||
@ -281,9 +281,9 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
'listDirectory',
|
||||
[
|
||||
{
|
||||
parentId: backend.DirectoryId(TEAMS_DIRECTORY_ID),
|
||||
parentId: TEAMS_DIRECTORY_ID,
|
||||
filterBy: backend.FilterBy.active,
|
||||
labels: [],
|
||||
labels: null,
|
||||
recentProjects: false,
|
||||
},
|
||||
'Teams',
|
||||
|
@ -1,4 +1,6 @@
|
||||
/** @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 AssetEventType from '#/events/AssetEventType'
|
||||
@ -7,10 +9,14 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
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 { useMutation } from '@tanstack/react-query'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
const PATH_SCHEMA = z.string().refine((s): s is Path => 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. */
|
||||
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. */
|
||||
export function isCloudCategory(category: Category): category is AnyCloudCategory {
|
||||
return ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category).success
|
||||
|
@ -51,7 +51,6 @@ import { useText } from '#/providers/TextProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import { ProjectState, type CreatedProject, type Project, type ProjectId } from '#/services/Backend'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import PasteType from '#/utilities/PasteType'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
|
||||
// ================
|
||||
@ -209,7 +208,7 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
const pasteDataStatus = effectivePasteData && (
|
||||
<div className="flex items-center">
|
||||
<Text>
|
||||
{effectivePasteData.type === PasteType.copy ?
|
||||
{effectivePasteData.type === 'copy' ?
|
||||
getText('xItemsCopied', effectivePasteData.data.ids.size)
|
||||
: getText('xItemsCut', effectivePasteData.data.ids.size)}
|
||||
</Text>
|
||||
|
@ -1,6 +1,8 @@
|
||||
/** @file A context menu available everywhere in the directory. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
@ -18,7 +20,6 @@ import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
import { useDriveStore } from '#/providers/DriveProvider'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import { useStore } from 'zustand'
|
||||
|
||||
/** Props for a {@link GlobalContextMenu}. */
|
||||
export interface GlobalContextMenuProps {
|
||||
@ -35,8 +36,8 @@ export interface GlobalContextMenuProps {
|
||||
|
||||
/** A context menu available everywhere in the directory. */
|
||||
export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
const { hidden = false, backend, directoryKey, directoryId } = props
|
||||
const { rootDirectoryId, doPaste } = props
|
||||
const { hidden = false, backend, directoryKey, directoryId, rootDirectoryId } = props
|
||||
const { doPaste } = props
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
|
@ -120,6 +120,7 @@ export default function Labels(props: LabelsProps) {
|
||||
variant="icon"
|
||||
icon={Trash2Icon}
|
||||
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"
|
||||
/>
|
||||
<ConfirmDeleteModal
|
||||
|
@ -5,26 +5,19 @@ import BlankIcon from '#/assets/blank.svg'
|
||||
import CrossIcon from '#/assets/cross.svg'
|
||||
import Plus2Icon from '#/assets/plus2.svg'
|
||||
import ReloadIcon from '#/assets/reload.svg'
|
||||
|
||||
import type * as inputBindings from '#/configurations/inputBindings'
|
||||
|
||||
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 { mergeProps } from '#/components/aria'
|
||||
import { Button, ButtonGroup, DialogTrigger } from '#/components/AriaComponents'
|
||||
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
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 ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { unsafeEntries } from '#/utilities/object'
|
||||
|
||||
// ========================================
|
||||
// === KeyboardShortcutsSettingsSection ===
|
||||
@ -32,10 +25,9 @@ import * as object from '#/utilities/object'
|
||||
|
||||
/** Settings tab for viewing and editing keyboard shortcuts. */
|
||||
export default function KeyboardShortcutsSettingsSection() {
|
||||
const [refresh, doRefresh] = refreshHooks.useRefresh()
|
||||
const inputBindings = inputBindingsManager.useInputBindings()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const [refresh, doRefresh] = useRefresh()
|
||||
const inputBindings = useInputBindings()
|
||||
const { getText } = useText()
|
||||
const rootRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||
const allShortcuts = React.useMemo(() => {
|
||||
@ -45,36 +37,36 @@ export default function KeyboardShortcutsSettingsSection() {
|
||||
return new Set(Object.values(inputBindings.metadata).flatMap((value) => value.bindings))
|
||||
}, [inputBindings.metadata, refresh])
|
||||
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],
|
||||
)
|
||||
|
||||
const { onScroll } = scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef)
|
||||
const { onScroll } = useStickyTableHeaderOnScroll(rootRef, bodyRef)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button size="medium" variant="outline">
|
||||
<ButtonGroup>
|
||||
<DialogTrigger>
|
||||
<Button size="medium" variant="outline">
|
||||
{getText('resetAll')}
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('resetAllKeyboardShortcuts')}
|
||||
actionButtonLabel={getText('resetAll')}
|
||||
doDelete={() => {
|
||||
for (const k in inputBindings.metadata) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
inputBindings.reset(k as inputBindings.DashboardBindingKey)
|
||||
inputBindings.reset(k as DashboardBindingKey)
|
||||
}
|
||||
doRefresh()
|
||||
}}
|
||||
/>
|
||||
</ariaComponents.DialogTrigger>
|
||||
</ariaComponents.ButtonGroup>
|
||||
</DialogTrigger>
|
||||
</ButtonGroup>
|
||||
<FocusArea direction="vertical" focusChildClass="focus-default" focusDefaultClass="">
|
||||
{(innerProps) => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
|
||||
{...mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
|
||||
ref: rootRef,
|
||||
// There is a horizontal scrollbar for some reason without `px-px`.
|
||||
// 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"
|
||||
>
|
||||
<KeyboardShortcut shortcut={binding} />
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label={getText('removeShortcut')}
|
||||
@ -142,27 +134,25 @@ export default function KeyboardShortcutsSettingsSection() {
|
||||
))}
|
||||
<div className="grow" />
|
||||
<div className="gap-keyboard-shortcuts-buttons flex shrink-0 items-center">
|
||||
<ariaComponents.Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label={getText('addShortcut')}
|
||||
tooltipPlacement="top left"
|
||||
icon={Plus2Icon}
|
||||
showIconOnHover
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<CaptureKeyboardShortcutModal
|
||||
description={`'${info.name}'`}
|
||||
existingShortcuts={allShortcuts}
|
||||
onSubmit={(shortcut) => {
|
||||
inputBindings.add(action, shortcut)
|
||||
doRefresh()
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ariaComponents.Button
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label={getText('addShortcut')}
|
||||
tooltipPlacement="top left"
|
||||
icon={Plus2Icon}
|
||||
showIconOnHover
|
||||
/>
|
||||
<CaptureKeyboardShortcutModal
|
||||
description={`'${info.name}'`}
|
||||
existingShortcuts={allShortcuts}
|
||||
onSubmit={(shortcut) => {
|
||||
inputBindings.add(action, shortcut)
|
||||
doRefresh()
|
||||
}}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label={getText('resetShortcut')}
|
||||
|
@ -90,7 +90,7 @@ export default function MembersSettingsSection() {
|
||||
<table className="table-fixed self-start rounded-rows">
|
||||
<thead>
|
||||
<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')}
|
||||
</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">
|
||||
@ -101,7 +101,7 @@ export default function MembersSettingsSection() {
|
||||
<tbody className="select-text">
|
||||
{members.map((member) => (
|
||||
<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">
|
||||
{member.email}
|
||||
</ariaComponents.Text>
|
||||
|
@ -1,31 +1,29 @@
|
||||
/** @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 * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { ButtonGroup, Dialog, Form, Text } from '#/components/AriaComponents'
|
||||
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
import * as inputBindings from '#/utilities/inputBindings'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { useSetModal } from '#/providers/ModalProvider'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import {
|
||||
modifierFlagsForEvent,
|
||||
modifiersForModifierFlags,
|
||||
normalizedKeyboardSegmentLookup,
|
||||
} from '#/utilities/inputBindings'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
// ==============================
|
||||
// === eventToPartialShortcut ===
|
||||
// ==============================
|
||||
|
||||
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}. */
|
||||
function eventToPartialShortcut(event: KeyboardEvent | React.KeyboardEvent) {
|
||||
const modifiers = inputBindings
|
||||
.modifiersForModifierFlags(inputBindings.modifierFlagsForEvent(event))
|
||||
.join('+')
|
||||
function eventToPartialShortcut(event: KeyboardEvent | ReactKeyboardEvent) {
|
||||
const modifiers = modifiersForModifierFlags(modifierFlagsForEvent(event)).join('+')
|
||||
// `Tab` and `Shift+Tab` should be reserved for keyboard navigation
|
||||
const key =
|
||||
(
|
||||
@ -35,7 +33,7 @@ function eventToPartialShortcut(event: KeyboardEvent | React.KeyboardEvent) {
|
||||
null
|
||||
: event.key === ' ' ? 'Space'
|
||||
: event.key === DELETE_KEY ? 'OsDelete'
|
||||
: inputBindings.normalizedKeyboardSegmentLookup[event.key.toLowerCase()] ?? event.key
|
||||
: normalizedKeyboardSegmentLookup[event.key.toLowerCase()] ?? event.key
|
||||
return { key, modifiers }
|
||||
}
|
||||
|
||||
@ -53,10 +51,10 @@ export interface CaptureKeyboardShortcutModalProps {
|
||||
/** A modal for capturing an arbitrary keyboard shortcut. */
|
||||
export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShortcutModalProps) {
|
||||
const { description, existingShortcuts, onSubmit } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const [key, setKey] = React.useState<string | null>(null)
|
||||
const [modifiers, setModifiers] = React.useState<string>('')
|
||||
const { unsetModal } = useSetModal()
|
||||
const { getText } = useText()
|
||||
const [key, setKey] = useState<string | null>(null)
|
||||
const [modifiers, setModifiers] = useState<string>('')
|
||||
const shortcut =
|
||||
key == null ? modifiers
|
||||
: modifiers === '' ? key
|
||||
@ -65,13 +63,16 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
|
||||
const canSubmit = key != null && !doesAlreadyExist
|
||||
|
||||
return (
|
||||
<Modal centered className="bg-dim">
|
||||
<form
|
||||
<Dialog>
|
||||
<Form
|
||||
ref={(element) => {
|
||||
element?.focus()
|
||||
}}
|
||||
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) => {
|
||||
if (event.key === 'Escape' && key === 'Escape') {
|
||||
// Ignore.
|
||||
@ -99,8 +100,7 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onSubmit={() => {
|
||||
if (canSubmit) {
|
||||
unsetModal()
|
||||
onSubmit(shortcut)
|
||||
@ -109,34 +109,23 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
|
||||
>
|
||||
<div className="relative">{getText('enterTheNewKeyboardShortcutFor', description)}</div>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
className={twMerge(
|
||||
'relative flex scale-150 items-center justify-center',
|
||||
doesAlreadyExist && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{shortcut === '' ?
|
||||
<aria.Text className="text text-primary/30">{getText('noShortcutEntered')}</aria.Text>
|
||||
<Text>{getText('noShortcutEntered')}</Text>
|
||||
: <KeyboardShortcut shortcut={shortcut} />}
|
||||
</div>
|
||||
<aria.Text className="relative text-red-600">
|
||||
<Text className="relative text-red-600">
|
||||
{doesAlreadyExist ? 'This shortcut already exists.' : ''}
|
||||
</aria.Text>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
isDisabled={!canSubmit}
|
||||
onPress={() => {
|
||||
unsetModal()
|
||||
onSubmit(shortcut)
|
||||
}}
|
||||
>
|
||||
{getText('confirm')}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button variant="outline" onPress={unsetModal}>
|
||||
{getText('cancel')}
|
||||
</ariaComponents.Button>
|
||||
</ariaComponents.ButtonGroup>
|
||||
</form>
|
||||
</Modal>
|
||||
</Text>
|
||||
<ButtonGroup>
|
||||
<Form.Submit isDisabled={!canSubmit}>{getText('confirm')}</Form.Submit>
|
||||
<Form.Submit action="cancel" />
|
||||
</ButtonGroup>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
@ -3,11 +3,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
import { Heading, Text } from '#/components/aria'
|
||||
import { ButtonGroup, Checkbox, Form, Input } from '#/components/AriaComponents'
|
||||
import { ButtonGroup, Checkbox, Form, Input, Popover, Text } from '#/components/AriaComponents'
|
||||
import ColorPicker from '#/components/ColorPicker'
|
||||
import Label from '#/components/dashboard/Label'
|
||||
import Modal from '#/components/Modal'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
@ -26,8 +24,7 @@ import { regexEscape } from '#/utilities/string'
|
||||
export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
|
||||
readonly backend: Backend
|
||||
readonly item: Asset
|
||||
/** If this is `null`, this modal will be centered. */
|
||||
readonly eventTarget: HTMLElement | null
|
||||
readonly triggerRef?: React.MutableRefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,14 +35,13 @@ export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
|
||||
export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
|
||||
props: ManageLabelsModalProps<Asset>,
|
||||
) {
|
||||
const { backend, item, eventTarget } = props
|
||||
const { backend, item, triggerRef } = props
|
||||
const { unsetModal } = useSetModal()
|
||||
const { getText } = useText()
|
||||
const toastAndLog = useToastAndLog()
|
||||
const { data: allLabels } = useBackendQuery(backend, 'listTags', [])
|
||||
const [color, setColor] = useState<LChColor | null>(null)
|
||||
const leastUsedColor = useMemo(() => findLeastUsedColor(allLabels ?? []), [allLabels])
|
||||
const position = useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
||||
|
||||
const createTagMutation = useMutation(backendMutationOptions(backend, 'createTag'))
|
||||
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
|
||||
@ -75,7 +71,7 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
|
||||
|
||||
const formRef = useSyncRef(form)
|
||||
useEffect(() => {
|
||||
formRef.current.setValue('labels', item.labels ?? [])
|
||||
formRef.current.resetField('labels', { defaultValue: item.labels ?? [] })
|
||||
}, [formRef, item.labels])
|
||||
|
||||
const query = Form.useWatch({ control: form.control, name: 'name' })
|
||||
@ -89,85 +85,56 @@ export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
|
||||
const canCreateNewLabel = canSelectColor
|
||||
|
||||
return (
|
||||
<Modal
|
||||
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">
|
||||
<Heading
|
||||
slot="title"
|
||||
level={2}
|
||||
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">
|
||||
{(innerProps) => (
|
||||
<ButtonGroup className="relative" {...innerProps}>
|
||||
<Input
|
||||
form={form}
|
||||
name="name"
|
||||
autoFocus
|
||||
type="text"
|
||||
size="small"
|
||||
placeholder={getText('labelSearchPlaceholder')}
|
||||
/>
|
||||
<Form.Submit isDisabled={!canCreateNewLabel}>{getText('create')}</Form.Submit>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</FocusArea>
|
||||
{canSelectColor && <ColorPicker setColor={setColor} className="w-full" />}
|
||||
<FocusArea direction="vertical">
|
||||
{(innerProps) => (
|
||||
<Checkbox.Group
|
||||
<Popover size="xsmall" {...(triggerRef ? { triggerRef } : {})}>
|
||||
<Form form={form} className="relative flex flex-col gap-modal rounded-default p-modal">
|
||||
<Text.Heading slot="title" level={2} variant="subtitle">
|
||||
{getText('labels')}
|
||||
</Text.Heading>
|
||||
<FocusArea direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<ButtonGroup className="relative" {...innerProps}>
|
||||
<Input
|
||||
form={form}
|
||||
name="labels"
|
||||
className="max-h-manage-labels-list overflow-auto"
|
||||
onChange={async (values) => {
|
||||
await associateTagMutation.mutateAsync([
|
||||
item.id,
|
||||
values.map(LabelName),
|
||||
item.title,
|
||||
])
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<>
|
||||
{(allLabels ?? [])
|
||||
.filter((label) => regex.test(label.value))
|
||||
.map((label) => {
|
||||
const isActive = labels.includes(label.value)
|
||||
return (
|
||||
<Checkbox key={label.id} value={String(label.value)} isSelected={isActive}>
|
||||
<Label active={isActive} color={label.color} onPress={() => {}}>
|
||||
{label.value}
|
||||
</Label>
|
||||
</Checkbox>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</FocusArea>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
name="name"
|
||||
autoFocus
|
||||
type="text"
|
||||
size="small"
|
||||
placeholder={getText('labelSearchPlaceholder')}
|
||||
/>
|
||||
<Form.Submit isDisabled={!canCreateNewLabel}>{getText('create')}</Form.Submit>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</FocusArea>
|
||||
{canSelectColor && <ColorPicker setColor={setColor} className="w-full" />}
|
||||
<FocusArea direction="vertical">
|
||||
{(innerProps) => (
|
||||
<Checkbox.Group
|
||||
form={form}
|
||||
name="labels"
|
||||
className="max-h-manage-labels-list overflow-auto"
|
||||
onChange={async (values) => {
|
||||
await associateTagMutation.mutateAsync([item.id, values.map(LabelName), item.title])
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<>
|
||||
{allLabels
|
||||
?.filter((label) => regex.test(label.value))
|
||||
.map((label) => {
|
||||
const isActive = labels.includes(label.value)
|
||||
return (
|
||||
<Checkbox key={label.id} value={String(label.value)}>
|
||||
<Label active={isActive} color={label.color} onPress={() => {}}>
|
||||
{label.value}
|
||||
</Label>
|
||||
</Checkbox>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</FocusArea>
|
||||
</Form>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
@ -1,32 +1,41 @@
|
||||
/** @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 * as toast from 'react-toastify'
|
||||
import { toast } from 'react-toastify'
|
||||
import isEmail from 'validator/es/lib/isEmail'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
import * as billingHooks from '#/hooks/billing'
|
||||
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 { Heading } from '#/components/aria'
|
||||
import { Button } from '#/components/AriaComponents'
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Permission from '#/components/dashboard/Permission'
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import Modal from '#/components/Modal'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import { PaywallAlert } from '#/components/Paywall'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissionsModule from '#/utilities/permissions'
|
||||
import { backendMutationOptions, useAssetPassiveListenerStrict } from '#/hooks/backendHooks'
|
||||
import { usePaywall } from '#/hooks/billing'
|
||||
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
|
||||
import type { Category } from '#/layouts/CategorySwitcher/Category'
|
||||
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 ===
|
||||
@ -43,12 +52,11 @@ const TYPE_SELECTOR_Y_OFFSET_PX = 32
|
||||
// ==============================
|
||||
|
||||
/** Props for a {@link ManagePermissionsModal}. */
|
||||
export interface ManagePermissionsModalProps<
|
||||
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
|
||||
> {
|
||||
readonly item: Pick<Asset, 'id' | 'permissions' | 'type'>
|
||||
readonly setItem: React.Dispatch<React.SetStateAction<Asset>>
|
||||
readonly self: backendModule.AssetPermission
|
||||
export interface ManagePermissionsModalProps<Asset extends AnyAsset = AnyAsset> {
|
||||
readonly backend: Backend
|
||||
readonly category: Category
|
||||
readonly item: Pick<Asset, 'id' | 'parentId' | 'permissions' | 'type'>
|
||||
readonly self: AssetPermission
|
||||
/**
|
||||
* Remove the current user's permissions from this asset. This MUST be a prop because it should
|
||||
* 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.
|
||||
* This should never happen, as this modal should not be accessible in either case.
|
||||
*/
|
||||
export default function ManagePermissionsModal<
|
||||
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
|
||||
>(props: ManagePermissionsModalProps<Asset>) {
|
||||
const { item, setItem, self, doRemoveSelf, eventTarget } = props
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
export default function ManagePermissionsModal<Asset extends AnyAsset = AnyAsset>(
|
||||
props: ManagePermissionsModalProps<Asset>,
|
||||
) {
|
||||
const { backend, category, item: itemRaw, self, doRemoveSelf, eventTarget } = props
|
||||
const item = useAssetPassiveListenerStrict(backend.type, itemRaw.id, itemRaw.parentId, category)
|
||||
const remoteBackend = useRemoteBackendStrict()
|
||||
const { user } = useFullUserSession()
|
||||
const { unsetModal } = useSetModal()
|
||||
const toastAndLog = useToastAndLog()
|
||||
const { getText } = useText()
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
|
||||
const isUnderPaywall = isFeatureUnderPaywall('shareFull')
|
||||
|
||||
const listedUsers = useQuery({
|
||||
@ -88,27 +97,25 @@ export default function ManagePermissionsModal<
|
||||
queryFn: () => remoteBackend.listUserGroups(),
|
||||
})
|
||||
|
||||
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
|
||||
const [usersAndUserGroups, setUserAndUserGroups] = React.useState<
|
||||
readonly (backendModule.UserGroupInfo | backendModule.UserInfo)[]
|
||||
const [permissions, setPermissions] = useState(item.permissions ?? [])
|
||||
const [usersAndUserGroups, setUserAndUserGroups] = useState<
|
||||
readonly (UserGroupInfo | UserInfo)[]
|
||||
>([])
|
||||
const [email, setEmail] = React.useState<string | null>(null)
|
||||
const [action, setAction] = React.useState(permissionsModule.PermissionAction.view)
|
||||
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
||||
const editablePermissions = React.useMemo(
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [action, setAction] = useState(PermissionAction.view)
|
||||
const position = useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
||||
const editablePermissions = useMemo(
|
||||
() =>
|
||||
self.permission === permissionsModule.PermissionAction.own ?
|
||||
self.permission === PermissionAction.own ?
|
||||
permissions
|
||||
: permissions.filter(
|
||||
(permission) => permission.permission !== permissionsModule.PermissionAction.own,
|
||||
),
|
||||
: permissions.filter((permission) => permission.permission !== PermissionAction.own),
|
||||
[permissions, self.permission],
|
||||
)
|
||||
const permissionsHoldersNames = React.useMemo(
|
||||
() => new Set(item.permissions?.map(backendModule.getAssetPermissionName)),
|
||||
const permissionsHoldersNames = useMemo(
|
||||
() => new Set(item.permissions?.map(getAssetPermissionName)),
|
||||
[item.permissions],
|
||||
)
|
||||
const emailsOfUsersWithPermission = React.useMemo(
|
||||
const emailsOfUsersWithPermission = useMemo(
|
||||
() =>
|
||||
new Set<string>(
|
||||
item.permissions?.flatMap((userPermission) =>
|
||||
@ -117,30 +124,24 @@ export default function ManagePermissionsModal<
|
||||
),
|
||||
[item.permissions],
|
||||
)
|
||||
const isOnlyOwner = React.useMemo(
|
||||
const isOnlyOwner = useMemo(
|
||||
() =>
|
||||
self.permission === permissionsModule.PermissionAction.own &&
|
||||
self.permission === PermissionAction.own &&
|
||||
permissions.every(
|
||||
(permission) =>
|
||||
permission.permission !== permissionsModule.PermissionAction.own ||
|
||||
(backendModule.isUserPermission(permission) && permission.user.userId === user.userId),
|
||||
permission.permission !== PermissionAction.own ||
|
||||
(isUserPermission(permission) && permission.user.userId === user.userId),
|
||||
),
|
||||
[user.userId, permissions, self.permission],
|
||||
)
|
||||
const selfId = backendModule.getAssetPermissionId(self)
|
||||
const selfId = getAssetPermissionId(self)
|
||||
|
||||
const inviteUserMutation = useMutation(backendMutationOptions(remoteBackend, 'inviteUser'))
|
||||
const createPermissionMutation = useMutation(
|
||||
backendMutationOptions(remoteBackend, 'createPermission'),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
// 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(
|
||||
const canAdd = useMemo(
|
||||
() => [
|
||||
...(listedUsers.data ?? []).filter(
|
||||
(listedUser) =>
|
||||
@ -153,7 +154,7 @@ export default function ManagePermissionsModal<
|
||||
],
|
||||
[emailsOfUsersWithPermission, permissionsHoldersNames, listedUsers, listedUserGroups],
|
||||
)
|
||||
const willInviteNewUser = React.useMemo(() => {
|
||||
const willInviteNewUser = useMemo(() => {
|
||||
if (usersAndUserGroups.length !== 0 || email == null || email === '') {
|
||||
return false
|
||||
} else {
|
||||
@ -184,47 +185,44 @@ export default function ManagePermissionsModal<
|
||||
setUserAndUserGroups([])
|
||||
setEmail('')
|
||||
if (email != null) {
|
||||
await inviteUserMutation.mutateAsync([{ userEmail: backendModule.EmailAddress(email) }])
|
||||
toast.toast.success(getText('inviteSuccess', email))
|
||||
await inviteUserMutation.mutateAsync([{ userEmail: EmailAddress(email) }])
|
||||
toast.success(getText('inviteSuccess', email))
|
||||
}
|
||||
} catch (error) {
|
||||
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
|
||||
}
|
||||
} else {
|
||||
setUserAndUserGroups([])
|
||||
const addedPermissions = usersAndUserGroups.map<backendModule.AssetPermission>(
|
||||
(newUserOrUserGroup) =>
|
||||
'userId' in newUserOrUserGroup ?
|
||||
{ user: newUserOrUserGroup, permission: action }
|
||||
: { userGroup: newUserOrUserGroup, permission: action },
|
||||
const addedPermissions = usersAndUserGroups.map<AssetPermission>((newUserOrUserGroup) =>
|
||||
'userId' in newUserOrUserGroup ?
|
||||
{ user: newUserOrUserGroup, permission: action }
|
||||
: { userGroup: newUserOrUserGroup, permission: action },
|
||||
)
|
||||
const addedUsersIds = new Set(
|
||||
addedPermissions.flatMap((permission) =>
|
||||
backendModule.isUserPermission(permission) ? [permission.user.userId] : [],
|
||||
isUserPermission(permission) ? [permission.user.userId] : [],
|
||||
),
|
||||
)
|
||||
const addedUserGroupsIds = new Set(
|
||||
addedPermissions.flatMap((permission) =>
|
||||
backendModule.isUserGroupPermission(permission) ? [permission.userGroup.id] : [],
|
||||
isUserGroupPermission(permission) ? [permission.userGroup.id] : [],
|
||||
),
|
||||
)
|
||||
const isPermissionNotBeingOverwritten = (permission: backendModule.AssetPermission) =>
|
||||
backendModule.isUserPermission(permission) ?
|
||||
const isPermissionNotBeingOverwritten = (permission: AssetPermission) =>
|
||||
isUserPermission(permission) ?
|
||||
!addedUsersIds.has(permission.user.userId)
|
||||
: !addedUserGroupsIds.has(permission.userGroup.id)
|
||||
|
||||
try {
|
||||
setPermissions((oldPermissions) =>
|
||||
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...addedPermissions].sort(
|
||||
backendModule.compareAssetPermissions,
|
||||
compareAssetPermissions,
|
||||
),
|
||||
)
|
||||
await createPermissionMutation.mutateAsync([
|
||||
{
|
||||
actorsIds: addedPermissions.map((permission) =>
|
||||
backendModule.isUserPermission(permission) ?
|
||||
permission.user.userId
|
||||
: permission.userGroup.id,
|
||||
isUserPermission(permission) ? permission.user.userId : permission.userGroup.id,
|
||||
),
|
||||
resourceId: item.id,
|
||||
action: action,
|
||||
@ -233,7 +231,7 @@ export default function ManagePermissionsModal<
|
||||
} catch (error) {
|
||||
setPermissions((oldPermissions) =>
|
||||
[...oldPermissions.filter(isPermissionNotBeingOverwritten), ...oldPermissions].sort(
|
||||
backendModule.compareAssetPermissions,
|
||||
compareAssetPermissions,
|
||||
),
|
||||
)
|
||||
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) {
|
||||
doRemoveSelf()
|
||||
} else {
|
||||
const oldPermission = permissions.find(
|
||||
(permission) => backendModule.getAssetPermissionId(permission) === permissionId,
|
||||
(permission) => getAssetPermissionId(permission) === permissionId,
|
||||
)
|
||||
try {
|
||||
setPermissions((oldPermissions) =>
|
||||
oldPermissions.filter(
|
||||
(permission) => backendModule.getAssetPermissionId(permission) !== permissionId,
|
||||
),
|
||||
oldPermissions.filter((permission) => getAssetPermissionId(permission) !== permissionId),
|
||||
)
|
||||
await createPermissionMutation.mutateAsync([
|
||||
{
|
||||
@ -264,7 +260,7 @@ export default function ManagePermissionsModal<
|
||||
} catch (error) {
|
||||
if (oldPermission != null) {
|
||||
setPermissions((oldPermissions) =>
|
||||
[...oldPermissions, oldPermission].sort(backendModule.compareAssetPermissions),
|
||||
[...oldPermissions, oldPermission].sort(compareAssetPermissions),
|
||||
)
|
||||
}
|
||||
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="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')}
|
||||
</aria.Heading>
|
||||
</Heading>
|
||||
{/* Space reserved for other tabs. */}
|
||||
</div>
|
||||
<FocusArea direction="horizontal">
|
||||
@ -319,7 +315,7 @@ export default function ManagePermissionsModal<
|
||||
isDisabled={willInviteNewUser}
|
||||
selfPermission={self.permission}
|
||||
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
|
||||
action={permissionsModule.PermissionAction.view}
|
||||
action={PermissionAction.view}
|
||||
assetType={item.type}
|
||||
onChange={setAction}
|
||||
/>
|
||||
@ -366,7 +362,7 @@ export default function ManagePermissionsModal<
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</div>
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="medium"
|
||||
variant="submit"
|
||||
isDisabled={
|
||||
@ -378,16 +374,13 @@ export default function ManagePermissionsModal<
|
||||
onPress={doSubmit}
|
||||
>
|
||||
{willInviteNewUser ? getText('invite') : getText('share')}
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</FocusArea>
|
||||
<div className="max-h-manage-permissions-modal-permissions-list overflow-auto px-manage-permissions-modal-input">
|
||||
{editablePermissions.map((permission) => (
|
||||
<div
|
||||
key={backendModule.getAssetPermissionName(permission)}
|
||||
className="flex h-row items-center"
|
||||
>
|
||||
<div key={getAssetPermissionName(permission)} className="flex h-row items-center">
|
||||
<Permission
|
||||
backend={remoteBackend}
|
||||
asset={item}
|
||||
@ -395,12 +388,12 @@ export default function ManagePermissionsModal<
|
||||
isOnlyOwner={isOnlyOwner}
|
||||
permission={permission}
|
||||
setPermission={(newPermission) => {
|
||||
const permissionId = backendModule.getAssetPermissionId(newPermission)
|
||||
const permissionId = getAssetPermissionId(newPermission)
|
||||
setPermissions((oldPermissions) =>
|
||||
oldPermissions.map((oldPermission) =>
|
||||
backendModule.getAssetPermissionId(oldPermission) === permissionId ?
|
||||
newPermission
|
||||
: oldPermission,
|
||||
getAssetPermissionId(oldPermission) === permissionId ? newPermission : (
|
||||
oldPermission
|
||||
),
|
||||
),
|
||||
)
|
||||
if (selfId === permissionId) {
|
||||
@ -423,7 +416,7 @@ export default function ManagePermissionsModal<
|
||||
</div>
|
||||
|
||||
{isUnderPaywall && (
|
||||
<paywall.PaywallAlert feature="shareFull" label={getText('shareFullPaywallMessage')} />
|
||||
<PaywallAlert feature="shareFull" label={getText('shareFullPaywallMessage')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@ 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 { Button, ButtonGroup } from '#/components/AriaComponents'
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
@ -110,18 +110,14 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
|
||||
</div>
|
||||
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
|
||||
</aria.TextField>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
isDisabled={!canSubmit}
|
||||
onPress={eventModule.submitForm}
|
||||
>
|
||||
<ButtonGroup className="relative">
|
||||
<Button variant="submit" isDisabled={!canSubmit} onPress={eventModule.submitForm}>
|
||||
{getText('create')}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button variant="outline" onPress={unsetModal}>
|
||||
</Button>
|
||||
<Button variant="outline" onPress={unsetModal}>
|
||||
{getText('cancel')}
|
||||
</ariaComponents.Button>
|
||||
</ariaComponents.ButtonGroup>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
@ -59,19 +59,21 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
|
||||
|
||||
const content = (
|
||||
<Form form={form} testId="upsert-secret-modal" gap="none" className="w-full">
|
||||
<Input
|
||||
form={form}
|
||||
name="name"
|
||||
size="custom"
|
||||
rounded="full"
|
||||
autoFocus={isNameEditable}
|
||||
autoComplete="off"
|
||||
isDisabled={!isNameEditable}
|
||||
label={getText('name')}
|
||||
placeholder={getText('secretNamePlaceholder')}
|
||||
variants={CLASSIC_INPUT_STYLES}
|
||||
fieldVariants={CLASSIC_FIELD_STYLES}
|
||||
/>
|
||||
{isNameEditable && (
|
||||
<Input
|
||||
form={form}
|
||||
name="name"
|
||||
size="custom"
|
||||
rounded="full"
|
||||
autoFocus={isNameEditable}
|
||||
autoComplete="off"
|
||||
isDisabled={!isNameEditable}
|
||||
label={getText('name')}
|
||||
placeholder={getText('secretNamePlaceholder')}
|
||||
variants={CLASSIC_INPUT_STYLES}
|
||||
fieldVariants={CLASSIC_FIELD_STYLES}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
form={form}
|
||||
name="value"
|
||||
|
@ -162,6 +162,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
setCategoryRaw(newCategory)
|
||||
setStoreCategory(newCategory)
|
||||
})
|
||||
const backend = backendProvider.useBackend(category)
|
||||
|
||||
const projectsStore = useProjectsStore()
|
||||
const page = usePage()
|
||||
@ -275,11 +276,9 @@ function DashboardInner(props: DashboardProps) {
|
||||
if (asset != null && self != null) {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
backend={backend}
|
||||
category={category}
|
||||
item={asset}
|
||||
setItem={(updater) => {
|
||||
const nextAsset = updater instanceof Function ? updater(asset) : updater
|
||||
assetManagementApiRef.current?.setAsset(asset.id, nextAsset)
|
||||
}}
|
||||
self={self}
|
||||
doRemoveSelf={() => {
|
||||
doRemoveSelf(selectedProject)
|
||||
|
@ -91,6 +91,8 @@ export default function DriveProvider(props: ProjectsProviderProps) {
|
||||
targetDirectory: null,
|
||||
selectedKeys: EMPTY_SET,
|
||||
visuallySelectedKeys: null,
|
||||
suggestions: EMPTY_ARRAY,
|
||||
assetPanelProps: null,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
@ -505,11 +505,14 @@ export default class RemoteBackend extends Backend {
|
||||
return await this.throw(response, 'listRootFolderBackendError')
|
||||
}
|
||||
} else {
|
||||
return (await response.json()).assets
|
||||
const ret = (await response.json()).assets
|
||||
.map((asset) =>
|
||||
object.merge(asset, {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
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) =>
|
||||
@ -518,6 +521,7 @@ export default class RemoteBackend extends Backend {
|
||||
}),
|
||||
)
|
||||
.map((asset) => this.dynamicAssetUser(asset))
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
/** @file Paths used by the `RemoteBackend`. */
|
||||
import type * as backend from '#/services/Backend'
|
||||
import { newtypeConstructor, type Newtype } from 'enso-common/src/utilities/data/newtype'
|
||||
|
||||
// =============
|
||||
// === Paths ===
|
||||
@ -187,7 +188,12 @@ export function getCheckoutSessionPath(checkoutSessionId: backend.CheckoutSessio
|
||||
// === 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. */
|
||||
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. */
|
||||
export const TEAMS_DIRECTORY_ID = 'directory-0000000000000000000000teams'
|
||||
export const TEAMS_DIRECTORY_ID = DirectoryId('directory-0000000000000000000000teams')
|
||||
|
@ -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
|
@ -3,7 +3,7 @@ import type * as React from 'react'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
|
||||
|
||||
// ===========================
|
||||
// === setDragImageToBlank ===
|
||||
|
@ -1,9 +1,7 @@
|
||||
/** @file Types related to pasting. */
|
||||
import type PasteType from '#/utilities/PasteType'
|
||||
|
||||
// =================
|
||||
// === PasteData ===
|
||||
// =================
|
||||
/** The type of operation that should be triggered on paste. */
|
||||
export type PasteType = 'copy' | 'move'
|
||||
|
||||
/** All information required to paste assets. */
|
||||
export interface PasteData<T> {
|
||||
|
@ -1,5 +1,14 @@
|
||||
/** @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 = {
|
||||
meta: { persist: false },
|
||||
@ -10,3 +19,31 @@ export const STATIC_QUERY_OPTIONS = {
|
||||
refetchOnReconnect: false,
|
||||
refetchIntervalInBackground: false,
|
||||
} 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
|
||||
>[],
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
/** @file A function that generates a unique string. */
|
||||
|
||||
export * from 'enso-common/src/utilities/uniqueString'
|
@ -9,4 +9,3 @@
|
||||
src: url('/font-dejavu/DejaVuSansMono-Bold.ttf');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
@ -51,4 +51,3 @@
|
||||
src: url('/font-enso/Enso-Black.ttf');
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
|
@ -2,4 +2,3 @@
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('/font-mplus1/MPLUS1[wght].ttf');
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user