Dashboard improvements (8 Oct 2024) (#11268)

- ⚠️ Follow up to #11219. MUST NOT be merged in before that PR.

- Changes:
- Add optional overlay to `Popover`s
- Add and use `useAssetPassiveListener` everywhere to get reactive updates to asset state even outside Asset Panel
- `setItem` has been removed in favor of simply waiting for invalidations
- Migrate more `Modal`s to `Popover`s
- Migrate more inputs to `Form`s
- Periodically refetch Datalink in Asset Panel
- Show optimistic state for asset description (adding this because it is trivial to add)
- Remove usages of `get*` as mutations throughout the entire codebase - replace with `fetchQuery`
- Fixes most of rest of https://github.com/enso-org/cloud-v2/issues/1529
- (1) ℹ️ fixed in #11219
- (2)  backend issue
- (3)  out of scope
- (4)  backend issue
- (5)  out of scope
- (6)  [wontfix]? i think this is intentional, it's not so much slow scrolling and moreso snapped scrolling
- (7)  backend issue
- (8) ℹ️ fixed in #11126
- (9)  out of scope (potentially requires a way to trigger a tooltip on a disabled button)
- (10)  (will check later) Make sure you are not able to open a project opened by another user: cmd + click is not always working.
- (11) Drag from team space to user space should copy asset
- (12)  (will check later) Drag from user space to team should move (and swap ownership)
- (13) ℹ️ fixed in #11219
- (14) ℹ️ fixed somewhere (?)
- (15) ℹ️ fixed somewhere (?)
- (16) Show correct (and up-to-date) description for projects
- (17) ℹ️ fixed in #11219
- (18) ℹ️ fixed in #11219
- Fix https://github.com/enso-org/cloud-v2/issues/1535
- Completely remove optimistic UI for "copy asset"
- Fix https://github.com/enso-org/cloud-v2/issues/1541
- Make selection brush work again
- Unintentionally regressed in 51733ee876 (diff-f3e29bffcda342ab6a9dbafc58dde88ce26638eaecda1f17f40ca7e319c90cc8L89)

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

View File

@ -8,7 +8,7 @@ import * as remoteBackendPaths from '#/services/remoteBackendPaths'
import * as dateTime from '#/utilities/dateTime'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as uniqueString from '#/utilities/uniqueString'
import * as uniqueString from 'enso-common/src/utilities/uniqueString'
import * as actions from './actions'

View File

@ -72,6 +72,7 @@ test.test('asset panel contents', ({ page }) =>
.do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions.
await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
// This is not visible; "Shared with" should only be visible on the Enterprise plan.
// await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
}),
)

View File

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

View File

@ -216,7 +216,9 @@ export const BUTTON_STYLES = tv({
end: { content: 'flex-row-reverse' },
},
showIconOnHover: {
true: { icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' },
true: {
icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 disabled:opacity-0 aria-disabled:opacity-0 disabled:group-hover:opacity-50 aria-disabled:group-hover:opacity-50',
},
},
extraClickZone: {
true: {
@ -341,6 +343,7 @@ export const Button = forwardRef(function Button(
const isLoading = loading || implicitlyLoading
const isDisabled = props.isDisabled ?? isLoading
const shouldUseVisualTooltip = shouldShowTooltip && isDisabled
React.useLayoutEffect(() => {
const delay = 350
@ -436,6 +439,13 @@ export const Button = forwardRef(function Button(
}
}
const { tooltip: visualTooltip, targetProps } = ariaComponents.useVisualTooltip({
targetRef: contentRef,
children: tooltipElement,
isDisabled: !shouldUseVisualTooltip,
...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }),
})
const button = (
<Tag
// @ts-expect-error ts errors are expected here because we are merging props with different types
@ -456,7 +466,11 @@ export const Button = forwardRef(function Button(
>
{(render: aria.ButtonRenderProps | aria.LinkRenderProps) => (
<span className={styles.wrapper()}>
<span ref={contentRef} className={styles.content({ className: contentClassName })}>
<span
ref={contentRef}
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */}
{childrenFactory(render)}
</span>
@ -471,8 +485,14 @@ export const Button = forwardRef(function Button(
</Tag>
)
return tooltipElement == null ? button : (
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
return (
tooltipElement == null ? button
: shouldUseVisualTooltip ?
<>
{button}
{visualTooltip}
</>
: <ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
{button}
<ariaComponents.Tooltip
@ -481,5 +501,5 @@ export const Button = forwardRef(function Button(
{tooltipElement}
</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)
)
})

View File

@ -29,7 +29,7 @@ export function FormProvider<Schema extends types.TSchema>(
const { children, form } = props
return (
// eslint-disable-next-line no-restricted-syntax,@typescript-eslint/no-explicit-any
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any
<FormContext.Provider value={{ form: form as types.UseFormReturn<any> }}>
{children}
</FormContext.Provider>
@ -50,7 +50,7 @@ export function useFormContext<Schema extends types.TSchema>(
invariant(ctx, 'FormContext not found')
// This is safe, as it's we pass the value transparently and it's typed outside
// This is safe, as we pass the value transparently and it is typed outside
// eslint-disable-next-line no-restricted-syntax
return ctx.form as unknown as types.UseFormReturn<Schema>
}

View File

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

View File

@ -23,7 +23,7 @@ const DROPDOWN_STYLES = tv({
isFocused: {
true: {
container: 'z-1',
options: 'before:h-full before:shadow-soft',
options: 'before:h-full before:shadow-soft before:bg-frame before:backdrop-blur-md',
optionsContainer: 'grid-rows-1fr',
input: 'z-1',
},
@ -47,7 +47,7 @@ const DROPDOWN_STYLES = tv({
slots: {
container: 'absolute left-0 h-full w-full min-w-max',
options:
'relative backdrop-blur-md before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
optionsSpacing: 'padding relative h-6',
optionsContainer:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
/** @file A table row for an arbitrary asset. */
import * as React from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useStore } from 'zustand'
import BlankIcon from '#/assets/blank.svg'
@ -9,12 +10,7 @@ import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import {
useDriveStore,
useSetAssetPanelProps,
useSetIsAssetPanelTemporarilyVisible,
useSetSelectedKeys,
} from '#/providers/DriveProvider'
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -34,7 +30,12 @@ import * as localBackend from '#/services/LocalBackend'
import * as backendModule from '#/services/Backend'
import { Text } from '#/components/AriaComponents'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { useCutAndPaste } from '#/events/assetListEvent'
import {
backendMutationOptions,
backendQueryOptions,
useBackendMutationState,
} from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
@ -49,7 +50,6 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import { useMutation, useQuery } from '@tanstack/react-query'
// =================
// === Constants ===
@ -133,9 +133,9 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const [item, setItem] = React.useState(rawItem)
const driveStore = useDriveStore()
const queryClient = useQueryClient()
const { user } = useFullUserSession()
const setSelectedKeys = useSetSelectedKeys()
const setAssetPanelProps = useSetAssetPanelProps()
const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) =>
(visuallySelectedKeys ?? selectedKeys).has(item.key),
)
@ -151,17 +151,15 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const draggableProps = dragAndDropHooks.useDraggable()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const cutAndPaste = useCutAndPaste(category)
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible()
const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus)
const asset = item.item
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility }),
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
assetRowUtils.INITIAL_ROW_STATE,
)
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
@ -176,12 +174,14 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
readonly parentKeys: Map<backendModule.AssetId, backendModule.DirectoryId>
} | null>(null)
const outerVisibility = visibilities.get(item.key)
const visibility =
outerVisibility == null || outerVisibility === Visibility.visible ?
insertionVisibility
: outerVisibility
const hidden = hiddenRaw || visibility === Visibility.hidden
const isDeleting =
useBackendMutationState(backend, 'deleteAsset', {
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
}).length !== 0
const isRestoring =
useBackendMutationState(backend, 'undoDeleteAsset', {
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
}).length !== 0
const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({
@ -194,14 +194,26 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const toastAndLog = useToastAndLog()
const getProjectDetailsMutation = useMutation(
backendMutationOptions(backend, 'getProjectDetails'),
)
const getFileDetailsMutation = useMutation(backendMutationOptions(backend, 'getFileDetails'))
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const outerVisibility = visibilities.get(item.key)
const insertionVisibility = useStore(driveStore, (driveState) =>
driveState.pasteData?.type === 'move' && driveState.pasteData.data.ids.has(item.key) ?
Visibility.faded
: Visibility.visible,
)
const createPermissionVariables = createPermissionMutation.variables?.[0]
const isRemovingSelf =
createPermissionVariables != null &&
createPermissionVariables.action == null &&
createPermissionVariables.actorsIds[0] === user.userId
const visibility =
isRemovingSelf ? Visibility.hidden
: outerVisibility === Visibility.visible ? insertionVisibility
: outerVisibility ?? insertionVisibility
const hidden = isDeleting || isRestoring || hiddenRaw || visibility === Visibility.hidden
const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState()
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
@ -247,20 +259,6 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
}, [item.item.id, updateAssetRef])
React.useEffect(() => {
if (isSoleSelected && item.item.id !== driveStore.getState().assetPanelProps?.item?.item.id) {
setAssetPanelProps({ backend, item, setItem })
setIsAssetPanelTemporarilyVisible(false)
}
}, [
item,
isSoleSelected,
backend,
setAssetPanelProps,
setIsAssetPanelTemporarilyVisible,
driveStore,
])
const doDelete = React.useCallback(
(forever = false) => {
void doDeleteRaw(item.item, forever)
@ -285,7 +283,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
payload != null && payload.every((innerItem) => innerItem.key !== directoryKey)
const canPaste = (() => {
if (!isPayloadMatch) {
return true
return false
} else {
if (nodeMap.current !== nodeParentKeysRef.current?.nodeMap.deref()) {
const parentKeys = new Map(
@ -296,12 +294,21 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
)
nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys }
}
return !payload.some((payloadItem) => {
return payload.every((payloadItem) => {
const parentKey = nodeParentKeysRef.current?.parentKeys.get(payloadItem.key)
const parent = parentKey == null ? null : nodeMap.current.get(parentKey)
return !parent ? true : (
permissions.isTeamPath(parent.path) && permissions.isUserPath(item.path)
if (!parent) {
return false
} else if (permissions.isTeamPath(parent.path)) {
return true
} else {
// Assume user path; check permissions
const permission = permissions.tryFindSelfPermission(user, item.item.permissions)
return (
permission != null &&
permissions.canPermissionModifyDirectoryContents(permission.permission)
)
}
})
}
})()
@ -314,263 +321,229 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
eventListProvider.useAssetEventListener(async (event) => {
if (state.category.type === 'trash') {
switch (event.type) {
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
doDelete(true)
}
break
}
case AssetEventType.restore: {
if (event.ids.has(item.key)) {
await doRestore(item.item)
}
break
}
default: {
break
switch (event.type) {
case AssetEventType.move: {
if (event.ids.has(item.key)) {
await doMove(event.newParentKey, item.item)
}
break
}
} else {
switch (event.type) {
case AssetEventType.cut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.faded)
}
break
case AssetEventType.delete: {
if (event.ids.has(item.key)) {
doDelete(false)
}
case AssetEventType.cancelCut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.visible)
}
break
break
}
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
doDelete(true)
}
case AssetEventType.move: {
if (event.ids.has(item.key)) {
setInsertionVisibility(Visibility.visible)
await doMove(event.newParentKey, item.item)
}
break
break
}
case AssetEventType.restore: {
if (event.ids.has(item.key)) {
await doRestore(item.item)
}
case AssetEventType.delete: {
if (event.ids.has(item.key)) {
doDelete(false)
}
break
}
case AssetEventType.deleteForever: {
if (event.ids.has(item.key)) {
doDelete(true)
}
break
}
case AssetEventType.restore: {
if (event.ids.has(item.key)) {
await doRestore(item.item)
}
break
}
case AssetEventType.download:
case AssetEventType.downloadSelected: {
if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) {
if (isCloud) {
switch (asset.type) {
case backendModule.AssetType.project: {
try {
const details = await getProjectDetailsMutation.mutateAsync([
break
}
case AssetEventType.download:
case AssetEventType.downloadSelected: {
if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) {
if (isCloud) {
switch (asset.type) {
case backendModule.AssetType.project: {
try {
const details = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getProjectDetails', [
asset.id,
asset.parentId,
asset.title,
])
if (details.url != null) {
await backend.download(details.url, `${asset.title}.enso-project`)
} else {
const error: unknown = getText('projectHasNoSourceFilesPhrase')
toastAndLog('downloadProjectError', error, asset.title)
}
} catch (error) {
]),
)
if (details.url != null) {
await backend.download(details.url, `${asset.title}.enso-project`)
} else {
const error: unknown = getText('projectHasNoSourceFilesPhrase')
toastAndLog('downloadProjectError', error, asset.title)
}
break
} catch (error) {
toastAndLog('downloadProjectError', error, asset.title)
}
case backendModule.AssetType.file: {
try {
const details = await getFileDetailsMutation.mutateAsync([
asset.id,
asset.title,
])
if (details.url != null) {
await backend.download(details.url, asset.title)
} else {
const error: unknown = getText('fileNotFoundPhrase')
toastAndLog('downloadFileError', error, asset.title)
}
} catch (error) {
break
}
case backendModule.AssetType.file: {
try {
const details = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title]),
)
if (details.url != null) {
await backend.download(details.url, asset.title)
} else {
const error: unknown = getText('fileNotFoundPhrase')
toastAndLog('downloadFileError', error, asset.title)
}
break
}
case backendModule.AssetType.datalink: {
try {
const value = await getDatalinkMutation.mutateAsync([asset.id, asset.title])
const fileName = `${asset.title}.datalink`
download(
URL.createObjectURL(
new File([JSON.stringify(value)], fileName, {
type: 'application/json+x-enso-data-link',
}),
),
fileName,
)
} catch (error) {
toastAndLog('downloadDatalinkError', error, asset.title)
}
break
}
default: {
toastAndLog('downloadInvalidTypeError')
break
} catch (error) {
toastAndLog('downloadFileError', error, asset.title)
}
break
}
} else {
if (asset.type === backendModule.AssetType.project) {
const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id
const uuid = localBackend.extractTypeAndId(asset.id).id
const queryString = new URLSearchParams({ projectsDirectory }).toString()
await backend.download(
`./api/project-manager/projects/${uuid}/enso-project?${queryString}`,
`${asset.title}.enso-project`,
)
case backendModule.AssetType.datalink: {
try {
const value = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]),
)
const fileName = `${asset.title}.datalink`
download(
URL.createObjectURL(
new File([JSON.stringify(value)], fileName, {
type: 'application/json+x-enso-data-link',
}),
),
fileName,
)
} catch (error) {
toastAndLog('downloadDatalinkError', error, asset.title)
}
break
}
default: {
toastAndLog('downloadInvalidTypeError')
break
}
}
}
break
}
case AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user.isEnabled) {
setInsertionVisibility(Visibility.hidden)
try {
await createPermissionMutation.mutateAsync([
{
action: null,
resourceId: asset.id,
actorsIds: [user.userId],
},
])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog(null, error)
} else {
if (asset.type === backendModule.AssetType.project) {
const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id
const uuid = localBackend.extractTypeAndId(asset.id).id
const queryString = new URLSearchParams({ projectsDirectory }).toString()
await backend.download(
`./api/project-manager/projects/${uuid}/enso-project?${queryString}`,
`${asset.title}.enso-project`,
)
}
}
break
}
case AssetEventType.temporarilyAddLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
setRowState((oldRowState) =>
(
oldRowState.temporarilyAddedLabels === labels &&
oldRowState.temporarilyRemovedLabels === set.EMPTY_SET
) ?
oldRowState
: object.merge(oldRowState, {
temporarilyAddedLabels: labels,
temporarilyRemovedLabels: set.EMPTY_SET,
}),
)
break
}
case AssetEventType.temporarilyRemoveLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
setRowState((oldRowState) =>
(
oldRowState.temporarilyAddedLabels === set.EMPTY_SET &&
oldRowState.temporarilyRemovedLabels === labels
) ?
oldRowState
: object.merge(oldRowState, {
temporarilyAddedLabels: set.EMPTY_SET,
temporarilyRemovedLabels: labels,
}),
)
break
}
case AssetEventType.addLabels: {
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
const labels = asset.labels
if (
event.ids.has(item.key) &&
(labels == null || [...event.labelNames].some((label) => !labels.includes(label)))
) {
const newLabels = [
...(labels ?? []),
...[...event.labelNames].filter((label) => labels?.includes(label) !== true),
]
setAsset(object.merger({ labels: newLabels }))
try {
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
} catch (error) {
setAsset(object.merger({ labels }))
toastAndLog(null, error)
}
break
}
case AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user.isEnabled) {
try {
await createPermissionMutation.mutateAsync([
{
action: null,
resourceId: asset.id,
actorsIds: [user.userId],
},
])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
toastAndLog(null, error)
}
break
}
case AssetEventType.removeLabels: {
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
const labels = asset.labels
if (
event.ids.has(item.key) &&
labels != null &&
[...event.labelNames].some((label) => labels.includes(label))
) {
const newLabels = labels.filter((label) => !event.labelNames.has(label))
setAsset(object.merger({ labels: newLabels }))
try {
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
} catch (error) {
setAsset(object.merger({ labels }))
toastAndLog(null, error)
}
break
}
case AssetEventType.temporarilyAddLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
setRowState((oldRowState) =>
(
oldRowState.temporarilyAddedLabels === labels &&
oldRowState.temporarilyRemovedLabels === set.EMPTY_SET
) ?
oldRowState
: object.merge(oldRowState, {
temporarilyAddedLabels: labels,
temporarilyRemovedLabels: set.EMPTY_SET,
}),
)
break
}
case AssetEventType.temporarilyRemoveLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY_SET
setRowState((oldRowState) =>
(
oldRowState.temporarilyAddedLabels === set.EMPTY_SET &&
oldRowState.temporarilyRemovedLabels === labels
) ?
oldRowState
: object.merge(oldRowState, {
temporarilyAddedLabels: set.EMPTY_SET,
temporarilyRemovedLabels: labels,
}),
)
break
}
case AssetEventType.addLabels: {
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
const labels = asset.labels
if (
event.ids.has(item.key) &&
(labels == null || [...event.labelNames].some((label) => !labels.includes(label)))
) {
const newLabels = [
...(labels ?? []),
...[...event.labelNames].filter((label) => labels?.includes(label) !== true),
]
setAsset(object.merger({ labels: newLabels }))
try {
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
} catch (error) {
setAsset(object.merger({ labels }))
toastAndLog(null, error)
}
break
}
case AssetEventType.deleteLabel: {
setAsset((oldAsset) => {
const oldLabels = oldAsset.labels ?? []
const labels: backendModule.LabelName[] = []
break
}
case AssetEventType.removeLabels: {
setRowState((oldRowState) =>
oldRowState.temporarilyAddedLabels === set.EMPTY_SET ?
oldRowState
: object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }),
)
const labels = asset.labels
if (
event.ids.has(item.key) &&
labels != null &&
[...event.labelNames].some((label) => labels.includes(label))
) {
const newLabels = labels.filter((label) => !event.labelNames.has(label))
setAsset(object.merger({ labels: newLabels }))
try {
await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title])
} catch (error) {
setAsset(object.merger({ labels }))
toastAndLog(null, error)
}
}
break
}
case AssetEventType.deleteLabel: {
setAsset((oldAsset) => {
const oldLabels = oldAsset.labels ?? []
const labels: backendModule.LabelName[] = []
for (const label of oldLabels) {
if (label !== event.labelName) {
labels.push(label)
}
for (const label of oldLabels) {
if (label !== event.labelName) {
labels.push(label)
}
return oldLabels.length !== labels.length ?
object.merge(oldAsset, { labels })
: oldAsset
})
break
}
case AssetEventType.setItem: {
if (asset.id === event.id) {
setAsset(event.valueOrUpdater)
}
break
}
default: {
return
return oldLabels.length !== labels.length ? object.merge(oldAsset, { labels }) : oldAsset
})
break
}
case AssetEventType.setItem: {
if (asset.id === event.id) {
setAsset(event.valueOrUpdater)
}
break
}
default: {
return
}
}
}, item.initialAssetEvents)
@ -658,6 +631,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
<AssetContextMenu
innerProps={innerProps}
rootDirectoryId={rootDirectoryId}
triggerRef={rootRef}
event={event}
eventTarget={
event.target instanceof HTMLElement ? event.target : event.currentTarget
@ -742,12 +716,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const ids = payload
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
.map((dragItem) => dragItem.key)
dispatchAssetEvent({
type: AssetEventType.move,
newParentKey: directoryKey,
newParentId: directoryId,
ids: new Set(ids),
})
cutAndPaste(
directoryKey,
directoryId,
{ backendType: backend.type, ids: new Set(ids), category },
nodeMap.current,
)
} else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
@ -801,6 +775,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
setRowState,
}}
rootDirectoryId={rootDirectoryId}
triggerRef={rootRef}
event={{ pageX: 0, pageY: 0 }}
eventTarget={null}
doCopy={doCopy}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,37 @@
/** @file Hooks for interacting with the backend. */
import * as React from 'react'
import { useMemo } from 'react'
import * as reactQuery from '@tanstack/react-query'
import {
queryOptions,
useMutationState,
useQuery,
type Mutation,
type MutationKey,
type UseMutationOptions,
type UseQueryOptions,
type UseQueryResult,
} from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as backendQuery from 'enso-common/src/backendQuery'
import {
backendQueryOptions as backendQueryOptionsBase,
type BackendMethods,
} from 'enso-common/src/backendQuery'
import { CATEGORY_TO_FILTER_BY, type Category } from '#/layouts/CategorySwitcher/Category'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import {
AssetType,
BackendType,
type AnyAsset,
type AssetId,
type DirectoryAsset,
type DirectoryId,
type User,
type UserGroupInfo,
} from '#/services/Backend'
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime'
// ============================
// === DefineBackendMethods ===
@ -44,10 +69,6 @@ export type MutationMethod = DefineBackendMethods<
| 'deleteUser'
| 'deleteUserGroup'
| 'duplicateProject'
// TODO: `get*` are not mutations, but are currently used in some places.
| 'getDatalink'
| 'getFileDetails'
| 'getProjectDetails'
| 'inviteUser'
| 'logEvent'
| 'openProject'
@ -71,55 +92,75 @@ export type MutationMethod = DefineBackendMethods<
// === useBackendQuery ===
// =======================
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
'queryFn' | 'queryKey'
> &
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
'queryFn' | 'queryKey'
> &
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): reactQuery.UseQueryResult<
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryOptions<
// eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Backend[Method]>> | undefined
>
/** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
'queryFn' | 'queryKey'
> &
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
return reactQuery.useQuery<Awaited<ReturnType<Backend[Method]>>>({
// @ts-expect-error This call is generic over the presence or absence of `inputData`.
return queryOptions<Awaited<ReturnType<Backend[Method]>>>({
...options,
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
...backendQueryOptionsBase(backend, method, args, options?.queryKey),
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
queryFn: () => (backend?.[method] as any)?.(...args),
})
}
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<
// eslint-disable-next-line no-restricted-syntax
Awaited<ReturnType<Backend[Method]>> | undefined
>
/** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
return useQuery(backendQueryOptions(backend, method, args, options))
}
// ==========================
// === useBackendMutation ===
// ==========================
const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
const INVALIDATION_MAP: Partial<
Record<MutationMethod, readonly (backendQuery.BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
Record<MutationMethod, readonly (BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
> = {
createUser: ['usersMe'],
updateUser: ['usersMe'],
@ -141,7 +182,7 @@ const INVALIDATION_MAP: Partial<
createDirectory: ['listDirectory'],
createSecret: ['listDirectory'],
updateSecret: ['listDirectory'],
createDatalink: ['listDirectory'],
createDatalink: ['listDirectory', 'getDatalink'],
uploadFile: ['listDirectory'],
copyAsset: ['listDirectory', 'listAssetVersions'],
deleteAsset: ['listDirectory', 'listAssetVersions'],
@ -151,34 +192,29 @@ const INVALIDATION_MAP: Partial<
updateDirectory: ['listDirectory'],
}
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
'mutationFn'
>,
): reactQuery.UseMutationOptions<
/** The type of the corresponding mutation for the given backend method. */
export type BackendMutation<Method extends MutationMethod> = Mutation<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): reactQuery.UseMutationOptions<
): UseMutationOptions<
Awaited<ReturnType<Backend[Method]>> | undefined,
Error,
Parameters<Backend[Method]>
@ -188,24 +224,16 @@ export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>,
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): reactQuery.UseMutationOptions<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
> {
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>> {
return {
...options,
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
mutationFn: (args) => (backend?.[method] as any)?.(...args),
networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online',
networkMode: backend?.type === BackendType.local ? 'always' : 'online',
meta: {
invalidates: [
...(options?.meta?.invalidates ?? []),
@ -223,8 +251,8 @@ export function backendMutationOptions<Method extends MutationMethod>(
// ==================================
/** A user group, as well as the users that are a part of the user group. */
export interface UserGroupInfoWithUsers extends backendModule.UserGroupInfo {
readonly users: readonly backendModule.User[]
export interface UserGroupInfoWithUsers extends UserGroupInfo {
readonly users: readonly User[]
}
/** A list of user groups, taking into account optimistic state. */
@ -233,12 +261,12 @@ export function useListUserGroupsWithUsers(
): readonly UserGroupInfoWithUsers[] | null {
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
return React.useMemo(() => {
return useMemo(() => {
if (listUserGroupsQuery.data == null || listUsersQuery.data == null) {
return null
} else {
const result = listUserGroupsQuery.data.map((userGroup) => {
const usersInGroup: readonly backendModule.User[] = listUsersQuery.data.filter((user) =>
const usersInGroup: readonly User[] = listUsersQuery.data.filter((user) =>
user.userGroups?.includes(userGroup.id),
)
return { ...userGroup, users: usersInGroup }
@ -247,3 +275,96 @@ export function useListUserGroupsWithUsers(
}
}, [listUserGroupsQuery.data, listUsersQuery.data])
}
/** Data for a specific asset. */
export function useAssetPassiveListener(
backendType: BackendType,
assetId: AssetId | null | undefined,
parentId: DirectoryId | null | undefined,
category: Category,
) {
const listDirectoryQuery = useQuery<readonly AnyAsset<AssetType>[] | undefined>({
queryKey: [
backendType,
'listDirectory',
parentId,
{
labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent',
},
],
initialData: undefined,
})
const asset = listDirectoryQuery.data?.find((child) => child.id === assetId)
if (asset || !assetId || !parentId) {
return asset
}
switch (assetId) {
case USERS_DIRECTORY_ID: {
return {
id: assetId,
parentId,
type: AssetType.directory,
projectState: null,
title: 'Users',
description: '',
modifiedAt: toRfc3339(new Date()),
permissions: [],
labels: [],
} satisfies DirectoryAsset
}
case TEAMS_DIRECTORY_ID: {
return {
id: assetId,
parentId,
type: AssetType.directory,
projectState: null,
title: 'Teams',
description: '',
modifiedAt: toRfc3339(new Date()),
permissions: [],
labels: [],
} satisfies DirectoryAsset
}
default: {
return
}
}
}
/** Data for a specific asset. */
export function useAssetPassiveListenerStrict(
backendType: BackendType,
assetId: AssetId | null | undefined,
parentId: DirectoryId | null | undefined,
category: Category,
) {
const asset = useAssetPassiveListener(backendType, assetId, parentId, category)
invariant(asset, 'Asset not found')
return asset
}
/** Return matching in-flight mutations */
export function useBackendMutationState<Method extends MutationMethod, Result>(
backend: Backend,
method: Method,
options: {
mutationKey?: MutationKey
predicate?: (mutation: BackendMutation<Method>) => boolean
select?: (mutation: BackendMutation<Method>) => Result
} = {},
) {
const { mutationKey, predicate, select } = options
return useMutationState({
filters: {
...backendMutationOptions(backend, method, mutationKey ? { mutationKey } : {}),
predicate: (mutation: BackendMutation<Method>) =>
mutation.state.status === 'pending' && (predicate?.(mutation) ?? true),
},
// This is UNSAFE when the `Result` parameter is explicitly specified in the
// generic parameter list.
// eslint-disable-next-line no-restricted-syntax
select: select as (mutation: Mutation<unknown, Error, unknown, unknown>) => Result,
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -59,19 +59,21 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
const content = (
<Form form={form} testId="upsert-secret-modal" gap="none" className="w-full">
<Input
form={form}
name="name"
size="custom"
rounded="full"
autoFocus={isNameEditable}
autoComplete="off"
isDisabled={!isNameEditable}
label={getText('name')}
placeholder={getText('secretNamePlaceholder')}
variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES}
/>
{isNameEditable && (
<Input
form={form}
name="name"
size="custom"
rounded="full"
autoFocus={isNameEditable}
autoComplete="off"
isDisabled={!isNameEditable}
label={getText('name')}
placeholder={getText('secretNamePlaceholder')}
variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES}
/>
)}
<Input
form={form}
name="value"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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