update: index suggestions mobile views with accordion.

This commit is contained in:
Darshan
2025-09-27 12:29:00 +05:30
parent 101416b19e
commit 8f577e9ecd
@@ -1,5 +1,5 @@
<script lang="ts">
import { Icon, Layout, Skeleton, Typography } from '@appwrite.io/pink-svelte';
import { Alert, Accordion, Icon, Layout, Skeleton, Typography } from '@appwrite.io/pink-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { Button, InputSelect /*InputNumber*/ } from '$lib/elements/forms';
import {
@@ -9,9 +9,11 @@
type SuggestedIndexSchema
} from './store';
import { Modal } from '$lib/components';
import { IndexType } from '@appwrite.io/console';
import SideSheet from '../table-[table]/layout/sidesheet.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { IndexType, type Models } from '@appwrite.io/console';
import { capitalize } from '$lib/helpers/string';
import { table } from '../table-[table]/store';
import { type Columns, table } from '../table-[table]/store';
import { isRelationship } from '../table-[table]/rows/store';
import { VARS } from '$lib/system';
import { sleep } from '$lib/helpers/promises';
@@ -21,14 +23,19 @@
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { onDestroy, onMount } from 'svelte';
import { type ComponentType, onDestroy, onMount } from 'svelte';
import { columnOptions as baseColumnOptions } from '../table-[table]/columns/store';
const MAX_INDEXES = 5;
let modalError = $state(null);
let creatingIndexes = $state(false);
let indexes = $state<SuggestedIndexSchema[]>([]);
let columnOptions: Array<{ value: string; label: string }> = $state();
let columnOptions: Array<{
value: string;
label: string;
leadingIcon: ComponentType;
}> = $state();
const tableId = page.params.table;
const databaseId = page.params.database;
@@ -37,14 +44,17 @@
if (VARS.MOCK_AI_SUGGESTIONS) {
columnOptions = mockSuggestions.columns.map((column) => ({
value: column.name,
label: column.name
label: column.name,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type).icon
}));
} else {
columnOptions = $table.columns
.filter((column) => !isRelationship(column))
.map((column) => ({
value: column.key,
label: column.key
label: column.key,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)
.icon
}));
}
}
@@ -67,7 +77,7 @@
.forProject(page.params.region, page.params.project)
.console.suggestIndexes({
databaseId,
tableId: $table.$id,
tableId: $table.$id
});
indexes = suggestions.indexes.map((index) => {
@@ -112,7 +122,54 @@
}));
}
async function applySuggestedIndexes() {
function generateUniqueIndexKey(index: SuggestedIndexSchema, usedKeys: Set<string>): string {
const existingKeys = $table.indexes.map((idx) => idx.key);
let suggestedKey = `suggested_${index.key || index.columns[0]}_${index.type.toLowerCase()}`;
let uniqueKey = suggestedKey;
let counter = 1;
while (existingKeys.includes(uniqueKey) || usedKeys.has(uniqueKey)) {
uniqueKey = `${suggestedKey}_${counter}`;
counter++;
}
usedKeys.add(uniqueKey);
return uniqueKey;
}
function prepareIndexForCreation(index: SuggestedIndexSchema, columnMap: Map<string, Columns>) {
// prepare orders array
const orders = index.orders !== null ? index.columns.map(() => String(index.orders)) : [];
// prepare lengths array
let lengths: (number | null)[];
if (index.type === IndexType.Key) {
// Only validate if it's a key index
lengths = index.columns.map((columnKey, i) => {
const column = columnMap.get(columnKey);
if (column?.type === 'string') {
const stringColumn = column as Models.ColumnString;
const requestedLength = index.lengths?.[i];
if (
requestedLength &&
stringColumn.size &&
requestedLength > stringColumn.size
) {
return stringColumn.size;
}
return requestedLength || null;
}
return null;
});
} else {
// non-key indexes, lengths are null!
lengths = Array(index.columns.length).fill(null);
}
return { orders, lengths };
}
async function applySuggestedIndexes(): Promise<boolean> {
modalError = null;
creatingIndexes = true;
@@ -120,24 +177,30 @@
if (!index.key || !index.type) {
modalError = `Index ${i + 1}: Selected column key or type invalid`;
creatingIndexes = false;
return;
return true; // keep sheet open!
}
}
const client = sdk.forProject(page.params.region, page.params.project);
let successCount = 0;
const usedKeys = new Set<string>();
const columnMap = new Map($table.columns.map((col) => [col.key, col]));
const sdkClient = sdk.forProject(page.params.region, page.params.project);
for (const [_, index] of indexes.entries()) {
try {
const orders = index.orders !== null ? [String(index.orders)] : [];
// Prepare and validate index data
const { orders, lengths } = prepareIndexForCreation(index, columnMap);
await client.tablesDB.createIndex({
// Generate unique key name for the index
index.key = generateUniqueIndexKey(index, usedKeys);
await sdkClient.tablesDB.createIndex({
databaseId,
tableId,
key: index.key,
type: index.type,
columns: index.columns,
lengths: index.lengths,
lengths,
...(orders.length ? { orders } : {})
});
@@ -147,23 +210,46 @@
modalError = err.message;
}
trackError(err, Submit.IndexCreate);
break;
creatingIndexes = false;
return true; // keep sheet open!
}
}
creatingIndexes = false;
if (successCount > 0) {
trackEvent(Submit.IndexCreate, { type: 'suggestions', count: successCount });
}
if (successCount === indexes.length) {
// all succeeded
addNotification({
message: `${successCount} index${successCount > 1 ? 'es are' : ' is'} being created`,
type: 'success'
});
trackEvent(Submit.IndexCreate, { type: 'suggestions', count: successCount });
$showIndexesSuggestions = false;
// invalidate dependencies.
await invalidate(Dependencies.TABLE);
} else if (successCount > 0) {
// some succeeded, some failed
addNotification({
message: 'Some indexes could not be created',
type: 'warning'
});
// invalidate dependencies.
await invalidate(Dependencies.TABLE);
} else {
// all failed
addNotification({
message: 'Failed to create indexes',
type: 'error'
});
return true; // keep sheet open!
}
creatingIndexes = false;
return false; // close the sheet!
}
const typeOptions = Object.values(IndexType).map((type) => ({
@@ -176,118 +262,205 @@
onDestroy(() => showIndexesSuggestions.set(false));
</script>
<Modal
title="Suggested indexes"
bind:error={modalError}
bind:show={$showIndexesSuggestions}
onSubmit={applySuggestedIndexes}>
<Layout.Stack gap="l">
{#await loadIndexSuggestions()}
{#each Array(3) as _, index}
{@const firstItem = index === 0}
<Layout.Stack direction="row" justifyContent="space-evenly" alignItems="center">
{@render fieldSkeleton({ label: 'Key', showLabel: firstItem })}
{@render fieldSkeleton({ label: 'Type', showLabel: firstItem })}
{@render fieldSkeleton({ label: 'Order', showLabel: firstItem })}
<!--{@render fieldSkeleton({ label: 'Length', showLabel: firstItem })}-->
{#if !$isSmallViewport}
<Modal
dismissible={false}
title="Suggested indexes"
bind:error={modalError}
bind:show={$showIndexesSuggestions}
onSubmit={async () => {
await applySuggestedIndexes();
}}>
<Layout.Stack gap="l">
{#await loadIndexSuggestions()}
{#each Array(3) as _, index}
{@const firstItem = index === 0}
<Layout.Stack direction="row" justifyContent="space-evenly" alignItems="center">
{@render fieldSkeleton({ label: 'Key', showLabel: firstItem })}
{@render fieldSkeleton({ label: 'Type', showLabel: firstItem })}
{@render fieldSkeleton({ label: 'Order', showLabel: firstItem })}
<!--{@render fieldSkeleton({ label: 'Length', showLabel: firstItem })}-->
<div style:margin-top={firstItem ? '27.6px' : '0'}>
<Skeleton variant="square" width={30} height={30} style="opacity: 0.25" />
</div>
</Layout.Stack>
{/each}
<div style:margin-top={firstItem ? '27.6px' : '0'}>
<Skeleton
variant="square"
width={30}
height={30}
style="opacity: 0.25" />
</div>
</Layout.Stack>
{/each}
<Layout.Stack direction="row" justifyContent="flex-start">
<Button icon size="s" disabled compact>
<Icon icon={IconPlus} size="s" />
{@render addIndexButton()}
{:then suggestedIndexes}
{#each suggestedIndexes as index, count}
{@render indexEditForm({ index, count, isDesktop: true })}
{/each}
Add Index
</Button>
</Layout.Stack>
{:then suggestedIndexes}
{#each suggestedIndexes as index, count}
{@const firstItem = count === 0}
<Layout.Stack direction="row" gap="m" alignItems="center">
<InputSelect
id="key-{count}"
label={firstItem ? 'Key' : undefined}
bind:value={index.key}
options={columnOptions}
placeholder="Select column"
required />
{@render addIndexButton()}
{/await}
</Layout.Stack>
<InputSelect
id="type-{count}"
label={firstItem ? 'Type' : ''}
bind:value={index.type}
options={typeOptions}
required />
<svelte:fragment slot="footer">
<Layout.Stack direction="row" justifyContent="flex-end" alignItems="center" inline>
<Layout.Stack direction="row" gap="m">
<Button text size="s" on:click={() => ($showIndexesSuggestions = false)}
>Cancel</Button>
<InputSelect
id="order-{count}"
label={firstItem ? 'Order' : ''}
bind:value={index.orders}
options={getOrderOptions(index.type)}
required />
<!--<InputNumber
id="length-{count}"
label={firstItem ? 'Length' : undefined}
disabled={index.type === IndexType.Key}
placeholder='Lengths'
bind:value={index.length}
/>-->
<div style:margin-top={firstItem ? '27.6px' : '0'}>
<Button
icon
size="xs"
secondary
on:click={() => removeIndex(count)}
disabled={indexes.length <= 1}>
<Icon icon={IconX} color="--fgcolor-danger-primary" />
</Button>
</div>
</Layout.Stack>
{/each}
{#if indexes.length < MAX_INDEXES}
<Layout.Stack direction="row" justifyContent="flex-start">
<Button icon size="s" compact on:click={addIndex}>
<Icon icon={IconPlus} size="s" />
Add Index
<Button
size="s"
submit
submissionLoader
disabled={indexes.length === 0}
forceShowLoader={creatingIndexes}>
Create
</Button>
</Layout.Stack>
{/if}
{/await}
</Layout.Stack>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" justifyContent="flex-end" alignItems="center" inline>
<Layout.Stack direction="row" gap="m">
<Button text size="s" on:click={() => ($showIndexesSuggestions = false)}
>Cancel</Button>
<Button
size="s"
submit
submissionLoader
disabled={indexes.length === 0}
forceShowLoader={creatingIndexes}>
Create
</Button>
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Modal>
</svelte:fragment>
</Modal>
{/if}
{#snippet fieldSkeleton({ label, showLabel })}
{#if $isSmallViewport}
<SideSheet
closeOnBlur={false}
title="Suggested indexes"
bind:show={$showIndexesSuggestions}
submit={{
text: 'Create',
disabled: indexes.length === 0 || creatingIndexes,
onClick: async () => await applySuggestedIndexes()
}}>
{#if modalError}
<Alert.Inline status="error">
{modalError}
</Alert.Inline>
{/if}
{#await loadIndexSuggestions()}
<Layout.Stack gap="m" alignItems="center">
{#each Array(3) as _}
{@render fieldSkeleton({
label: undefined,
showLabel: false,
isDesktop: false
})}
{/each}
</Layout.Stack>
{:then suggestedIndexes}
<Layout.Stack gap="m">
{#each suggestedIndexes as index, count}
<Accordion title="Index {count + 1}: {index.key || 'Untitled'}">
{@render indexEditForm({ index, count, isDesktop: false })}
</Accordion>
{/each}
{@render addIndexButton()}
</Layout.Stack>
{/await}
</SideSheet>
{/if}
{#snippet fieldSkeleton({ label, showLabel, isDesktop = true })}
<Layout.Stack gap="xxxs">
{#if showLabel}
<Typography.Text variant="m-500" style="margin-block-end: var(--gap-s);"
>{label}</Typography.Text>
{/if}
<Skeleton variant="line" width="100%" height={33} style="opacity: 0.25" />
<Skeleton
variant="line"
width="100%"
height={isDesktop ? 33 : 39.398}
style="opacity: 0.25" />
</Layout.Stack>
{/snippet}
{#snippet indexEditForm({ index, count, isDesktop = true })}
{@const firstItem = count === 0}
{#if isDesktop}
<Layout.Stack direction="row" gap="m" alignItems="center">
<InputSelect
id="key-{count}"
label={firstItem ? 'Key' : undefined}
bind:value={index.key}
options={columnOptions}
placeholder="Select column"
required />
<InputSelect
id="type-{count}"
label={firstItem ? 'Type' : ''}
bind:value={index.type}
options={typeOptions}
required />
<InputSelect
id="order-{count}"
label={firstItem ? 'Order' : ''}
bind:value={index.orders}
options={getOrderOptions(index.type)}
required />
{@render removeIndexButton({ count, isDesktop: true })}
</Layout.Stack>
{:else}
<!-- Mobile: Vertical layout -->
<Layout.Stack gap="m">
<InputSelect
id="key-{count}-mobile"
label="Key"
value={index.key}
options={columnOptions}
placeholder="Select column"
required />
<Layout.Stack direction="row" gap="m">
<InputSelect
id="type-{count}-mobile"
label="Type"
bind:value={index.type}
options={typeOptions}
required />
<InputSelect
id="order-{count}-mobile"
label="Order"
bind:value={index.orders}
options={getOrderOptions(index.type)}
required />
</Layout.Stack>
{@render removeIndexButton({ count, isDesktop: false })}
</Layout.Stack>
{/if}
{/snippet}
{#snippet addIndexButton()}
{#if indexes.length < MAX_INDEXES}
<Layout.Stack direction="row" justifyContent="flex-start">
<Button icon size="s" compact on:click={addIndex}>
<Icon icon={IconPlus} size="s" />
Add Index
</Button>
</Layout.Stack>
{/if}
{/snippet}
{#snippet removeIndexButton({ count, isDesktop = true })}
{#if isDesktop}
<div style:margin-top={count === 0 ? '27.6px' : '0'}>
<Button
icon
size="xs"
secondary
on:click={() => removeIndex(count)}
disabled={indexes.length <= 1}>
<Icon icon={IconX} color="--fgcolor-danger-primary" />
</Button>
</div>
{:else if indexes.length > 1}
<Layout.Stack direction="row" justifyContent="flex-end">
<Button text size="s" on:click={() => removeIndex(count)}>Remove index</Button>
</Layout.Stack>
{/if}
{/snippet}