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 { useEventCallback } from '#/hooks/eventCallbackHooks'
import { AnimatePresence, motion } from 'framer-motion'
import { useOverlayTriggerState } from 'react-stately'
const PLACEHOLDER = <div />
@ -14,7 +14,9 @@ const PLACEHOLDER = <div />
* Props passed to the render function of a {@link DialogTrigger}.
*/
export interface DialogTriggerRenderProps {
readonly isOpened: boolean
readonly isOpen: boolean
readonly close: () => void
readonly open: () => void
}
/**
* 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. */
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 onOpenStableCallback = useEventCallback(onOpen)
@ -53,40 +54,29 @@ export function DialogTrigger(props: DialogTriggerProps) {
onCloseStableCallback()
}
setIsOpened(opened)
state.setOpen(opened)
onOpenChange?.(opened)
})
React.useEffect(() => {
if (isOpened) {
if (state.isOpen) {
onOpenStableCallback()
}
}, [isOpened, onOpenStableCallback])
const renderProps = {
isOpened,
} satisfies DialogTriggerRenderProps
}, [state.isOpen, onOpenStableCallback])
const [trigger, dialog] = children
const renderProps = {
isOpen: state.isOpen,
close: state.close.bind(state),
open: state.open.bind(state),
} satisfies DialogTriggerRenderProps
return (
<aria.DialogTrigger onOpenChange={onOpenChangeInternal} {...triggerProps}>
<aria.DialogTrigger {...state} onOpenChange={onOpenChangeInternal}>
{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}
</motion.div>
)}
</AnimatePresence>
{typeof dialog === 'function' ? dialog(renderProps) : dialog}
</aria.DialogTrigger>
)
}

View File

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

View File

@ -16,6 +16,7 @@ import { useSyncRef } from '#/hooks/syncRefHooks'
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv } from '#/utilities/tailwindVariants'
import { DIALOG_BACKGROUND } from '../../Dialog'
const DROPDOWN_STYLES = tv({
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: {
container: 'absolute left-0 h-full w-full min-w-max',
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',
optionsContainer:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
optionsContainer: DIALOG_BACKGROUND({
className:
'relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows',
}),
optionsList: 'overflow-hidden',
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',

View File

@ -1,23 +1,11 @@
/** @file A context menu. */
import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import Modal from '#/components/Modal'
import { forwardRef } from '#/utilities/react'
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 ===
// ===================
@ -46,7 +34,7 @@ function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivE
<div
data-testid="context-menus"
ref={ref}
style={{ left: event.pageX - HALF_MENU_WIDTH, top: event.pageY }}
style={{ left: event.pageX, top: event.pageY }}
className={tailwindMerge.twMerge(
'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 : ''}
size={1}
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',
)}
placeholder={getText('enterText')}
@ -139,7 +139,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
value={typeof value === 'number' ? value : ''}
size={1}
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',
)}
placeholder={getText('enterInteger')}
@ -191,7 +191,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
isDisabled={!isOptional}
isActive={!isOptional || isPresent}
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',
)}
onPress={() => {
@ -216,6 +216,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
>
{'title' in childSchema ? String(childSchema.title) : key}
</Button>
{isPresent && (
<div className="col-start-2">
<JSONSchemaInput
@ -302,7 +303,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
readOnly={readOnly}
items={childSchemas}
selectedIndex={selectedChildIndex}
className="self-start"
className="w-full self-start"
onChange={(childSchema, index) => {
setSelectedChildIndex(index)
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 createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
const editDescriptionMutation = useMutation(backendMutationOptions(backend, 'updateAsset'))
const setSelected = useEventCallback((newSelected: boolean) => {
const { selectedKeys } = driveStore.getState()
@ -224,9 +225,11 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}, [grabKeyboardFocusRef, isKeyboardSelected, item])
React.useImperativeHandle(updateAssetRef, () => ({ setAsset, item }))
if (updateAssetRef.current) {
updateAssetRef.current[item.item.id] = setAsset
}
React.useEffect(() => {
return () => {
if (updateAssetRef.current) {
@ -250,25 +253,25 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
[doDeleteRaw, item.item],
)
const doTriggerDescriptionEdit = React.useCallback(() => {
const doTriggerDescriptionEdit = useEventCallback(() => {
setModal(
<EditAssetDescriptionModal
doChangeDescription={async (description) => {
if (description !== asset.description) {
setAsset(object.merger({ description }))
await backend
.updateAsset(item.item.id, { parentDirectoryId: null, description }, item.item.title)
.catch((error) => {
setAsset(object.merger({ description: asset.description }))
throw error
})
// eslint-disable-next-line no-restricted-syntax
return editDescriptionMutation.mutateAsync([
asset.id,
{ description, parentDirectoryId: null },
item.item.title,
])
}
}}
initialDescription={asset.description}
/>,
)
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
})
const clearDragState = React.useCallback(() => {
setIsDraggedOver(false)

View File

@ -1,7 +1,9 @@
/** @file A component that renders the modal instance from the modal React Context. */
import * as React from 'react'
import { DialogTrigger } from '#/components/AriaComponents'
import * as modalProvider from '#/providers/ModalProvider'
import { AnimatePresence, motion } from 'framer-motion'
// ================
// === TheModal ===
@ -9,7 +11,25 @@ import * as modalProvider from '#/providers/ModalProvider'
/** Renders the modal instance from the modal React Context (if any). */
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
data-testid="asset-panel"
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' : '',
)}
onClick={(event) => {
@ -184,6 +184,7 @@ export default function AssetPanel(props: AssetPanelProps) {
: <>
{tab === AssetPanelTab.properties && (
<AssetProperties
key={item.item.id}
backend={backend}
isReadonly={isReadonly}
item={item}

View File

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

View File

@ -72,10 +72,18 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
!isCloud ||
Array.from(selectedKeys, (key) => {
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(
backendModule.isUserPermissionAnd((permission) => permission.user.userId === user.userId),
)
return selfPermission?.permission === permissions.PermissionAction.own
return (
(selfPermission ?? selfGroupPermission)?.permission === permissions.PermissionAction.own
)
}).every((isOwner) => isOwner)
// 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()
return (
<Dialog fitContent title={getText('createDatalink')} className="min-w-max">
<Dialog
fitContent
title={getText('createDatalink')}
className="min-w-max"
isDismissable={false}
>
{({ close }) => (
<Form
method="dialog"
@ -55,7 +60,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
label={getText('name')}
placeholder={getText('datalinkNamePlaceholder')}
/>
<div className="relative">
<div className="relative w-full">
<DatalinkFormInput form={form} name="value" dropdownTitle={getText('type')} />
</div>
<ButtonGroup className="relative">

View File

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

View File

@ -1,5 +1,6 @@
/** @file The React provider for modals, along with hooks to use the provider via
* the shared React context. */
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as React from 'react'
// =====================
@ -17,10 +18,11 @@ interface ModalStaticContextType {
/** State contained in a `ModalContext`. */
interface ModalContextType {
readonly key: number
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>({
setModal: () => {
@ -37,23 +39,34 @@ export interface ModalProviderProps extends Readonly<React.PropsWithChildren> {}
export default function ModalProvider(props: ModalProviderProps) {
const { children } = props
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)
React.useEffect(() => {
modalRef.current = 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,
// so that a change of `modal` does not trigger VDOM changes everywhere in the page.
const setModalProvider = React.useMemo(
() => (
<ModalStaticProvider setModal={setModal} modalRef={modalRef}>
<ModalStaticProvider setModal={setModalStableCallback} modalRef={modalRef}>
{children}
</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}. */
@ -79,8 +92,8 @@ function ModalStaticProvider(props: InternalModalStaticProviderProps) {
/** A React context hook exposing the currently active modal, if one is currently visible. */
export function useModal() {
const { modal } = React.useContext(ModalContext)
return { modal } as const
const { modal, key } = React.useContext(ModalContext)
return { modal, key } as const
}
// ===================