right click actions

This commit is contained in:
Harsh Mahajan
2026-01-16 13:45:08 +05:30
parent 36f5ba408b
commit 663bceee2e
4 changed files with 441 additions and 221 deletions
+10 -1
View File
@@ -198,7 +198,16 @@ export enum Click {
WebsiteOpenClick = 'click_open_website',
CopyPromptStarterKitClick = 'click_copy_prompt_starter_kit',
OpenInCursorClick = 'click_open_in_cursor',
OpenInLovableClick = 'click_open_in_lovable'
OpenInLovableClick = 'click_open_in_lovable',
RowCopyUrl = 'click_row_copy_url',
RowCopyJson = 'click_row_copy_json',
RowCopySnippet = 'click_row_copy_snippet',
RowContextMenuOpen = 'click_row_context_menu_open',
RowUpdate = 'click_row_update',
RowDuplicate = 'click_row_duplicate',
RowDelete = 'click_row_delete',
RowPermissions = 'click_row_permissions',
RowActivity = 'click_row_activity'
}
export enum Submit {
@@ -0,0 +1,159 @@
<script lang="ts">
import { createContextMenu, melt } from '@melt-ui/svelte';
import { Card, ActionMenu, Divider } from '@appwrite.io/pink-svelte';
import type { ComponentType } from 'svelte';
import {
IconPencil,
IconDuplicate,
IconKey,
IconChartBar,
IconLink,
IconTrash,
IconClipboardCopy,
IconCode,
IconChevronRight
} from '@appwrite.io/pink-icons-svelte';
import type { RowCellAction } from './sheetOptions.svelte';
import { trackEvent, Click } from '$lib/actions/analytics';
export let onSelect: (action: RowCellAction) => void;
const {
elements: { menu, trigger, item, separator },
builders: { createSubmenu }
} = createContextMenu({
positioning: {
placement: 'right-start',
gutter: 2,
sameWidth: false
},
onOpenChange: ({ next }) => {
if (next) {
trackEvent(Click.RowContextMenuOpen);
}
return next;
}
});
const {
elements: { subMenu: copySubMenu, subTrigger: copySubTrigger }
} = createSubmenu({
positioning: {
placement: 'right-start',
gutter: 8
},
arrowSize: 0
});
const menuItems: {
label?: string;
icon?: ComponentType;
action?: RowCellAction;
subMenu?: string;
divider?: boolean;
danger?: boolean;
}[] = [
{ label: 'Update row', icon: IconPencil, action: 'update' },
{ label: 'Duplicate row', icon: IconDuplicate, action: 'duplicate-row' },
{ divider: true },
{ label: 'Manage permissions', icon: IconKey, action: 'permissions' },
{ label: 'View activity', icon: IconChartBar, action: 'activity' },
{ divider: true },
{ label: 'Copy', icon: IconDuplicate, subMenu: 'copy' },
{ divider: true },
{ label: 'Delete', icon: IconTrash, action: 'delete', danger: true }
];
const copyMenuItems: { label: string; icon: ComponentType; action: RowCellAction }[] = [
{ label: 'URL', icon: IconLink, action: 'copy-url' },
{ label: 'JSON', icon: IconClipboardCopy, action: 'copy-json' },
{ label: 'Code snippet', icon: IconCode, action: 'copy-snippet' }
];
</script>
<div use:melt={$trigger} style="display: contents;">
<slot />
</div>
<div class="menu" use:melt={$menu}>
<Card.Base padding="none">
<div class="action-menu-root">
<ActionMenu.Root>
{#each menuItems as menuItem, index}
{#if menuItem.divider}
{@const isLastDivider = index === menuItems.length - 2}
<div
use:melt={$separator}
style:margin-inline="-1rem"
style:padding-block-start="0.5rem"
style:padding-block-end={isLastDivider ? '0.25rem' : '0.5rem'}>
<Divider />
</div>
{:else if menuItem.subMenu === 'copy'}
<!-- Copy Submenu Trigger -->
<div use:melt={$copySubTrigger} class="sub-trigger">
<ActionMenu.Root>
<ActionMenu.Item.Button
leadingIcon={menuItem.icon}
trailingIcon={IconChevronRight}>
{menuItem.label}
</ActionMenu.Item.Button>
</ActionMenu.Root>
</div>
{:else}
<div
use:melt={$item}
style:display="contents"
on:m-click={() => onSelect(menuItem.action as RowCellAction)}>
<ActionMenu.Item.Button
leadingIcon={menuItem.icon}
status={menuItem.danger ? 'danger' : undefined}>
{menuItem.label}
</ActionMenu.Item.Button>
</div>
{/if}
{/each}
</ActionMenu.Root>
</div>
<!-- Copy Submenu (outside ActionMenu.Root, inside Card.Base) -->
<div class="menu submenu" use:melt={$copySubMenu}>
<Card.Base padding="none">
<div class="action-menu-root">
{#each copyMenuItems as copyItem}
<div use:melt={$item} on:m-click={() => onSelect(copyItem.action)}>
<ActionMenu.Root>
<ActionMenu.Item.Button leadingIcon={copyItem.icon}>
{copyItem.label}
</ActionMenu.Item.Button>
</ActionMenu.Root>
</div>
{/each}
</div>
</Card.Base>
</div>
</Card.Base>
</div>
<style>
.menu {
z-index: 50;
min-width: 220px;
outline: none;
border-radius: var(--border-radius-m);
box-shadow: var(--shadow-large);
}
.submenu {
margin-inline: -4px;
margin-block: -4px;
}
.action-menu-root {
margin-inline-start: calc(var(--space-2) * -1);
& :global(:first-child) {
overflow: visible;
}
}
</style>
@@ -16,7 +16,7 @@
| 'activity'
| 'copy-url'
| 'copy-json'
// | 'copy-snippet'
| 'copy-snippet'
| 'delete';
</script>
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { goto, invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Submit, trackError, trackEvent, Click } from '$lib/actions/analytics';
import { Confirm, Id, SortButton } from '$lib/components';
import { Dependencies, SPREADSHEET_PAGE_LIMIT } from '$lib/constants';
import { Button as ConsoleButton, InputSelect } from '$lib/elements/forms';
@@ -96,6 +96,7 @@
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { chunks } from '$lib/helpers/array';
import { mapToQueryParams } from '$lib/components/filters/store';
import RowContextMenu from './rowContextMenu.svelte';
export let data: PageData;
export let showRowCreateSheet: {
@@ -504,7 +505,8 @@
action: HeaderCellAction | RowCellAction,
columnId: string,
type: 'header' | 'row',
row: Models.Row | null = null
row: Models.Row | null = null,
source: string = 'unknown'
) {
if (type === 'header') {
if (action === 'update') {
@@ -548,6 +550,7 @@
}
} else if (type === 'row') {
if (action === 'update') {
trackEvent(Click.RowUpdate, { source });
databaseRowSheetOptions.update((opts) => {
const rowIndex = rowIndexMap.get(row.$id) ?? -1;
return {
@@ -562,6 +565,7 @@
}
if (action === 'duplicate-row') {
trackEvent(Click.RowDuplicate, { source });
/**
* remove dates because
* console can override timestamps!
@@ -573,6 +577,7 @@
}
if (action === 'permissions') {
trackEvent(Click.RowPermissions, { source });
$rowPermissionSheet.row = row;
$rowPermissionSheet.show = true;
}
@@ -580,6 +585,7 @@
if (action === 'copy-url') {
try {
await copy(buildRowUrl(row.$id));
trackEvent(Click.RowCopyUrl, { source });
addNotification({
type: 'success',
message: 'Row url copied'
@@ -596,6 +602,7 @@
const stringified = JSON.stringify(row, null, 2);
try {
await copy(stringified);
trackEvent(Click.RowCopyJson, { source });
addNotification({
type: 'success',
message: 'Row copied'
@@ -608,12 +615,35 @@
}
}
if (action === 'copy-snippet') {
const snippet = `const result = await databases.getDocument(
'${databaseId}', // databaseId
'${tableId}', // collectionId
'${row.$id}', // documentId
);`;
try {
await copy(snippet);
trackEvent(Click.RowCopySnippet, { source });
addNotification({
type: 'success',
message: 'Code snippet copied'
});
} catch (e) {
addNotification({
type: 'error',
message: e.message
});
}
}
if (action === 'delete') {
trackEvent(Click.RowDelete, { source });
showDelete = true;
selectedRowForDelete = row.$id;
}
if (action === 'activity') {
trackEvent(Click.RowActivity, { source });
$rowActivitySheet.row = row;
$rowActivitySheet.show = true;
}
@@ -891,141 +921,158 @@
{/each}
</Spreadsheet.Row.Base>
{:else}
<Spreadsheet.Row.Base
{root}
{index}
id={row?.$id}
virtualItem={item}
select={rowSelection}
hoverEffect
showSelectOnHover
valueWithoutHover={row.$sequence}>
{#each $tableColumns as { id: columnId, isEditable, hide } (columnId)}
{@const rowColumn = $columns.find((col) => col.key === columnId)}
{#if columnId === '$id' && !hide}
<button
on:mouseenter={() => {
showExpandIconForId = index;
}}
on:mouseleave={() => {
showExpandIconForId = null;
}}>
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
<Layout.Stack
gap="none"
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<Id value={row.$id} tooltipPortal tooltipDelay={200}
>{row.$id}</Id>
<RowContextMenu
onSelect={(action) => {
$databaseRowSheetOptions.autoFocus = true;
onSelectSheetOption(action, null, 'row', row, 'context-menu');
}}>
<Spreadsheet.Row.Base
{root}
{index}
id={row?.$id}
virtualItem={item}
select={rowSelection}
hoverEffect
showSelectOnHover
valueWithoutHover={row.$sequence}>
{#each $tableColumns as { id: columnId, isEditable, hide } (columnId)}
{@const rowColumn = $columns.find((col) => col.key === columnId)}
{#if columnId === '$id' && !hide}
<button
on:mouseenter={() => {
showExpandIconForId = index;
}}
on:mouseleave={() => {
showExpandIconForId = null;
}}>
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
<Layout.Stack
gap="none"
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<Id value={row.$id} tooltipPortal tooltipDelay={200}
>{row.$id}</Id>
<Popover let:show let:hide portal padding="none">
{@const opacityValue =
showExpandIconForId === index ? '1' : '0'}
<button
on:mouseenter={show}
on:mouseleave={hide}
style:opacity={opacityValue}
style:transition="opacity 225ms ease-in-out">
<Button.Button
size="xs"
icon
variant="secondary"
on:click={() => {
hide();
previouslyFocusedElement =
document.activeElement;
$databaseRowSheetOptions.autoFocus = false;
onSelectSheetOption(
'update',
null,
'row',
row
);
}}>
<Icon icon={IconArrowExpand} size="s" />
</Button.Button>
</button>
<svelte:fragment slot="tooltip">
<Layout.Stack
inline
gap="xxs"
direction="row"
alignItems="center"
alignContent="center"
style="padding: var(--gap-XS, 6px) var(--gap-S, 8px);">
Expand row
<Popover let:show let:hide portal padding="none">
{@const opacityValue =
showExpandIconForId === index ? '1' : '0'}
<button
on:mouseenter={show}
on:mouseleave={hide}
style:opacity={opacityValue}
style:transition="opacity 225ms ease-in-out">
<Button.Button
size="xs"
icon
variant="secondary"
on:click={() => {
hide();
previouslyFocusedElement =
document.activeElement;
$databaseRowSheetOptions.autoFocus = false;
onSelectSheetOption(
'update',
null,
'row',
row
);
}}>
<Icon icon={IconArrowExpand} size="s" />
</Button.Button>
</button>
<svelte:fragment slot="tooltip">
<Layout.Stack
inline
gap="xxxs"
gap="xxs"
direction="row"
alignItems="center"
alignContent="center">
<Keyboard size="s" key="⌘" />
<Keyboard
key={'Enter'}
autoWidth
size="s" />
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Popover>
</Layout.Stack>
</Spreadsheet.Cell>
</button>
{:else}
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
{#if columnId === '$createdAt' || columnId === '$updatedAt'}
<DualTimeView
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
<SheetOptions
type="row"
column={rowColumn}
onSelect={(option) => {
$databaseRowSheetOptions.autoFocus = true;
onSelectSheetOption(option, null, 'row', row);
}}
onVisibilityChanged={(visible) => {
canShowDatetimePopover = !visible;
}}>
{#snippet children(toggle)}
<Button.Button
icon
variant="extra-compact"
on:click={toggle}>
<Icon
icon={IconDotsHorizontal}
color="--fgcolor-neutral-primary" />
</Button.Button>
{/snippet}
</SheetOptions>
{:else if isRelationship(rowColumn)}
{@const args = getDisplayNamesForTable(row[columnId])}
{#if !isRelationshipToMany(rowColumn)}
{#if row[columnId]}
{@const displayValue = args
.map((arg) => row[columnId]?.[arg])
.filter(Boolean)
.join(' | ')}
alignContent="center"
style="padding: var(--gap-XS, 6px) var(--gap-S, 8px);">
Expand row
{#if displayValue}
<Link.Button
variant="muted"
on:click={() => {
$databaseRelatedRowSheetOptions.tableId =
row[columnId]?.['$tableId'];
$databaseRelatedRowSheetOptions.rows =
row[columnId]?.['$id'];
$databaseRelatedRowSheetOptions.show = true;
}}>
{displayValue}
</Link.Button>
<Layout.Stack
inline
gap="xxxs"
direction="row"
alignItems="center"
alignContent="center">
<Keyboard size="s" key="⌘" />
<Keyboard
key={'Enter'}
autoWidth
size="s" />
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Popover>
</Layout.Stack>
</Spreadsheet.Cell>
</button>
{:else}
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
{#if columnId === '$createdAt' || columnId === '$updatedAt'}
<DualTimeView
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
<SheetOptions
type="row"
column={rowColumn}
onSelect={(option) => {
$databaseRowSheetOptions.autoFocus = true;
onSelectSheetOption(
option,
null,
'row',
row,
'button-menu'
);
}}
onVisibilityChanged={(visible) => {
canShowDatetimePopover = !visible;
}}>
{#snippet children(toggle)}
<Button.Button
icon
variant="extra-compact"
on:click={toggle}>
<Icon
icon={IconDotsHorizontal}
color="--fgcolor-neutral-primary" />
</Button.Button>
{/snippet}
</SheetOptions>
{:else if isRelationship(rowColumn)}
{@const args = getDisplayNamesForTable(row[columnId])}
{#if !isRelationshipToMany(rowColumn)}
{#if row[columnId]}
{@const displayValue = args
.map((arg) => row[columnId]?.[arg])
.filter(Boolean)
.join(' | ')}
{#if displayValue}
<Link.Button
variant="muted"
on:click={() => {
$databaseRelatedRowSheetOptions.tableId =
row[columnId]?.['$tableId'];
$databaseRelatedRowSheetOptions.rows =
row[columnId]?.['$id'];
$databaseRelatedRowSheetOptions.show = true;
}}>
{displayValue}
</Link.Button>
{:else}
<Badge
variant="secondary"
content="NULL"
size="xs" />
{/if}
{:else}
<Badge
variant="secondary"
@@ -1033,106 +1080,111 @@
size="xs" />
{/if}
{:else}
{@const itemsNum = row[columnId]?.length}
Items <Badge
content={itemsNum}
variant="secondary"
size="s" />
{/if}
{:else if isSpatialType(rowColumn) && row[columnId] !== null}
<Typography.Text truncate>
{JSON.stringify(row[columnId])}
</Typography.Text>
{:else}
{@const value = row[columnId]}
{@const formatted = formatColumn(row[columnId])}
{@const isEmptyArray = formatted === 'Empty'}
{@const isDatetimeAttribute =
rowColumn.type === 'datetime'}
{@const isEncryptedAttribute =
isString(rowColumn) && rowColumn.encrypt}
{#if isDatetimeAttribute}
<DualTimeView time={value}>
<span slot="title">Timestamp</span>
{toLocaleDateTime(value, true)}
</DualTimeView>
{:else if isEncryptedAttribute}
<button on:click={(e) => e.preventDefault()}>
<InteractiveText
copy={false}
variant="secret"
isVisible={false}
text={formatted} />
</button>
{:else if formatted.length > 20}
<Tooltip placement="bottom" portal>
<Typography.Text truncate>
{formatted}
</Typography.Text>
<span
slot="tooltip"
style:white-space="pre-wrap"
style:word-break="break-word">
{formatted}
</span>
</Tooltip>
{:else if formatted === 'null'}
<Badge
variant="secondary"
content="NULL"
size="xs" />
{/if}
{:else}
{@const itemsNum = row[columnId]?.length}
Items <Badge
content={itemsNum}
variant="secondary"
size="s" />
{/if}
{:else if isSpatialType(rowColumn) && row[columnId] !== null}
<Typography.Text truncate>
{JSON.stringify(row[columnId])}
</Typography.Text>
{:else}
{@const value = row[columnId]}
{@const formatted = formatColumn(row[columnId])}
{@const isEmptyArray = formatted === 'Empty'}
{@const isDatetimeAttribute = rowColumn.type === 'datetime'}
{@const isEncryptedAttribute =
isString(rowColumn) && rowColumn.encrypt}
{#if isDatetimeAttribute}
<DualTimeView time={value}>
<span slot="title">Timestamp</span>
{toLocaleDateTime(value, true)}
</DualTimeView>
{:else if isEncryptedAttribute}
<button on:click={(e) => e.preventDefault()}>
<InteractiveText
copy={false}
variant="secret"
isVisible={false}
text={formatted} />
</button>
{:else if formatted.length > 20}
<Tooltip placement="bottom" portal>
{:else if isEmptyArray}
<Badge
variant="secondary"
content={formatted}
size="xs" />
{:else}
<Typography.Text truncate>
{formatted}
</Typography.Text>
<span
slot="tooltip"
style:white-space="pre-wrap"
style:word-break="break-word">
{formatted}
</span>
</Tooltip>
{:else if formatted === 'null'}
<Badge variant="secondary" content="NULL" size="xs" />
{:else if isEmptyArray}
<Badge
variant="secondary"
content={formatted}
size="xs" />
{:else}
<Typography.Text truncate>
{formatted}
</Typography.Text>
{/if}
{/if}
{/if}
<svelte:fragment slot="cell-editor" let:close>
{@const isRelatedToMany = isRelationshipToMany(rowColumn)}
{@const hasItems = isRelatedToMany
? row[columnId]?.length
: false}
<svelte:fragment slot="cell-editor" let:close>
{@const isRelatedToMany =
isRelationshipToMany(rowColumn)}
{@const hasItems = isRelatedToMany
? row[columnId]?.length
: false}
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={async (row) => {
const success = await updateRowContents(row);
if (success) {
// database update succeeded!
paginatedRows.update(index, row);
}
return success;
}}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={async (row) => {
const success = await updateRowContents(row);
if (success) {
// database update succeeded!
paginatedRows.update(index, row);
}
return success;
}}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
if (isRelationshipToMany(rowColumn)) {
openSideSheetForRelationsToMany(
row[columnId],
rowColumn
);
} else {
$databaseRowSheetOptions.autoFocus = true;
onSelectSheetOption('update', null, 'row', row);
}
}} />
</svelte:fragment>
</Spreadsheet.Cell>
{/if}
{/each}
</Spreadsheet.Row.Base>
if (isRelationshipToMany(rowColumn)) {
openSideSheetForRelationsToMany(
row[columnId],
rowColumn
);
} else {
$databaseRowSheetOptions.autoFocus = true;
onSelectSheetOption(
'update',
null,
'row',
row
);
}
}} />
</svelte:fragment>
</Spreadsheet.Cell>
{/if}
{/each}
</Spreadsheet.Row.Base>
</RowContextMenu>
{/if}
</svelte:fragment>