mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
add table context menu
This commit is contained in:
+150
-19
@@ -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%;
|
||||
|
||||
+161
@@ -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>
|
||||
Reference in New Issue
Block a user