update: abstracted indexes for base views.

This commit is contained in:
Darshan
2025-09-29 19:49:48 +05:30
parent 3fae9d611c
commit bc16dea7b0
18 changed files with 871 additions and 368 deletions
+12 -4
View File
@@ -12,13 +12,21 @@ type ResolveArgs<T extends RouteId | Pathname> = T extends RouteId
: [route: T, params: RouteParams<T>]
: [route: T];
export function withPath(base: string, ...parts: string[]) {
return [base.replace(/\/+$/, ''), ...parts].join('/');
}
export function resolveRoute<T extends RouteId>(route: T, params?: Record<string, string>) {
// type cast is necessary here!
const resolveArgs = params ? ([route, params] as [T, RouteParams<T>]) : [route];
return resolve(...(resolveArgs as ResolveArgs<T>));
}
export function navigate<T extends RouteId>(
route: T,
params?: Record<string, string>
): Promise<void> {
// type cast is necessary here!
const resolveArgs = params ? ([route, params] as [T, RouteParams<T>]) : [route];
const resolvedPathname = resolve(...(resolveArgs as ResolveArgs<T>));
return goto(resolvedPathname);
return goto(resolveRoute(route, params));
}
@@ -9,6 +9,7 @@ import type { Term, TerminologyResult, TerminologyShape } from '$database/(entit
export type DatabaseType = 'legacy' | 'tablesdb' | 'documentsdb' | 'vectordb';
export type Entity = Partial<Models.Table>;
export type Field = Partial<Columns>;
export type Index = Partial<Models.Index | Models.ColumnIndex>;
export const baseTerminology = {
tablesdb: {
@@ -1,4 +1,5 @@
export * from './helpers';
export * from './views/indexes';
export * from './views/settings';
export { default as Usage } from './views/usage/view.svelte';
export { default as EntityContainer } from './views/container.svelte';
@@ -0,0 +1,2 @@
export { default as SideSheet } from './sidesheet.svelte';
export { default as SpreadsheetContainer } from './spreadsheet.svelte';
@@ -0,0 +1,174 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Copy } from '$lib/components';
import { writable } from 'svelte/store';
import { Button, Form } from '$lib/elements/forms';
import { isTabletViewport } from '$lib/stores/viewport';
import { Badge, Divider, Layout, Sheet, Tag, Typography } from '@appwrite.io/pink-svelte';
let {
show = $bindable(false),
title,
closeOnBlur = false,
submit,
children = null,
footer = null,
titleBadge = null,
topAction = null
}: {
show: boolean;
title: string;
titleBadge?: string;
closeOnBlur?: boolean;
topAction?:
| {
text: string;
value: string;
show?: boolean;
mode?: 'copy-tag' | 'plaintext';
onClick?: () => string | Promise<string>;
}
| undefined;
submit?:
| {
text: string;
disabled?: boolean;
onClick?: () => boolean | void | Promise<boolean | void>;
}
| undefined;
children?: Snippet;
footer?: Snippet | null;
} = $props();
let form: Form;
let submitting = $state(writable(false));
let copyText = $state(undefined);
</script>
<div class="sheet-container" data-side-sheet-visible={show}>
<Sheet bind:open={show} {closeOnBlur}>
<div slot="header" style:width="100%">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<Layout.Stack direction="row" gap="m" alignItems="center">
<Typography.Text variant="m-400">{title}</Typography.Text>
{#if titleBadge}
<Badge variant="secondary" content={titleBadge} size="s" />
{/if}
{#if topAction && topAction.text && topAction.show}
{#if topAction.mode === 'copy-tag'}
<Copy value={topAction.value} {copyText}>
<Tag size="xs" variant="code">
{topAction.text}
</Tag>
</Copy>
{:else}
<Button
extraCompact
text
size="xs"
on:click={topAction.onClick ?? undefined}>
{topAction.text}
</Button>
{/if}
{/if}
</Layout.Stack>
</Layout.Stack>
</div>
<Layout.Stack direction="column" justifyContent="space-evenly">
<Form
bind:this={form}
bind:isSubmitting={submitting}
onSubmit={async () => {
try {
const keepOpen = await submit?.onClick?.();
if (!keepOpen) {
show = false;
}
} catch (error) {
// error occurred, dont close the sidebar
}
}}>
<Layout.Stack gap="xl" class="sheet-content">
{@render children?.()}
</Layout.Stack>
</Form>
{#if submit}
<div class="sheet-footer">
<Layout.Stack gap="l">
<Divider />
<div class="sheet-footer-actions">
<Layout.Stack
gap="m"
direction="row"
justifyContent="flex-end"
alignItems="center">
{#if footer}
{@render footer?.()}
{/if}
<Button size="s" secondary on:click={() => (show = false)}
>Cancel</Button>
<Button
size="s"
submit
disabled={submit.disabled || $submitting}
forceShowLoader={$submitting && $isTabletViewport}
submissionLoader={$submitting && $isTabletViewport}
on:click={() => form?.triggerSubmit()}>
{submit.text}
</Button>
</Layout.Stack>
</div>
</Layout.Stack>
</div>
{/if}
</Layout.Stack>
</Sheet>
</div>
<style lang="scss">
.sheet-container {
top: 0;
position: absolute;
@media (max-width: 768px) {
& :global(aside header) {
margin-top: 6rem;
}
}
& :global(.sheet-content) {
// overflow-y: auto;
padding-bottom: 5rem;
@media (max-width: 768px) {
/*
* different mobile browsers handle bottom spaces differently,
* therefore, having extra bottom space doesn't hurt anyone imo!
*/
padding-bottom: 15rem;
}
}
}
.sheet-footer {
left: 0;
right: 0;
bottom: 0;
position: absolute;
background: var(--bgcolor-neutral-primary);
@media (max-width: 768px) {
position: fixed;
}
& .sheet-footer-actions {
padding-inline: var(--space-8);
padding-block-end: var(--space-6);
}
}
</style>
@@ -0,0 +1,123 @@
<script lang="ts">
import { debounce } from '$lib/helpers/debounce';
import { scrollStore, sheetHeightStore } from './store';
import { onMount, onDestroy, type Snippet, tick } from 'svelte';
let {
children
}: {
children: Snippet;
} = $props();
let spreadsheetWrapper: HTMLDivElement;
let spreadsheetGridContainer: HTMLDivElement;
/** resizing logic variables */
let resizeObserver: ResizeObserver;
let mutationObserver: MutationObserver;
/** to avoid querySelector for perf! */
let cachedElements = new Set<Element>();
/** writable store to prevent jumps when changing views */
let spreadsheetHeight = $state($sheetHeightStore);
const handleResize = debounce(() => resizeSheet(), 125);
function observeElement(selector: string) {
const element = document.querySelector(selector);
if (element && !cachedElements.has(element)) {
cachedElements.add(element);
resizeObserver.observe(element);
}
}
/** get the actual spreadsheet-container */
function initSpreadsheetGridContainer(): boolean {
if (spreadsheetGridContainer) return true;
spreadsheetGridContainer = spreadsheetWrapper?.querySelector('.spreadsheet-container');
return !!spreadsheetGridContainer;
}
/** adjust height to fill remaining viewport space */
function resizeSheet(): void {
if (!spreadsheetWrapper) return;
const wrapperRect = spreadsheetWrapper.getBoundingClientRect();
const wrapperTop = wrapperRect.top;
const viewportHeight = window.innerHeight;
const availableHeight = viewportHeight - wrapperTop;
const finalHeight = Math.max(100, availableHeight);
const currentHeight = parseFloat(spreadsheetHeight);
const heightChanged = Math.abs(currentHeight - finalHeight) > 1;
if (heightChanged) {
const newHeight = `${finalHeight}px`;
spreadsheetHeight = newHeight;
sheetHeightStore.set(newHeight);
}
}
function addObservers() {
/** grab the sheet container */
initSpreadsheetGridContainer();
resizeObserver = new ResizeObserver(handleResize);
/** banners */
observeElement('.top-banner');
/** expand / collapse tabs */
observeElement('.layout-header');
/** just in case */
resizeObserver.observe(document.body);
/** add an observer when a banner pops-in */
mutationObserver = new MutationObserver(() => {
observeElement('.top-banner');
});
mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
}
/** save grid sheet scroll for restore */
export function saveGridSheetScroll(): void {
if (initSpreadsheetGridContainer()) {
scrollStore.set(spreadsheetGridContainer.scrollLeft || 0);
}
}
/** restore grid sheet scroll from before */
export function restoreGridSheetScroll(): void {
if (initSpreadsheetGridContainer() && spreadsheetGridContainer.scrollWidth > 0) {
spreadsheetGridContainer.scrollTop = 0;
spreadsheetGridContainer.scrollLeft = $scrollStore;
}
}
onMount(async () => {
await tick();
addObservers();
resizeSheet();
});
onDestroy(() => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
});
</script>
<div bind:this={spreadsheetWrapper} class="spreadsheet-wrapper" style:height={spreadsheetHeight}>
{@render children()}
</div>
<style>
.spreadsheet-wrapper {
transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
@@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export const scrollStore = writable(null);
export const sheetHeightStore = writable('74.5vh');
@@ -0,0 +1,34 @@
<script lang="ts">
import Modal from '$lib/components/modal.svelte';
import Button from '$lib/elements/forms/button.svelte';
import { Icon } from '@appwrite.io/pink-svelte';
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
let {
show = $bindable(),
error,
title,
header
}: {
show: boolean;
error: string;
title: string;
header: string;
} = $props();
</script>
<!-- TODO: @itznotabug, Stacks please -->
<Modal {title} bind:show>
<div class="box u-flex-vertical u-gap-24">
<p class="u-inline-flex u-cross-center u-gap-8">
<Icon icon={IconExclamationCircle} size="m" color="--color-danger-100" />
{header}
</p>
<p>{error}</p>
</div>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (show = false)}>Close</Button>
</svelte:fragment>
</Modal>
@@ -1,5 +1,14 @@
<script module lang="ts">
export type CreateIndexesCallbackType = {
key: string;
type: IndexType;
fields: string[];
lengths: number[];
orders: string[];
};
</script>
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { goto, invalidate } from '$app/navigation';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
@@ -7,40 +16,49 @@
import { Button, InputNumber, InputSelect, InputText } from '$lib/elements/forms';
import { remove } from '$lib/helpers/array';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { IndexType } from '@appwrite.io/console';
import { isRelationship, isSpatialType } from '../rows/store';
import { table, indexes } from '../store';
import { isRelationship, isSpatialType } from '$database/table-[table]/rows/store';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import type { DependenciesResult, Entity, TerminologyResult } from '$database/(entity)';
import { resolveRoute, withPath } from '$lib/stores/navigation';
import { IndexType } from '@appwrite.io/console';
let {
entity,
terminology,
dependencies,
showCreateIndex = $bindable(false),
externalColumnKey = null
externalFieldKey = null,
onCreateIndex
}: {
entity: Entity;
terminology: TerminologyResult;
dependencies: DependenciesResult;
showCreateIndex: boolean;
externalColumnKey?: string;
externalFieldKey?: string;
onCreateIndex: (index: CreateIndexesCallbackType) => Promise<void>;
} = $props();
const databaseId = page.params.database;
let key = $state('');
let selectedType = $state<IndexType>(IndexType.Key);
let columnOptions = $derived(
$table.columns
.filter((column) => {
const fieldOptions = $derived(
// TODO: could be columns or attributes!
entity.columns
.filter((field) => {
if (selectedType === IndexType.Spatial) {
return isSpatialType(column); // keep only spatial
// keep only spatial
return isSpatialType(field);
}
return !isRelationship(column) && !isSpatialType(column); // keep non-relationship and non-spatial
// keep non-relationship and non-spatial
return !isRelationship(field) && !isSpatialType(field);
})
.map((column) => ({ value: column.key, label: column.key }))
.map((field) => ({ value: field.key, label: field.key }))
);
let columnList = $state([{ value: '', order: '', length: null }]);
let fieldList = $state([{ value: '', order: '', length: null }]);
const types = [
{ value: IndexType.Key, label: 'Key' },
@@ -63,16 +81,16 @@
]
);
// spatial type selected -> reset column list to single empty column
// and the column already is not spatial type
// spatial type selected -> reset field list to single empty field
// and the field already is not spatial type
$effect(() => {
if (selectedType === IndexType.Spatial && !columnList.at(0).value) {
columnList = [{ value: '', order: null, length: null }];
if (selectedType === IndexType.Spatial && !fieldList.at(0).value) {
fieldList = [{ value: '', order: null, length: null }];
}
});
function generateIndexKey() {
let indexKeys = $indexes.map((index) => index.key);
let indexKeys = entity.indexes.map((index) => index.key);
let highestIndex = indexKeys.reduce((max, key) => {
const match = key.match(/^index_(\d+)$/);
@@ -83,26 +101,33 @@
}
function initialize() {
const column = $table.columns.filter((column) => externalColumnKey === column.key);
const isSpatial = column.length && isSpatialType(column[0]);
// TODO: could be columns or attributes or something else!
const field = entity.columns.filter((field) => externalFieldKey === field.key);
const isSpatial = field.length && isSpatialType(field[0]);
const order = isSpatial ? null : 'ASC';
selectedType = isSpatial ? IndexType.Spatial : IndexType.Key;
columnList = externalColumnKey
? [{ value: externalColumnKey, order, length: null }]
fieldList = externalFieldKey
? [{ value: externalFieldKey, order, length: null }]
: [{ value: '', order, length: null }];
key = `index_${$indexes.length + 1}`;
key = `index_${entity.indexes.length + 1}`;
}
const addColumnDisabled = $derived(
const addFieldDisabled = $derived(
selectedType === IndexType.Spatial ||
!columnList.at(-1)?.value ||
(!columnList.at(-1)?.order && columnList.at(-1)?.order !== null)
!fieldList.at(-1)?.value ||
(!fieldList.at(-1)?.order && fieldList.at(-1)?.order !== null)
);
const isOnIndexesPage = $derived(page.route.id?.endsWith('/indexes'));
const navigatorPathToIndexes = $derived(
`${base}/project-${page.params.region}-${page.params.project}/databases/database-${databaseId}/table-${$table?.$id}/indexes`
);
const navigatorPathToIndexes = $derived.by(() => {
const type = terminology.entity.lower.singular;
const base = resolveRoute(
'/(console)/project-[region]-[project]/databases/database-[database]',
page.params
);
return withPath(base, `${type}-${entity.$id}`, 'indexes');
});
let initializedForOpen = $state(false);
$effect(() => {
@@ -117,28 +142,27 @@
});
export async function create() {
if (!key || !selectedType || (selectedType !== IndexType.Spatial && addColumnDisabled)) {
const fieldType = terminology.field.lower.singular;
if (!key || !selectedType || (selectedType !== IndexType.Spatial && addFieldDisabled)) {
addNotification({
type: 'error',
message: 'Selected column key or type invalid'
message: `Selected ${fieldType} key or type invalid`
});
throw new Error('Selected column key or type invalid');
throw new Error(`Selected ${fieldType} key or type invalid`);
}
try {
const orders = columnList.map((a) => a.order).filter((order) => order !== null);
await sdk.forProject(page.params.region, page.params.project).tablesDB.createIndex({
databaseId,
tableId: $table.$id,
const orders = fieldList.map((a) => a.order).filter((order) => order !== null);
await onCreateIndex({
key,
type: selectedType,
columns: columnList.map((a) => a.value),
lengths: columnList.map((a) => (a.length ? Number(a.length) : null)),
...(orders.length ? { orders } : {})
fields: fieldList.map((a) => a.value),
lengths: fieldList.map((a) => (a.length ? Number(a.length) : null)),
orders: orders.length ? orders : []
});
await Promise.allSettled([
invalidate(Dependencies.TABLE),
invalidate(dependencies.entity.singular),
invalidate(Dependencies.DATABASE)
]);
@@ -166,11 +190,11 @@
}
}
function addColumn() {
if (addColumnDisabled) return;
function addField() {
if (addFieldDisabled) return;
// We assign instead of pushing to trigger Svelte's reactivity
columnList = [...columnList, { value: '', order: '', length: null }];
fieldList = [...fieldList, { value: '', order: '', length: null }];
}
</script>
@@ -183,10 +207,12 @@
bind:value={key}
autofocus />
<InputSelect required options={types} id="type" label="Index type" bind:value={selectedType} />
<InputSelect required id="type" options={types} label="Index type" bind:value={selectedType} />
<Layout.Stack gap="s">
{#each columnList as column, index}
{@const fieldType = terminology.field.title.singular}
{@const fieldTypeLower = terminology.field.lower.singular}
{#each fieldList as field, index}
{@const direction = $isSmallViewport ? 'column' : 'row'}
<Layout.Stack {direction}>
<InputSelect
@@ -200,19 +226,19 @@
{ value: '$createdAt', label: '$createdAt' },
{ value: '$updatedAt', label: '$updatedAt' }
]),
...columnOptions
...fieldOptions
]}
id={`column-${index}`}
label={index === 0 ? 'Column' : undefined}
placeholder="Select column"
bind:value={column.value} />
id={`field-${index}`}
label={index === 0 ? fieldType : undefined}
placeholder="Select {fieldType}"
bind:value={field.value} />
<InputSelect
options={orderOptions}
required
id={`order-${index}`}
label={index === 0 ? 'Order' : undefined}
bind:value={column.order}
bind:value={field.order}
placeholder="Select order" />
{#if selectedType === IndexType.Key}
@@ -220,7 +246,7 @@
id={`length-${index}`}
label={index === 0 ? 'Length' : undefined}
placeholder="Enter length"
bind:value={column.length} />
bind:value={field.length} />
{/if}
{#if $isSmallViewport}
@@ -228,9 +254,9 @@
<Button
text
secondary
disabled={columnList.length <= 1}
disabled={fieldList.length <= 1}
on:click={() => {
columnList = remove(columnList, index);
fieldList = remove(fieldList, index);
}}>
Remove
</Button>
@@ -241,9 +267,9 @@
icon
size="s"
secondary
disabled={columnList.length <= 1}
disabled={fieldList.length <= 1}
on:click={() => {
columnList = remove(columnList, index);
fieldList = remove(fieldList, index);
}}>
<Icon icon={IconX} size="s" />
</Button>
@@ -252,9 +278,9 @@
</Layout.Stack>
{/each}
<div>
<Button compact on:click={addColumn} disabled={addColumnDisabled}>
<Button compact on:click={addField} disabled={addFieldDisabled}>
<Icon icon={IconPlus} slot="start" size="s" />
Add column
Add {fieldTypeLower}
</Button>
</div>
</Layout.Stack>
@@ -1,49 +1,57 @@
<script lang="ts">
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import { page } from '$app/state';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import Confirm from '$lib/components/confirm.svelte';
import type { DependenciesResult } from '$database/(entity)';
let {
dependencies,
showDelete = $bindable(false),
selectedIndex = $bindable(null)
selectedIndex = $bindable(null),
onDeleteIndexes
}: {
dependencies: DependenciesResult;
showDelete: boolean;
selectedIndex: Models.ColumnIndex | string[];
selectedIndex: Models.ColumnIndex | string[] | null;
onDeleteIndexes: (selectedKeys: string[]) => Promise<void>;
} = $props();
let error: string = $state(null);
let selectedKeys = $derived(getKeys(selectedIndex));
function getKeys(selected: Models.ColumnIndex | string[]): string[] {
console.log(`getKeys`, selected);
return Array.isArray(selected) ? selected : [selected.key];
}
async function cleanup() {
// reset selection!
selectedIndex = [];
showDelete = false; // hide.
// events and notif!
trackEvent(Submit.IndexDelete);
addNotification({
type: 'success',
message:
selectedKeys.length === 1
? 'Index has been deleted'
: `${selectedKeys.length} indexes have been deleted`
});
console.log(`cleanup > selectedIndex`, selectedIndex, selectedKeys);
// invalidate proper dependency.
await invalidate(dependencies.entity.singular);
}
async function handleDelete() {
try {
await Promise.all(
selectedKeys.map((key) =>
sdk.forProject(page.params.region, page.params.project).tablesDB.deleteIndex({
databaseId: page.params.database,
tableId: page.params.table,
key
})
)
);
await invalidate(Dependencies.TABLE);
showDelete = false;
addNotification({
type: 'success',
message:
selectedKeys.length === 1
? 'Index has been deleted'
: `${selectedKeys.length} indexes have been deleted`
});
trackEvent(Submit.IndexDelete);
await onDeleteIndexes(selectedKeys);
await cleanup();
} catch (e) {
error = e.message;
trackError(e, Submit.IndexDelete);
@@ -0,0 +1,2 @@
export { default as Indexes } from './view.svelte';
export { default as CreateIndex, type CreateIndexesCallbackType } from './create.svelte';
@@ -2,12 +2,17 @@
import { InputText } from '$lib/elements/forms';
import { Layout } from '@appwrite.io/pink-svelte';
import type { Models } from '@appwrite.io/console';
import type { TerminologyResult } from '$database/(entity)';
let {
terminology,
selectedIndex = null
}: {
terminology: TerminologyResult;
selectedIndex: Models.ColumnIndex;
} = $props();
const entityType = terminology.entity.title.singular;
</script>
<InputText
@@ -25,12 +30,13 @@
value={selectedIndex.type}
readonly />
<!-- TODO: could be attributes or columns -->
{#if selectedIndex?.columns?.length}
{#each selectedIndex.columns as column, i}
<Layout.Stack direction="row">
<InputText
required
label={i === 0 ? 'Column' : ''}
label={i === 0 ? entityType : ''}
id={`value-${column}`}
value={column}
readonly />
@@ -3,6 +3,8 @@
import { Icon } from '@appwrite.io/pink-svelte';
import { IconChevronDown } from '@appwrite.io/pink-icons-svelte';
// TODO: @itznotabug: svelte5
export let value: string;
export let id: string;
export let label: string;
@@ -0,0 +1,308 @@
<script lang="ts">
import { Container } from '$lib/layout';
import Delete from './delete.svelte';
import { Button } from '$lib/elements/forms';
import Overview from './overview.svelte';
import CreateIndex from './create.svelte';
import FailedModal from '../failedModal.svelte';
import type { Models } from '@appwrite.io/console';
import { canWriteTables } from '$lib/stores/roles';
import {
ActionMenu,
Badge,
Divider,
FloatingActionBar,
Icon,
Layout,
Link,
Popover,
Spreadsheet,
Typography
} from '@appwrite.io/pink-svelte';
import {
IconDotsHorizontal,
IconEye,
IconPlus,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { type ComponentProps, onDestroy, type Snippet } from 'svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { isSmallViewport } from '$lib/stores/viewport';
import { type Entity, EntityContainer } from '$database/(entity)';
import { SpreadsheetContainer, SideSheet } from '$database/(entity)/views/(layouts)';
import type { CreateIndexesCallbackType } from '$database/(entity)/views/indexes/create.svelte';
// TODO: change `column` to `entity`!
let {
entity,
showCreateColumnSheet = $bindable(),
onCreateIndex,
onDeleteIndexes,
emptyIndexesSheetView,
emptyColumnsSheetView
}: {
entity: Entity;
showCreateColumnSheet: boolean;
onCreateIndex: (index: CreateIndexesCallbackType) => Promise<void>;
onDeleteIndexes: (indexKeys: string[]) => Promise<void>;
emptyIndexesSheetView: Snippet<[() => void]>;
emptyColumnsSheetView?: Snippet<[() => void]>;
} = $props();
let showCreateIndex = $state(false);
let selectedIndex: Models.ColumnIndex = $state(null);
let createIndex: CreateIndex;
let selectedIndexes = $state([]);
let error = $state('');
let showFailed = $state(false);
let showDelete = $state(false);
let showOverview = $state(false);
let columns = $state([
{ id: 'key' },
{ id: 'type' },
{ id: 'columns' },
// { id: 'orders' }, // design doesn't have orders atm
{ id: 'lengths' },
{ id: 'actions', width: 40, isAction: true }
]);
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
switch (status) {
case 'processing':
return 'warning';
case 'deleting':
case 'stuck':
case 'failed':
return 'error';
default:
return undefined;
}
}
onDestroy(() => (showCreateColumnSheet = false));
const emptyCellsLimit = $derived($isSmallViewport ? 14 : 17);
const emptyCellsCount = $derived(
entity.indexes.length >= emptyCellsLimit ? 0 : emptyCellsLimit - entity.indexes.length
);
$effect(() => {
console.log(`$effect > selectedIndexes`, $state.snapshot(selectedIndexes));
});
</script>
<EntityContainer>
{#snippet children(_, dependencies, terminology)}
<Container
expanded
expandHeightButton={$isSmallViewport}
style="background: var(--bgcolor-neutral-primary)">
<Layout.Stack direction="row" justifyContent="flex-end">
{#if $canWriteTables}
<Button
secondary
event="create_index"
disabled={!entity.columns?.length}
on:click={() => (showCreateIndex = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create index
</Button>
{/if}
</Layout.Stack>
</Container>
<div class="databases-spreadsheet">
{#if entity.columns?.length}
{#if entity.indexes.length}
<SpreadsheetContainer>
<Spreadsheet.Root
let:root
{columns}
height="100%"
allowSelection
emptyCells={emptyCellsCount}
bind:selectedRows={selectedIndexes}
bottomActionClick={() => (showCreateIndex = true)}>
<svelte:fragment slot="header" let:root>
<Spreadsheet.Header.Cell column="key" {root}
>Key</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="type" {root}
>Type</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="columns" {root}
>Columns</Spreadsheet.Header.Cell>
<!-- <Spreadsheet.Header.Cell column="orders" {root}-->
<!-- >Orders</Spreadsheet.Header.Cell>-->
<Spreadsheet.Header.Cell column="lengths" {root}
>Lengths</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each entity.indexes as index (index.key)}
<Spreadsheet.Row.Base {root} id={index.key}>
<Spreadsheet.Cell column="key" {root} isEditable={false}>
<Layout.Stack direction="row" alignItems="center">
{index.key}
{#if index.status !== 'available'}
<Badge
size="s"
variant="secondary"
content={index.status}
type={getColumnStatusBadge(index.status)} />
{#if index.error}
<Link.Button
variant="muted"
on:click={(e) => {
e.preventDefault();
error = index.error;
showFailed = true;
}}>Details</Link.Button>
{/if}
{/if}
</Layout.Stack>
</Spreadsheet.Cell>
<Spreadsheet.Cell column="type" {root} isEditable={false}
>{index.type}</Spreadsheet.Cell>
<Spreadsheet.Cell column="columns" {root} isEditable={false}>
{index.columns.join(', ')}
</Spreadsheet.Cell>
<!-- <Spreadsheet.Cell column="orders" {root} isEditable={false}>-->
<!-- {index.orders}-->
<!-- </Spreadsheet.Cell>-->
<Spreadsheet.Cell column="lengths" {root} isEditable={false}>
{index.lengths}
</Spreadsheet.Cell>
<Spreadsheet.Cell column="actions" {root}>
<Popover
let:toggle
padding="none"
placement="bottom-end"
portal>
<Button
text
icon
ariaLabel="more options"
on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip" let:toggle>
<ActionMenu.Item.Button
leadingIcon={IconEye}
on:click={() => {
toggle();
selectedIndex = index;
showOverview = true;
}}>Overview</ActionMenu.Item.Button>
<div style:padding-block="0.25rem">
<Divider />
</div>
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={() => {
toggle();
showDelete = true;
selectedIndex = index;
trackEvent(Click.DatabaseIndexDelete);
}}>Delete</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Spreadsheet.Cell>
</Spreadsheet.Row.Base>
{/each}
<svelte:fragment slot="footer">
<Layout.Stack
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Typography.Text
variant="m-400"
color="--fgcolor-neutral-secondary">
{@const length = entity.indexes.length}
{length}
{length === 1 ? 'index' : 'indexes'}
</Typography.Text>
</Layout.Stack>
</svelte:fragment>
</Spreadsheet.Root>
</SpreadsheetContainer>
{:else}
{@render emptyIndexesSheetView(() => (showCreateIndex = true))}
{/if}
{:else}
{@render emptyColumnsSheetView(() => (showCreateColumnSheet = true))}
{/if}
{#if selectedIndexes.length > 0}
<div class="floating-action-bar">
<FloatingActionBar>
<svelte:fragment slot="start">
<div style:width="max-content">
<Layout.Stack direction="row" alignItems="center" gap="m">
<Badge content={selectedIndexes.length.toString()} />
<span style:font-size="14px">
{selectedIndexes.length > 1 ? 'indexes' : 'index'}
selected
</span>
</Layout.Stack>
</div>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIndexes = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
</div>
{/if}
</div>
<SideSheet
title="Create index"
bind:show={showCreateIndex}
submit={{
text: 'Create',
onClick: async () => await createIndex.create()
}}>
<CreateIndex
{entity}
{terminology}
{dependencies}
{onCreateIndex}
{showCreateIndex}
bind:this={createIndex} />
</SideSheet>
{#if selectedIndex}
<Delete bind:showDelete {onDeleteIndexes} {dependencies} bind:selectedIndex />
{:else if selectedIndexes && selectedIndexes.length}
<Delete
bind:showDelete
{dependencies}
{onDeleteIndexes}
bind:selectedIndex={selectedIndexes} />
{/if}
<SideSheet title="Preview index" bind:show={showOverview}>
<Overview {selectedIndex} {terminology} />
</SideSheet>
<FailedModal {error} bind:show={showFailed} title="Create index" header="Creation failed" />
{/snippet}
</EntityContainer>
<style lang="scss">
.floating-action-bar {
left: 50%;
width: 100%;
z-index: 14;
position: absolute;
transform: translateX(-50%);
}
</style>
@@ -57,13 +57,15 @@
import { generateFakeRecords, generateColumns } from '$lib/helpers/faker';
import { addNotification } from '$lib/stores/notifications';
import { sleep } from '$lib/helpers/promises';
import CreateIndex from './indexes/createIndex.svelte';
import { hash } from '$lib/helpers/string';
import { preferences } from '$lib/stores/preferences';
import { buildRowUrl, isRelationship } from './rows/store';
import { chunks } from '$lib/helpers/array';
import { Submit, trackEvent } from '$lib/actions/analytics';
import { CreateIndex } from '$database/(entity)';
import { EntityContainer } from '$database/(entity)/index.js';
let editRow: EditRow;
let editRelatedRow: EditRelatedRow;
let editRowPermissions: EditRowPermissions;
@@ -431,10 +433,32 @@
await createIndex.create();
}
}}>
<CreateIndex
bind:this={createIndex}
bind:showCreateIndex={$showCreateIndexSheet.show}
externalColumnKey={$showCreateIndexSheet.column} />
<EntityContainer>
<!-- TODO: @itznotabug, not the best way, see what we can do. -->
<!-- Maybe a better setContext logic would suffice! would also avoid using snippets! -->
{#snippet children(_, dependencies, terminology)}
<CreateIndex
entity={$table}
bind:this={createIndex}
bind:showCreateIndex={$showCreateIndexSheet.show}
externalFieldKey={$showCreateIndexSheet.column}
{dependencies}
{terminology}
onCreateIndex={async (index) => {
await sdk
.forProject(page.params.region, page.params.project)
.tablesDB.createIndex({
databaseId: page.params.database,
tableId: page.params.table,
key: index.key,
type: index.type,
columns: index.fields,
lengths: index.lengths,
orders: index.orders
});
}} />
{/snippet}
</EntityContainer>
</SideSheet>
<SideSheet
@@ -474,5 +474,5 @@
</SideSheet>
{#if showFailed}
<FailedModal bind:show={showFailed} title="Create attribute" header="Creation failed" {error} />
<FailedModal bind:show={showFailed} title="Create column" header="Creation failed" {error} />
{/if}
@@ -1,288 +1,68 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { table } from '../store';
import Delete from './deleteIndex.svelte';
import CreateIndex from './createIndex.svelte';
import Overview from './overviewIndex.svelte';
import type { Models } from '@appwrite.io/console';
import { Button } from '$lib/elements/forms';
import FailedModal from '../failedModal.svelte';
import { canWriteTables } from '$lib/stores/roles';
import {
ActionMenu,
Badge,
Divider,
FloatingActionBar,
Icon,
Layout,
Link,
Popover,
Spreadsheet,
Typography
} from '@appwrite.io/pink-svelte';
import {
IconDotsHorizontal,
IconEye,
IconPlus,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { type ComponentProps, onDestroy } from 'svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import type { PageProps } from './$types';
import EmptySheet from '../layout/emptySheet.svelte';
import SpreadsheetContainer from '../layout/spreadsheet.svelte';
import SideSheet from '../layout/sidesheet.svelte';
import type { PageData } from './$types';
import { showCreateColumnSheet } from '../store';
import { isSmallViewport } from '$lib/stores/viewport';
import { showCreateColumnSheet } from '$database/table-[table]/store';
import { type CreateIndexesCallbackType, Indexes } from '$database/(entity)';
let {
data
}: {
data: PageData;
} = $props();
let { data }: PageProps = $props();
let showCreateIndex = $state(false);
let selectedIndex: Models.ColumnIndex = $state(null);
const params = $derived({
databaseId: page.params.database,
tableId: page.params.table
});
let selectedIndexes = $state([]);
let createIndex: CreateIndex;
const tablesDB = $derived(sdk.forProject(page.params.region, page.params.project).tablesDB);
let error = $state('');
let showFailed = $state(false);
let showDelete = $state(false);
let showOverview = $state(false);
let columns = $state([
{ id: 'key' },
{ id: 'type' },
{ id: 'columns' },
// { id: 'orders' }, // design doesn't have orders atm
{ id: 'lengths' },
{ id: 'actions', width: 40, isAction: true }
]);
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
switch (status) {
case 'processing':
return 'warning';
case 'deleting':
case 'stuck':
case 'failed':
return 'error';
default:
return undefined;
}
async function onCreateIndex(index: CreateIndexesCallbackType) {
await tablesDB.createIndex({
...params,
key: index.key,
type: index.type,
columns: index.fields,
lengths: index.lengths,
orders: index.orders
});
}
onDestroy(() => ($showCreateColumnSheet.show = false));
const emptyCellsLimit = $derived($isSmallViewport ? 14 : 17);
const emptyCellsCount = $derived(
data.table.indexes.length >= emptyCellsLimit
? 0
: emptyCellsLimit - data.table.indexes.length
);
async function onDeleteIndexes(selectedKeys: string[]) {
await Promise.all(
selectedKeys.map((key) =>
tablesDB.deleteIndex({
...params,
key
})
)
);
}
</script>
<Container
expanded
expandHeightButton={$isSmallViewport}
style="background: var(--bgcolor-neutral-primary)">
<Layout.Stack direction="row" justifyContent="flex-end">
{#if $canWriteTables}
<Button
secondary
event="create_index"
disabled={!$table?.columns?.length}
on:click={() => (showCreateIndex = true)}>
<Icon icon={IconPlus} slot="start" size="s" />
Create index
</Button>
{/if}
</Layout.Stack>
</Container>
<Indexes
{onCreateIndex}
{onDeleteIndexes}
entity={data.table}
showCreateColumnSheet={$showCreateColumnSheet.show}>
{#snippet emptyIndexesSheetView(toggle)}
<EmptySheet
mode="indexes"
actions={{
primary: {
onClick: toggle,
disabled: !data.table.columns?.length
}
}} />
{/snippet}
<div class="databases-spreadsheet">
{#if data.table?.columns?.length}
{#if data.table.indexes.length}
<SpreadsheetContainer>
<Spreadsheet.Root
let:root
{columns}
height="100%"
allowSelection
emptyCells={emptyCellsCount}
bind:selectedRows={selectedIndexes}
bottomActionClick={() => (showCreateIndex = true)}>
<svelte:fragment slot="header" let:root>
<Spreadsheet.Header.Cell column="key" {root}>Key</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="type" {root}>Type</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="columns" {root}
>Columns</Spreadsheet.Header.Cell>
<!-- <Spreadsheet.Header.Cell column="orders" {root}-->
<!-- >Orders</Spreadsheet.Header.Cell>-->
<Spreadsheet.Header.Cell column="lengths" {root}
>Lengths</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.table.indexes as index}
<Spreadsheet.Row.Base {root} id={index.key}>
<Spreadsheet.Cell column="key" {root} isEditable={false}>
<Layout.Stack direction="row" alignItems="center">
{index.key}
{#if index.status !== 'available'}
<Badge
size="s"
variant="secondary"
content={index.status}
type={getColumnStatusBadge(index.status)} />
{#if index.error}
<Link.Button
variant="muted"
on:click={(e) => {
e.preventDefault();
error = index.error;
showFailed = true;
}}>Details</Link.Button>
{/if}
{/if}
</Layout.Stack>
</Spreadsheet.Cell>
<Spreadsheet.Cell column="type" {root} isEditable={false}
>{index.type}</Spreadsheet.Cell>
<Spreadsheet.Cell column="columns" {root} isEditable={false}>
{index.columns.join(', ')}
</Spreadsheet.Cell>
<!-- <Spreadsheet.Cell column="orders" {root} isEditable={false}>-->
<!-- {index.orders}-->
<!-- </Spreadsheet.Cell>-->
<Spreadsheet.Cell column="lengths" {root} isEditable={false}>
{index.lengths}
</Spreadsheet.Cell>
<Spreadsheet.Cell column="actions" {root}>
<Popover let:toggle padding="none" placement="bottom-end" portal>
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip" let:toggle>
<ActionMenu.Item.Button
leadingIcon={IconEye}
on:click={() => {
toggle();
selectedIndex = index;
showOverview = true;
}}>Overview</ActionMenu.Item.Button>
<div style:padding-block="0.25rem">
<Divider />
</div>
<ActionMenu.Item.Button
status="danger"
leadingIcon={IconTrash}
on:click={() => {
toggle();
showDelete = true;
selectedIndex = index;
trackEvent(Click.DatabaseIndexDelete);
}}>Delete</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Spreadsheet.Cell>
</Spreadsheet.Row.Base>
{/each}
<svelte:fragment slot="footer">
<Layout.Stack
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Typography.Text variant="m-400" color="--fgcolor-neutral-secondary">
{@const length = data.table.indexes.length}
{length}
{length === 1 ? 'index' : 'indexes'}
</Typography.Text>
</Layout.Stack>
</svelte:fragment>
</Spreadsheet.Root>
</SpreadsheetContainer>
{:else}
<EmptySheet
mode="indexes"
actions={{
primary: {
onClick: () => (showCreateIndex = true),
disabled: !$table?.columns?.length
}
}} />
{/if}
{:else}
{#snippet emptyColumnsSheetView(toggle)}
<EmptySheet
mode="indexes"
title="You have no columns yet"
actions={{
primary: {
text: 'Create columns',
onClick: async () => {
$showCreateColumnSheet.show = true;
}
onClick: toggle
}
}} />
{/if}
{#if selectedIndexes.length > 0}
<div class="floating-action-bar">
<FloatingActionBar>
<svelte:fragment slot="start">
<div style:width="max-content">
<Layout.Stack direction="row" alignItems="center" gap="m">
<Badge content={selectedIndexes.length.toString()} />
<span style:font-size="14px">
{selectedIndexes.length > 1 ? 'indexes' : 'index'}
selected
</span>
</Layout.Stack>
</div>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIndexes = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
</div>
{/if}
</div>
<SideSheet
title="Create index"
bind:show={showCreateIndex}
submit={{
text: 'Create',
onClick: async () => await createIndex.create()
}}>
<CreateIndex {showCreateIndex} bind:this={createIndex} />
</SideSheet>
{#if selectedIndex}
<Delete bind:showDelete {selectedIndex} />
{:else if selectedIndexes && selectedIndexes.length}
<Delete bind:showDelete bind:selectedIndex={selectedIndexes} />
{/if}
<SideSheet title="Preview index" bind:show={showOverview}>
<Overview {selectedIndex} />
</SideSheet>
<FailedModal bind:show={showFailed} title="Create index" header="Creation failed" {error} />
<style lang="scss">
.floating-action-bar {
left: 50%;
width: 100%;
z-index: 14;
position: absolute;
transform: translateX(-50%);
}
</style>
{/snippet}
</Indexes>
@@ -12,7 +12,6 @@
UpdateStatus
} from '$database/(entity)';
const tablesDB = sdk.forProject(page.params.region, page.params.project).tablesDB;
const params = $derived.by(() => {
return {
name: $table.name,
@@ -22,7 +21,7 @@
});
async function deleteTable() {
await tablesDB.deleteTable({ ...params });
await sdk.forProject(page.params.region, page.params.project).tablesDB.deleteTable({ ...params });
}
async function updateTable(
@@ -33,7 +32,7 @@
rowSecurity: boolean;
}>
) {
await tablesDB.updateTable({ ...params, ...updates });
await sdk.forProject(page.params.region, page.params.project).tablesDB.updateTable({ ...params, ...updates });
}
</script>