Cloud backend issues (#11067)

Closes: enso-org/cloud-v2#1481

(cherry picked from commit bdadedbde5)
This commit is contained in:
Sergei Garin 2024-09-13 18:46:30 +03:00 committed by James Dunkerley
parent e267e471be
commit 05de1a2440
13 changed files with 108 additions and 79 deletions

View File

@ -6,7 +6,7 @@ import * as modalProvider from '#/providers/ModalProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { AnimatePresence, motion } from 'framer-motion' import { useOverlayTriggerState } from 'react-stately'
const PLACEHOLDER = <div /> const PLACEHOLDER = <div />
@ -14,7 +14,9 @@ const PLACEHOLDER = <div />
* Props passed to the render function of a {@link DialogTrigger}. * Props passed to the render function of a {@link DialogTrigger}.
*/ */
export interface DialogTriggerRenderProps { export interface DialogTriggerRenderProps {
readonly isOpened: boolean readonly isOpen: boolean
readonly close: () => void
readonly open: () => void
} }
/** /**
* Props for a {@link DialogTrigger}. * Props for a {@link DialogTrigger}.
@ -33,11 +35,10 @@ export interface DialogTriggerProps extends Omit<aria.DialogTriggerProps, 'child
/** A DialogTrigger opens a dialog when a trigger element is pressed. */ /** A DialogTrigger opens a dialog when a trigger element is pressed. */
export function DialogTrigger(props: DialogTriggerProps) { export function DialogTrigger(props: DialogTriggerProps) {
const { children, onOpenChange, onOpen = () => {}, onClose = () => {}, ...triggerProps } = props const { children, onOpenChange, onOpen = () => {}, onClose = () => {} } = props
const state = useOverlayTriggerState(props)
const [isOpened, setIsOpened] = React.useState(
triggerProps.isOpen ?? triggerProps.defaultOpen ?? false,
)
const { setModal, unsetModal } = modalProvider.useSetModal() const { setModal, unsetModal } = modalProvider.useSetModal()
const onOpenStableCallback = useEventCallback(onOpen) const onOpenStableCallback = useEventCallback(onOpen)
@ -53,40 +54,29 @@ export function DialogTrigger(props: DialogTriggerProps) {
onCloseStableCallback() onCloseStableCallback()
} }
setIsOpened(opened) state.setOpen(opened)
onOpenChange?.(opened) onOpenChange?.(opened)
}) })
React.useEffect(() => { React.useEffect(() => {
if (isOpened) { if (state.isOpen) {
onOpenStableCallback() onOpenStableCallback()
} }
}, [isOpened, onOpenStableCallback]) }, [state.isOpen, onOpenStableCallback])
const renderProps = {
isOpened,
} satisfies DialogTriggerRenderProps
const [trigger, dialog] = children const [trigger, dialog] = children
const renderProps = {
isOpen: state.isOpen,
close: state.close.bind(state),
open: state.open.bind(state),
} satisfies DialogTriggerRenderProps
return ( return (
<aria.DialogTrigger onOpenChange={onOpenChangeInternal} {...triggerProps}> <aria.DialogTrigger {...state} onOpenChange={onOpenChangeInternal}>
{trigger} {trigger}
{/* We're using AnimatePresence here to animate the dialog in and out. */}
<AnimatePresence>
{isOpened && (
<motion.div
style={{ display: 'none' }}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
>
{typeof dialog === 'function' ? dialog(renderProps) : dialog} {typeof dialog === 'function' ? dialog(renderProps) : dialog}
</motion.div>
)}
</AnimatePresence>
</aria.DialogTrigger> </aria.DialogTrigger>
) )
} }

View File

@ -109,6 +109,7 @@ export const DatePicker = forwardRef(function DatePicker<
isRequired, isRequired,
className, className,
size, size,
variants = DATE_PICKER_STYLES,
} = props } = props
const { fieldState, formInstance } = Form.useField({ const { fieldState, formInstance } = Form.useField({
@ -118,7 +119,7 @@ export const DatePicker = forwardRef(function DatePicker<
defaultValue, defaultValue,
}) })
const styles = DATE_PICKER_STYLES({ size }) const styles = variants({ size })
return ( return (
<Form.Field <Form.Field

View File

@ -16,6 +16,7 @@ import { useSyncRef } from '#/hooks/syncRefHooks'
import { mergeRefs } from '#/utilities/mergeRefs' import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants'
import { DIALOG_BACKGROUND } from '../../Dialog'
const DROPDOWN_STYLES = tv({ const DROPDOWN_STYLES = tv({
base: 'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy', base: 'focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy',
@ -47,10 +48,12 @@ const DROPDOWN_STYLES = tv({
slots: { slots: {
container: 'absolute left-0 h-full w-full min-w-max', container: 'absolute left-0 h-full w-full min-w-max',
options: options:
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors', 'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:transition-colors',
optionsSpacing: 'padding relative h-6', optionsSpacing: 'padding relative h-6',
optionsContainer: optionsContainer: DIALOG_BACKGROUND({
className:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows', 'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
}),
optionsList: 'overflow-hidden', optionsList: 'overflow-hidden',
optionsItem: optionsItem:
'flex h-6 items-center gap-dropdown-arrow rounded-input px-input-x transition-colors focus:cursor-default focus:bg-frame focus:font-bold focus:focus-ring not-focus:hover:bg-hover-bg not-selected:hover:bg-hover-bg', 'flex h-6 items-center gap-dropdown-arrow rounded-input px-input-x transition-colors focus:cursor-default focus:bg-frame focus:font-bold focus:focus-ring not-focus:hover:bg-hover-bg not-selected:hover:bg-hover-bg',

View File

@ -1,23 +1,11 @@
/** @file A context menu. */ /** @file A context menu. */
import * as React from 'react' import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import Modal from '#/components/Modal' import Modal from '#/components/Modal'
import { forwardRef } from '#/utilities/react' import { forwardRef } from '#/utilities/react'
import * as tailwindMerge from '#/utilities/tailwindMerge' import * as tailwindMerge from '#/utilities/tailwindMerge'
// =================
// === Constants ===
// =================
const DEFAULT_MENU_WIDTH = 256
const MACOS_MENU_WIDTH = 230
/** The width of a single context menu. */
const MENU_WIDTH = detect.isOnMacOS() ? MACOS_MENU_WIDTH : DEFAULT_MENU_WIDTH
const HALF_MENU_WIDTH = Math.floor(MENU_WIDTH / 2)
// =================== // ===================
// === ContextMenu === // === ContextMenu ===
// =================== // ===================
@ -46,7 +34,7 @@ function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivE
<div <div
data-testid="context-menus" data-testid="context-menus"
ref={ref} ref={ref}
style={{ left: event.pageX - HALF_MENU_WIDTH, top: event.pageY }} style={{ left: event.pageX, top: event.pageY }}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'pointer-events-none sticky flex w-min items-start gap-context-menus', 'pointer-events-none sticky flex w-min items-start gap-context-menus',
)} )}

View File

@ -92,7 +92,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'string' ? value : ''} value={typeof value === 'string' ? value : ''}
size={1} size={1}
className={twMerge( className={twMerge(
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only', 'focus-child text w-60 w-full grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60', getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)} )}
placeholder={getText('enterText')} placeholder={getText('enterText')}
@ -139,7 +139,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''} value={typeof value === 'number' ? value : ''}
size={1} size={1}
className={twMerge( className={twMerge(
'focus-child min-6- text40 w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only', 'focus-child min-6- text40 w-full grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60', getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
)} )}
placeholder={getText('enterInteger')} placeholder={getText('enterInteger')}
@ -191,7 +191,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
isDisabled={!isOptional} isDisabled={!isOptional}
isActive={!isOptional || isPresent} isActive={!isOptional || isPresent}
className={twMerge( className={twMerge(
'col-start-1 inline-block whitespace-nowrap rounded-full px-button-x', 'col-start-1 inline-block justify-self-start whitespace-nowrap rounded-full px-button-x',
isOptional && 'hover:bg-hover-bg', isOptional && 'hover:bg-hover-bg',
)} )}
onPress={() => { onPress={() => {
@ -216,6 +216,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
> >
{'title' in childSchema ? String(childSchema.title) : key} {'title' in childSchema ? String(childSchema.title) : key}
</Button> </Button>
{isPresent && ( {isPresent && (
<div className="col-start-2"> <div className="col-start-2">
<JSONSchemaInput <JSONSchemaInput
@ -302,7 +303,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
readOnly={readOnly} readOnly={readOnly}
items={childSchemas} items={childSchemas}
selectedIndex={selectedChildIndex} selectedIndex={selectedChildIndex}
className="self-start" className="w-full self-start"
onChange={(childSchema, index) => { onChange={(childSchema, index) => {
setSelectedChildIndex(index) setSelectedChildIndex(index)
const newConstantValue = constantValueOfSchema(defs, childSchema, true) const newConstantValue = constantValueOfSchema(defs, childSchema, true)

View File

@ -193,6 +193,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink')) const getDatalinkMutation = useMutation(backendMutationOptions(backend, 'getDatalink'))
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const editDescriptionMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const setSelected = useEventCallback((newSelected: boolean) => { const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState() const { selectedKeys } = driveStore.getState()
@ -224,9 +225,11 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}, [grabKeyboardFocusRef, isKeyboardSelected, item]) }, [grabKeyboardFocusRef, isKeyboardSelected, item])
React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item })) React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item }))
if (updateAssetRef.current) { if (updateAssetRef.current) {
updateAssetRef.current[item.item.id] = setAsset updateAssetRef.current[item.item.id] = setAsset
} }
React.useEffect(() => { React.useEffect(() => {
return () => { return () => {
if (updateAssetRef.current) { if (updateAssetRef.current) {
@ -250,25 +253,25 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
[doDeleteRaw, item.item], [doDeleteRaw, item.item],
) )
const doTriggerDescriptionEdit = React.useCallback(() => { const doTriggerDescriptionEdit = useEventCallback(() => {
setModal( setModal(
<EditAssetDescriptionModal <EditAssetDescriptionModal
doChangeDescription={async (description) => { doChangeDescription={async (description) => {
if (description !== asset.description) { if (description !== asset.description) {
setAsset(object.merger({ description })) setAsset(object.merger({ description }))
await backend // eslint-disable-next-line no-restricted-syntax
.updateAsset(item.item.id, { parentDirectoryId: null, description }, item.item.title) return editDescriptionMutation.mutateAsync([
.catch((error) => { asset.id,
setAsset(object.merger({ description: asset.description })) { description, parentDirectoryId: null },
throw error item.item.title,
}) ])
} }
}} }}
initialDescription={asset.description} initialDescription={asset.description}
/>, />,
) )
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title]) })
const clearDragState = React.useCallback(() => { const clearDragState = React.useCallback(() => {
setIsDraggedOver(false) setIsDraggedOver(false)

View File

@ -1,7 +1,9 @@
/** @file A component that renders the modal instance from the modal React Context. */ /** @file A component that renders the modal instance from the modal React Context. */
import * as React from 'react' import * as React from 'react'
import { DialogTrigger } from '#/components/AriaComponents'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import { AnimatePresence, motion } from 'framer-motion'
// ================ // ================
// === TheModal === // === TheModal ===
@ -9,7 +11,25 @@ import * as modalProvider from '#/providers/ModalProvider'
/** Renders the modal instance from the modal React Context (if any). */ /** Renders the modal instance from the modal React Context (if any). */
export default function TheModal() { export default function TheModal() {
const { modal } = modalProvider.useModal() const { modal, key } = modalProvider.useModal()
return <>{modal}</> return (
<AnimatePresence>
{modal && (
<motion.div
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
/* eslint-disable-next-line @typescript-eslint/no-magic-numbers */
exit={{ opacity: 0.8 }}
/* eslint-disable-next-line @typescript-eslint/no-magic-numbers */
transition={{ duration: 0.2 }}
>
<DialogTrigger key={key} defaultOpen>
<></>
{modal}
</DialogTrigger>
</motion.div>
)}
</AnimatePresence>
)
} }

View File

@ -125,7 +125,7 @@ export default function AssetPanel(props: AssetPanelProps) {
<div <div
data-testid="asset-panel" data-testid="asset-panel"
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-white p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow', 'pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel bg-invert p-4 pl-asset-panel-l transition-[box-shadow] clip-path-left-shadow',
isVisible ? 'shadow-softer' : '', isVisible ? 'shadow-softer' : '',
)} )}
onClick={(event) => { onClick={(event) => {
@ -184,6 +184,7 @@ export default function AssetPanel(props: AssetPanelProps) {
: <> : <>
{tab === AssetPanelTab.properties && ( {tab === AssetPanelTab.properties && (
<AssetProperties <AssetProperties
key={item.item.id}
backend={backend} backend={backend}
isReadonly={isReadonly} isReadonly={isReadonly}
item={item} item={item}

View File

@ -45,14 +45,13 @@ export interface AssetPropertiesProps {
/** Display and modify the properties of an asset. */ /** Display and modify the properties of an asset. */
export default function AssetProperties(props: AssetPropertiesProps) { export default function AssetProperties(props: AssetPropertiesProps) {
const { backend, item: itemRaw, setItem: setItemRaw, category } = props const { backend, item, setItem, category } = props
const { isReadonly = false } = props const { isReadonly = false } = props
const { user } = authProvider.useFullUserSession() const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const localBackend = backendProvider.useLocalBackend() const localBackend = backendProvider.useLocalBackend()
const [item, setItemInner] = React.useState(itemRaw)
const [isEditingDescription, setIsEditingDescription] = React.useState(false) const [isEditingDescription, setIsEditingDescription] = React.useState(false)
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null) const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
const [description, setDescription] = React.useState('') const [description, setDescription] = React.useState('')
@ -65,13 +64,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
() => datalinkValidator.validateDatalink(datalinkValue), () => datalinkValidator.validateDatalink(datalinkValue),
[datalinkValue], [datalinkValue],
) )
const setItem = React.useCallback(
(valueOrUpdater: React.SetStateAction<assetTreeNode.AnyAssetTreeNode>) => {
setItemInner(valueOrUpdater)
setItemRaw(valueOrUpdater)
},
[setItemRaw],
)
const labels = useBackendQuery(backend, 'listTags', []).data ?? [] const labels = useBackendQuery(backend, 'listTags', []).data ?? []
const self = permissions.tryFindSelfPermission(user, item.item.permissions) const self = permissions.tryFindSelfPermission(user, item.item.permissions)
const ownsThisAsset = self?.permission === permissions.PermissionAction.own const ownsThisAsset = self?.permission === permissions.PermissionAction.own

View File

@ -72,10 +72,18 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
!isCloud || !isCloud ||
Array.from(selectedKeys, (key) => { Array.from(selectedKeys, (key) => {
const userPermissions = nodeMapRef.current.get(key)?.item.permissions const userPermissions = nodeMapRef.current.get(key)?.item.permissions
const selfGroupPermission = userPermissions?.find(
backendModule.isUserGroupPermissionAnd(
(permission) => user.userGroups?.includes(permission.userGroup.id) ?? false,
),
)
const selfPermission = userPermissions?.find( const selfPermission = userPermissions?.find(
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId), backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
) )
return selfPermission?.permission === permissions.PermissionAction.own
return (
(selfPermission ?? selfGroupPermission)?.permission === permissions.PermissionAction.own
)
}).every((isOwner) => isOwner) }).every((isOwner) => isOwner)
// This is not a React component even though it contains JSX. // This is not a React component even though it contains JSX.

View File

@ -35,7 +35,12 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
const { getText } = useText() const { getText } = useText()
return ( return (
<Dialog fitContent title={getText('createDatalink')} className="min-w-max"> <Dialog
fitContent
title={getText('createDatalink')}
className="min-w-max"
isDismissable={false}
>
{({ close }) => ( {({ close }) => (
<Form <Form
method="dialog" method="dialog"
@ -55,7 +60,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
label={getText('name')} label={getText('name')}
placeholder={getText('datalinkNamePlaceholder')} placeholder={getText('datalinkNamePlaceholder')}
/> />
<div className="relative"> <div className="relative w-full">
<DatalinkFormInput form={form} name="value" dropdownTitle={getText('type')} /> <DatalinkFormInput form={form} name="value" dropdownTitle={getText('type')} />
</div> </div>
<ButtonGroup className="relative"> <ButtonGroup className="relative">

View File

@ -23,7 +23,10 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
const isNameEditable = nameRaw == null const isNameEditable = nameRaw == null
return ( return (
<Dialog title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}> <Dialog
title={isCreatingSecret ? getText('newSecret') : getText('editSecret')}
isDismissable={false}
>
<Form <Form
testId="upsert-secret-modal" testId="upsert-secret-modal"
method="dialog" method="dialog"

View File

@ -1,5 +1,6 @@
/** @file The React provider for modals, along with hooks to use the provider via /** @file The React provider for modals, along with hooks to use the provider via
* the shared React context. */ * the shared React context. */
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as React from 'react' import * as React from 'react'
// ===================== // =====================
@ -17,10 +18,11 @@ interface ModalStaticContextType {
/** State contained in a `ModalContext`. */ /** State contained in a `ModalContext`. */
interface ModalContextType { interface ModalContextType {
readonly key: number
readonly modal: Modal | null readonly modal: Modal | null
} }
const ModalContext = React.createContext<ModalContextType>({ modal: null }) const ModalContext = React.createContext<ModalContextType>({ modal: null, key: 0 })
const ModalStaticContext = React.createContext<ModalStaticContextType>({ const ModalStaticContext = React.createContext<ModalStaticContextType>({
setModal: () => { setModal: () => {
@ -37,23 +39,34 @@ export interface ModalProviderProps extends Readonly<React.PropsWithChildren> {}
export default function ModalProvider(props: ModalProviderProps) { export default function ModalProvider(props: ModalProviderProps) {
const { children } = props const { children } = props
const [modal, setModal] = React.useState<Modal | null>(null) const [modal, setModal] = React.useState<Modal | null>(null)
// we use key to tell react to invaldidate the modal when we change it.
const [key, setKey] = React.useState(0)
const modalRef = React.useRef(modal) const modalRef = React.useRef(modal)
React.useEffect(() => { React.useEffect(() => {
modalRef.current = modal modalRef.current = modal
}, [modal]) }, [modal])
const setModalStableCallback = useEventCallback(
(nextModal: React.SetStateAction<React.JSX.Element | null>) => {
React.startTransition(() => {
setModal(nextModal)
setKey((currentKey) => currentKey + 1)
})
},
)
// This is NOT for optimization purposes - this is for debugging purposes, // This is NOT for optimization purposes - this is for debugging purposes,
// so that a change of `modal` does not trigger VDOM changes everywhere in the page. // so that a change of `modal` does not trigger VDOM changes everywhere in the page.
const setModalProvider = React.useMemo( const setModalProvider = React.useMemo(
() => ( () => (
<ModalStaticProvider setModal={setModal} modalRef={modalRef}> <ModalStaticProvider setModal={setModalStableCallback} modalRef={modalRef}>
{children} {children}
</ModalStaticProvider> </ModalStaticProvider>
), ),
[children], [children, setModalStableCallback],
) )
return <ModalContext.Provider value={{ modal }}>{setModalProvider}</ModalContext.Provider> return <ModalContext.Provider value={{ modal, key }}>{setModalProvider}</ModalContext.Provider>
} }
/** Props for a {@link ModalStaticProvider}. */ /** Props for a {@link ModalStaticProvider}. */
@ -79,8 +92,8 @@ function ModalStaticProvider(props: InternalModalStaticProviderProps) {
/** A React context hook exposing the currently active modal, if one is currently visible. */ /** A React context hook exposing the currently active modal, if one is currently visible. */
export function useModal() { export function useModal() {
const { modal } = React.useContext(ModalContext) const { modal, key } = React.useContext(ModalContext)
return { modal } as const return { modal, key } as const
} }
// =================== // ===================