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