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:
Sergei Garin 2024-04-02 20:02:13 +04:00 committed by GitHub
parent 84c8535587
commit 06b416d499
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 206 additions and 17 deletions

View File

@ -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,
}

View File

@ -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',

View File

@ -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}
/>
)}
</>

View File

@ -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'],

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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"
}