add table context menu

This commit is contained in:
Harsh Mahajan
2026-01-16 16:14:10 +05:30
parent eb2bf3c29b
commit 0afc08df96
2 changed files with 311 additions and 19 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import type { PageData } from './$types';
import { showSubNavigation } from '$lib/stores/layout';
@@ -29,6 +30,14 @@
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { subNavigation } from '$lib/stores/database';
import TableContextMenu, { type TableAction } from './tableContextMenu.svelte';
import CopySnippetModal from '$lib/components/copySnippetModal.svelte';
import { addNotification } from '$lib/stores/notifications';
import { trackEvent, trackError, Submit } from '$lib/actions/analytics';
import { isCsvImportInProgress } from './table-[table]/store';
import FilePicker from '$lib/components/filePicker.svelte';
import { preferences } from '$lib/stores/preferences';
const data = $derived(page.data) as PageData;
@@ -45,10 +54,25 @@
tables: []
});
const sortedTables = $derived.by(() =>
tables?.tables?.slice().sort((a, b) => a.name.localeCompare(b.name))
const pinnedTablesKey = $derived(`pinned_tables_${databaseId}`);
const pinnedTableIds = $derived(
($preferences.miscellaneous?.[pinnedTablesKey]?.toString()?.split(',') ?? []).filter(
Boolean
)
);
const sortedTables = $derived.by(() => {
return tables?.tables?.slice().sort((a, b) => {
const aPinned = pinnedTableIds.includes(a.$id);
const bPinned = pinnedTableIds.includes(b.$id);
if (aPinned && !bPinned) return -1;
if (!aPinned && bPinned) return 1;
return a.name.localeCompare(b.name);
});
});
const selectedTable = $derived.by(() =>
sortedTables?.find((table: Models.Table) => table.$id === tableId)
);
@@ -84,6 +108,89 @@
openBottomSheet = false;
}
}
let showCopySnippetModal = $state(false);
let showImportCSV = $state(false);
let selectedTableForAction = $state<Models.Table | null>(null);
async function onSelectFile(file: Models.File, localFile = false) {
if (!selectedTableForAction) return;
$isCsvImportInProgress = true;
try {
await sdk.forProject(region, project).migrations.createCSVImport({
bucketId: file.bucketId,
fileId: file.$id,
resourceId: `${databaseId}:${selectedTableForAction.$id}`,
internalFile: localFile
});
addNotification({
type: 'success',
message: 'Rows import from csv has started'
});
trackEvent(Submit.DatabaseImportCsv);
} catch (e) {
trackError(e, Submit.DatabaseImportCsv);
addNotification({
type: 'error',
message: e.message
});
} finally {
$isCsvImportInProgress = false;
}
}
async function handleTableAction(action: TableAction, table: Models.Table) {
selectedTableForAction = table;
switch (action) {
case 'copy-snippet':
showCopySnippetModal = true;
break;
case 'upload-csv':
showImportCSV = true;
break;
case 'copy-url':
await navigator.clipboard.writeText(
`${window.location.origin}${base}/project-${region}-${project}/databases/database-${databaseId}/table-${table.$id}`
);
addNotification({
type: 'success',
message: 'URL copied to clipboard'
});
break;
case 'copy-json':
await navigator.clipboard.writeText(JSON.stringify(table, null, 2));
addNotification({
type: 'success',
message: 'JSON copied to clipboard'
});
break;
case 'pin': {
const isPinned = pinnedTableIds.includes(table.$id);
const newPinnedIds = isPinned
? pinnedTableIds.filter((id) => id !== table.$id)
: [...pinnedTableIds, table.$id];
await preferences.setKey(pinnedTablesKey, newPinnedIds.join(','));
addNotification({
type: 'success',
message: `Table ${isPinned ? 'unpinned' : 'pinned'}`
});
break;
}
case 'permissions':
await goto(
`${base}/project-${region}-${project}/databases/database-${databaseId}/table-${table.$id}/settings`
);
break;
case 'delete':
break;
default:
break;
}
}
</script>
<svelte:window on:resize={onResize} />
@@ -122,23 +229,27 @@
{@const isLast = index === sortedTables.length - 1}
<Layout.Stack gap="s" direction="row" alignItems="center">
<li
class:is-last={isLast}
class:is-first={isFirst}
class:is-selected={isSelected}>
<a
class="u-padding-block-8 u-padding-inline-end-4 u-padding-inline-start-8 u-flex u-cross-center u-gap-8"
{href}>
<Icon
icon={IconTable}
size="s"
color={isSelected
? '--fgcolor-neutral-tertiary'
: '--fgcolor-neutral-weak'} />
<span class="text table-name" data-private
>{table.name}</span>
</a>
</li>
<TableContextMenu
onSelect={(action) => handleTableAction(action, table)}
isPinned={pinnedTableIds.includes(table.$id)}>
<li
class:is-last={isLast}
class:is-first={isFirst}
class:is-selected={isSelected}>
<a
class="u-padding-block-8 u-padding-inline-end-4 u-padding-inline-start-8 u-flex u-cross-center u-gap-8"
{href}>
<Icon
icon={IconTable}
size="s"
color={isSelected
? '--fgcolor-neutral-tertiary'
: '--fgcolor-neutral-weak'} />
<span class="text table-name" data-private
>{table.name}</span>
</a>
</li>
</TableContextMenu>
</Layout.Stack>
{/each}
</ul>
@@ -254,6 +365,26 @@
}}></BottomSheet.Menu>
{/if}
<CopySnippetModal
bind:show={showCopySnippetModal}
row={null}
{databaseId}
collectionId={selectedTableForAction?.$id ?? ''} />
{#if showImportCSV}
<FilePicker
onSelect={onSelectFile}
showLocalFileBucket
localFileBucketTitle="Upload CSV file"
mimeTypeQuery="text/"
allowedExtension="csv"
bind:show={showImportCSV}
gridImageDimensions={{
imageHeight: 32,
imageWidth: 32
}} />
{/if}
<style lang="scss">
.list-container {
height: 100%;
@@ -0,0 +1,161 @@
<script lang="ts" module>
export type TableAction =
| 'download-csv'
| 'upload-csv'
| 'permissions'
| 'pin'
| 'copy-url'
| 'copy-json'
| 'copy-snippet'
| 'delete';
</script>
<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 {
IconDuplicate,
IconKey,
IconLink,
IconTrash,
IconClipboardCopy,
IconCode,
IconChevronRight,
IconUpload,
IconPinned
} from '@appwrite.io/pink-icons-svelte';
export let onSelect: (action: TableAction) => void;
export let isPinned = false;
const {
elements: { menu, trigger, item },
builders: { createSubmenu }
} = createContextMenu({
positioning: {
placement: 'right-start',
gutter: 2,
sameWidth: false
},
onOpenChange: ({ next }) => {
if (next) {
// trackEvent(Click.TableContextMenuOpen); // Define click event if needed
}
return next;
}
});
const {
elements: { subMenu: copySubMenu, subTrigger: copySubTrigger }
} = createSubmenu({
positioning: {
placement: 'right-start',
gutter: 8
},
arrowSize: 0
});
$: menuItems = [
{ label: 'Upload CSV', icon: IconUpload, action: 'upload-csv' } as const,
{ divider: true },
{ label: 'Manage permissions', icon: IconKey, action: 'permissions' } as const,
{
label: isPinned ? 'Unpin from top' : 'Pin to top',
icon: IconPinned,
action: 'pin'
} as const,
{ divider: true },
{ label: 'Copy', icon: IconDuplicate, subMenu: 'copy' },
{ divider: true },
{ label: 'Delete', icon: IconTrash, action: 'delete', danger: true } as const
];
const copyMenuItems: { label: string; icon: ComponentType; action: TableAction }[] = [
{ 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}
{#if menuItem.divider}
<div style:padding-block="0.25rem">
<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 TableAction)}>
<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>