mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
update: index suggestions mobile views with accordion.
This commit is contained in:
+291
-118
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user