Merge pull request #2407 from appwrite/index-suggestions

Feat: AI Index Suggestions
This commit is contained in:
Jake Barnby
2025-09-30 18:02:56 +13:00
committed by GitHub
22 changed files with 1044 additions and 153 deletions
+3 -3
View File
@@ -22,11 +22,11 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
+15 -15
View File
@@ -12,20 +12,20 @@ importers:
specifier: ^1.1.24
version: 1.1.24(svelte@5.25.3)(zod@3.24.3)
'@appwrite.io/console':
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39
'@appwrite.io/pink-icons':
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877(svelte@5.25.3)
'@appwrite.io/pink-legacy':
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877(svelte@5.25.3)
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
@@ -260,8 +260,8 @@ packages:
'@analytics/type-utils@0.6.2':
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74':
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39':
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39}
version: 1.10.0
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1':
@@ -269,8 +269,8 @@ packages:
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c}
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877}
version: 2.0.0-RC.1
peerDependencies:
svelte: ^4.0.0
@@ -284,8 +284,8 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877}
version: 2.0.0-RC.2
peerDependencies:
svelte: ^4.0.0
@@ -3703,13 +3703,13 @@ snapshots:
'@analytics/type-utils@0.6.2': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39': {}
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3722,7 +3722,7 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
@@ -4,13 +4,13 @@
import { Typography } from '@appwrite.io/pink-svelte';
import { user } from '$lib/stores/user';
import SendVerificationEmailModal from '../account/sendVerificationEmailModal.svelte';
import { page } from '$app/stores';
import { page } from '$app/state';
import { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard';
import { isCloud, VARS } from '$lib/system';
import { isCloud, isProductionCloud, VARS } from '$lib/system';
const hasUser = $derived(!!$user);
const needsEmailVerification = $derived(hasUser && !$user.emailVerification);
const notOnOnboarding = $derived(!$page.route.id.includes('/onboarding'));
const notOnOnboarding = $derived(!page.route.id.includes('/onboarding'));
const notOnWizard = $derived(!$wizard.show && !$isNewWizardStatusOpen);
const isEnabledViaEnvConfig = $derived(VARS.EMAIL_VERIFICATION);
const shouldShowEmailBanner = $derived(
@@ -23,9 +23,10 @@
);
let showSendVerification = $state(false);
let isProduction = $derived(isProductionCloud(page.url));
</script>
{#if shouldShowEmailBanner}
{#if shouldShowEmailBanner && isProduction}
<HeaderAlert type="warning" title="Your email address needs to be verified">
<svelte:fragment>
To avoid losing access to your projects, make sure <Typography.Text
+10 -4
View File
@@ -198,15 +198,21 @@ function generateSingleValue(
case 'integer': {
const intAttr = column as Models.ColumnInteger;
const min = !isWithinSafeRange(intAttr.min) ? 0 : intAttr.min;
const max = !isWithinSafeRange(intAttr.max) ? 100 : intAttr.max;
const min = isWithinSafeRange(intAttr.min) ? intAttr.min : 0;
const fallbackMax = Math.max(min + 100, 100);
const max = isWithinSafeRange(intAttr.max)
? intAttr.max
: Math.min(fallbackMax, Number.MAX_SAFE_INTEGER);
return faker.number.int({ min, max });
}
case 'double': {
const floatAttr = column as Models.ColumnFloat;
const min = !isWithinSafeRange(floatAttr.min) ? 0 : floatAttr.min;
const max = !isWithinSafeRange(floatAttr.max) ? 100 : floatAttr.max;
const min = isWithinSafeRange(floatAttr.min) ? floatAttr.min : 0;
const fallbackMax = Math.max(min + 100, 100);
const max = isWithinSafeRange(floatAttr.max)
? floatAttr.max
: Math.min(fallbackMax, Number.MAX_SAFE_INTEGER);
const precision = 4;
return faker.number.float({ min, max, fractionDigits: precision });
+5
View File
@@ -181,6 +181,11 @@
&.hide {
display: none;
}
& :global(i) {
// because the `IconCloud` shows above floating action bars.
position: unset;
}
}
:global(main:has(.sub-navigation)) footer {
+4 -1
View File
@@ -11,13 +11,16 @@
{#each $notifications as notification (notification.id)}
<span animate:flip={{ duration: 500 }} transition:fly|global={{ x: 50 }}>
<Toast
isHtml={notification.isHtml}
title={notification.title}
status={notification.type}
icon={notification.icon}
description={notification.message}
actions={notification.buttons?.map((button) => {
return {
label: button.name,
onClick: button.method
onClick: button.method,
isHtml: button.isHtml
};
})}
on:dismiss={() => dismissNotification(notification.id)} />
+31 -10
View File
@@ -1,13 +1,14 @@
import { writable } from 'svelte/store';
import type { ComponentType } from 'svelte';
export type Notification = {
id: number;
type: 'success' | 'error' | 'info' | 'warning';
type?: 'success' | 'error' | 'info' | 'warning';
dismissible?: boolean;
timeout?: number;
message: string;
title?: string;
icon?: string;
icon?: ComponentType;
buttons?: Buttons[];
isHtml?: boolean;
};
@@ -15,32 +16,52 @@ export type Notification = {
export type Buttons = {
method: () => void | Promise<void>;
name: string;
isHtml?: boolean;
};
let counter = 0;
const activeTimeouts = new Map<number, NodeJS.Timeout>();
export const notifications = writable<Notification[]>([]);
export const dismissNotification = (id: number) => {
const timeoutId = activeTimeouts.get(id);
if (timeoutId) {
clearTimeout(timeoutId);
activeTimeouts.delete(id);
}
notifications.update((all) => all.filter((t) => t.id !== id));
};
export const dismissAllNotifications = () => {
activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
activeTimeouts.clear();
notifications.set([]);
};
export const addNotification = (notification: Omit<Notification, 'id'>) => {
const defaults = {
export const addNotification = (notification: Omit<Notification, 'id'>): number => {
const defaults: Notification = {
id: counter++,
type: 'info',
dismissible: true,
timeout: 6000
timeout: 6000,
...notification
};
const n = { ...defaults, ...notification };
notifications.update((all) => {
return [n, ...all.slice(0, 4)];
});
notifications.update((all) => [defaults, ...all.slice(0, 4)]);
if (n.timeout) setTimeout(() => dismissNotification(n.id), n.timeout);
if (defaults.timeout) {
const timeoutId = setTimeout(() => {
activeTimeouts.delete(defaults.id);
dismissNotification(defaults.id);
}, defaults.timeout);
activeTimeouts.set(defaults.id, timeoutId);
}
return defaults.id;
};
@@ -10,7 +10,7 @@
FloatingActionBar,
Popover
} from '@appwrite.io/pink-svelte';
import { IconCalendar, IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
import type { Column } from '$lib/helpers/types';
import { expandTabs } from '../table-[table]/store';
@@ -25,9 +25,10 @@
tableColumnSuggestions,
basicColumnOptions,
mockSuggestions,
createTableRequest
createTableRequest,
showIndexesSuggestions
} from './store';
import { addNotification } from '$lib/stores/notifications';
import { addNotification, dismissNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { sleep } from '$lib/helpers/promises';
import { invalidate, beforeNavigate, goto } from '$app/navigation';
@@ -41,7 +42,9 @@
import Options from './options.svelte';
import { InputSelect, InputText } from '$lib/elements/forms';
import { Confirm } from '$lib/components';
import { VARS } from '$lib/system';
import { isCloud, VARS } from '$lib/system';
import IconAINotification from './icon/aiNotification.svelte';
let resizeObserver: ResizeObserver;
let spreadsheetContainer: HTMLElement;
@@ -51,7 +54,10 @@
let rangeOverlayEl: HTMLDivElement | null = null;
let fadeBottomOverlayEl: HTMLDivElement | null = null;
let customColumns = $state([]);
let customColumns = $state<
(SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean })[]
>(Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index)));
let showFloatingBar = $state(true);
let hasTransitioned = $state(false);
let scrollAnimationFrame: number | null = null;
@@ -63,6 +69,8 @@
let creatingColumns = $state(false);
const baseColProps = { draggable: false, resizable: false };
const NOTIFICATION_AND_MOCK_DELAY = 1250;
const getColumnWidth = (columnKey: string) => Math.max(180, columnKey.length * 8 + 60);
const safeNumericValue = (value: number | undefined) =>
value !== undefined && isWithinSafeRange(value) ? value : undefined;
@@ -128,12 +136,6 @@
rangeOverlayEl.style.display = 'block';
}
if (customColumns.length === 0) {
spreadsheetContainer.style.setProperty('--group-left', '40px');
spreadsheetContainer.style.setProperty('--group-width', '100%');
return;
}
// Custom columns mode: calculate precise overlay bounds
const containerRect = spreadsheetContainer.getBoundingClientRect();
const getById = (id: string) =>
@@ -141,6 +143,33 @@
`[role="cell"][data-header="true"][data-column-id="${id}"]`
);
const hasRealColumns = customColumns.some((col) => !col.isPlaceholder);
if (!hasRealColumns) {
// For placeholders or no columns, position overlay to cover custom columns area
const idCell = getById('$id');
const actionsCell = headerElement!.querySelector<HTMLElement>(
'[role="cell"][data-column-id="actions"]'
);
if (idCell && actionsCell) {
const idRect = idCell.getBoundingClientRect();
const actionsRect = actionsCell.getBoundingClientRect();
const left = Math.round(idRect.right - containerRect.left);
const actionsLeft = actionsRect.left - containerRect.left;
const width = Math.min(
customColumns.length * 180, // estimated minimum placeholder width
actionsLeft - left // don't exceed actions column
);
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
spreadsheetContainer.style.setProperty('--group-width', `${width + 2}px`);
} else {
spreadsheetContainer.style.setProperty('--group-left', '40px');
spreadsheetContainer.style.setProperty('--group-width', 'calc(100% - 80px)');
}
return;
}
// Calculate visible viewport bounds
const scrollerRect = hScroller ? hScroller.getBoundingClientRect() : containerRect;
const visibleRight = scrollerRect.right;
@@ -219,7 +248,7 @@
// only for mobile, we can remove if not needed!
const scrollToFirstCustomColumn = () => {
if (!$isSmallViewport || customColumns.length === 0) return;
if (!$isSmallViewport) return;
if (!headerElement || !headerElement.isConnected) {
headerElement = spreadsheetContainer.querySelector('[role="rowheader"]');
@@ -231,12 +260,19 @@
`[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]`
);
if (firstCustomColumnCell && hScroller) {
const cellRect = firstCustomColumnCell.getBoundingClientRect();
const scrollerRect = hScroller.getBoundingClientRect();
const scrollLeft = hScroller.scrollLeft + cellRect.left - scrollerRect.left - 40;
const directAccessScroller =
hScroller ??
findHorizontalScroller(headerElement) ??
// internal spreadsheet root main container!
spreadsheetContainer.querySelector('.spreadsheet-container');
hScroller.scrollTo({
if (firstCustomColumnCell && directAccessScroller) {
const cellRect = firstCustomColumnCell.getBoundingClientRect();
const scrollerRect = directAccessScroller.getBoundingClientRect();
const scrollLeft =
directAccessScroller.scrollLeft + cellRect.left - scrollerRect.left - 40;
directAccessScroller.scrollTo({
left: Math.max(0, scrollLeft),
behavior: 'smooth'
});
@@ -263,7 +299,8 @@
// Handle create table requests from subNavigation
const unsubscribeCreateTable = createTableRequest.subscribe((requested) => {
if (requested) {
if (customColumns.length > 0 && !creatingColumns) {
const hasRealColumns = customColumns.some((col) => !col.isPlaceholder);
if (hasRealColumns && !creatingColumns) {
confirmNavigation = true;
pendingNavigationUrl = 'create-table';
} else {
@@ -311,26 +348,6 @@
...baseColProps
},
...customSuggestedColumns,
...(customColumns.length === 0
? [
{
id: '$createdAt',
title: '$createdAt',
type: 'datetime' as Column['type'],
width: 180,
icon: IconCalendar,
...baseColProps
},
{
id: '$updatedAt',
title: '$updatedAt',
type: 'datetime' as Column['type'],
width: 180,
icon: IconCalendar,
...baseColProps
}
]
: []),
{
id: 'actions',
title: '',
@@ -343,7 +360,8 @@
// Handle browser back/forward navigation
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (customColumns.length > 0 && !creatingColumns) {
const hasRealColumns = customColumns.some((col) => !col.isPlaceholder);
if (hasRealColumns && !creatingColumns) {
event.preventDefault();
event.returnValue =
'You have unsaved column suggestions. Are you sure you want to leave?';
@@ -365,7 +383,8 @@
});
beforeNavigate(({ cancel, to }) => {
if (customColumns.length > 0 && !creatingColumns) {
const hasRealColumns = customColumns.some((col) => !col.isPlaceholder);
if (hasRealColumns && !creatingColumns) {
cancel();
confirmNavigation = true;
$navigationCancelled = true;
@@ -391,6 +410,12 @@
async function suggestColumns() {
$tableColumnSuggestions.thinking = true;
if ($isSmallViewport) {
await tick();
scrollToFirstCustomColumn();
}
let suggestedColumns: {
total: number;
columns: ColumnInput[];
@@ -402,7 +427,7 @@
try {
if (VARS.MOCK_AI_SUGGESTIONS) {
/* animation */
await sleep(1250);
await sleep(NOTIFICATION_AND_MOCK_DELAY);
suggestedColumns = mockSuggestions;
} else {
suggestedColumns = (await sdk
@@ -410,7 +435,8 @@
.console.suggestColumns({
databaseId: page.params.database,
tableId: page.params.table,
context: $tableColumnSuggestions.context ?? undefined
context: $tableColumnSuggestions.context ?? undefined,
min: 6 // TODO: to not break the sheet layout's width!
})) as unknown as {
total: number;
columns: ColumnInput[];
@@ -423,21 +449,42 @@
total: suggestedColumns.total
});
customColumns = mapSuggestedColumns(suggestedColumns.columns);
const mappedColumns = mapSuggestedColumns(suggestedColumns.columns);
if (customColumns.length > 0) {
setTimeout(scrollToFirstCustomColumn, 100);
setTimeout(() => (hasTransitioned = true), 300);
}
} catch (error) {
addNotification({
type: 'error',
message: error.message
mappedColumns.forEach((column, index) => {
if (index < customColumns.length) {
setTimeout(() => {
customColumns[index] = { ...column, isPlaceholder: false };
// recalculate overlay bounds
// after each column is populated!
requestAnimationFrame(() => updateOverlayBounds());
}, index * 150);
}
});
trackError(error, Submit.ColumnSuggestions);
} finally {
if (mappedColumns.length > 0) {
setTimeout(
() => {
hasTransitioned = true;
// final recal after
// all animations complete
requestAnimationFrame(() => {
recalcAll();
});
},
mappedColumns.length * 150 + 300
);
}
resetSuggestionsStore(false);
} catch (error) {
// remove completely!
resetSuggestionsStore();
// track & notify!
trackError(error, Submit.ColumnSuggestions);
addNotification({ type: 'error', message: error.message });
}
}
@@ -483,6 +530,31 @@
return !['$id', '$createdAt', '$updatedAt', 'actions'].includes(id);
}
function showIndexSuggestionsNotification() {
// safeguard anyways!
if (!isCloud) return;
setTimeout(() => {
const notifId = addNotification({
isHtml: true,
title: '<b>Next step: Add indexes</b>',
message: 'See suggested indexes based on your columns',
dismissible: true,
icon: IconAINotification,
timeout: 10000, // ten seconds
buttons: [
{
name: 'Create indexes',
method: () => {
dismissNotification(notifId);
showIndexesSuggestions.update(() => true);
}
}
]
});
}, NOTIFICATION_AND_MOCK_DELAY);
}
async function createColumns() {
creatingColumns = true;
const client = sdk.forProject(page.params.region, page.params.project);
@@ -589,9 +661,13 @@
addNotification({
type: 'success',
message: 'Columns created successfully'
message: 'Columns created successfully',
timeout: NOTIFICATION_AND_MOCK_DELAY
});
// show index notification!
showIndexSuggestionsNotification();
trackEvent(Submit.ColumnCreate, { type: 'suggestions' });
} catch (error) {
trackError(error, Submit.ColumnCreate);
@@ -603,6 +679,23 @@
}
}
function createPlaceholderColumn(
index: number
): SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean } {
return {
key: `column${index + 1}`,
type: 'string',
required: false,
default: null,
format: null,
size: undefined,
min: undefined,
max: undefined,
elements: undefined,
isPlaceholder: true
};
}
onDestroy(() => {
resizeObserver?.disconnect();
hScroller?.removeEventListener('scroll', recalcAllThrottled);
@@ -621,6 +714,7 @@
<div
bind:this={spreadsheetContainer}
class:custom-columns={customColumns.length > 0}
class:thinking={$tableColumnSuggestions.thinking}
class="databases-spreadsheet spreadsheet-container-outer"
style:--overlay-icon-color="#fd366e99"
style:--non-overlay-icon-color="--fgcolor-neutral-weak">
@@ -643,7 +737,6 @@
bottomActionClick={() => {}}>
<svelte:fragment slot="header" let:root>
{#each spreadsheetColumns as column, index (index)}
{@const isColumnInteractable = isCustomColumn(column.id)}
{#if column.isAction}
<Spreadsheet.Header.Cell column="actions" {root}>
<Button.Button icon variant="extra-compact">
@@ -658,6 +751,8 @@
{@const columnIconColor = !columnObj?.type
? '--non-overlay-icon-color'
: '--overlay-icon-color'}
{@const isColumnInteractable =
isCustomColumn(column.id) && !columnObj.isPlaceholder}
<Options
enabled={isColumnInteractable}
@@ -680,30 +775,42 @@
alignItems="center"
alignContent="center"
justifyContent="space-between">
{column.title}
<span
class="column-title"
class:animate-in={!columnObj?.isPlaceholder}
style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`}>
{column.title}
</span>
<Popover
let:toggle
portal
padding="none"
placement="bottom-start">
<Button.Button
size="xs"
variant="extra-compact"
disabled={!isColumnInteractable}
on:click={(event) => {
if (
isColumnInteractable &&
!$isTabletViewport
) {
toggle(event);
}
}}>
<Icon
size="s"
color={columnIconColor}
icon={column.icon ?? undefined} />
</Button.Button>
<div
class="column-icon-wrapper"
class:animate-in={!columnObj?.isPlaceholder}
style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`}>
<Button.Button
size="xs"
variant="extra-compact"
disabled={!isColumnInteractable}
on:click={(event) => {
if (
isColumnInteractable &&
!$isTabletViewport
) {
toggle(event);
}
}}>
{#if !columnObj?.isPlaceholder}
<Icon
size="s"
color={columnIconColor}
icon={column.icon ?? undefined} />
{/if}
</Button.Button>
</div>
<div
let:toggle
@@ -832,9 +939,12 @@
<svelte:fragment slot="start">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<Spinner size="s" />
<Typography.Text style="white-space: nowrap">
<Typography.Caption
variant="500"
color="--fgcolor-neutral-secondary"
style="white-space: nowrap">
Thinking of column suggestions
</Typography.Text>
</Typography.Caption>
</Layout.Stack>
</svelte:fragment>
<svelte:fragment slot="end">
@@ -847,7 +957,7 @@
</svelte:fragment>
</FloatingActionBar>
</div>
{:else if customColumns.length > 0 && showFloatingBar}
{:else if customColumns.some((col) => !col.isPlaceholder) && showFloatingBar}
<div
class="floating-action-wrapper"
class:expanded={!creatingColumns}
@@ -959,7 +1069,7 @@
transition: box-shadow 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
& :global(.column-resizer-disabled) {
& :global([role='cell']:not([data-column-id='actions']) .column-resizer-disabled) {
border-left: var(--border-width-s, 1px) solid rgba(253, 54, 110, 0.24) !important;
transition: border-color 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
@@ -1021,7 +1131,17 @@
& .floating-action-wrapper {
& :global(:first-child) {
z-index: 21;
left: calc(65% - 525px / 2);
transition: all 600ms cubic-bezier(0.4, 0, 0.2, 1);
@media (max-width: 1024px) {
left: calc(50% - 525px / 2);
}
@media (max-width: 768px) {
left: calc(50% - 400px / 2);
max-width: 400px !important;
}
}
&.expanded :global(:first-child) {
@@ -1059,6 +1179,12 @@
scrollbar-width: none;
}
&.thinking {
& :global(.spreadsheet-container) {
overflow: hidden !important;
}
}
& :global([data-select='true']) {
opacity: 0.85;
pointer-events: none;
@@ -1146,7 +1272,8 @@
border: var(--border-width-L, 2px) solid rgba(253, 54, 110, 0.24) !important;
& :global(i) {
margin-inline-end: 8px !important;
margin-inline-end: 6px !important;
margin-block-start: -4px !important;
}
& :global(::selection) {
@@ -1157,4 +1284,21 @@
:global(.filter-modal-actions-menu.variant) {
max-height: 184px;
}
/* Sequential animation for column titles and icons */
.column-title,
.column-icon-wrapper {
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.4s ease-out,
transform 0.4s ease-out;
}
.column-title.animate-in,
.column-icon-wrapper.animate-in {
opacity: 1;
transform: translateY(0);
transition-delay: var(--animation-delay, 0ms);
}
</style>
@@ -1,8 +1,18 @@
<script lang="ts">
import { Layout } from '@appwrite.io/pink-svelte';
let {
notification = false
}: {
notification?: boolean;
} = $props();
</script>
<Layout.Stack inline alignItems="center" justifyContent="center" class="ai-icon-holder">
<Layout.Stack
inline
alignItems="center"
justifyContent="center"
class="ai-icon-holder {notification ? 'notification' : ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
<path
d="M11.0156 6.20801C11.3539 5.29392 12.6461 5.29392 12.9844 6.20801L14.5801 10.5186C14.7776 11.052 15.198 11.4724 15.7314 11.6699L20.042 13.2656C20.9561 13.6039 20.9561 14.8961 20.042 15.2344L15.7314 16.8301C15.198 17.0276 14.7776 17.448 14.5801 17.9814L12.9844 22.292C12.6461 23.2061 11.3539 23.2061 11.0156 22.292L9.41992 17.9814C9.2224 17.448 8.80197 17.0276 8.26855 16.8301L3.95801 15.2344C3.04392 14.8961 3.04392 13.6039 3.95801 13.2656L8.26855 11.6699C8.80196 11.4724 9.2224 11.052 9.41992 10.5186L11.0156 6.20801Z"
@@ -64,4 +74,9 @@
width: 40px !important;
height: 40px !important;
}
:global(.ai-icon-holder.notification) {
width: 36px !important;
height: 32px !important;
}
</style>
@@ -0,0 +1,5 @@
<script lang="ts">
import IconAI from './ai.svelte';
</script>
<IconAI notification />
@@ -0,0 +1,569 @@
<script lang="ts">
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 {
showIndexesSuggestions,
IndexOrder,
mockSuggestions,
type SuggestedIndexSchema
} from './store';
import { Modal, Confirm } from '$lib/components';
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 { 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';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
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 loadingSuggestions = $state(false);
let indexes = $state<SuggestedIndexSchema[]>([]);
let confirmDismiss = $state(false);
let columnOptions: Array<{
value: string;
label: string;
leadingIcon?: ComponentType;
}> = $state();
const tableId = page.params.table;
const databaseId = page.params.database;
function makeColumnOptions() {
if (VARS.MOCK_AI_SUGGESTIONS) {
columnOptions = mockSuggestions.columns.map((column) => ({
value: 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,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)
?.icon
}));
}
}
async function loadIndexSuggestions(): Promise<SuggestedIndexSchema[]> {
modalError = null;
loadingSuggestions = true;
if (VARS.MOCK_AI_SUGGESTIONS) {
await sleep(1250);
indexes = mockSuggestions.columns.slice(0, 3).map((column, index) => ({
key: column.name,
type: IndexType.Key,
columns: [column.name],
orders: index === 2 ? IndexOrder.DESC : IndexOrder.ASC,
lengths: []
}));
} else {
try {
const suggestions = await sdk
.forProject(page.params.region, page.params.project)
.console.suggestIndexes({
databaseId,
tableId: $table.$id
});
indexes = suggestions.indexes.map((index) => {
return {
key: index.columns[0],
type: index.type as IndexType,
orders: (index.orders?.[0] as IndexOrder) || IndexOrder.ASC,
columns: index.columns,
lengths: index.lengths ?? []
};
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
await closeAndInvalidate();
return []; // formality, UI is closed by now!
}
}
makeColumnOptions();
loadingSuggestions = false;
return indexes;
}
function addIndex() {
if (indexes.length < MAX_INDEXES) {
indexes.push({
key: '',
type: IndexType.Key,
orders: IndexOrder.ASC,
columns: [],
lengths: null
});
}
}
function removeIndex(index: number) {
indexes.splice(index, 1);
}
function syncIndexState(event: CustomEvent, index: SuggestedIndexSchema) {
const selected = event.detail;
index.key = selected;
index.columns = selected ? [selected] : [];
if (index.lengths) {
index.lengths = index.lengths.slice(0, index.columns.length);
}
}
function getOrderOptions(selectedType: IndexType) {
const base = [IndexOrder.ASC, IndexOrder.DESC];
const values = selectedType === IndexType.Spatial ? [...base, IndexOrder.NONE] : base;
return values.map((order) => ({
label: capitalize(String(order)),
value: order
}));
}
function generateUniqueIndexKey(index: SuggestedIndexSchema, usedKeys: Set<string>): string {
const existingKeys = $table.indexes.map((idx) => idx.key);
let suggestedKey = `${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 };
}
function dismissIndexes() {
indexes = [];
confirmDismiss = false;
$showIndexesSuggestions = false;
}
async function closeAndInvalidate() {
// close modal/sheet.
$showIndexesSuggestions = false;
/**
* Invalidate dependencies after modal/sheet close,
* because otherwise the `await` block will re-run in the modal.
*/
await invalidate(Dependencies.TABLE);
}
async function applySuggestedIndexes(): Promise<boolean> {
modalError = null;
creatingIndexes = true;
for (const [i, index] of indexes.entries()) {
if (!index.key || !index.type || !index.columns || index.columns.length === 0) {
modalError = `Index ${i + 1}: Selected column or type invalid`;
creatingIndexes = false;
return true; // keep sheet open!
}
}
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 {
// prepare and validate index data
const { orders, lengths } = prepareIndexForCreation(index, columnMap);
// generate unique key name for the index
const uniqueIndexKey = generateUniqueIndexKey(index, usedKeys);
await sdkClient.tablesDB.createIndex({
databaseId,
tableId,
key: uniqueIndexKey,
type: index.type,
columns: index.columns,
lengths,
...(orders.length ? { orders } : {})
});
successCount++;
} catch (error) {
if (!modalError) {
modalError = error.message;
}
trackError(error, Submit.IndexCreate);
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'
});
await closeAndInvalidate();
} else if (successCount > 0) {
// some succeeded, some failed
addNotification({
message: 'Some indexes could not be created',
type: 'warning'
});
await closeAndInvalidate();
} else {
// all failed
addNotification({
message: 'Failed to create indexes',
type: 'error'
});
return true; // keep sheet open!
}
return false; // close the sheet!
}
const typeOptions = Object.values(IndexType).map((type) => ({
label: capitalize(type),
value: type
}));
onMount(() => showIndexesSuggestions.set(false));
onDestroy(() => showIndexesSuggestions.set(false));
</script>
{#if !$isSmallViewport}
<Modal
dismissible={false}
title="Suggested indexes"
bind:error={modalError}
bind:show={$showIndexesSuggestions}
onSubmit={async () => {
await applySuggestedIndexes();
}}>
<Layout.Stack gap="m">
{#await loadIndexSuggestions()}
<Layout.Stack gap="xs">
<Layout.Stack gap="m">
{#each Array(3) as _, index}
{@const firstItem = index === 0}
<Layout.Stack gap="m" direction="row" alignItems="center">
{@render fieldSkeleton({ label: 'Column', 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="33px"
height="33px"
style="opacity: 0.25" />
</div>
</Layout.Stack>
{/each}
</Layout.Stack>
{@render addIndexButton()}
</Layout.Stack>
{:then suggestedIndexes}
<Layout.Stack gap="xs">
<Layout.Stack gap="m">
{#each suggestedIndexes as index, count}
{@render indexEditForm({ index, count, isDesktop: true })}
{/each}
</Layout.Stack>
{@render addIndexButton()}
</Layout.Stack>
{/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"
disabled={loadingSuggestions || creatingIndexes}
on:click={() => {
if (indexes.length > 0 && !creatingIndexes) {
confirmDismiss = true;
} else {
$showIndexesSuggestions = false;
}
}}>Cancel</Button>
<Button
size="s"
submit
submissionLoader
disabled={indexes.length === 0 || loadingSuggestions}
forceShowLoader={creatingIndexes}>
Create
</Button>
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Modal>
{/if}
{#if $isSmallViewport}
<SideSheet
closeOnBlur={false}
title="Suggested indexes"
data-index-edit-form-sheet
bind:show={$showIndexesSuggestions}
submit={{
text: 'Create',
disabled: indexes.length === 0 || creatingIndexes || loadingSuggestions,
onClick: async () => await applySuggestedIndexes()
}}
cancel={{
disabled: loadingSuggestions || creatingIndexes,
onClick: () => {
if (indexes.length > 0 && !creatingIndexes) {
confirmDismiss = true;
} else {
$showIndexesSuggestions = false;
}
}
}}>
{#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="s">
{#each suggestedIndexes as index, count}
<Accordion title="{index.key}_index">
{@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={isDesktop ? 33.602 : 39.398}
style="opacity: 0.25" />
</Layout.Stack>
{/snippet}
{#snippet indexEditForm({ index, count, isDesktop = true })}
{@const firstItem = count === 0}
{#if isDesktop}
<Layout.Stack gap="m" direction="row" alignItems="center" class="desktop-selects-form">
<InputSelect
id="key-{count}"
label={firstItem ? 'Column' : undefined}
bind:value={index.key}
on:change={(event) => syncIndexState(event, index)}
options={columnOptions}
placeholder="Select column"
required
disabled={creatingIndexes} />
<InputSelect
id="type-{count}"
label={firstItem ? 'Type' : ''}
bind:value={index.type}
options={typeOptions}
required
disabled={creatingIndexes} />
<InputSelect
id="order-{count}"
label={firstItem ? 'Order' : ''}
bind:value={index.orders}
options={getOrderOptions(index.type)}
required
disabled={creatingIndexes} />
{@render removeIndexButton({ count, isDesktop: true })}
</Layout.Stack>
{:else}
<!-- Mobile: Vertical layout -->
<Layout.Stack gap="m">
<Layout.Stack gap="s">
<InputSelect
id="key-{count}-mobile"
bind:value={index.key}
on:change={(event) => syncIndexState(event, index)}
options={columnOptions}
placeholder="Select column"
required />
<InputSelect
id="type-{count}-mobile"
bind:value={index.type}
options={typeOptions}
required />
<InputSelect
id="order-{count}-mobile"
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}
disabled={loadingSuggestions || creatingIndexes}>
<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 || creatingIndexes}>
<Icon icon={IconX} color="--fgcolor-danger-primary" />
</Button>
</div>
{:else if indexes.length > 1}
<Layout.Stack direction="row" justifyContent="flex-start">
<Button
size="s"
secondary
on:click={() => removeIndex(count)}
disabled={indexes.length <= 1 || creatingIndexes}>Delete</Button>
</Layout.Stack>
{/if}
{/snippet}
<Confirm
confirmDeletion
action="Dismiss"
title="Dismiss indexes"
bind:open={confirmDismiss}
onSubmit={dismissIndexes}>
Are you sure you want to dismiss these suggested indexes? This action cannot be undone.
</Confirm>
<style lang="scss">
// Custom logic to hide the Sheet's
// `X` close button (not configurable via props)
:global([data-index-edit-form-sheet] header) {
& :global(.divider),
& :global(button) {
visibility: hidden !important;
}
}
// stack > div > input-select button!
:global(.desktop-selects-form :first-child button:first-of-type) {
width: 164px;
max-width: 164px;
}
</style>
@@ -2,19 +2,34 @@
import IconAI from './icon/ai.svelte';
import { slide } from 'svelte/transition';
import { tableColumnSuggestions } from './store';
import { InputTextarea } from '$lib/elements/forms';
import { Card, Layout, Selector, Typography } from '@appwrite.io/pink-svelte';
import { Button, InputTextarea } from '$lib/elements/forms';
import { Card, Icon, Layout, Selector, Typography } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { isCloud } from '$lib/system';
import { IconExternalLink } from '@appwrite.io/pink-icons-svelte';
onMount(() => {
// enable by default!
$tableColumnSuggestions.enabled = true;
if (featureActive) {
$tableColumnSuggestions.enabled = true;
}
});
const featureActive = $derived(isCloud);
const subtitle = $derived.by(() => {
return featureActive
? 'Enable AI to suggest useful columns based on your table name'
: 'AI suggestions are available on all Appwrite Cloud projects';
});
</script>
<Card.Base variant="secondary" radius="s" padding="xs">
<Layout.Stack gap="m">
<Layout.Stack direction="row" gap="s" alignItems="center">
<Layout.Stack
gap="s"
direction="row"
alignItems={$isSmallViewport ? 'flex-start' : 'center'}>
<IconAI />
<Layout.Stack direction="column" gap="none">
@@ -26,17 +41,33 @@
<Selector.Switch
id="suggestions"
label={undefined}
disabled={!featureActive}
bind:checked={$tableColumnSuggestions.enabled} />
</div>
</Layout.Stack>
<Typography.Text color="--fgcolor-neutral-secondary">
Enable AI to suggest useful columns based on your table name
{subtitle}
</Typography.Text>
</Layout.Stack>
</Layout.Stack>
{#if $tableColumnSuggestions.enabled}
{#if !featureActive}
<Layout.Stack inline alignItems="flex-end">
<Button
external
secondary
fullWidthMobile
href="https://cloud.appwrite.io/register">
Sign up
<Icon icon={IconExternalLink} size="s" />
</Button>
</Layout.Stack>
{/if}
<!-- just being safe with extra guard! -->
{#if $tableColumnSuggestions.enabled && featureActive}
<div transition:slide={{ duration: 200 }}>
<InputTextarea
id="context"
@@ -50,7 +81,7 @@
</Card.Base>
<style lang="scss">
.suggestions-switch :global(button) {
.suggestions-switch :global(button):not(:disabled) {
cursor: pointer;
}
</style>
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
import { IndexType } from '@appwrite.io/console';
import { columnOptions } from '../table-[table]/columns/store';
export type TableColumnSuggestions = {
@@ -24,6 +25,20 @@ export type SuggestedColumnSchema = {
format?: string | null;
};
export enum IndexOrder {
ASC = 'ASC',
DESC = 'DESC',
NONE = null
}
export type SuggestedIndexSchema = {
key: string;
type: IndexType;
orders: IndexOrder;
columns: string[];
lengths?: number[] | undefined;
};
export const tableColumnSuggestions = writable<TableColumnSuggestions>({
enabled: false,
context: null,
@@ -33,6 +48,8 @@ export const tableColumnSuggestions = writable<TableColumnSuggestions>({
export const createTableRequest = writable<boolean>(false);
export const showIndexesSuggestions = writable<boolean>(false);
export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
total: 7,
columns: [
@@ -23,6 +23,7 @@
import { trackEvent } from '$lib/actions/analytics';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
import IconQuestionMarkCircle from './components/questionIcon.svelte';
let policyCreateError: string;
let totalPolicies: UserBackupPolicy[] = [];
@@ -40,7 +41,7 @@
if (parsedCounter === showOnCount || !counter) {
addNotification({
type: 'info',
icon: 'question-mark-circle',
icon: IconQuestionMarkCircle,
message:
'How was your experience with our new Backups feature? Give us your feedback and help us improve!',
timeout: 15000,
@@ -0,0 +1,6 @@
<script lang="ts">
import { Icon } from '@appwrite.io/pink-svelte';
import { IconQuestionMarkCircle } from '@appwrite.io/pink-icons-svelte';
</script>
<Icon icon={IconQuestionMarkCircle} />
@@ -64,6 +64,9 @@
import { chunks } from '$lib/helpers/array';
import { Submit, trackEvent } from '$lib/actions/analytics';
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
import { showIndexesSuggestions, tableColumnSuggestions } from '../(suggestions)';
let editRow: EditRow;
let editRelatedRow: EditRelatedRow;
let editRowPermissions: EditRowPermissions;
@@ -89,7 +92,15 @@
response.events.includes('databases.*.tables.*.columns.*') ||
response.events.includes('databases.*.tables.*.indexes.*')
) {
if (!isWaterfallFromFaker) {
// don't invalidate when -
// 1. from faker
// 2. ai columns creation
// 3. ai indexes creation
if (
!isWaterfallFromFaker &&
!$showIndexesSuggestions &&
!$tableColumnSuggestions.table
) {
invalidate(Dependencies.TABLE);
}
}
@@ -470,3 +481,5 @@
</Layout.Stack>
</svelte:fragment>
</Dialog>
<IndexesSuggestions />
@@ -31,6 +31,7 @@
import EmptySheet from './layout/emptySheet.svelte';
import CreateRow from './rows/create.svelte';
import { onDestroy } from 'svelte';
import { isCloud } from '$lib/system';
import { Empty as SuggestionsEmptySheet, tableColumnSuggestions } from '../(suggestions)';
export let data: PageData;
@@ -75,6 +76,12 @@
$: hasColumns = !!$table.columns.length;
$: hasValidColumns = $table?.columns?.some((col) => col.status === 'available');
$: canShowSuggestionsSheet =
// enabled, has table details
// and it matches current table
$tableColumnSuggestions.enabled &&
$tableColumnSuggestions.table &&
$tableColumnSuggestions.table.id === page.params.table;
async function onSelect(file: Models.File, localFile = false) {
$isCsvImportInProgress = true;
@@ -216,7 +223,7 @@
}
}} />
{/if}
{:else if $tableColumnSuggestions.enabled && $tableColumnSuggestions.table && $tableColumnSuggestions.table.id === page.params.table}
{:else if isCloud && canShowSuggestionsSheet}
<SuggestionsEmptySheet />
{:else}
<EmptySheet
@@ -51,6 +51,7 @@
import { type Models } from '@appwrite.io/console';
import { preferences } from '$lib/stores/preferences';
import { page } from '$app/state';
import { formatName } from '$lib/helpers/string';
const updatedColumnsForSheet = $derived.by(() => {
const baseAttrs = [
@@ -182,6 +183,10 @@
return `Type: ${formattedType}`;
}
function isSystemColumnKey(column: Columns) {
return column.key.startsWith('$');
}
onDestroy(() => ($showCreateColumnSheet.show = false));
$effect(() => {
@@ -226,9 +231,9 @@
bind:selectedRows={selectedColumns}
columns={[
// more size until we decide if we want a new column!
{ id: 'key', width: { min: $isSmallViewport ? 250 : 200 } },
{ id: 'indexed', width: { min: 150 } },
{ id: 'default', width: { min: 200 } },
{ id: 'key', width: { min: 300 }, resizable: false },
{ id: 'indexed', width: { min: 150 }, resizable: false },
{ id: 'default', width: { min: 200 }, resizable: false },
{ id: 'actions', width: 40, isAction: true }
]}
bottomActionClick={() => ($showCreateColumnSheet.show = true)}>
@@ -269,13 +274,17 @@
direction="row"
alignItems="center"
gap="xxs">
<span class="text u-trim-1" data-private>
{#if column.key === '$id' || column.key === '$sequence' || column.key === '$createdAt' || column.key === '$updatedAt'}
{column['name']}
<Typography.Text truncate>
{#if isSystemColumnKey(column)}
{column.key}
{:else}
{column.key} {column.array ? '[]' : undefined}
{@const key = !column.required
? column.key
: formatName(column.key, 6)}
{key}
{column.array ? '[]' : undefined}
{/if}
</span>
</Typography.Text>
{#if isString(column) && column.encrypt}
<Tooltip>
<Icon
@@ -52,12 +52,12 @@
let showDelete = $state(false);
let showOverview = $state(false);
let columns = $state([
{ id: 'key' },
{ id: 'type' },
{ id: 'columns' },
let columns = $derived([
{ id: 'key', width: { min: $isSmallViewport ? 250 : 200 }, resizable: false },
{ id: 'type', width: 120, resizable: false },
{ id: 'columns', width: { min: 200 }, resizable: false },
// { id: 'orders' }, // design doesn't have orders atm
{ id: 'lengths' },
{ id: 'lengths', width: { min: 180 }, resizable: false },
{ id: 'actions', width: 40, isAction: true }
]);
@@ -12,8 +12,9 @@
import { isRelationship, isSpatialType } from '../rows/store';
import { table, indexes } from '../store';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { IconCalendar, IconFingerPrint, IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { columnOptions as baseColumnOptions } from '../columns/store';
let {
showCreateIndex = $bindable(false),
@@ -37,7 +38,11 @@
}
return !isRelationship(column) && !isSpatialType(column); // keep non-relationship and non-spatial
})
.map((column) => ({ value: column.key, label: column.key }))
.map((column) => ({
value: column.key,
label: column.key,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)?.icon
}))
);
let columnList = $state([{ value: '', order: '', length: null }]);
@@ -154,7 +159,7 @@
]
: undefined
});
trackEvent(Submit.IndexCreate);
trackEvent(Submit.IndexCreate, { type: 'manual' });
showCreateIndex = false;
} catch (err) {
addNotification({
@@ -196,9 +201,17 @@
...(selectedType === IndexType.Spatial
? []
: [
{ value: '$id', label: '$id' },
{ value: '$createdAt', label: '$createdAt' },
{ value: '$updatedAt', label: '$updatedAt' }
{ value: '$id', label: '$id', leadingIcon: IconFingerPrint },
{
value: '$createdAt',
label: '$createdAt',
leadingIcon: IconCalendar
},
{
value: '$updatedAt',
label: '$updatedAt',
leadingIcon: IconCalendar
}
]),
...columnOptions
]}
@@ -34,8 +34,6 @@
})
)
);
await invalidate(Dependencies.TABLE);
showDelete = false;
addNotification({
type: 'success',
message:
@@ -43,7 +41,13 @@
? 'Index has been deleted'
: `${selectedKeys.length} indexes have been deleted`
});
trackEvent(Submit.IndexDelete);
await invalidate(Dependencies.TABLE);
showDelete = false;
selectedIndex = Array.isArray(selectedIndex) ? [] : null;
} catch (e) {
error = e.message;
trackError(e, Submit.IndexDelete);
@@ -5,16 +5,19 @@
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';
import type { HTMLAttributes } from 'svelte/elements';
let {
show = $bindable(false),
title,
closeOnBlur = false,
submit,
cancel,
children = null,
footer = null,
titleBadge = null,
topAction = null
topAction = null,
...restProps
}: {
show: boolean;
title: string;
@@ -36,9 +39,16 @@
onClick?: () => boolean | void | Promise<boolean | void>;
}
| undefined;
cancel?:
| {
text?: string;
disabled?: boolean;
onClick?: () => void;
}
| undefined;
children?: Snippet;
footer?: Snippet | null;
} = $props();
} & HTMLAttributes<HTMLDivElement> = $props();
let form: Form;
let submitting = $state(writable(false));
@@ -46,7 +56,7 @@
let copyText = $state(undefined);
</script>
<div class="sheet-container" data-side-sheet-visible={show}>
<div class="sheet-container" data-side-sheet-visible={show} {...restProps}>
<Sheet bind:open={show} {closeOnBlur}>
<div slot="header" style:width="100%">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
@@ -110,8 +120,19 @@
{#if footer}
{@render footer?.()}
{/if}
<Button size="s" secondary on:click={() => (show = false)}
>Cancel</Button>
<Button
size="s"
secondary
disabled={cancel?.disabled}
on:click={() => {
if (cancel?.onClick) {
cancel.onClick();
} else {
show = false;
}
}}>{cancel?.text ?? 'Cancel'}</Button>
<Button
size="s"
submit