mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 05:12:31 +03:00
Context menu should have a Edit Description on files (#9530)
Closes: enso-org/cloud-v2#1083 Tl;dr: This PR introduces a new menu entry that allows to open edit description dialog from context menu in dashboard. This supposed to work only in cloud. When you right-click on an item in Cloud Drive, you can choose "Edit description" option to change the description of the selected item. https://github.com/enso-org/enso/assets/61194245/53e949df-8a31-401c-ba48-52eddad468fa Context: See enso-org/cloud-v2#1083 . I decided to open a dialog insted of the sidebar because latter takes to much time and effort to make it properly. This Change: Added new variants for button component(submit & cancel), also - loading state.Added a new dialog that opens when you select "Edit description" in context menu. Test Plan: 1. We shouldn't allow users to change the description for local files 2. Changes in the Dialog(after save) should reflect in sidebar(Description should update in sidebar) 3. Loading state/Errors should be displayed in dialog.
This commit is contained in:
parent
84c8535587
commit
06b416d499
@ -3,17 +3,21 @@
|
||||
*
|
||||
* Button component
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import clsx from 'clsx'
|
||||
import * as reactAriaComponents from 'react-aria-components'
|
||||
import * as tailwindMerge from 'tailwind-merge'
|
||||
|
||||
import Spinner, * as spinnerModule from '#/components/Spinner'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
/**
|
||||
* Props for the Button component
|
||||
*/
|
||||
export interface ButtonProps extends reactAriaComponents.ButtonProps {
|
||||
readonly variant: 'icon'
|
||||
readonly loading?: boolean
|
||||
readonly variant: 'cancel' | 'delete' | 'icon' | 'submit'
|
||||
readonly icon?: string
|
||||
/**
|
||||
* FIXME: This is not yet implemented
|
||||
@ -24,31 +28,45 @@ export interface ButtonProps extends reactAriaComponents.ButtonProps {
|
||||
}
|
||||
|
||||
const DEFAULT_CLASSES =
|
||||
'flex cursor-pointer rounded-sm border border-transparent transition-opacity duration-200 ease-in-out'
|
||||
'flex cursor-pointer rounded-full border border-transparent transition-opacity duration-200 ease-in-out'
|
||||
const FOCUS_CLASSES =
|
||||
'focus-visible:outline-offset-2 focus:outline-none focus-visible:outline focus-visible:outline-primary'
|
||||
const SUBMIT_CLASSES = 'bg-invite text-white opacity-80 hover:opacity-100'
|
||||
const CANCEL_CLASSES = 'bg-selected-frame opacity-80 hover:opacity-100'
|
||||
const DELETE_CLASSES = 'bg-delete text-white'
|
||||
const ICON_CLASSES = 'opacity-50 hover:opacity-100'
|
||||
const EXTRA_CLICK_ZONE_CLASSES = 'flex relative before:inset-[-12px] before:absolute before:z-10'
|
||||
const DISABLED_CLASSES = 'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
const SIZE_CLASSES = 'px-2 py-1'
|
||||
|
||||
/**
|
||||
* A button allows a user to perform an action, with mouse, touch, and keyboard interactions.
|
||||
*/
|
||||
export function Button(props: ButtonProps) {
|
||||
const { className, children, icon, ...ariaButtonProps } = props
|
||||
export function Button(props: ButtonProps): React.JSX.Element {
|
||||
const { className, children, variant, icon, loading = false, ...ariaButtonProps } = props
|
||||
|
||||
const classes = clsx(DEFAULT_CLASSES, DISABLED_CLASSES, FOCUS_CLASSES, ICON_CLASSES)
|
||||
const classes = clsx(
|
||||
DEFAULT_CLASSES,
|
||||
DISABLED_CLASSES,
|
||||
FOCUS_CLASSES,
|
||||
SIZE_CLASSES,
|
||||
VARIANT_TO_CLASSES[variant]
|
||||
)
|
||||
|
||||
const childrenFactory = () => {
|
||||
return icon != null ? (
|
||||
<>
|
||||
<div className={EXTRA_CLICK_ZONE_CLASSES}>
|
||||
<SvgMask src={icon} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
const childrenFactory = (): React.ReactNode => {
|
||||
if (loading) {
|
||||
return <Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
|
||||
} else if (variant === 'icon' && icon != null) {
|
||||
return (
|
||||
<>
|
||||
<div className={EXTRA_CLICK_ZONE_CLASSES}>
|
||||
<SvgMask src={icon} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return <>{children}</>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@ -65,3 +83,10 @@ export function Button(props: ButtonProps) {
|
||||
</reactAriaComponents.Button>
|
||||
)
|
||||
}
|
||||
|
||||
const VARIANT_TO_CLASSES: Record<ButtonProps['variant'], string> = {
|
||||
cancel: CANCEL_CLASSES,
|
||||
delete: DELETE_CLASSES,
|
||||
icon: ICON_CLASSES,
|
||||
submit: SUBMIT_CLASSES,
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ const ACTION_TO_TEXT_ID: Readonly<Record<inputBindings.DashboardBindingKey, text
|
||||
uploadToCloud: 'uploadToCloudShortcut',
|
||||
rename: 'renameShortcut',
|
||||
edit: 'editShortcut',
|
||||
editDescription: 'editDescriptionShortcut',
|
||||
snapshot: 'snapshotShortcut',
|
||||
delete: 'deleteShortcut',
|
||||
undelete: 'undeleteShortcut',
|
||||
|
@ -23,6 +23,8 @@ import * as columnModule from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
@ -359,6 +361,26 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
}, [backend, dispatchAssetListEvent, asset, toastAndLog, /* should never change */ item.key])
|
||||
|
||||
const doTriggerDescriptionEdit = React.useCallback(() => {
|
||||
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
|
||||
})
|
||||
}
|
||||
}}
|
||||
initialDescription={asset.description}
|
||||
/>
|
||||
)
|
||||
}, [setModal, asset.description, setAsset, backend, item.item.id, item.item.title])
|
||||
|
||||
eventHooks.useEventHandler(assetEvents, async event => {
|
||||
switch (event.type) {
|
||||
// These events are handled in the specific `NameColumn` files.
|
||||
@ -691,6 +713,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
doCut={doCut}
|
||||
doPaste={doPaste}
|
||||
doDelete={doDelete}
|
||||
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
@ -821,6 +844,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
doCut={doCut}
|
||||
doPaste={doPaste}
|
||||
doDelete={doDelete}
|
||||
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -56,6 +56,7 @@ export const BINDINGS = inputBindings.defineBindings({
|
||||
rename: { name: 'Rename', bindings: ['Mod+R'], icon: PenIcon },
|
||||
edit: { name: 'Edit', bindings: ['Mod+E'], icon: PenIcon },
|
||||
snapshot: { name: 'Snapshot', bindings: ['Mod+S'], icon: CameraIcon },
|
||||
editDescription: { name: 'Edit Description', bindings: ['Mod+Shift+E'], icon: PenIcon },
|
||||
delete: {
|
||||
name: 'Delete',
|
||||
bindings: ['OsDelete'],
|
||||
|
@ -49,6 +49,7 @@ export interface AssetContextMenuProps {
|
||||
readonly doDelete: () => void
|
||||
readonly doCopy: () => void
|
||||
readonly doCut: () => void
|
||||
readonly doTriggerDescriptionEdit: () => void
|
||||
readonly doPaste: (
|
||||
newParentKey: backendModule.AssetId,
|
||||
newParentId: backendModule.DirectoryId
|
||||
@ -57,7 +58,16 @@ export interface AssetContextMenuProps {
|
||||
|
||||
/** The context menu for an arbitrary {@link backendModule.Asset}. */
|
||||
export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const { innerProps, event, eventTarget, doCopy, doCut, doPaste, doDelete } = props
|
||||
const {
|
||||
innerProps,
|
||||
event,
|
||||
eventTarget,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
doDelete,
|
||||
doTriggerDescriptionEdit,
|
||||
} = props
|
||||
const { hidden = false } = props
|
||||
const { item, setItem, state, setRowState } = innerProps
|
||||
const { category, hasPasteData, labels, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
@ -257,6 +267,16 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCloud && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
action="editDescription"
|
||||
label={getText('editDescriptionShortcut')}
|
||||
doAction={() => {
|
||||
doTriggerDescriptionEdit()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCloud && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
|
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Modal for editing an asset's description.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface EditAssetDescriptionModalProps {
|
||||
readonly actionButtonLabel?: string
|
||||
readonly initialDescription: string | null
|
||||
/**
|
||||
* Callback to change the asset's description.
|
||||
*/
|
||||
readonly doChangeDescription: (newDescription: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for editing an asset's description.
|
||||
*/
|
||||
export default function EditAssetDescriptionModal(props: EditAssetDescriptionModalProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const {
|
||||
doChangeDescription,
|
||||
initialDescription,
|
||||
actionButtonLabel = getText('editAssetDescriptionModalSubmit'),
|
||||
} = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const [description, setDescription] = React.useState(initialDescription ?? '')
|
||||
const initialdescriptionRef = React.useRef(initialDescription)
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const { isPending, error, mutate } = reactQuery.useMutation({
|
||||
mutationFn: doChangeDescription,
|
||||
onSuccess: () => {
|
||||
unsetModal()
|
||||
},
|
||||
})
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (
|
||||
textareaRef.current &&
|
||||
typeof initialdescriptionRef.current === 'string' &&
|
||||
initialdescriptionRef.current.length > 0
|
||||
) {
|
||||
textareaRef.current.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal centered className="bg-dim">
|
||||
<form
|
||||
data-testid="edit-description-modal"
|
||||
className="pointer-events-auto relative flex w-confirm-delete-modal flex-col gap-modal rounded-default p-modal-wide py-modal before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Escape') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
mutate(description)
|
||||
}}
|
||||
>
|
||||
<div className="relative text-sm font-semibold">
|
||||
{getText('editAssetDescriptionModalTitle')}
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="relative h-16 resize-none rounded-default bg-selected-frame px-4 py-2"
|
||||
placeholder={getText('editAssetDescriptionModalPlaceholder')}
|
||||
onChange={event => {
|
||||
setDescription(event.target.value)
|
||||
}}
|
||||
value={description}
|
||||
disabled={isPending}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{error && <div className="relative text-sm text-red-500">{error.message}</div>}
|
||||
|
||||
<div className="relative flex gap-buttons">
|
||||
<ariaComponents.Button variant="submit" type="submit" loading={isPending}>
|
||||
{actionButtonLabel}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button
|
||||
variant="cancel"
|
||||
type="button"
|
||||
onPress={unsetModal}
|
||||
isDisabled={isPending}
|
||||
>
|
||||
{getText('editAssetDescriptionModalCancel')}
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -406,6 +406,7 @@
|
||||
"uploadToCloudShortcut": "Upload To Cloud",
|
||||
"renameShortcut": "Rename",
|
||||
"editShortcut": "Edit",
|
||||
"editDescriptionShortcut": "Edit Description",
|
||||
"snapshotShortcut": "Snapshot",
|
||||
"deleteShortcut": "Delete",
|
||||
"undeleteShortcut": "Restore From Trash",
|
||||
@ -475,5 +476,10 @@
|
||||
"thursdayAbbr": "Th",
|
||||
"fridayAbbr": "F",
|
||||
"saturdayAbbr": "Sa",
|
||||
"sundayAbbr": "Su"
|
||||
"sundayAbbr": "Su",
|
||||
|
||||
"editAssetDescriptionModalTitle": "Edit Asset Description",
|
||||
"editAssetDescriptionModalPlaceholder": "Enter a description for the asset",
|
||||
"editAssetDescriptionModalSubmit": "Submit",
|
||||
"editAssetDescriptionModalCancel": "Cancel"
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user