Merge branch 'main' of github.com:appwrite/console

This commit is contained in:
Christy Jacob
2025-10-26 13:05:45 +05:30
175 changed files with 3316 additions and 1333 deletions
+5 -1
View File
@@ -41,6 +41,7 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
@@ -82,6 +83,7 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
@@ -120,7 +122,8 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=self-hosted"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
@@ -159,6 +162,7 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
+3 -1
View File
@@ -11,7 +11,7 @@ RUN corepack prepare pnpm@10.0.0 --activate
ADD ./package.json /app/package.json
ADD ./pnpm-lock.yaml /app/pnpm-lock.yaml
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile
ADD ./build.js /app/build.js
ADD ./tsconfig.json /app/tsconfig.json
@@ -23,6 +23,7 @@ ADD ./static /app/static
ARG PUBLIC_CONSOLE_MODE
ARG PUBLIC_CONSOLE_FEATURE_FLAGS
ARG PUBLIC_APPWRITE_MULTI_REGION
ARG PUBLIC_CONSOLE_EMAIL_VERIFICATION
ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
@@ -35,6 +36,7 @@ ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
ENV PUBLIC_CONSOLE_FEATURE_FLAGS=$PUBLIC_CONSOLE_FEATURE_FLAGS
ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION
ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION
ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
+2
View File
@@ -28,6 +28,8 @@ async function main() {
logEnv('MULTI REGION', env?.PUBLIC_APPWRITE_MULTI_REGION);
logEnv('APPWRITE ENDPOINT', env?.PUBLIC_APPWRITE_ENDPOINT, 'relative');
logEnv('GROWTH ENDPOINT', env?.PUBLIC_GROWTH_ENDPOINT);
logEnv('CONSOLE EMAIL VERIFICATION', env?.PUBLIC_CONSOLE_EMAIL_VERIFICATION);
logEnv('CONSOLE MOCK AI SUGGESTIONS', env?.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS);
log();
logDelimiter();
await build();
+14 -2
View File
@@ -17,10 +17,22 @@ export async function createFreeProject(page: Page): Promise<Metadata> {
await page.waitForURL(/\/organization-[^/]+/);
await page.getByRole('button', { name: 'create project' }).first().click();
const dialog = page.locator('dialog[open]');
await dialog.getByPlaceholder('Project name').fill('test project');
let region = 'fra'; // for fallback
const regionPicker = dialog.locator('button[role="combobox"]');
if (await regionPicker.isVisible()) {
await regionPicker.click();
await page.getByRole('option', { name: /New York/i }).click();
region = 'nyc';
}
await dialog.getByRole('button', { name: 'create' }).click();
await page.waitForURL(/\/project-fra-[^/]+/);
expect(page.url()).toContain('/console/project-fra-');
await page.waitForURL(new RegExp(`/project-${region}-[^/]+`));
expect(page.url()).toContain(`/console/project-${region}-`);
return getProjectIdFromUrl(page.url());
});
+13 -2
View File
@@ -50,10 +50,21 @@ export async function createProProject(page: Page): Promise<Metadata> {
await page.waitForURL(/\/organization-[^/]+/);
await page.getByRole('button', { name: 'create project' }).first().click();
const dialog = page.locator('dialog[open]');
await dialog.getByPlaceholder('Project name').fill('test project');
let region = 'fra'; // for fallback
const regionPicker = dialog.locator('button[role="combobox"]');
if (await regionPicker.isVisible()) {
await regionPicker.click();
await page.getByRole('option', { name: /New York/i }).click();
region = 'nyc';
}
await dialog.getByRole('button', { name: 'create' }).click();
await page.waitForURL(/\/project-fra-[^/]+/);
expect(page.url()).toContain('/console/project-fra-');
await page.waitForURL(new RegExp(`/project-${region}-[^/]+`));
expect(page.url()).toContain(`/console/project-${region}-`);
return getProjectIdFromUrl(page.url());
});
+4 -4
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@fe3277e",
"@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@50b60cc",
"@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@10305c4",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
@@ -52,7 +52,7 @@
"@eslint/js": "^9.31.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.5",
"@playwright/test": "^1.51.1",
"@playwright/test": "^1.55.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.42.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
+45 -45
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@fe3277e
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e
'@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@50b60cc
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc(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@10305c4
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4(svelte@5.25.3)
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
@@ -97,8 +97,8 @@ importers:
specifier: ^0.86.5
version: 0.86.5(svelte@5.25.3)
'@playwright/test':
specifier: ^1.51.1
version: 1.51.1
specifier: ^1.55.1
version: 1.56.1
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.8(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.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@fe3277e':
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e}
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@50b60cc':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc}
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@10305c4':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4}
version: 2.0.0-RC.2
peerDependencies:
svelte: ^4.0.0
@@ -335,8 +335,8 @@ packages:
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.25.9':
@@ -352,8 +352,8 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/parser@7.28.4':
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
'@babel/parser@7.28.5':
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -373,8 +373,8 @@ packages:
resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.4':
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
'@babel/types@7.28.5':
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
engines: {node: '>=6.9.0'}
'@csstools/color-helpers@5.0.2':
@@ -993,8 +993,8 @@ packages:
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@playwright/test@1.51.1':
resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
@@ -2607,8 +2607,8 @@ packages:
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@0.30.7:
resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==}
@@ -2830,13 +2830,13 @@ packages:
resolution: {integrity: sha512-hMhneYm3GCPyQon88SZrVJx+LlqhM1kZFQbuAgXPoh/Az2YvO1B6bitT9qlhpiTdJlsT5lsr3gPmzoVjb5CDXA==}
engines: {node: '>=10'}
playwright-core@1.51.1:
resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==}
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.51.1:
resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==}
playwright@1.56.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
engines: {node: '>=18'}
hasBin: true
@@ -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@fe3277e': {}
'@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@50b60cc(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@10305c4(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
@@ -3812,7 +3812,7 @@ snapshots:
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/helper-validator-option@7.25.9': {}
@@ -3825,9 +3825,9 @@ snapshots:
dependencies:
'@babel/types': 7.27.0
'@babel/parser@7.28.4':
'@babel/parser@7.28.5':
dependencies:
'@babel/types': 7.28.4
'@babel/types': 7.28.5
'@babel/runtime@7.27.0':
dependencies:
@@ -3856,10 +3856,10 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/types@7.28.4':
'@babel/types@7.28.5':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@csstools/color-helpers@5.0.2': {}
@@ -4453,9 +4453,9 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.1
optional: true
'@playwright/test@1.51.1':
'@playwright/test@1.56.1':
dependencies:
playwright: 1.51.1
playwright: 1.56.1
'@polka/url@1.0.0-next.28': {}
@@ -5124,7 +5124,7 @@ snapshots:
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.28.4
'@babel/parser': 7.28.5
'@vue/shared': 3.5.13
entities: 4.5.0
estree-walker: 2.0.2
@@ -5137,13 +5137,13 @@ snapshots:
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.28.4
'@babel/parser': 7.28.5
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
magic-string: 0.30.19
magic-string: 0.30.21
postcss: 8.5.6
source-map-js: 1.2.1
@@ -6285,7 +6285,7 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magic-string@0.30.19:
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -6486,11 +6486,11 @@ snapshots:
plausible-tracker@0.3.9: {}
playwright-core@1.51.1: {}
playwright-core@1.56.1: {}
playwright@1.51.1:
playwright@1.56.1:
dependencies:
playwright-core: 1.51.1
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
+24
View File
@@ -7,6 +7,30 @@
content="Appwrite is an open-source platform for building applications at any scale, using your preferred programming languages and tools." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/logos/appwrite-icon.svg" />
<link rel="mask-icon" type="image/png" href="%sveltekit.assets%/logos/appwrite-icon.png" />
<!-- apple touch icons for ios/ipados -->
<link
rel="apple-touch-icon"
sizes="180x180"
href="%sveltekit.assets%/logos/apple-touch-icon-180x180.png" />
<link
rel="apple-touch-icon"
sizes="167x167"
href="%sveltekit.assets%/logos/apple-touch-icon-167x167.png" />
<link
rel="apple-touch-icon"
sizes="152x152"
href="%sveltekit.assets%/logos/apple-touch-icon-152x152.png" />
<link
rel="apple-touch-icon"
sizes="120x120"
href="%sveltekit.assets%/logos/apple-touch-icon-120x120.png" />
<!-- apple web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Appwrite Console" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="stylesheet" href="%sveltekit.assets%/css/loading.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
@@ -1,79 +1,195 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { invalidate, goto } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { get } from 'svelte/store';
import { page } from '$app/state';
import Link from '$lib/elements/link.svelte';
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Dependencies } from '$lib/constants';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import { slide } from 'svelte/transition';
let { show = $bindable(false) } = $props();
let {
show = $bindable(false),
email
}: {
show?: boolean;
email?: string;
} = $props();
let error = $state(null);
let creating = $state(false);
let emailSent = $state(false);
let resendTimer = $state(0);
let timerInterval: ReturnType<typeof setInterval> | null = null;
let cleanUrl = $derived(page.url.origin + page.url.pathname);
async function logout() {
error = null;
try {
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
await invalidate(Dependencies.ACCOUNT);
await goto(resolve('/login'));
} catch (err) {
error = err.message;
}
}
const cleanUrl = $derived(page.url.origin + page.url.pathname);
// manage resend timer in localStorage
const EMAIL_SENT_KEY = 'email_verification_sent';
const TIMER_END_KEY = 'email_verification_timer_end';
function startResendTimer() {
resendTimer = 60;
emailSent = true;
const timerEndTime = Date.now() + 60 * 1000;
if (browser) {
localStorage.setItem(EMAIL_SENT_KEY, 'true');
localStorage.setItem(TIMER_END_KEY, timerEndTime.toString());
}
startTimerCountdown(timerEndTime);
}
function restoreTimerState() {
if (!browser) return;
const savedTimerEnd = localStorage.getItem(TIMER_END_KEY);
const savedEmailSent = localStorage.getItem(EMAIL_SENT_KEY);
if (savedTimerEnd && savedEmailSent) {
const timerEndTime = parseInt(savedTimerEnd);
const now = Date.now();
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000));
if (remainingTime > 0) {
resendTimer = remainingTime;
emailSent = true;
startTimerCountdown(timerEndTime);
} else {
// timer has expired, clean up
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
resendTimer = 0;
emailSent = false;
}
}
}
function startTimerCountdown(timerEndTime: number) {
timerInterval = setInterval(() => {
const now = Date.now();
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000));
resendTimer = remainingTime;
if (remainingTime <= 0) {
clearInterval(timerInterval);
timerInterval = null;
if (browser) {
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
}
}
}, 1000);
}
async function onSubmit() {
if (creating) return;
if (creating || resendTimer > 0) return;
error = null;
creating = true;
try {
await sdk.forConsole.account.createVerification({ url: cleanUrl });
addNotification({ message: 'Verification email has been sent', type: 'success' });
emailSent = true;
show = false;
} catch (error) {
addNotification({ message: error.message, type: 'error' });
startResendTimer();
} catch (err) {
error = err.message;
} finally {
creating = false;
}
}
async function updateEmailVerification() {
const searchParams = page.url.searchParams;
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
onMount(restoreTimerState);
if (userId && secret) {
try {
await sdk.forConsole.account.updateVerification({ userId, secret });
addNotification({
message: 'Email verified successfully',
type: 'success'
});
await Promise.all([
invalidate(Dependencies.ACCOUNT),
invalidate(Dependencies.FACTORS)
]);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
}
onDestroy(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
}
onMount(() => {
updateEmailVerification();
if (browser) {
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
}
});
</script>
<Modal bind:show title="Send verification email" {onSubmit}>
<Card.Base variant="secondary" padding="s">
<Layout.Stack gap="m">
<Typography.Text gap="m">
To continue using Appwrite Cloud, please verify your email address. An email will be
sent to <Typography.Text variant="m-600" style="display: inline;"
>{get(user)?.email}</Typography.Text>
</Typography.Text>
</Layout.Stack>
</Card.Base>
<div class="email-verification-scrim">
<Modal
bind:show
bind:error
title="Verify your email address"
{onSubmit}
dismissible={false}
autoClose={false}
backdrop={false}>
<Card.Base variant="secondary" padding="s">
<Layout.Stack gap="xxs">
<Typography.Text gap="m">
To continue using Appwrite Cloud, please verify your email address. An email
will be sent to <Typography.Text
variant="m-600"
color="neutral-secondary"
style="display: inline;">{email || get(user)?.email}</Typography.Text>
</Typography.Text>
<svelte:fragment slot="footer">
<Button submit disabled={creating}>{emailSent ? 'Resend email' : 'Send email'}</Button>
</svelte:fragment>
</Modal>
<Link variant="default" on:click={() => logout()}>Switch account</Link>
{#if emailSent && resendTimer > 0}
<div transition:slide={{ duration: 150 }}>
<Typography.Text
color="neutral-secondary"
style="margin-block-start: var(--gap-L, 16px);">
Didn't get the email? Try again in {resendTimer}s
</Typography.Text>
</div>
{/if}
</Layout.Stack>
</Card.Base>
<svelte:fragment slot="footer">
<Button
submit
submissionLoader
forceShowLoader={creating}
disabled={creating || resendTimer > 0}>
{emailSent ? 'Resend email' : 'Send email'}
</Button>
</svelte:fragment>
</Modal>
</div>
<style>
.email-verification-scrim {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: hsl(240 5% 8% / 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
/* avoids the background scroll and bars */
:global(html:has(.email-verification-scrim)) {
height: 100%;
overflow: hidden !important;
}
</style>
@@ -1,42 +0,0 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
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 { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard';
import { isCloud, VARS } from '$lib/system';
const hasUser = $derived(!!$user);
const needsEmailVerification = $derived(hasUser && !$user.emailVerification);
const notOnOnboarding = $derived(!$page.route.id.includes('/onboarding'));
const notOnWizard = $derived(!$wizard.show && !$isNewWizardStatusOpen);
const isEnabledViaEnvConfig = $derived(VARS.EMAIL_VERIFICATION);
const shouldShowEmailBanner = $derived(
isEnabledViaEnvConfig &&
isCloud &&
hasUser &&
needsEmailVerification &&
notOnOnboarding &&
notOnWizard
);
let showSendVerification = $state(false);
</script>
{#if shouldShowEmailBanner}
<HeaderAlert type="warning" title="Your email address needs to be verified">
<svelte:fragment>
To avoid losing access to your projects, make sure <Typography.Text
variant="m-500"
style="display:inline">{$user.email}</Typography.Text> is valid and up to date. Email
verification will be required soon.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button secondary size="s" on:click={() => (showSendVerification = true)}
>Verify email</Button>
</svelte:fragment>
</HeaderAlert>
<SendVerificationEmailModal bind:show={showSendVerification} />
{/if}
+1 -1
View File
@@ -169,7 +169,7 @@
migrations.migrations.forEach(updateOrAddItem);
});
return sdk.forConsoleIn(page.params.region).client.subscribe('console', (response) => {
return sdk.forConsoleIn(page.params.region).realtime.subscribe('console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
updateOrAddItem(response.payload as Payload);
+3 -1
View File
@@ -27,10 +27,12 @@
id = null;
}
if (!id?.length) {
if (id !== null && !id.length) {
id = null;
}
});
$effect(() => {
if (show) {
trackEvent(Click.ShowCustomIdClick);
}
@@ -13,6 +13,7 @@
import { Click, trackEvent } from '$lib/actions/analytics';
import RepositoryBehaviour from '$lib/components/git/repositoryBehaviour.svelte';
import { page } from '$app/state';
import { connectGitHub } from '$lib/stores/git';
let {
show = $bindable(false),
@@ -121,7 +122,7 @@
<svelte:fragment slot="footer">
{#if repositoryBehaviour === 'existing'}
<Layout.Stack>
<Link variant="quiet" href="#/">
<Link variant="quiet" href={connectGitHub(callbackState).toString()}>
<Layout.Stack direction="row" gap="xs">
Missing a repository? check your permissions <Icon
icon={IconArrowSmRight} />
+1 -1
View File
@@ -85,5 +85,5 @@ export { default as ViewToggle } from './viewToggle.svelte';
export { default as RegionEndpoint } from './regionEndpoint.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
+2 -1
View File
@@ -14,6 +14,7 @@
};
export let title = '';
export let hideFooter = false;
export let backdrop: boolean = true;
let alert: HTMLElement;
@@ -29,7 +30,7 @@
</script>
<Form isModal {onSubmit}>
<Modal {size} {title} bind:open={show} {hideFooter} {dismissible}>
<Modal {backdrop} {size} {title} bind:open={show} {hideFooter} {dismissible}>
<slot slot="description" name="description" />
{#if error}
<div bind:this={alert}>
+281 -83
View File
@@ -1,11 +1,14 @@
<script lang="ts">
import { type ComponentProps, type Snippet } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import type { Models } from '@appwrite.io/console';
import { AvatarInitials } from '../';
import { isSmallViewport } from '$lib/stores/viewport';
import {
Button,
Badge,
Divider,
Icon,
InteractiveText,
Layout,
Link,
Popover,
@@ -13,31 +16,118 @@
Typography
} from '@appwrite.io/pink-svelte';
import Avatar from '../avatar.svelte';
import { IconAnonymous, IconExternalLink, IconMinusSm } from '@appwrite.io/pink-icons-svelte';
import { base } from '$app/paths';
import { IconAnonymous, IconMinusSm } from '@appwrite.io/pink-icons-svelte';
import { page } from '$app/state';
import { menuOpen } from '$lib/components/menu/store';
import { base } from '$app/paths';
import { formatName } from '$lib/helpers/string';
export let role: string;
type PermissionData = Partial<Models.User & Models.Team> & {
notFound?: boolean;
roleName?: string;
customName?: string;
};
async function getData(
permission: string
): Promise<
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>>
> {
const role = permission.split(':')[0];
const id = permission.split(':')[1].split('/')[0];
if (role === 'user') {
const user = await sdk
.forProject(page.params.region, page.params.project)
.users.get({ userId: id });
return user;
const permissionDataCache: Map<string, Promise<PermissionData>> = new Map();
interface Props {
role: string;
placement?: ComponentProps<Popover>['placement'];
children?: Snippet;
}
let { role, placement = 'bottom-start', children }: Props = $props();
type ParsedPermission = {
type: 'user' | 'team' | 'other';
id: string;
roleName?: string;
isValid: boolean;
};
function parsePermission(permission: string): ParsedPermission {
try {
const [type, rest] = permission.split(':');
if (!rest) {
return { type: 'other', id: permission, isValid: false };
}
const [id, roleName] = rest.split('/');
if (!id) {
return { type: 'other', id: permission, isValid: false };
}
if (type === 'user' || type === 'team') {
return {
type: type as 'user' | 'team',
id,
roleName,
isValid: true
};
}
return { type: 'other', id: permission, isValid: false };
} catch (error) {
return { type: 'other', id: permission, isValid: false };
}
if (role === 'team') {
const team = await sdk
.forProject(page.params.region, page.params.project)
.teams.get({ teamId: id });
return team;
}
async function fetchPermissionData(parsed: ParsedPermission): Promise<PermissionData> {
if (!parsed.isValid || parsed.type === 'other') {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
if (parsed.type === 'user') {
try {
return await sdk
.forProject(page.params.region, page.params.project)
.users.get({ userId: parsed.id });
} catch (error) {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
}
if (parsed.type === 'team') {
try {
return await sdk
.forProject(page.params.region, page.params.project)
.teams.get({ teamId: parsed.id });
} catch (error) {
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
}
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
}
async function getData(permission: string): Promise<PermissionData> {
const cached = permissionDataCache.get(permission);
if (cached) return cached;
const parsed = parsePermission(permission);
const fetchPromise = fetchPermissionData(parsed);
permissionDataCache.set(permission, fetchPromise);
return fetchPromise;
}
let isMouseOverTooltip = $state(false);
function hidePopover(hideTooltip: () => void, timeout = true) {
if (!timeout) {
isMouseOverTooltip = false;
return hideTooltip();
}
setTimeout(() => {
if (!isMouseOverTooltip) {
hideTooltip();
}
}, 150);
}
function isCustomPermission(role: string): boolean {
const parsed = parsePermission(role);
return !!parsed.roleName || !parsed.isValid;
}
</script>
@@ -48,70 +138,178 @@
{:else if role === 'any'}
<div>Any</div>
{:else}
<Popover let:toggle placement="bottom-start">
<Link.Button on:click={toggle}>{role}</Link.Button>
<div let:showing slot="tooltip" style:width="200px">
{#key showing}
{#await getData(role)}
<Layout.Stack alignItems="center">
<Spinner />
</Layout.Stack>
{:then data}
{@const isUser = role.startsWith('user')}
{@const isTeam = role.startsWith('team')}
{@const isAnonymous = !data.email && !data.phone && !data.name && isUser}
<Layout.Stack>
<Layout.Stack direction="row" gap="s" alignItems="center">
{#if isAnonymous}
<Avatar alt="avatar" size="xs">
<Icon icon={IconAnonymous} size="s" />
</Avatar>
{:else if data.name}
<AvatarInitials name={data.name} size="xs" />
{:else}
<Avatar alt="avatar" size="xs">
<Icon icon={IconMinusSm} size="s" />
</Avatar>
{/if}
<Typography.Text truncate color="--fgcolor-neutral-primary">
{data.name ?? data?.email ?? data?.phone ?? '-'}
</Typography.Text>
</Layout.Stack>
<Popover let:show let:hide {placement} portal>
<button
onmouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
}
}}
onmouseleave={() => hidePopover(hide)}>
{@render children?.()}
{#if isCustomPermission(role)}
<Typography.Text style="text-decoration: underline;">
{formatName(role, $isSmallViewport ? 8 : 15)}
</Typography.Text>
{:else}
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
<Typography.Text>
{#await getData(role)}
{role}
{:then data}
{formatName(
data.name ?? data?.email ?? data?.phone ?? '-',
$isSmallViewport ? 5 : 7
)}
{/await}
</Typography.Text>
<Badge
size="xs"
variant="secondary"
content={role.startsWith('user') ? 'User' : 'Team'} />
</Layout.Stack>
{/if}
</button>
<Divider />
{#if isUser}
{#if data?.email}
<Typography.Text truncate>Email: {data?.email}</Typography.Text>
{/if}
{#if data?.phone}
<Typography.Text truncate>Phone: {data?.phone}</Typography.Text>
{/if}
<div>
<Button.Anchor
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${data?.$id}`}
size="xs"
target="_blank"
variant="secondary">
View user
<Icon slot="end" icon={IconExternalLink} size="s" />
</Button.Anchor>
</div>
{:else if isTeam}
<Typography.Text>Members: {data?.total}</Typography.Text>
<div>
<Button.Anchor
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${data?.$id}`}
size="s"
target="_blank"
variant="secondary">
View team
<Icon slot="end" icon={IconExternalLink} size="s" />
</Button.Anchor>
</div>
<div
let:hide
let:showing
slot="tooltip"
role="tooltip"
class="popover"
onmouseenter={() => (isMouseOverTooltip = true)}
onmouseleave={() => hidePopover(hide, false)}>
{#if showing}
<Layout.Stack gap="s" alignContent="flex-start">
{#await getData(role)}
<Layout.Stack alignItems="center">
<Spinner />
</Layout.Stack>
{:then data}
{#if data.notFound}
<Layout.Stack gap="s" alignItems="flex-start">
<Layout.Stack
direction="row"
gap="s"
alignItems="center"
justifyContent="flex-start">
<Avatar alt="avatar" size="m">
<Icon icon={IconMinusSm} size="s" />
</Avatar>
<Layout.Stack alignItems="flex-start" gap="xxs">
<Layout.Stack style="padding-left: 0.25rem;">
<Typography.Text
size="s"
color="--fgcolor-neutral-primary">
{data.customName}
</Typography.Text>
</Layout.Stack>
{#if data.roleName}
<InteractiveText
isVisible
variant="copy"
text={data.roleName}
value={data.roleName} />
{:else}
<InteractiveText
isVisible
variant="copy"
text={role}
value={role} />
{/if}
</Layout.Stack>
</Layout.Stack>
</Layout.Stack>
{:else}
{@const isUser = role.startsWith('user')}
{@const isAnonymous =
!data.email && !data.phone && !data.name && isUser}
{@const parsed = parsePermission(role)}
{@const id = parsed.id}
<Layout.Stack gap="s" alignItems="flex-start">
<Layout.Stack
direction="row"
gap="s"
alignItems="center"
justifyContent="flex-start">
{#if isAnonymous}
<Avatar alt="avatar" size="m">
<Icon icon={IconAnonymous} size="s" />
</Avatar>
{:else if data.name}
<AvatarInitials name={data.name} size="m" />
{:else}
<Avatar alt="avatar" size="m">
<Icon icon={IconMinusSm} size="s" />
</Avatar>
{/if}
<Layout.Stack alignItems="flex-start" gap="xxs">
<Layout.Stack style="padding-left: 0.25rem;">
<Link.Anchor
variant="quiet"
href={role.startsWith('user')
? `${base}/project-${page.params.region}-${page.params.project}/auth/user-${id}`
: `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${id}`}>
<Typography.Text
size="s"
color="--fgcolor-neutral-primary">
{formatName(
data.name ??
data?.email ??
data?.phone ??
'-',
$isSmallViewport ? 12 : 20
)}
</Typography.Text>
</Link.Anchor>
</Layout.Stack>
<InteractiveText
isVisible
variant="copy"
text={id}
value={id} />
</Layout.Stack>
</Layout.Stack>
{#if isUser && (data.email || data.phone)}
<Divider />
<Layout.Stack gap="xs" alignItems="flex-start">
{#if data.email}
<Typography.Text
size="xs"
color="--fgcolor-neutral-secondary">
Email: {data.email}
</Typography.Text>
{/if}
{#if data.phone}
<Typography.Text
size="xs"
color="--fgcolor-neutral-secondary">
Phone: {data.phone}
</Typography.Text>
{/if}
</Layout.Stack>
{/if}
</Layout.Stack>
{/if}
</Layout.Stack>
{/await}
{/key}
{/await}
</Layout.Stack>
{/if}
</div>
</Popover>
{/if}
<style lang="scss">
.popover {
display: flex;
width: 280px;
min-width: 260px;
padding: var(--space-5, 10px) var(--space-6, 12px);
align-items: flex-start;
gap: var(--gap-XXS, 4px);
margin: -1rem;
}
</style>
+18 -17
View File
@@ -4,7 +4,7 @@
export let id: string;
export let label: string = '';
export let value: string;
export let value: string | null;
export let required = false;
export let nullable = false;
export let disabled = false;
@@ -17,6 +17,7 @@
let error: string;
let element: HTMLInputElement;
let previousValue: string | null = null;
onMount(() => {
if (element && autofocus) {
@@ -24,14 +25,16 @@
}
});
let prevValue = '';
function handleNullChange(e: CustomEvent<boolean>) {
const isNull = e.detail;
if (isNull) {
prevValue = value;
if (value !== null) {
previousValue = value;
}
value = null;
} else {
value = prevValue;
value = previousValue;
}
}
@@ -39,9 +42,7 @@
error = null;
}
function onChange(event: CustomEvent) {
value = (event.target as HTMLInputElement).value;
}
$: isValueNull = value === null;
</script>
<Layout.Stack gap="s" direction="row">
@@ -51,20 +52,20 @@
{disabled}
{readonly}
{required}
{value}
bind:value
{step}
{type}
helper={error}
{leadingIcon}
on:change={onChange}
autocomplete={autocomplete ? 'on' : 'off'}>
{#if nullable}
<Selector.Checkbox
size="s"
slot="end"
label="NULL"
checked={value === null}
on:change={handleNullChange} />
{/if}
<svelte:fragment slot="end">
{#if nullable}
<Selector.Checkbox
size="s"
label="NULL"
checked={isValueNull}
on:change={handleNullChange} />
{/if}
</svelte:fragment>
</Input.DateTime>
</Layout.Stack>
+1 -5
View File
@@ -12,11 +12,7 @@
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
event.preventDefault();
if (event.currentTarget.validity.patternMismatch) {
error = true;
return;
}
error = true; // show on any error
};
$: if (value) {
+1 -5
View File
@@ -12,11 +12,7 @@
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
event.preventDefault();
if (event.currentTarget.validity.patternMismatch) {
error = true;
return;
}
error = true; // show on any error
};
$: if (value) {
+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 });
+21 -4
View File
@@ -18,21 +18,38 @@ export function toDecimals(num: number, decimals: number = 1): number {
return parseFloat(num.toFixed(decimals));
}
export function formatNumberWithCommas(number: number): string {
export function formatNumberWithCommas(number: number, min: number = 0): string {
if (isNaN(number)) return String(number);
const formatter = new Intl.NumberFormat('en');
return formatter.format(number);
return formatter.format(clampMin(number, min));
}
export function formatCurrency(number: number, locale = 'en-US', currency = 'USD'): string {
export function formatCurrency(
number: number,
locale = 'en-US',
currency = 'USD',
min: number = 0
): string {
if (isNaN(number)) return String(number);
const formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency
});
return formatter.format(number);
return formatter.format(clampMin(number, min));
}
export function isWithinSafeRange(val: number) {
return Math.abs(val) < Number.MAX_SAFE_INTEGER;
}
/**
* Clamps a number to a minimum value
*
* @export
* @param {number} value
* @param {number} min
* @returns {number}
*/
export function clampMin(value: number, min: number = 0): number {
return Math.max(min, value || 0);
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { clampMin } from './numbers';
/**
* Capitalizes the first letter of a string
*
@@ -45,8 +47,8 @@ const formatter = Intl.NumberFormat('en', {
notation: 'compact'
});
export function formatNum(number: number): string {
return formatter.format(number);
export function formatNum(number: number, min: number = 0): string {
return formatter.format(clampMin(number, min));
}
/**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+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)} />
-8
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { navigationCancelled } from '$lib/stores/navigation';
import { afterNavigate, beforeNavigate } from '$app/navigation';
const minimum = 0.08;
@@ -98,13 +97,6 @@
complete();
});
navigationCancelled.subscribe((cancelled) => {
if (cancelled) {
complete();
navigationCancelled.set(false);
}
});
$: barStyle = (width && width * 100 && `width: ${width * 100}%;`) || '';
</script>
@@ -106,11 +106,11 @@
{#if hasSearch}
<SearchQuery placeholder={searchPlaceholder} />
{/if}
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
{#if hasFilters && $columns?.length}
<QuickFilters {columns} {analyticsSource} {filterCols} />
{/if}
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
{#if hasDisplaySettings}
<ViewSelector ui="new" {view} {columns} {hideView} {hideColumns} />
{/if}
+2 -2
View File
@@ -4,7 +4,7 @@
import { Card, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { page } from '$app/state';
import { type Models } from '@appwrite.io/console';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { formatNumberWithCommas, clampMin } from '$lib/helpers/numbers';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
export let title: string;
@@ -41,7 +41,7 @@
<Card>
{#if count}
{@const totalCount = total.reduce((a, b) => a + b, 0)}
{@const totalCount = clampMin(total.reduce((a, b) => a + b, 0))}
<Layout.Stack gap="xs">
<Typography.Title>{formatNumberWithCommas(totalCount)}</Typography.Title>
+19 -8
View File
@@ -21,14 +21,21 @@
</script>
<Layout.Stack>
<header class="form-header" class:hide-divider={!$$slots.subtitle}>
<Typography.Title><slot name="title" /></Typography.Title>
{#if $$slots.subtitle}
<p>
<slot name="subtitle" />
</p>
{/if}
</header>
{#if $$slots.title || $$slots.subtitle}
<header
class="form-header"
class:hide-divider={!$$slots.subtitle}
class:only-subtitle={!$$slots.title && $$slots.subtitle}>
{#if $$slots.title}
<Typography.Title><slot name="title" /></Typography.Title>
{/if}
{#if $$slots.subtitle}
<p>
<slot name="subtitle" />
</p>
{/if}
</header>
{/if}
<slot />
</Layout.Stack>
@@ -42,4 +49,8 @@
padding-block-end: 0;
border-block-end: none;
}
.only-subtitle {
margin-block-end: 0.5rem;
}
</style>
-3
View File
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const navigationCancelled = writable(false);
+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;
};
+2
View File
@@ -224,6 +224,8 @@ function createPreferences() {
delete teamPreferences?.displayNames?.[tableId];
delete teamPreferences?.columnOrder?.[tableId];
delete teamPreferences?.columnWidths?.[tableId];
delete teamPreferences?.columnWidths?.[tableId + '#columns'];
delete teamPreferences?.columnWidths?.[tableId + '#indexes'];
const removeTablePreferences = sdk.forConsole.teams.updatePrefs({
teamId: orgId,
+10 -3
View File
@@ -21,7 +21,8 @@ import {
Sites,
Tokens,
TablesDB,
Domains
Domains,
Realtime
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Backups } from '../sdk/backups';
@@ -90,7 +91,8 @@ function createConsoleSdk(client: Client) {
sources: new Sources(client),
sites: new Sites(client),
domains: new Domains(client),
storage: new Storage(client)
storage: new Storage(client),
realtime: new Realtime(client)
};
}
@@ -103,8 +105,8 @@ const clientProject = new Client();
const clientRealtime = new Client();
if (!building) {
scopedConsoleClient.setProject('console');
clientConsole.setEndpoint(endpoint).setProject('console');
scopedConsoleClient.setMode(endpoint).setProject('console');
clientRealtime.setEndpoint(endpoint).setProject('console');
clientProject.setEndpoint(endpoint).setMode('admin');
@@ -185,6 +187,11 @@ export enum RuleTrigger {
MANUAL = 'manual'
}
/**
* Some type imports are broken on the SDK, this works correctly for the time being!
*/
export type AppwriteRealtimeSubscription = Awaited<ReturnType<Realtime['subscribe']>>;
export const createAdminClient = () => {
return new Client().setEndpoint(getApiEndpoint()).setMode('admin').setProject(getProjectId());
};
+2
View File
@@ -26,6 +26,8 @@ export function getFrameworkIcon(framework: string) {
return 'vite';
case framework.toLocaleLowerCase().includes('lynx'):
return 'lynx';
case framework.toLocaleLowerCase().includes('tanstack'):
return 'tanstack';
case framework.toLocaleLowerCase().includes('other'):
return 'empty';
+1 -3
View File
@@ -45,7 +45,7 @@
import { headerAlert } from '$lib/stores/headerAlert';
import { UsageRates } from '$lib/components/billing';
import { canSeeProjects } from '$lib/stores/roles';
import { BottomModalAlert, EmailVerificationBanner } from '$lib/components';
import { BottomModalAlert } from '$lib/components';
import {
IconAnnotation,
IconBookOpen,
@@ -346,8 +346,6 @@
<Footer slot="footer" />
</Shell>
<EmailVerificationBanner />
{#if $wizard.show && $wizard.component}
<svelte:component this={$wizard.component} {...$wizard.props} />
{:else if $wizard.cover}
+10 -54
View File
@@ -1,76 +1,32 @@
import { isCloud } from '$lib/system';
import { isSameDay } from '$lib/helpers/date';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
import SpatialColumnsLight from '$lib/images/promos/spatial-columns-api-light.png';
import SpatialColumnsDark from '$lib/images/promos/spatial-columns-api-dark.png';
import InversionQueriesDark from '$lib/images/promos/inversion-queries-dark.png';
import InversionQueriesLight from '$lib/images/promos/inversion-queries-light.png';
import TimeHelperQueriesDark from '$lib/images/promos/time-helper-queries-dark.png';
import TimeHelperQueriesLight from '$lib/images/promos/time-helper-queries-light.png';
import TransactionsApiDark from '$lib/images/promos/transactions-api-dark.png';
import TransactionsApiLight from '$lib/images/promos/transactions-api-light.png';
const listOfPromotions: BottomModalAlertItem[] = [];
if (isCloud) {
const spatialColumnsPromo: BottomModalAlertItem = {
id: 'modal:spatial_columns_announcement',
const transactionsApiPromo: BottomModalAlertItem = {
id: 'modal:transactions_api_announcement',
src: {
dark: SpatialColumnsDark,
light: SpatialColumnsLight
dark: TransactionsApiDark,
light: TransactionsApiLight
},
title: 'Announcing API for spatial columns',
message: 'Store and query geo data directly in your database.',
title: 'Announcing Transactions API',
message: 'Ensure data consistency across tables with atomic, all-or-nothing commits.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-spatial-columns',
link: () => 'https://appwrite.io/blog/post/announcing-transactions-api',
external: true,
hideOnClick: true
},
show: true
};
const inversionQueriesPromo: BottomModalAlertItem = {
id: 'modal:inversion_queries_announcement',
src: {
dark: InversionQueriesDark,
light: InversionQueriesLight
},
title: 'Announcing inversion queries',
message: 'New NOT operators to exclude data directly in queries.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-inversion-queries',
external: true,
hideOnClick: true
},
show: true
};
const timeHelperQueriesPromo: BottomModalAlertItem = {
id: 'modal:time_helper_queries_announcement',
src: {
dark: TimeHelperQueriesDark,
light: TimeHelperQueriesLight
},
title: 'Announcing Time helper queries',
message: 'New before/after filters for simpler time-based queries.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-time-helper-queries',
external: true,
hideOnClick: true
},
show: true
};
listOfPromotions.push(spatialColumnsPromo, inversionQueriesPromo, timeHelperQueriesPromo);
listOfPromotions.push(transactionsApiPromo);
}
export function addBottomModalAlerts() {
@@ -83,12 +83,13 @@
}
$: projectCreationDisabled =
(isCloud && getServiceLimit('projects') <= data.projects.total) ||
(isCloud && getServiceLimit('projects', null, data.currentPlan) <= data.projects.total) ||
(isCloud && $readOnly && !GRACE_PERIOD_OVERRIDE) ||
!$canWriteProjects;
$: reachedProjectLimit = isCloud && getServiceLimit('projects') <= data.projects.total;
$: projectsLimit = getServiceLimit('projects');
$: reachedProjectLimit =
isCloud && getServiceLimit('projects', null, data.currentPlan) <= data.projects.total;
$: projectsLimit = getServiceLimit('projects', null, data.currentPlan);
$: $registerCommands([
{
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import { CardGrid, PaginationInline } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Invoice, InvoiceList } from '$lib/sdk/billing';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
@@ -112,8 +112,9 @@
{#each invoiceList?.invoices as invoice (invoice.$id)}
{@const status = invoice.status}
<Table.Row.Base {root}>
<Table.Cell column="dueDate" {root}
>{toLocaleDate(invoice.dueAt)}</Table.Cell>
<Table.Cell column="dueDate" {root}>
<DualTimeView time={invoice.dueAt} />
</Table.Cell>
<Table.Cell column="status" {root}>
{@const isDanger =
status === 'overdue' ||
@@ -16,7 +16,7 @@
const dispatch = createEventDispatcher();
let id: string;
let id: string = '';
let error: string;
let showCustomId = false;
let disabled: boolean = false;
@@ -28,7 +28,7 @@
disabled = true;
showSubmissionLoader = true;
const project = await sdk.forConsole.projects.create({
projectId: id ?? ID.unique(),
projectId: id || ID.unique(),
name,
teamId
});
@@ -4,7 +4,7 @@
import { EmptySearch, PaginationWithLimit, ViewSelector } from '$lib/components/index.js';
import { Button } from '$lib/elements/forms';
import Link from '$lib/elements/link.svelte';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import Container from '$lib/layout/container.svelte';
import { protocol } from '$routes/(console)/store.js';
import {
@@ -103,9 +103,13 @@
{:else if column.id === 'nameservers'}
{domain.nameservers || '-'}
{:else if column.id === 'expiry_date'}
{domain?.expire ? toLocaleDateTime(domain.expire) : '-'}
{#if domain?.expire}
<DualTimeView time={domain.expire} />
{:else}-{/if}
{:else if column.id === 'renewal'}
{domain?.renewal ? toLocaleDateTime(domain.renewal) : '-'}
{#if domain?.renewal}
<DualTimeView time={domain.renewal} />
{:else}-{/if}
{:else if column.id === 'auto_renewal'}
{domain?.autoRenewal ? 'On' : 'Off'}
{/if}
@@ -29,7 +29,7 @@
let areMembersLimited: boolean = $state(false);
$effect(() => {
const limit = getServiceLimit('members') || Infinity;
const limit = getServiceLimit('members', null, page.data.currentPlan) || Infinity;
const isLimited = limit !== 0 && limit < Infinity;
areMembersLimited =
isCloud &&
@@ -15,7 +15,7 @@
SearchQuery
} from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Container } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
@@ -196,9 +196,13 @@
{user.labels.join(', ')}
</Typography.Text>
{:else if id === 'joined'}
{toLocaleDateTime(user.registration)}
<DualTimeView time={user.registration} />
{:else if id === 'lastActivity'}
{user.accessedAt ? toLocaleDate(user.accessedAt) : 'never'}
{#if user.accessedAt}
<DualTimeView time={user.accessedAt} />
{:else}
never
{/if}
{:else}
{user[id]}
{/if}
@@ -6,7 +6,7 @@
import type { Models } from '@appwrite.io/console';
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { PageData } from './$types';
import CreateMember from '../createMembership.svelte';
import DeleteMembership from '../deleteMembership.svelte';
@@ -117,7 +117,7 @@
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
{toLocaleDateTime(membership.joined)}
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
@@ -54,7 +54,7 @@
</p>
{#if error}
<Alert.Inline status="warning">{error}</Alert.Inline>
<Alert.Inline status="warning" title={error} />
{/if}
</Layout.Stack>
@@ -2,7 +2,7 @@
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import type { PageData } from './$types';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
@@ -87,7 +87,7 @@
{#if !identity[column.id]}
-
{:else}
{toLocaleDateTime(identity[column.id])}
<DualTimeView time={identity[column.id]} />
{/if}
{:else}
{identity[column.id]}
@@ -7,7 +7,7 @@
import DeleteMembership from '../deleteMembership.svelte';
import type { Models } from '@appwrite.io/console';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
Table,
Layout,
@@ -108,7 +108,7 @@
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
{toLocaleDateTime(membership.joined)}
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
@@ -4,7 +4,7 @@
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal, CustomId } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Badge, Icon } from '@appwrite.io/pink-svelte';
import { Tag, Icon } from '@appwrite.io/pink-svelte';
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
import { Button, InputText, InputSelect, InputPhone } from '$lib/elements/forms';
import InputEmail from '$lib/elements/forms/inputEmail.svelte';
@@ -18,7 +18,7 @@
let identifier = '';
let name = '';
let providerId = '';
let id: string = null;
let id: string | null = null;
let showCustomId = false;
const providerTypeOptions = [
@@ -115,12 +115,10 @@
{#if !showCustomId}
<div>
<Badge
variant="secondary"
content="Target ID"
on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" slot="start" />
</Badge>
<Tag size="s" on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" />
Target ID
</Tag>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Target" bind:id autofocus={false} />
@@ -3,7 +3,7 @@
import { Button } from '$lib/elements/forms';
import type { PageData } from './$types';
import { columns } from './store';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import ProviderType from '$routes/(console)/project-[region]-[project]/messaging/providerType.svelte';
import Provider from '$routes/(console)/project-[region]-[project]/messaging/provider.svelte';
import { sdk } from '$lib/stores/sdk';
@@ -83,7 +83,7 @@
<Provider provider={provider.provider} />
{/if}
{:else if column.id === '$createdAt'}
{toLocaleDateTime(target[column.id])}
<DualTimeView time={target[column.id]} />
{:else}
{target[column.id]}
{/if}
@@ -31,7 +31,10 @@
</script>
<svelte:head>
<title>Databases - Appwrite</title>
<!-- svelte bug, the table header just stays! -->
{#key page.url.pathname}
<title>Databases - Appwrite</title>
{/key}
</svelte:head>
<slot />
@@ -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,23 +25,21 @@
tableColumnSuggestions,
basicColumnOptions,
mockSuggestions,
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';
import { showCreateTable } from '../store';
import { showSubNavigation } from '$lib/stores/layout';
import { navigationCancelled } from '$lib/stores/navigation';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { isWithinSafeRange } from '$lib/helpers/numbers';
import type { Columns } from '../table-[table]/store';
import { columnOptions } from '../table-[table]/columns/store';
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,18 +49,19 @@
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;
let confirmDismiss = $state(false);
let confirmNavigation = $state(false);
let pendingNavigationUrl: string | null = null;
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 +127,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 +134,31 @@
`[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 = actionsLeft - left;
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;
@@ -190,27 +208,22 @@
const left = Math.round(startLeft - containerRect.left);
// maximum possible width of all custom columns
const totalFullWidth = customColumns.reduce(
(total, col) => total + getColumnWidth(col.key),
0
);
// get the actions column and use its left border as the boundary
const actionsCell = headerElement!.querySelector<HTMLElement>(
'[role="cell"][data-column-id="actions"]'
);
const rawVisibleWidth = Math.round(visibleRight - idRect.right);
let maxAllowedWidth = rawVisibleWidth;
if (actionsCell) {
const actionsRect = actionsCell.getBoundingClientRect();
const actionsLeft = actionsRect.left - containerRect.left;
maxAllowedWidth = Math.min(rawVisibleWidth, actionsLeft - left);
if (!actionsCell) {
if (rangeOverlayEl) {
rangeOverlayEl.style.display = 'none';
}
return;
}
// Set overlay width to not exceed actions column boundary
const width = Math.min(totalFullWidth, maxAllowedWidth);
const actionsRect = actionsCell.getBoundingClientRect();
const actionsLeft = actionsRect.left - containerRect.left;
const width = actionsLeft - left;
// Apply overlay positioning
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
@@ -219,7 +232,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 +244,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'
});
@@ -260,25 +280,6 @@
});
};
// Handle create table requests from subNavigation
const unsubscribeCreateTable = createTableRequest.subscribe((requested) => {
if (requested) {
if (customColumns.length > 0 && !creatingColumns) {
confirmNavigation = true;
pendingNavigationUrl = 'create-table';
} else {
executeCreateTable();
}
createTableRequest.set(false);
}
});
function executeCreateTable() {
$showCreateTable = true;
$showSubNavigation = false;
}
const customSuggestedColumns = $derived.by(() => {
return customColumns.map((col: SuggestedColumnSchema) => {
const columnOption = getColumnOption(col.type, col.format);
@@ -301,54 +302,54 @@
});
});
const getRowColumns = (): Column[] => [
{
id: '$id',
title: '$id',
type: 'string',
width: 180,
icon: IconFingerPrint,
...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: '',
type: 'string' as Column['type'],
width: 40,
isAction: true,
...baseColProps
}
];
const getRowColumns = (): Column[] => {
const minColumnWidth = 180;
const fixedWidths = { id: minColumnWidth, actions: 40, selection: 40 };
// Handle browser back/forward navigation
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (customColumns.length > 0 && !creatingColumns) {
event.preventDefault();
event.returnValue =
'You have unsaved column suggestions. Are you sure you want to leave?';
return event.returnValue;
}
// calculate base widths and total
const columnsWithBase = customSuggestedColumns.map((col) => ({
...col,
baseWidth: Math.max(minColumnWidth, getColumnWidth(col.id))
}));
const totalUsed =
fixedWidths.id +
fixedWidths.actions +
fixedWidths.selection +
columnsWithBase.reduce((sum, col) => sum + col.baseWidth, 0);
// distribute excess space equally across custom columns
const viewportWidth =
spreadsheetContainer?.clientWidth ||
(typeof window !== 'undefined' ? window.innerWidth : totalUsed);
const extraPerColumn =
Math.max(0, viewportWidth - totalUsed) / (columnsWithBase.length || 1);
const finalCustomColumns = columnsWithBase.map((col) => ({
...col,
width: { min: col.baseWidth + extraPerColumn }
}));
return [
{
id: '$id',
title: '$id',
type: 'string',
width: fixedWidths.id,
icon: IconFingerPrint,
...baseColProps
},
...finalCustomColumns,
{
id: 'actions',
title: '',
type: 'string' as Column['type'],
width: fixedWidths.actions,
isAction: true,
...baseColProps
}
];
};
const spreadsheetColumns = $derived(getRowColumns());
@@ -364,15 +365,6 @@
await suggestColumns();
});
beforeNavigate(({ cancel, to }) => {
if (customColumns.length > 0 && !creatingColumns) {
cancel();
confirmNavigation = true;
$navigationCancelled = true;
pendingNavigationUrl = to?.url?.pathname || null;
}
});
function resetSuggestionsStore(fullReset: boolean = true) {
if ($tableColumnSuggestions.table?.id !== page.params.table) {
return;
@@ -391,6 +383,12 @@
async function suggestColumns() {
$tableColumnSuggestions.thinking = true;
if ($isSmallViewport) {
await tick();
scrollToFirstCustomColumn();
}
let suggestedColumns: {
total: number;
columns: ColumnInput[];
@@ -402,7 +400,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 +408,8 @@
.console.suggestColumns({
databaseId: page.params.database,
tableId: page.params.table,
context: $tableColumnSuggestions.context ?? undefined
context: $tableColumnSuggestions.context ?? undefined,
min: 6
})) as unknown as {
total: number;
columns: ColumnInput[];
@@ -423,21 +422,54 @@
total: suggestedColumns.total
});
customColumns = mapSuggestedColumns(suggestedColumns.columns);
const mappedColumns = mapSuggestedColumns(suggestedColumns.columns);
if (customColumns.length > 0) {
setTimeout(scrollToFirstCustomColumn, 100);
setTimeout(() => (hasTransitioned = true), 300);
// replace with actual columns and trim excess
if (mappedColumns.length < customColumns.length) {
customColumns = customColumns.slice(0, mappedColumns.length);
}
} catch (error) {
addNotification({
type: 'error',
message: error.message
// replace existing placeholders and
// add any additional columns if needed
mappedColumns.forEach((column, index) => {
setTimeout(() => {
if (index < customColumns.length) {
// replace existing placeholder
customColumns[index] = { ...column, isPlaceholder: false };
} else {
// new column directly if we have more than expected
// just added in case the max ever changes on backend!
customColumns.push({ ...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 +515,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 +646,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 +664,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);
@@ -612,15 +690,15 @@
customColumns = [];
resetSuggestionsStore();
unsubscribeCreateTable();
});
</script>
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} on:beforeunload={handleBeforeUnload} />
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} />
<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 +721,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 +735,8 @@
{@const columnIconColor = !columnObj?.type
? '--non-overlay-icon-color'
: '--overlay-icon-color'}
{@const isColumnInteractable =
isCustomColumn(column.id) && !columnObj.isPlaceholder}
<Options
enabled={isColumnInteractable}
@@ -680,30 +759,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 +923,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 +941,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}
@@ -878,7 +972,10 @@
size="xs"
variant="text"
disabled={creatingColumns}
on:click={() => (confirmDismiss = true)}
on:click={() => {
customColumns = [];
resetSuggestionsStore();
}}
style="opacity: {creatingColumns ? '0' : '1'}"
>Dismiss
</Button.Button>
@@ -897,48 +994,13 @@
{/if}
</div>
<Confirm
confirmDeletion
action="Dismiss"
title="Dismiss columns"
bind:open={confirmDismiss}
onSubmit={() => {
customColumns = [];
resetSuggestionsStore();
}}>
Are you sure you want to dismiss these columns suggested by AI? This action is irreversible.
</Confirm>
<Confirm
confirmDeletion
action="Leave"
title="Leave page"
bind:open={confirmNavigation}
onSubmit={() => {
customColumns = [];
resetSuggestionsStore();
confirmNavigation = false;
if (pendingNavigationUrl) {
if (pendingNavigationUrl === 'create-table') {
executeCreateTable();
} else {
goto(pendingNavigationUrl);
}
pendingNavigationUrl = null;
}
}}>
You have unsaved column suggestions. If you leave this page, you'll lose these suggestions. Are
you sure you want to continue?
</Confirm>
<style lang="scss">
.spreadsheet-container-outer {
width: 100%;
position: fixed;
overflow: hidden;
overflow: visible;
scrollbar-width: none;
--columns-range-pink-border-color: rgba(253, 54, 110, 0.24);
&.custom-columns {
width: unset;
@@ -948,19 +1010,19 @@
display: none;
}
&:not(:has(.columns-range-overlay.thinking)) {
&:has(.columns-range-overlay) {
&
:global(
[role='cell']:has(.column-resizer-disabled):not([data-column-id^='$']):not(
[data-column-id='actions']
)
) {
box-shadow: 0 -1px 0 0 rgba(253, 54, 110, 0.24) inset !important;
box-shadow: 0 -1px 0 0 var(--columns-range-pink-border-color) inset !important;
transition: box-shadow 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
& :global(.column-resizer-disabled) {
border-left: var(--border-width-s, 1px) solid rgba(253, 54, 110, 0.24) !important;
& :global([role='cell']:not([data-column-id='actions']) .column-resizer-disabled) {
border-left: var(--border-width-s, 1px) solid var(--columns-range-pink-border-color) !important;
transition: border-color 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
}
@@ -1010,7 +1072,7 @@
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.8),
transparent
);
animation: inner-shimmer 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
@@ -1021,7 +1083,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) {
@@ -1054,11 +1126,17 @@
}
& :global(.spreadsheet-container) {
overflow-x: hidden;
overflow-y: hidden;
overflow-x: auto;
overflow-y: auto;
scrollbar-width: none;
}
&.thinking {
& :global(.spreadsheet-container) {
overflow: hidden !important;
}
}
& :global([data-select='true']) {
opacity: 0.85;
pointer-events: none;
@@ -1079,7 +1157,7 @@
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.855) 21%,
rgba(255, 255, 255, 0.86) 32.25%,
#fff 100%
);
z-index: 20; /* under overlay */
@@ -1093,10 +1171,9 @@
:global(.theme-dark) .spreadsheet-fade-bottom {
background: linear-gradient(
180deg,
rgba(25, 25, 28, 0.38) 13%,
rgba(25, 25, 28, 0.7) 21%,
rgba(25, 25, 28, 0.95) 38%,
var(--bgcolor-neutral-default, #19191c) 100%
rgba(29, 29, 33, 0) 0%,
rgba(29, 29, 33, 0.86) 21%,
#1d1d21 100%
);
}
@@ -1117,6 +1194,10 @@
}
}
:global(.theme-dark) .spreadsheet-container-outer {
--columns-range-pink-border-color: rgba(253, 54, 110, 0.12) !important;
}
:global(.theme-dark) .columns-range-overlay.thinking {
&::before {
background: linear-gradient(
@@ -1146,7 +1227,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 +1239,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,567 @@
<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" title={modalError} />
{/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>
@@ -1,42 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
import { isCloud } from '$lib/system';
import IconAI from './icon/ai.svelte';
import { slide } from 'svelte/transition';
import { tableColumnSuggestions } from './store';
import { InputTextarea } from '$lib/elements/forms';
import { Button, InputTextarea } from '$lib/elements/forms';
import { Card, Layout, Selector, Typography } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
onMount(() => {
// enable by default!
$tableColumnSuggestions.enabled = true;
if (featureActive) {
$tableColumnSuggestions.enabled = true;
}
});
const featureActive = $derived(isCloud);
const title = $derived.by(() => {
return featureActive
? 'Smart column suggestions'
: 'Smart column suggestions available on Cloud';
});
const subtitle = $derived.by(() => {
return featureActive
? 'Enable AI to suggest useful columns based on your table name'
: 'Sign up for Cloud to generate columns based on your table name';
});
</script>
<Card.Base variant="secondary" radius="s" padding="xs">
<Layout.Stack gap="m">
<Layout.Stack direction="row" gap="s" alignItems="center">
<Layout.Stack gap={featureActive ? 'm' : 'l'}>
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
<IconAI />
<Layout.Stack direction="column" gap="none">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
>Smart column suggestions</Typography.Text>
<div class="suggestions-switch">
<Selector.Switch
id="suggestions"
label={undefined}
bind:checked={$tableColumnSuggestions.enabled} />
</div>
</Layout.Stack>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
>{title}</Typography.Text>
<Typography.Text color="--fgcolor-neutral-secondary">
Enable AI to suggest useful columns based on your table name
{subtitle}
</Typography.Text>
</Layout.Stack>
{#if featureActive}
<div class="suggestions-switch">
<Selector.Switch
id="suggestions"
label={undefined}
bind:checked={$tableColumnSuggestions.enabled} />
</div>
{/if}
</Layout.Stack>
{#if $tableColumnSuggestions.enabled}
{#if !featureActive}
<Layout.Stack>
<Button external secondary href="https://cloud.appwrite.io/register">
Sign up
</Button>
</Layout.Stack>
{/if}
<!-- just being safe with extra guard! -->
{#if $tableColumnSuggestions.enabled && featureActive}
<div transition:slide={{ duration: 200 }}>
<InputTextarea
id="context"
@@ -50,7 +75,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,
@@ -31,7 +46,7 @@ export const tableColumnSuggestions = writable<TableColumnSuggestions>({
table: null
});
export const createTableRequest = writable<boolean>(false);
export const showIndexesSuggestions = writable<boolean>(false);
export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
total: 7,
@@ -138,7 +138,10 @@
</script>
<svelte:head>
<title>Database - Appwrite</title>
<!-- svelte bug, the table header just stays! -->
{#key page.url.pathname}
<title>Database - Appwrite</title>
{/key}
</svelte:head>
<slot />
@@ -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} />
@@ -19,6 +19,7 @@
} = $props();
const databaseId = page.params.database;
const isOnTablesPage = $derived(page.route?.id.endsWith('table-[table]'));
let name = $state('');
let id: string = $state(null);
@@ -27,6 +28,20 @@
let creatingTable = $state(false);
function enableThinkingModeForSuggestions(table: Models.Table) {
if ($tableColumnSuggestions.enabled) {
// if enabled, trigger thinking mode!
tableColumnSuggestions.update((store) => ({
...store,
thinking: true,
table: {
id: table.$id,
name: table.name
}
}));
}
}
async function createTable() {
error = null;
try {
@@ -38,14 +53,6 @@
name
});
tableColumnSuggestions.update((store) => ({
...store,
table: {
id: table.$id,
name: table.name
}
}));
updateAndCleanup();
await onTableCreated(table);
@@ -53,6 +60,9 @@
name = id = null;
showCreate = false;
creatingTable = false;
// don't wait for UI to mount!
enableThinkingModeForSuggestions(table);
} catch (e) {
error = e.message;
trackError(e, Submit.TableCreate);
@@ -93,6 +103,15 @@
touchedId = false;
}
});
$effect(() => {
if (showCreate && isOnTablesPage && $tableColumnSuggestions.table) {
tableColumnSuggestions.update((store) => ({
...store,
table: null
}));
}
});
</script>
<Modal title="Create table" size="m" bind:show={showCreate} onSubmit={createTable} bind:error>
@@ -8,7 +8,7 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { database } from './store';
import { toLocaleDate } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { type Models, Query } from '@appwrite.io/console';
import { Spinner, Table } from '@appwrite.io/pink-svelte';
@@ -126,7 +126,9 @@
{#each tableItems as table}
<Table.Row.Base {root}>
<Table.Cell {root}>{table.name}</Table.Cell>
<Table.Cell {root}>{toLocaleDate(table.updatedAt)}</Table.Cell>
<Table.Cell {root}>
<DualTimeView time={table.updatedAt} />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
@@ -28,14 +28,13 @@
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { subNavigation } from '$lib/stores/database';
import { createTableRequest, tableColumnSuggestions } from './(suggestions)/store';
let data = $derived(page.data) as PageData;
const data = $derived(page.data) as PageData;
let region = $derived(page.params.region);
let project = $derived(page.params.project);
let tableId = $derived(page.params.table);
let databaseId = $derived(page.params.database);
const region = $derived(page.params.region);
const project = $derived(page.params.project);
const tableId = $derived(page.params.table);
const databaseId = $derived(page.params.database);
let openBottomSheet = $state(false);
@@ -56,10 +55,11 @@
const isMainDatabaseScreen = $derived(page.route.id.endsWith('database-[database]'));
// If banner open, `-1rem` to adjust banner size, else `-70.5px`.
// If banner open, adjust bottom position to account for banner container.
// 70.5px is the size of the container of the banner holder and not just the banner!
// Needed because things vary a bit much on how different browsers treat bottom layouts.
const bottomNavHeight = $derived(`calc(20% ${$bannerSpacing ? '- 1rem' : '- 70.5px'})`);
const bottomNavOffset = $derived($bannerSpacing ? '70.5px' : '0px');
const tableContentPadding = $derived($bannerSpacing ? '210px' : '140px');
async function loadTables() {
tables = await sdk.forProject(region, project).tablesDB.listTables({
@@ -93,7 +93,7 @@
{data.database?.name}
</a>
<div class="table-content">
<div class="table-content" style:padding-bottom={tableContentPadding}>
{#if tables?.total}
<ul class="drop-list u-margin-inline-start-8 u-margin-block-start-4">
{#each sortedTables as table, index}
@@ -147,25 +147,15 @@
<Button
compact
on:click={() => {
if (
$tableColumnSuggestions.enabled &&
$tableColumnSuggestions.table?.id
) {
$createTableRequest = true;
} else {
$showCreateTable = true;
$showSubNavigation = false;
}
$showCreateTable = true;
$showSubNavigation = false;
}}>
Create table
</Button>
</Layout.Stack>
</div>
<Layout.Stack
gap="xxs"
direction="column"
style="bottom: 1rem; position: relative; height: {bottomNavHeight}">
<div class="bottom-nav-container" style:bottom={bottomNavOffset}>
<div class="action-menu-divider">
<Divider />
</div>
@@ -177,7 +167,7 @@
{@const href = `${base}/project-${region}-${project}/databases/database-${databaseId}/${action.href}`}
<Layout.Stack gap="s" direction="row" alignItems="center">
<li>
<li class="bottom-nav-item">
<a
{href}
class="u-padding-block-8 u-padding-inline-end-4 u-padding-inline-start-8 u-flex u-cross-center u-gap-8">
@@ -191,7 +181,7 @@
</Layout.Stack>
{/each}
</ul>
</Layout.Stack>
</div>
</section>
</Sidebar.Base>
{:else if data?.database?.name && !isMainDatabaseScreen}
@@ -271,11 +261,15 @@
overflow-x: hidden;
min-height: 0;
margin-bottom: auto;
padding-bottom: 16px;
scrollbar-width: thin;
scrollbar-color: var(--border-neutral, #ededf0) transparent;
color: var(--fgcolor-neutral-secondary, #56565c);
/* hide scrollbars */
scrollbar-width: none;
-ms-overflow-style: none;
// scrollbar-width: thin;
// scrollbar-color: var(--border-neutral, #ededf0) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
@@ -304,6 +298,10 @@
font-size: var(--font-size-sm);
color: var(--fgcolor-neutral-secondary);
&::-webkit-scrollbar {
display: none;
}
&:not(.bottom-nav)::before {
content: '';
right: 99%;
@@ -335,12 +333,16 @@
position: relative;
padding-inline-end: 0.5rem;
margin-inline-start: 0.5rem;
}
li:hover {
color: var(--fgcolor-neutral-primary);
border-radius: var(--border-radius-s, 6px);
background: var(--bgcolor-neutral-secondary);
&:hover {
color: var(--fgcolor-neutral-primary);
border-radius: var(--border-radius-s, 6px);
background: var(--bgcolor-neutral-secondary);
}
&.bottom-nav-item:hover {
margin-inline-end: 1.25rem;
}
}
.table-name {
@@ -382,8 +384,16 @@
line-height: 150%; /* 21px */
}
.bottom-nav-container {
right: 0;
bottom: 0;
left: 1.25rem;
position: absolute;
padding-block-end: 1rem;
}
.action-menu-divider {
margin-inline: -1.2rem;
padding-block-end: 0.25rem;
margin-inline-start: -1.25rem;
}
</style>
@@ -35,7 +35,8 @@
spreadsheetRenderKey,
expandTabs,
databaseRelatedRowSheetOptions,
rowPermissionSheet
rowPermissionSheet,
type Columns
} from './store';
import { addSubPanel, registerCommands, updateCommandGroupRanks } from '$lib/commandCenter';
import CreateColumn from './createColumn.svelte';
@@ -56,7 +57,6 @@
import { Button, Seekbar } from '$lib/elements/forms';
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';
@@ -64,6 +64,10 @@
import { chunks } from '$lib/helpers/array';
import { Submit, trackEvent } from '$lib/actions/analytics';
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
import { showIndexesSuggestions, tableColumnSuggestions } from '../(suggestions)';
import type { RealtimeResponseEvent } from '@appwrite.io/console';
let editRow: EditRow;
let editRelatedRow: EditRelatedRow;
let editRowPermissions: EditRowPermissions;
@@ -79,6 +83,8 @@
*/
let isWaterfallFromFaker = false;
let columnCreationHandler: ((response: RealtimeResponseEvent<unknown>) => void) | null = null;
onMount(() => {
expandTabs.set(preferences.getKey('tableHeaderExpanded', true));
@@ -89,7 +95,19 @@
response.events.includes('databases.*.tables.*.columns.*') ||
response.events.includes('databases.*.tables.*.indexes.*')
) {
if (!isWaterfallFromFaker) {
if (isWaterfallFromFaker) {
columnCreationHandler?.(response);
}
// don't invalidate when -
// 1. from faker
// 2. ai columns creation
// 3. ai indexes creation
if (
!isWaterfallFromFaker &&
!$showIndexesSuggestions &&
!$tableColumnSuggestions.table
) {
invalidate(Dependencies.TABLE);
}
}
@@ -242,21 +260,76 @@
indexes: 700
});
function setupColumnObserver() {
let expectedCount = 0;
let resolvePromise: () => void;
let timeout: ReturnType<typeof setTimeout>;
const availableColumns = new Set<string>();
const waitPromise = new Promise<void>((resolve) => (resolvePromise = resolve));
columnCreationHandler = (response) => {
const { events, payload } = response;
if (
events.includes('databases.*.tables.*.columns.*.create') ||
events.includes('databases.*.tables.*.columns.*.update')
) {
const asColumn = payload as Columns;
const columnId = asColumn.key;
const status = asColumn.status;
if (status === 'available') {
availableColumns.add(columnId);
if (expectedCount > 0 && availableColumns.size >= expectedCount) {
clearTimeout(timeout);
columnCreationHandler = null;
resolvePromise();
}
}
}
};
// return function to start waiting!
const startWaiting = (count: number) => {
expectedCount = count;
timeout = setTimeout(() => {
columnCreationHandler = null;
resolvePromise();
}, 10000);
if (availableColumns.size >= expectedCount) {
clearTimeout(timeout);
columnCreationHandler = null;
resolvePromise();
}
};
return { startWaiting, waitPromise };
}
async function createFakeData() {
isWaterfallFromFaker = true;
$spreadsheetLoading = true;
$randomDataModalState.show = false;
let columns = $table.columns;
let columns = page.data.table.columns as Columns[];
const hasAnyRelationships = columns.some((column) => isRelationship(column));
const filteredColumns = columns.filter((col) => col.type !== 'relationship');
if (!filteredColumns.length) {
try {
const { startWaiting, waitPromise } = setupColumnObserver();
columns = await generateColumns($project, page.params.database, page.params.table);
startWaiting(columns.length);
await waitPromise;
await invalidate(Dependencies.TABLE);
columns = page.data.table.columns as Columns[];
trackEvent(Submit.ColumnCreate, { type: 'faker' });
} catch (e) {
addNotification({
@@ -268,9 +341,6 @@
}
}
/* let the columns be processed! */
await sleep(1250);
let rowIds = [];
try {
const { rows, ids } = generateFakeRecords(columns, $randomDataModalState.value);
@@ -442,7 +512,7 @@
title="Row permissions"
bind:show={$rowPermissionSheet.show}
submit={{
text: 'Create',
text: 'Update',
disabled: editRowPermissions?.disableSubmit(),
onClick: async () => editRowPermissions?.updatePermissions()
}}>
@@ -470,3 +540,5 @@
</Layout.Stack>
</svelte:fragment>
</Dialog>
<IndexesSuggestions />
@@ -6,7 +6,7 @@
import { Container } from '$lib/layout';
import { preferences } from '$lib/stores/preferences';
import { canWriteTables, canWriteRows } from '$lib/stores/roles';
import { Icon, Layout, Divider } from '@appwrite.io/pink-svelte';
import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte';
import type { PageData } from './$types';
import {
table,
@@ -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;
@@ -114,21 +121,31 @@
<Layout.Stack direction="column" gap="xl">
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" gap="s">
<ViewSelector
onlyIcon
ui="new"
view={data.view}
columns={tableColumns}
hideView
showAnyway
isCustomTable />
<Tooltip>
<div>
<ViewSelector
onlyIcon
ui="new"
view={data.view}
columns={tableColumns}
hideView
showAnyway
isCustomTable />
</div>
<Filters
onlyIcon
query={data.query}
columns={filterColumns}
disabled={!(hasColumns && hasValidColumns)}
analyticsSource="database_rows" />
<svelte:fragment slot="tooltip">Columns</svelte:fragment>
</Tooltip>
<Tooltip>
<Filters
onlyIcon
query={data.query}
columns={filterColumns}
disabled={!(hasColumns && hasValidColumns)}
analyticsSource="database_tables" />
<svelte:fragment slot="tooltip">Filters</svelte:fragment>
</Tooltip>
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<Button
@@ -192,7 +209,7 @@
queries.clearAll();
queries.apply();
trackEvent(Submit.FilterClear, {
source: 'database_rows'
source: 'database_tables'
});
}
}
@@ -216,7 +233,7 @@
}
}} />
{/if}
{:else if $tableColumnSuggestions.enabled && $tableColumnSuggestions.table && $tableColumnSuggestions.table.id === page.params.table}
{:else if isCloud && canShowSuggestionsSheet}
<SuggestionsEmptySheet />
{:else}
<EmptySheet
@@ -20,6 +20,7 @@
import {
columns,
type Columns,
type ColumnsWidth,
indexes,
isCsvImportInProgress,
reorderItems,
@@ -39,7 +40,8 @@
IconTrash,
IconViewList,
IconLockClosed,
IconFingerPrint
IconFingerPrint,
IconMail
} from '@appwrite.io/pink-icons-svelte';
import { type ComponentProps, onDestroy, onMount } from 'svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
@@ -51,6 +53,14 @@
import { type Models } from '@appwrite.io/console';
import { preferences } from '$lib/stores/preferences';
import { page } from '$app/state';
import { debounce } from '$lib/helpers/debounce';
import type { PageData } from './$types';
const {
data
}: {
data: PageData;
} = $props();
const updatedColumnsForSheet = $derived.by(() => {
const baseAttrs = [
@@ -104,8 +114,11 @@
let selectedColumn: Columns = $state(null);
let columnIndexMap: Record<string, boolean> = $state({});
let columnsOrder = $state([]);
let columnsOrder = $state<string[]>([]);
let columnsWidth = $state<ColumnsWidth | null>(null);
const tableId = page.params.table;
const organizationId = data.organization.$id ?? data.project.teamId;
let showEdit = $state(false);
let editColumn: EditColumn;
@@ -113,7 +126,7 @@
const columnFormatIcon = {
ip: IconLocationMarker,
url: IconLink,
email: IconLink,
email: IconMail,
enum: IconViewList
};
@@ -126,6 +139,7 @@
onMount(() => {
columnsOrder = preferences.getColumnOrder(tableId);
columnsWidth = preferences.getColumnWidths(tableId + '#columns');
});
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
@@ -182,8 +196,80 @@
return `Type: ${formattedType}`;
}
function isSystemColumnKey(column: Columns) {
return column.key.startsWith('$');
}
function getColumnWidth(columnId: string, defaultWidth: number): number {
const savedWidth = columnsWidth?.[columnId];
if (!savedWidth) return defaultWidth;
return savedWidth.resized;
}
function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) {
const existing = columnsWidth?.[columnId];
const fixed = existing
? typeof existing?.fixed === 'number'
? existing.fixed
: existing?.fixed?.min
: newWidth;
columnsWidth = {
...(columnsWidth ?? {}),
[columnId]: {
fixed,
resized: Math.ceil(newWidth)
}
};
saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed });
}
const saveColumnWidthsToPreferences = debounce(
(column: { columnId: string; newWidth: number; fixedWidth: number }) => {
if (!organizationId) return;
preferences.saveColumnWidths(organizationId, tableId + '#columns', {
[column.columnId]: {
fixed: column.fixedWidth,
resized: Math.ceil(column.newWidth)
}
});
},
1000
);
onDestroy(() => ($showCreateColumnSheet.show = false));
const spreadsheetColumns = $derived([
{
id: 'key',
width: getColumnWidth('key', 300),
minimumWidth: 300,
resizable: true
},
{
id: 'type',
width: 150,
minimumWidth: 150,
resizable: false
},
{
id: 'indexed',
width: getColumnWidth('indexed', 150),
minimumWidth: 150,
resizable: true
},
{
id: 'default',
width: getColumnWidth('default', 200),
minimumWidth: 200,
resizable: true
},
{ id: 'actions', width: 40, isAction: true, resizable: false }
]);
$effect(() => {
if (!$showCreateIndexSheet.show && $showCreateIndexSheet.column) {
const columnKey = $showCreateIndexSheet.column;
@@ -220,34 +306,40 @@
<SpreadsheetContainer>
<Spreadsheet.Root
let:root
allowSelection
height="100%"
allowSelection
emptyCells={emptyCellsCount}
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: 'actions', width: 40, isAction: true }
]}
bottomActionClick={() => ($showCreateColumnSheet.show = true)}>
columns={spreadsheetColumns}
bottomActionClick={() => ($showCreateColumnSheet.show = true)}
on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}>
<svelte:fragment slot="header" let:root>
<Spreadsheet.Header.Cell column="key" {root}>Column name</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="type" {root}>Type</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="indexed" {root}>Indexed</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="default" {root}
>Default value</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each updatedColumnsForSheet as column, index}
{#each updatedColumnsForSheet as column, index (column.key)}
{@const isId = column.key === '$id'}
{@const option = columnOptions.find((option) => option.type === column.type)}
{@const isSelectable =
column['system'] || column.type === 'relationship' ? 'disabled' : true}
<Spreadsheet.Row.Base {root} select={isSelectable} id={column.key}>
<Spreadsheet.Cell column="key" {root} isEditable={false}>
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center" inline>
<Layout.Stack
direction="row"
alignItems="center"
justifyContent="space-between"
style="min-width:0">
<Layout.Stack
gap="s"
inline
direction="row"
alignItems="center"
style="min-width:0; flex:1 1 auto;">
{#if isRelationship(column)}
<Icon
size="s"
@@ -263,29 +355,35 @@
<Icon icon={option.icon} size="s" />
{/if}
<Layout.Stack direction="row" alignItems="center" gap="s">
<Layout.Stack
inline
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']}
{:else}
{column.key} {column.array ? '[]' : undefined}
{/if}
</span>
{#if isString(column) && column.encrypt}
<Tooltip>
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
<div slot="tooltip">Encrypted</div>
</Tooltip>
<Layout.Stack
gap="s"
inline
direction="row"
alignItems="center"
style="min-width:0; flex:1 1 auto; overflow:hidden;">
<Typography.Text truncate>
{#if isSystemColumnKey(column)}
{column.key}
{:else}
{column.key}{column.array ? '[]' : undefined}
{/if}
</Layout.Stack>
</Typography.Text>
{#if isString(column) && column.encrypt}
<Tooltip>
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
<div slot="tooltip">Encrypted</div>
</Tooltip>
{/if}
</Layout.Stack>
<Layout.Stack
gap="s"
inline
direction="row"
alignItems="center"
style="flex:0 0 auto; white-space:nowrap;">
{#if column.status !== 'available'}
<Badge
size="s"
@@ -323,12 +421,17 @@
{/if}
</Layout.Stack>
</Spreadsheet.Cell>
<Spreadsheet.Cell column="type" {root} isEditable={false}>
{@const columnType = column['format'] ? column['format'] : column.type}
{columnType.toLowerCase()}
</Spreadsheet.Cell>
<Spreadsheet.Cell column="indexed" {root} isEditable={false}>
{@const isActuallyIndexed = $indexes.some((index) =>
index.columns.includes(column.key)
)}
<!-- $id is always indexed internally -->
{@const isActuallyIndexed =
isId || $indexes.some((index) => index.columns.includes(column.key))}
{@const checked = isActuallyIndexed || !!columnIndexMap[column.key]}
<!-- $id is always indexed internally -->
{@const checked = isId || isActuallyIndexed || !!columnIndexMap[column.key]}
<Selector.Checkbox
size="s"
@@ -345,15 +448,18 @@
}} />
</Spreadsheet.Cell>
<Spreadsheet.Cell column="default" {root} isEditable={false}>
{@const _default =
column?.default !== null && column?.default !== undefined
? column?.default
: null}
{@const _default = column.required
? '-'
: column?.default !== null && column?.default !== undefined
? column?.default
: null}
{#if _default === null}
<Badge variant="secondary" content="NULL" size="xs" />
{:else if isSpatialType(column)}
{JSON.stringify(_default)}
{:else}
{isSpatialType(column) ? JSON.stringify(_default) : _default}
{_default}
{/if}
</Spreadsheet.Cell>
<Spreadsheet.Cell column="actions" {root} isEditable={false}>
@@ -363,8 +469,7 @@
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
</CsvDisabled>
{:else if column.key !== '$sequence'}
<!-- TODO: no portal, rather see if we can fix the cell -->
{:else if !isId}
<Popover let:toggle padding="none" placement="bottom-end" portal>
<Button text icon ariaLabel="more options" on:click={toggle}>
<Icon icon={IconDotsHorizontal} size="s" />
@@ -458,7 +563,7 @@
</div>
{#if selectedColumn}
<DeleteColumn bind:showDelete {selectedColumn} />
<DeleteColumn bind:showDelete bind:selectedColumn />
{:else if selectedColumns && selectedColumns.length}
<DeleteColumn bind:showDelete bind:selectedColumn={selectedColumns} />
{/if}
@@ -476,3 +581,13 @@
{#if showFailed}
<FailedModal bind:show={showFailed} title="Create attribute" header="Creation failed" {error} />
{/if}
<style>
.floating-action-bar {
left: 50%;
width: 100%;
z-index: 14;
position: absolute;
transform: translateX(-50%);
}
</style>
@@ -38,6 +38,9 @@
<script lang="ts">
import { InputSelect } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let data: Partial<Models.ColumnBoolean> = {
required: false,
@@ -45,9 +48,6 @@
default: null
};
import { createConservative } from '$lib/helpers/stores';
import { Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -67,6 +67,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -83,17 +84,5 @@
{ label: 'False', value: false }
]}
bind:value={data.default} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -42,13 +42,12 @@
<script lang="ts">
import { InputDateTime } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let data: Partial<Models.ColumnDatetime>;
import { createConservative } from '$lib/helpers/stores';
import { Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -68,6 +67,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -79,17 +79,5 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -61,7 +61,7 @@
await invalidate(Dependencies.TABLE);
showDelete = false;
selectedColumn = [];
selectedColumn = Array.isArray(selectedColumn) ? [] : null;
} catch (e) {
error = e.message;
trackError(e, Submit.ColumnDelete);
@@ -38,13 +38,12 @@
<script lang="ts">
import { InputEmail } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let data: Partial<Models.ColumnEmail>;
import { createConservative } from '$lib/helpers/stores';
import { Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -64,6 +63,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -76,17 +76,5 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -39,15 +39,15 @@
</script>
<script lang="ts">
import { createConservative } from '$lib/helpers/stores';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import { InputSelect, InputTags } from '$lib/elements/forms';
import { Icon, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let data: Partial<Models.ColumnEnum>;
import { createConservative } from '$lib/helpers/stores';
import { Icon, Selector, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -107,17 +107,5 @@
placeholder="Select a value"
{options}
bind:value={data.default} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -41,7 +41,10 @@
</script>
<script lang="ts">
import { Layout } from '@appwrite.io/pink-svelte';
import { InputNumber } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let data: Partial<Models.ColumnFloat> = {
@@ -52,9 +55,6 @@
array: false
};
import { createConservative } from '$lib/helpers/stores';
import { Layout, Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -85,14 +85,14 @@
label="Min"
placeholder="Enter size"
bind:value={data.min}
step="any"
step={0.1}
required={editing} />
<InputNumber
id="max"
label="Max"
placeholder="Enter size"
bind:value={data.max}
step="any"
step={0.1}
required={editing} />
</Layout.Stack>
<InputNumber
@@ -104,18 +104,6 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array}
step="any" />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
step={0.1} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -41,7 +41,10 @@
</script>
<script lang="ts">
import { Layout } from '@appwrite.io/pink-svelte';
import { InputNumber } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
@@ -53,9 +56,6 @@
array: false
};
import { createConservative } from '$lib/helpers/stores';
import { Layout, Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -103,17 +103,5 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -37,13 +37,12 @@
<script lang="ts">
import { InputText } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let data: Partial<Models.ColumnIp>;
import { createConservative } from '$lib/helpers/stores';
import { Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -63,6 +62,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -75,17 +75,5 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -0,0 +1,50 @@
<script lang="ts">
import { Selector, Tooltip } from '@appwrite.io/pink-svelte';
let {
required = $bindable(false),
array = $bindable(false),
editing = false
}: {
required: boolean;
array: boolean;
editing: boolean;
} = $props();
</script>
<Tooltip disabled={!array} maxWidth="275px" placement="bottom-start">
<div style:width="fit-content">
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={required}
disabled={array}
description="Indicate whether this column is required." />
</div>
<svelte:fragment slot="tooltip">
Required cannot be selected because array columns may contain more than one value.
</svelte:fragment>
</Tooltip>
<Tooltip disabled={!(required || editing)} maxWidth="275px" placement="bottom-start">
<div style:width="fit-content">
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={array}
disabled={required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
</div>
<svelte:fragment slot="tooltip">
{#if editing}
Array cannot be selected to avoid data incompatibility.
{:else}
Array cannot be selected because required columns must be populated in all rows with a
single value.
{/if}
</svelte:fragment>
</Tooltip>
@@ -44,6 +44,7 @@
import { currentPlan } from '$lib/stores/organization';
import { createConservative } from '$lib/helpers/stores';
import { ActionMenu, Selector } from '@appwrite.io/pink-svelte';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
import { InputNumber, InputText, InputTextarea } from '$lib/elements/forms';
import { Popover, Layout, Tag, Typography, Link } from '@appwrite.io/pink-svelte';
@@ -105,21 +106,7 @@
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required." />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<Layout.Stack gap="xs" direction="column">
<div
@@ -38,13 +38,12 @@
<script lang="ts">
import { InputURL } from '$lib/elements/forms';
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let data: Partial<Models.ColumnUrl>;
export let editing = false;
import { createConservative } from '$lib/helpers/stores';
import { Selector } from '@appwrite.io/pink-svelte';
let savedDefault = data.default;
function handleDefaultState(hideDefault: boolean) {
@@ -64,6 +63,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -76,17 +76,5 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
<Selector.Checkbox
size="s"
id="required"
label="Required"
bind:checked={data.required}
disabled={data.array}
description="Indicate whether this column is required" />
<Selector.Checkbox
size="s"
id="array"
label="Array"
bind:checked={data.array}
disabled={data.required || editing}
description="Indicate whether this column is an array. Defaults to an empty array." />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
@@ -1,6 +1,6 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { table } from '../store';
import { table, type ColumnsWidth } from '../store';
import Delete from './deleteIndex.svelte';
import CreateIndex from './createIndex.svelte';
import Overview from './overviewIndex.svelte';
@@ -8,6 +8,8 @@
import { Button } from '$lib/elements/forms';
import FailedModal from '../failedModal.svelte';
import { canWriteTables } from '$lib/stores/roles';
import { preferences } from '$lib/stores/preferences';
import { debounce } from '$lib/helpers/debounce';
import {
ActionMenu,
Badge,
@@ -26,7 +28,7 @@
IconPlus,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { type ComponentProps, onDestroy } from 'svelte';
import { type ComponentProps, onDestroy, onMount } from 'svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import EmptySheet from '../layout/emptySheet.svelte';
import SpreadsheetContainer from '../layout/spreadsheet.svelte';
@@ -34,6 +36,7 @@
import type { PageData } from './$types';
import { showCreateColumnSheet } from '../store';
import { isSmallViewport } from '$lib/stores/viewport';
import { page } from '$app/state';
let {
data
@@ -52,15 +55,49 @@
let showDelete = $state(false);
let showOverview = $state(false);
let columns = $state([
{ id: 'key' },
{ id: 'type' },
{ id: 'columns' },
let columnsWidth = $state<ColumnsWidth | null>(null);
const tableId = page.params.table;
const organizationId = data.organization.$id ?? data.project.teamId;
const spreadsheetColumns = $derived([
{
id: 'key',
width: getColumnWidth('key', $isSmallViewport ? 250 : 200),
minimumWidth: $isSmallViewport ? 250 : 200,
resizable: true
},
{
id: 'type',
width: getColumnWidth('type', 120),
minimumWidth: 120,
resizable: true
},
{
id: 'columns',
width: getColumnWidth('columns', 200),
minimumWidth: 200,
resizable: true
},
// { id: 'orders' }, // design doesn't have orders atm
{ id: 'lengths' },
{ id: 'actions', width: 40, isAction: true }
{
id: 'lengths',
width: getColumnWidth('lengths', 180),
minimumWidth: 180,
resizable: true
},
{
id: 'actions',
width: 40,
isAction: true,
resizable: false
}
]);
onMount(() => {
columnsWidth = preferences.getColumnWidths(tableId + '#indexes');
});
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
switch (status) {
case 'processing':
@@ -74,6 +111,46 @@
}
}
function getColumnWidth(columnId: string, defaultWidth: number): number {
const savedWidth = columnsWidth?.[columnId];
if (!savedWidth) return defaultWidth;
return savedWidth.resized;
}
function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) {
const existing = columnsWidth?.[columnId];
const fixed = existing
? typeof existing?.fixed === 'number'
? existing.fixed
: existing?.fixed?.min
: newWidth;
columnsWidth = {
...(columnsWidth ?? {}),
[columnId]: {
fixed,
resized: Math.ceil(newWidth)
}
};
saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed });
}
const saveColumnWidthsToPreferences = debounce(
(column: { columnId: string; newWidth: number; fixedWidth: number }) => {
if (!organizationId) return;
preferences.saveColumnWidths(organizationId, tableId + '#indexes', {
[column.columnId]: {
fixed: column.fixedWidth,
resized: Math.ceil(column.newWidth)
}
});
},
1000
);
onDestroy(() => ($showCreateColumnSheet.show = false));
const emptyCellsLimit = $derived($isSmallViewport ? 14 : 17);
@@ -108,12 +185,13 @@
<SpreadsheetContainer>
<Spreadsheet.Root
let:root
{columns}
columns={spreadsheetColumns}
height="100%"
allowSelection
emptyCells={emptyCellsCount}
bind:selectedRows={selectedIndexes}
bottomActionClick={() => (showCreateIndex = true)}>
bottomActionClick={() => (showCreateIndex = true)}
on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}>
<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>
@@ -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);
@@ -325,7 +325,7 @@
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.855) 21%,
rgba(255, 255, 255, 0.86) 32.25%,
#ffffff 100%
);
z-index: 20;
@@ -347,10 +347,9 @@
:global(.theme-dark) .spreadsheet-fade-bottom {
background: linear-gradient(
180deg,
rgba(25, 25, 28, 0.38) 13%,
rgba(25, 25, 28, 0.7) 21%,
rgba(25, 25, 28, 0.95) 38%,
var(--bgcolor-neutral-default, #19191c) 100%
rgba(29, 29, 33, 0) 0%,
rgba(29, 29, 33, 0.86) 21%,
#1d1d21 100%
);
}
@@ -5,16 +5,20 @@
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';
import { beforeNavigate } from '$app/navigation';
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,17 +40,27 @@
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));
let copyText = $state(undefined);
beforeNavigate(() => {
show = false;
});
</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 +124,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
@@ -31,5 +31,5 @@
min={column.min}
max={column.max}
required={column.required}
step={column.type === 'double' ? 'any' : 1}
step={column.type === 'double' ? 0.1 : 1}
leadingIcon={!limited ? IconHashtag : undefined} />
@@ -58,7 +58,7 @@
},
{} as Record<string, unknown>
),
permissions: [],
permissions: existingData?.$permissions ?? [],
columns: availableColumns
};
}
@@ -37,6 +37,7 @@
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { type Columns, databaseColumnSheetOptions } from './store';
import { isRelationship } from './rows/store';
interface MenuItem {
label?: string;
@@ -122,6 +123,11 @@
if (['delete', 'update', 'duplicate-header'].includes(item.action) && isSystemColumn) {
return false;
}
// hide sort options for relationship columns
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item.action ?? '')) {
return false;
}
}
return true;
@@ -97,7 +97,7 @@
export let data: PageData;
export let showRowCreateSheet: {
show: boolean;
row: Models.Row | null;
row: Partial<Models.Row> | null;
};
$: rows = writable(data.rows);
@@ -297,7 +297,7 @@
switch (type) {
case 'string':
return IconText;
case 'float':
case 'double':
case 'integer':
return IconHashtag;
case 'boolean':
@@ -543,7 +543,13 @@
}
if (action === 'duplicate-row') {
showRowCreateSheet.row = row;
/**
* remove dates because
* console can override timestamps!
*/
const { $createdAt, $updatedAt, ...rowWithoutDates } = row;
showRowCreateSheet.row = rowWithoutDates;
showRowCreateSheet.show = true;
}
@@ -793,10 +799,11 @@
</Tooltip>
</Spreadsheet.Header.Cell>
{:else}
{@const structureColumn = $columns.find((col) => col.key === column.id)}
<SheetOptions
type="header"
columnId={column.id}
column={$columns.find((col) => col.key === column.id)}
column={structureColumn}
onSelect={(option, columnId) =>
onSelectSheetOption(option, columnId, 'header')}>
{#snippet children(toggle)}
@@ -818,10 +825,12 @@
<!-- array indicator -->
{#if column.array}[]{/if}
<SortButton
onSort={sort}
column={column.id}
state={sortState} />
{#if !isRelationship(structureColumn)}
<SortButton
onSort={sort}
column={column.id}
state={sortState} />
{/if}
</Layout.Stack>
</Spreadsheet.Header.Cell>
{/snippet}
@@ -1147,7 +1156,7 @@
</Table.Root>
<Layout.Stack direction="column" gap="m">
<Alert.Inline>To change the selection edit the relationship settings.</Alert.Inline>
<Alert.Inline title="To change the selection edit the relationship settings." />
</Layout.Stack>
{:else}
<p class="u-bold">This action is irreversible.</p>
@@ -1211,6 +1220,10 @@
padding-inline: 8px !important;
}
& :global(.input:has([type^='date'])) {
padding: 12px !important;
}
& :global(.input:focus-within) {
top: 0 !important;
}
@@ -33,9 +33,12 @@ export const tableColumns = writable<Column[]>([]);
export const isCsvImportInProgress = writable(false);
export const columnsOrder = writable<string[]>([]);
export const columnsWidth = writable<{
export type ColumnsWidth = {
[columnId: string]: { fixed: number | { min: number }; resized: number };
}>();
};
export const columnsWidth = writable<ColumnsWidth>();
type DatabaseSheetOptions = {
show: boolean;
@@ -6,7 +6,7 @@
import { Id } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { addNotification } from '$lib/stores/notifications';
import { canWriteTables } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
@@ -14,6 +14,7 @@
import type { PageData } from './$types';
import { tableViewColumns } from './store';
import Confirm from '$lib/components/confirm.svelte';
import { subNavigation } from '$lib/stores/database';
export let data: PageData;
const databaseId = page.params.database;
@@ -38,7 +39,8 @@
type: 'success',
message: `${selectedTables.length} table${selectedTables.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.TABLES);
await invalidate(Dependencies.TABLES);
subNavigation.update();
} catch (error) {
addNotification({
type: 'error',
@@ -76,7 +78,7 @@
{:else if column.id === 'name'}
{table.name}
{:else}
{toLocaleDateTime(table[column.id])}
<DualTimeView time={table[column.id]} />
{/if}
</Table.Cell>
{/each}
@@ -11,7 +11,7 @@
import type { ComponentType } from 'svelte';
export let name: string;
export let id: string;
export let id: string | null;
export let entrypoint: string;
export let showEntrypoint = false;
export let runtime: string;
@@ -51,7 +51,7 @@
let isSubmitting = writable(false);
let name = '';
let id: string;
let id: string | null = null;
let runtime: Runtime;
let entrypoint = '';
let buildCommand = '';
@@ -46,7 +46,7 @@
let isSubmitting = writable(false);
let name = '';
let id: string;
let id: string | null = null;
let runtime: Runtime;
let entrypoint = '';
let buildCommand = '';
@@ -49,7 +49,7 @@
let isSubmitting = writable(false);
let name = data.template.name;
let id: string;
let id: string | null = null;
let runtime: Runtime;
let branch = 'main';
let rootDir = './';
@@ -86,10 +86,11 @@
</svelte:fragment>
</Alert.Inline>
{:else}
<Alert.Inline status="info" dismissible on:dismiss={() => (showAlert = false)}>
Some configuration changes are not live yet. Your function is redeploying —
changes will be applied once the build is complete.
</Alert.Inline>
<Alert.Inline
status="info"
dismissible
on:dismiss={() => (showAlert = false)}
title="Some configuration changes are not live yet. Your function is redeploying — changes will be applied once the build is complete." />
{/if}
{/if}
<Layout.Stack gap="xxxl">
@@ -16,7 +16,7 @@
export let data;
onMount(() => {
return sdk.forConsole.client.subscribe('console', (response) => {
return sdk.forConsole.realtime.subscribe('console', (response) => {
if (response.events.includes('functions.*.executions.*')) {
invalidate(Dependencies.EXECUTIONS);
}
@@ -1,6 +1,7 @@
<script lang="ts">
import Button from '$lib/elements/forms/button.svelte';
import type { Models } from '@appwrite.io/console';
import { ExecutionStatus } from '@appwrite.io/console';
import { IconChevronDown, IconChevronUp } from '@appwrite.io/pink-icons-svelte';
import { calculateTime } from '$lib/helpers/timeConversion';
import {
@@ -112,7 +113,7 @@
<Tooltip
disabled={!selectedLog?.scheduledAt ||
selectedLog.status !== 'scheduled'}
selectedLog.status !== ExecutionStatus.Scheduled}
maxWidth="400px">
<div>
<Status

Some files were not shown because too many files have changed in this diff Show More