mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 11:41:56 +03:00
parent
b5122348da
commit
bdadedbde5
@ -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>
|
||||
</aria.DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
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',
|
||||
|
@ -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',
|
||||
)}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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">
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
// ===================
|
||||
|
Loading…
Reference in New Issue
Block a user