Merge remote-tracking branch 'origin/feat-vectordb' into feat-dedicated-db
# Conflicts: # bun.lock # package.json # src/lib/actions/analytics.ts # src/lib/stores/sdk.ts # src/routes/(console)/project-[region]-[project]/databases/+page.svelte # src/routes/(console)/project-[region]-[project]/databases/create.svelte # src/routes/(console)/project-[region]-[project]/databases/create/+page.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/sdk.ts # src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/helpers/terminology.ts # src/routes/(console)/project-[region]-[project]/databases/database-[database]/(entity)/views/create.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/(suggestions)/empty.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/+layout.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/backups/createPolicy.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/(components)/editor/view.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/columns/edit.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/createColumn.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/spreadsheet.svelte # src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store.ts # src/routes/(console)/project-[region]-[project]/databases/empty.svelte
@@ -4,6 +4,7 @@ Tracking file for all missing dedicated DB features in console vs cloud/edge/ddb
|
||||
**Status: ALL ITEMS COMPLETE**
|
||||
|
||||
## Legend
|
||||
|
||||
- [x] Done
|
||||
|
||||
---
|
||||
@@ -99,6 +100,7 @@ Tracking file for all missing dedicated DB features in console vs cloud/edge/ddb
|
||||
## Files Changed Summary
|
||||
|
||||
### New Files (28)
|
||||
|
||||
- `settings/updateName.svelte`
|
||||
- `settings/updateTier.svelte`
|
||||
- `settings/updateStorage.svelte`
|
||||
@@ -123,6 +125,7 @@ Tracking file for all missing dedicated DB features in console vs cloud/edge/ddb
|
||||
- `monitoring/+page.ts`
|
||||
|
||||
### Modified Files (5)
|
||||
|
||||
- `src/lib/sdk/dedicatedDatabases.ts` — Complete rewrite with all types, enums, and 45 SDK methods
|
||||
- `dedicatedOverview.svelte` — Added 5 new CardGrid sections + IP/storageClass fields
|
||||
- `settings/+page.svelte` — Rewritten with dedicated type branch + 19 sub-component imports
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# VectorDB Console Bugs
|
||||
|
||||
> VectorsDB shares the `collection-[collection]` route with documentsdb -- no separate pages needed.
|
||||
> The `sdk.ts` abstraction (`useDatabaseSdk`) already handles all three types.
|
||||
> The `.bind()` ternary in spreadsheet.svelte is intentional -- sdk.ts returns `Record` types but the spreadsheet expects raw `Models.Document`. Refactoring would require touching the entire document pipeline.
|
||||
|
||||
## ~~Critical -- Will break vectorsdb in production~~ FIXED
|
||||
|
||||
### ~~1. `collection-[collection]/+page.ts` -- listDocuments hardcoded to `documentsDB`~~ FIXED
|
||||
|
||||
### ~~2. `collection-[collection]/+page.svelte` -- EmptySheet type hardcoded to `"documentsdb"`~~ FIXED
|
||||
|
||||
### ~~3. `empty.svelte` -- vectorsdb gets tablesdb layout~~ FIXED
|
||||
|
||||
### ~~4. `empty.svelte` CSS -- no vectorsdb selector~~ FIXED
|
||||
|
||||
### ~~5. `empty.svelte` -- index-only check excludes vectorsdb~~ FIXED
|
||||
|
||||
---
|
||||
|
||||
## ~~Major -- UX bugs~~ FIXED
|
||||
|
||||
### ~~6. Keyboard hint hardcodes `Cmd` (macOS only)~~ FIXED
|
||||
|
||||
### 7. `Mod-g` conflicts with browser "Find next"
|
||||
|
||||
**File:** `editor/view.svelte:1137`
|
||||
`Cmd+G` / `Ctrl+G` is the standard "Find next" shortcut after `Cmd+F`.
|
||||
**Status:** Deprioritized -- CodeMirror captures keys when focused, minor edge case.
|
||||
|
||||
---
|
||||
|
||||
## Minor -- Cleanup / consistency
|
||||
|
||||
### 9. `getCollectionService()` type signature too broad
|
||||
|
||||
**File:** `(entity)/helpers/sdk.ts:118`
|
||||
Accepts all `DatabaseType` but only handles `documentsdb` and `vectorsdb`. Calling with `legacy` or `tablesdb` throws at runtime.
|
||||
**Fix:** Narrow parameter type to `Extract<DatabaseType, 'documentsdb' | 'vectorsdb'>`.
|
||||
|
||||
### 10. `create.svelte` -- `dimension` not reset on modal close
|
||||
|
||||
**File:** `(entity)/views/create.svelte`
|
||||
`id` and `name` are reset in `updateAndCleanup()` but `dimension` is not. Reopening the modal after creating a collection with dimension 1536 will still show 1536.
|
||||
**Fix:** Add `dimension = 768;` to the reset paths.
|
||||
|
||||
### 11. `create.svelte` -- No max bound on dimension input
|
||||
|
||||
**File:** `(entity)/views/create.svelte`
|
||||
The `InputNumber` has `min={1}` but no `max`. A user could enter an arbitrarily large value.
|
||||
**Fix:** Add a sensible `max` (e.g., 16384).
|
||||
|
||||
### 12. Magic number `768` repeated
|
||||
|
||||
**Files:** `create.svelte:39`, `+layout.svelte:277`
|
||||
**Fix:** Extract to a constant like `DEFAULT_VECTOR_DIMENSION`.
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@appwrite/console",
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@9fa70f4",
|
||||
"@appwrite.io/console": "github:appwrite/sdk-for-console#fc5ed94",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@b92a389",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@b92a389",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bfe7ce3",
|
||||
"@codemirror/autocomplete": "^6.19.0",
|
||||
"@codemirror/commands": "^6.9.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -26,10 +26,10 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.55.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@threlte/core": "^8.3.1",
|
||||
"@threlte/extras": "^9.7.1",
|
||||
"ai": "^6.0.67",
|
||||
"analytics": "^0.8.16",
|
||||
"@threlte/core": "^8.5.2",
|
||||
"@threlte/extras": "^9.13.0",
|
||||
"ai": "^6.0.138",
|
||||
"analytics": "^0.8.19",
|
||||
"codemirror-json5": "^1.0.3",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -38,7 +38,7 @@
|
||||
"flatted": "^3.4.2",
|
||||
"ignore": "^6.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"nanoid": "^5.1.7",
|
||||
"nanotar": "^0.1.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"remarkable": "^2.0.1",
|
||||
@@ -47,8 +47,8 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.3.1",
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
@@ -93,13 +93,13 @@
|
||||
"flatted": "^3.4.2",
|
||||
"immutable": "^5.1.5",
|
||||
"minimatch": "10.2.3",
|
||||
"picomatch": "^2.3.2",
|
||||
"picomatch": "^4.0.4",
|
||||
"vite": "npm:rolldown-vite@latest",
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uM7kpZB5l977lW7+2X1+klBUxIZQ78+1a9jHlaHFEzcOcmmslTl3sdP0QqfuuBcO0YBM2gwOiqVdp8i4TRQYcw=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.83", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LvlWujbSdEkTBXBLFtF7GS6riXdHhH0O+DpDrCaNQvXeHmSF2jKsOg7JWXiCgygAHM5cWFAO3JYmZp83DjiuBQ=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
|
||||
|
||||
@@ -123,15 +123,15 @@
|
||||
|
||||
"@analytics/type-utils": ["@analytics/type-utils@0.6.4", "", {}, "sha512-Ou1gQxFakOWLcPnbFVsrPb8g1wLLUZYYJXDPjHkG07+5mustGs5yqACx42UAu4A6NszNN6Z5gGxhyH45zPWRxw=="],
|
||||
|
||||
"@appwrite.io/console": ["@appwrite.io/console@https://pkg.vc/-/@appwrite/@appwrite.io/console@9fa70f4", { "dependencies": { "json-bigint": "1.0.0" } }],
|
||||
"@appwrite.io/console": ["@appwrite.io/console@github:appwrite/sdk-for-console#fc5ed94", { "dependencies": { "json-bigint": "1.0.0" } }, "appwrite-sdk-for-console-fc5ed94"],
|
||||
|
||||
"@appwrite.io/pink-icons": ["@appwrite.io/pink-icons@0.25.0", "", {}, "sha512-0O3i2oEuh5mWvjO80i+X6rbzrWLJ1m5wmv2/M3a1p2PyBJsFxN8xQMTEmTn3Wl/D26SsM7SpzbdW6gmfgoVU9Q=="],
|
||||
|
||||
"@appwrite.io/pink-icons-svelte": ["@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@b92a389", { "peerDependencies": { "svelte": "^4.0.0" } }],
|
||||
"@appwrite.io/pink-icons-svelte": ["@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3", { "peerDependencies": { "svelte": "^4.0.0" } }],
|
||||
|
||||
"@appwrite.io/pink-legacy": ["@appwrite.io/pink-legacy@1.0.3", "", { "dependencies": { "@appwrite.io/pink-icons": "1.0.0", "the-new-css-reset": "^1.11.2" } }, "sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ=="],
|
||||
|
||||
"@appwrite.io/pink-svelte": ["@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@b92a389", { "dependencies": { "@appwrite.io/pink-icons-svelte": "2.0.0-RC.1", "@floating-ui/dom": "^1.6.13", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@tanstack/svelte-virtual": "^3.13.10", "ansicolor": "^2.0.3", "d3": "^7.9.0", "fuse.js": "^7.1.0", "pretty-bytes": "^6.1.1", "shiki": "^1.18.0", "svelte-motion": "^0.12.2", "svelte-sonner": "^0.3.28" }, "peerDependencies": { "svelte": "^4.0.0" } }],
|
||||
"@appwrite.io/pink-svelte": ["@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bfe7ce3", { "dependencies": { "@appwrite.io/pink-icons-svelte": "2.0.0-RC.1", "@floating-ui/dom": "^1.6.13", "@melt-ui/pp": "^0.3.2", "@melt-ui/svelte": "^0.86.6", "@tanstack/svelte-virtual": "^3.13.10", "ansicolor": "^2.0.3", "d3": "^7.9.0", "fuse.js": "^7.1.0", "pretty-bytes": "^6.1.1", "shiki": "^1.18.0", "svelte-motion": "^0.12.2", "svelte-sonner": "^0.3.28" }, "peerDependencies": { "svelte": "^4.0.0" } }],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
|
||||
|
||||
@@ -499,9 +499,9 @@
|
||||
|
||||
"@threejs-kit/instanced-sprite-mesh": ["@threejs-kit/instanced-sprite-mesh@2.5.1", "", { "dependencies": { "diet-sprite": "^0.0.1", "earcut": "^2.2.4", "maath": "^0.10.7", "three-instanced-uniforms-mesh": "^0.52.4", "troika-three-utils": "^0.52.4" }, "peerDependencies": { "three": ">=0.170.0" } }, "sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA=="],
|
||||
|
||||
"@threlte/core": ["@threlte/core@8.5.2", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-noxIsYlEYRFBo0U3T8Z4PWkfe23VCDxaHIlSzSWlOlBgd+mhKrhyM8lFmeznmZQS78z4obkWUJeYxx/jauD+rw=="],
|
||||
"@threlte/core": ["@threlte/core@8.5.4", "", { "dependencies": { "mitt": "^3.0.1" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-hqFkD0/CHVUFh/FavLKCI5snYqwGL4InO9hpYgbf+XirKyx/atqiDoiCv/gVhPjQAojojyur8Frj+lNu8u/J5Q=="],
|
||||
|
||||
"@threlte/extras": ["@threlte/extras@9.13.0", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-fHt5VcOoXyBT+wuytRHlKa1SUxNEI15L/kpudVWE9Z0G+U5TWIf01mt0BdQBGJOqwVJYxwFzRJQxfS2/kLAl9Q=="],
|
||||
"@threlte/extras": ["@threlte/extras@9.13.3", "", { "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", "camera-controls": "^3.1.2", "three-mesh-bvh": "^0.9.1", "three-perf": "^1.0.11", "three-viewport-gizmo": "^2.2.0", "troika-three-text": "^0.52.4" }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "sha512-ElXna1kGuS9b9pCG5swChpwpbrDxoMVxchCG8mXjjcXk9HW8qZDicf2mq0SBll2fLP0Nb8iGlkskSJ04qqWCGg=="],
|
||||
|
||||
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||
|
||||
@@ -607,7 +607,7 @@
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ai": ["ai@6.0.138", "", { "dependencies": { "@ai-sdk/gateway": "3.0.80", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-49OfPe0f5uxJ6jUdA5BBXjIinP6+ZdYfAtpF2aEH64GA5wPcxH2rf/TBUQQ0bbamBz/D+TLMV18xilZqOC+zaA=="],
|
||||
"ai": ["ai@6.0.141", "", { "dependencies": { "@ai-sdk/gateway": "3.0.83", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+GomGQWaId3xN0wcugUW/H7xMMaFkID2PiS7K/Wugj45G3efv0BXhQ3psRZoQVoRbOpdNoUqcK/KTB+FR4h6qg=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
@@ -1191,7 +1191,7 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
|
||||
@@ -201,7 +201,9 @@ test.describe('Dedicated databases', () => {
|
||||
});
|
||||
|
||||
test('URL params pre-populate engine, tier, and name', async ({ page }) => {
|
||||
await page.goto(`${CREATE_URL}?type=dedicated&engine=mysql&tier=s-1vcpu-1gb&name=TestDB`);
|
||||
await page.goto(
|
||||
`${CREATE_URL}?type=dedicated&engine=mysql&tier=s-1vcpu-1gb&name=TestDB`
|
||||
);
|
||||
await waitForCreatePage(page, 'Configuration');
|
||||
|
||||
// Name should be pre-filled
|
||||
@@ -528,9 +530,7 @@ test.describe('Dedicated databases', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The list page should have a create button
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Create database/ })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Create database/ })).toBeVisible();
|
||||
|
||||
// At least one database link should be visible
|
||||
const databaseLinks = page.locator('a[href*="/databases/database-"]');
|
||||
@@ -578,11 +578,21 @@ test.describe('Dedicated databases', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// If it is a dedicated overview, check status-related elements
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
// Status badge should be present (Ready, Provisioning, etc.)
|
||||
const statusTexts = ['Ready', 'Provisioning', 'Active', 'Paused', 'Failed'];
|
||||
const results = await Promise.all(
|
||||
statusTexts.map((s) => page.getByText(s).isVisible().catch(() => false))
|
||||
statusTexts.map((s) =>
|
||||
page
|
||||
.getByText(s)
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
)
|
||||
);
|
||||
expect(results.some(Boolean)).toBeTruthy();
|
||||
}
|
||||
@@ -622,7 +632,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
const refreshButton = page.getByRole('button', { name: /Refresh/ });
|
||||
await expect(refreshButton).toBeVisible();
|
||||
}
|
||||
@@ -642,7 +657,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
// Should show either connection details or a provisioning message
|
||||
const connectionTitle = page.getByText('Connection', { exact: true });
|
||||
const provisioningMessage = page.getByText('Provisioning in progress');
|
||||
@@ -670,7 +690,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(page.getByText('High Availability')).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -687,7 +712,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(page.getByText('Network', { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -704,7 +734,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(page.getByText('Backups', { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -721,7 +756,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Status', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Status', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
// Security card was removed (encryption at rest is infra-level)
|
||||
await expect(page.getByText('Encryption at Rest')).not.toBeVisible();
|
||||
await expect(page.getByText('Key Management')).not.toBeVisible();
|
||||
@@ -740,7 +780,12 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
if (await page.getByText('Network', { exact: true }).isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByText('Network', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(page.getByText('Connection Timeout')).toBeVisible();
|
||||
// "Sleep After Idle" was renamed to "Scale-to-Zero After"
|
||||
await expect(page.getByText('Sleep After Idle')).not.toBeVisible();
|
||||
@@ -753,9 +798,7 @@ test.describe('Dedicated databases', () => {
|
||||
await authenticate(page);
|
||||
});
|
||||
|
||||
test('dedicated database header shows Overview tab instead of Tables', async ({
|
||||
page
|
||||
}) => {
|
||||
test('dedicated database header shows Overview tab instead of Tables', async ({ page }) => {
|
||||
await page.goto(DATABASES_URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
@@ -805,7 +848,13 @@ test.describe('Dedicated databases', () => {
|
||||
// Auth tab only visible for dedicated databases — scope to header tabs area
|
||||
const tabsArea = page.locator('[class*="tabs"], nav').filter({ hasText: 'Backups' });
|
||||
const authTab = tabsArea.getByRole('link', { name: 'Auth' });
|
||||
if (await page.getByRole('link', { name: 'Overview' }).first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await page
|
||||
.getByRole('link', { name: 'Overview' })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(authTab).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
@@ -933,8 +982,15 @@ test.describe('Dedicated databases', () => {
|
||||
await databaseLink.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const sidebarBackups = page.locator('a[href*="/backups"]').filter({ hasText: 'Backups' });
|
||||
if (await sidebarBackups.first().isVisible().catch(() => false)) {
|
||||
const sidebarBackups = page
|
||||
.locator('a[href*="/backups"]')
|
||||
.filter({ hasText: 'Backups' });
|
||||
if (
|
||||
await sidebarBackups
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(sidebarBackups.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -952,7 +1008,12 @@ test.describe('Dedicated databases', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const sidebarAuth = page.locator('a[href*="/auth"]').filter({ hasText: 'Auth' });
|
||||
if (await sidebarAuth.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await sidebarAuth
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(sidebarAuth.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -972,7 +1033,12 @@ test.describe('Dedicated databases', () => {
|
||||
const sidebarMonitoring = page
|
||||
.locator('a[href*="/monitoring"]')
|
||||
.filter({ hasText: 'Monitoring' });
|
||||
if (await sidebarMonitoring.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await sidebarMonitoring
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(sidebarMonitoring.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -992,7 +1058,12 @@ test.describe('Dedicated databases', () => {
|
||||
const sidebarSettings = page
|
||||
.locator('a[href*="/settings"]')
|
||||
.filter({ hasText: 'Settings' });
|
||||
if (await sidebarSettings.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await sidebarSettings
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(sidebarSettings.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1010,7 +1081,12 @@ test.describe('Dedicated databases', () => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const sidebarUsage = page.locator('a[href*="/usage"]').filter({ hasText: 'Usage' });
|
||||
if (await sidebarUsage.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await sidebarUsage
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(sidebarUsage.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1159,7 +1235,12 @@ test.describe('Dedicated databases', () => {
|
||||
const backupTitle = page
|
||||
.getByText('Backup', { exact: false })
|
||||
.filter({ hasText: /Backup/ });
|
||||
if (await backupTitle.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await backupTitle
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(backupTitle.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1192,7 +1273,12 @@ test.describe('Dedicated databases', () => {
|
||||
|
||||
// UpdatePooler is only rendered for postgres
|
||||
const poolerTitle = page.getByText('Connection pooler', { exact: false });
|
||||
if (await poolerTitle.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await poolerTitle
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(poolerTitle.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1216,7 +1302,12 @@ test.describe('Dedicated databases', () => {
|
||||
}
|
||||
|
||||
const replicasTitle = page.getByText('Read replicas', { exact: false });
|
||||
if (await replicasTitle.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await replicasTitle
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(replicasTitle.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1228,7 +1319,12 @@ test.describe('Dedicated databases', () => {
|
||||
}
|
||||
|
||||
const crossRegion = page.getByText('Cross-region', { exact: false });
|
||||
if (await crossRegion.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await crossRegion
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(crossRegion.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1240,7 +1336,12 @@ test.describe('Dedicated databases', () => {
|
||||
}
|
||||
|
||||
const storageTitle = page.getByText('Storage', { exact: true });
|
||||
if (await storageTitle.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await storageTitle
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(storageTitle.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1252,7 +1353,12 @@ test.describe('Dedicated databases', () => {
|
||||
}
|
||||
|
||||
const maintenanceTitle = page.getByText('Maintenance', { exact: false });
|
||||
if (await maintenanceTitle.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await maintenanceTitle
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(maintenanceTitle.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1264,7 +1370,12 @@ test.describe('Dedicated databases', () => {
|
||||
}
|
||||
|
||||
const autoscalingTitle = page.getByText('Autoscaling', { exact: false });
|
||||
if (await autoscalingTitle.first().isVisible().catch(() => false)) {
|
||||
if (
|
||||
await autoscalingTitle
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await expect(autoscalingTitle.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
@@ -1400,9 +1511,20 @@ test.describe('Dedicated databases', () => {
|
||||
// For legacy databases, the policies/backups view is shown.
|
||||
// Either way, the page should have loaded successfully.
|
||||
const contentVisibilities = await Promise.all([
|
||||
page.getByText('Policies', { exact: true }).isVisible().catch(() => false),
|
||||
page.getByText('Backups', { exact: true }).first().isVisible().catch(() => false),
|
||||
page.getByText('Backup', { exact: false }).first().isVisible().catch(() => false)
|
||||
page
|
||||
.getByText('Policies', { exact: true })
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
page
|
||||
.getByText('Backups', { exact: true })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false),
|
||||
page
|
||||
.getByText('Backup', { exact: false })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
]);
|
||||
const hasContent = contentVisibilities.some(Boolean);
|
||||
|
||||
@@ -1419,9 +1541,7 @@ test.describe('Dedicated databases', () => {
|
||||
await page.goto(DATABASES_URL);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Create database/ })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Create database/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('database list renders database type indicators', async ({ page }) => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default ts.config(
|
||||
// TODO: @itznotabug, this requires a big refactor!
|
||||
'svelte/no-navigation-without-resolve': 'warn',
|
||||
'svelte/prefer-svelte-reactivity': 'warn',
|
||||
'svelte/prefer-writable-derived': 'warn'
|
||||
'svelte/prefer-writable-derived': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@9fa70f4",
|
||||
"@appwrite.io/console": "github:appwrite/sdk-for-console#fc5ed94",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@b92a389",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@b92a389",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bfe7ce3",
|
||||
"@codemirror/autocomplete": "^6.19.0",
|
||||
"@codemirror/commands": "^6.9.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -40,10 +40,10 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.55.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@threlte/core": "^8.3.1",
|
||||
"@threlte/extras": "^9.7.1",
|
||||
"ai": "^6.0.67",
|
||||
"analytics": "^0.8.16",
|
||||
"@threlte/core": "^8.5.2",
|
||||
"@threlte/extras": "^9.13.0",
|
||||
"ai": "^6.0.138",
|
||||
"analytics": "^0.8.19",
|
||||
"codemirror-json5": "^1.0.3",
|
||||
"cron-parser": "^4.9.0",
|
||||
"dayjs": "^1.11.20",
|
||||
@@ -52,7 +52,7 @@
|
||||
"flatted": "^3.4.2",
|
||||
"ignore": "^6.0.2",
|
||||
"json5": "^2.2.3",
|
||||
"nanoid": "^5.1.5",
|
||||
"nanoid": "^5.1.7",
|
||||
"nanotar": "^0.1.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"remarkable": "^2.0.1",
|
||||
@@ -61,8 +61,8 @@
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.3.1",
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
@@ -106,6 +106,6 @@
|
||||
"minimatch": "10.2.3",
|
||||
"immutable": "^5.1.5",
|
||||
"flatted": "^3.4.2",
|
||||
"picomatch": "^2.3.2"
|
||||
"picomatch": "^4.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,8 +154,8 @@ export enum Click {
|
||||
DatabaseRowDelete = 'click_row_delete',
|
||||
DatabaseDatabaseDelete = 'click_database_delete',
|
||||
DatabaseImportCsv = 'click_database_import_csv',
|
||||
DatabaseImportJson = 'click_database_import_json',
|
||||
DatabaseExportCsv = 'click_database_export_csv',
|
||||
DatabaseImportJson = 'click_database_import_json',
|
||||
DomainCreateClick = 'click_domain_create',
|
||||
DomainDeleteClick = 'click_domain_delete',
|
||||
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
id = $bindable(null),
|
||||
autofocus = true,
|
||||
isProject = false,
|
||||
required = true
|
||||
required = true,
|
||||
syncFrom = undefined,
|
||||
disabled = false
|
||||
}: {
|
||||
show: boolean;
|
||||
name: string;
|
||||
@@ -20,8 +22,29 @@
|
||||
autofocus?: boolean;
|
||||
isProject?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
syncFrom?: string | undefined;
|
||||
} = $props();
|
||||
|
||||
let touchedId = $state(false);
|
||||
|
||||
function toIdFormat(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\-_. ]+/g, '')
|
||||
.replace(/ /g, '_')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/\.+$/, '')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.slice(0, 36); // max length
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
if (!touchedId) {
|
||||
touchedId = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
id = null;
|
||||
@@ -37,6 +60,21 @@
|
||||
trackEvent(Click.ShowCustomIdClick);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (syncFrom && !touchedId) {
|
||||
const newId = toIdFormat(syncFrom);
|
||||
if (id !== newId) {
|
||||
id = newId;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
touchedId = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
@@ -61,9 +99,9 @@
|
||||
<Divider />
|
||||
</span>
|
||||
{#if isProject}
|
||||
<InputProjectId on:input bind:value={id} {autofocus} />
|
||||
<InputProjectId {disabled} on:input={handleInput} bind:value={id} {autofocus} />
|
||||
{:else}
|
||||
<InputId {required} on:input bind:value={id} {autofocus} />
|
||||
<InputId {disabled} {required} on:input={handleInput} bind:value={id} {autofocus} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
columnId = $bindable(null),
|
||||
arrayValues = $bindable([]),
|
||||
operatorKey = $bindable(null),
|
||||
singleCondition = false
|
||||
singleCondition = false,
|
||||
schema = true
|
||||
}: {
|
||||
// We cast to any to not cause type errors in the input components
|
||||
/* eslint @typescript-eslint/no-explicit-any: 'off' */
|
||||
@@ -36,11 +37,40 @@
|
||||
arrayValues?: string[];
|
||||
operatorKey?: string | null;
|
||||
singleCondition?: boolean;
|
||||
schema?: boolean;
|
||||
} = $props();
|
||||
|
||||
let columnsArray = $derived($columns);
|
||||
let column = $derived(columnsArray.find((c) => c.id === columnId));
|
||||
let operatorsForColumn = $derived.by(() => {
|
||||
const systemFieldColumns: Record<string, Column> = {
|
||||
$id: { id: '$id', title: '$id', type: 'string' },
|
||||
$createdAt: { id: '$createdAt', title: '$createdAt', type: 'datetime' },
|
||||
$updatedAt: { id: '$updatedAt', title: '$updatedAt', type: 'datetime' }
|
||||
};
|
||||
|
||||
const columnsArray = $derived($columns);
|
||||
const isCustomAttribute = $derived(
|
||||
!schema &&
|
||||
columnId &&
|
||||
!systemFieldColumns[columnId] &&
|
||||
!columnsArray.find((c) => c.id === columnId)
|
||||
);
|
||||
const column = $derived.by(() => {
|
||||
if (!schema && columnId) {
|
||||
if (systemFieldColumns[columnId]) {
|
||||
return systemFieldColumns[columnId];
|
||||
}
|
||||
const existingColumn = columnsArray.find((c) => c.id === columnId);
|
||||
if (!existingColumn) {
|
||||
return { id: columnId, title: columnId, type: 'string' } as Column;
|
||||
}
|
||||
return existingColumn;
|
||||
}
|
||||
return columnsArray.find((c) => c.id === columnId);
|
||||
});
|
||||
|
||||
const operatorsForColumn = $derived.by(() => {
|
||||
if (!schema && (!column || isCustomAttribute)) {
|
||||
return Object.entries(operators).map(([k]) => ({ label: k, value: k }));
|
||||
}
|
||||
if (!column?.type) return [];
|
||||
return Object.entries(operators)
|
||||
.filter(([, v]) => v.types.includes(column.type))
|
||||
@@ -104,16 +134,46 @@
|
||||
|
||||
const dispatch = createEventDispatcher<{ clear: void; apply: { applied: number } }>();
|
||||
|
||||
function coerceValueByOperatorType(value: any, operatorTypes: string[]): any {
|
||||
if (typeof value !== 'string' || !value) return value;
|
||||
|
||||
if (operatorTypes.includes('integer') || operatorTypes.includes('double')) {
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue) && value.trim() !== '') {
|
||||
return numValue;
|
||||
}
|
||||
} else if (operatorTypes.includes('boolean')) {
|
||||
const lowerValue = value.toLowerCase().trim();
|
||||
if (lowerValue === 'true' || lowerValue === '1') {
|
||||
return true;
|
||||
} else if (lowerValue === 'false' || lowerValue === '0') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function addFilterAndReset() {
|
||||
const columnsWithVirtual =
|
||||
column && !columnsArray.find((c) => c.id === columnId)
|
||||
? [...columnsArray, column]
|
||||
: columnsArray;
|
||||
|
||||
// For distance operators, pass the distance as a separate parameter
|
||||
if (isDistanceOperator && distanceValue !== null && value !== null) {
|
||||
addFilter(columnsArray, columnId, operatorKey, value, arrayValues, distanceValue);
|
||||
addFilter(columnsWithVirtual, columnId, operatorKey, value, arrayValues, distanceValue);
|
||||
} else {
|
||||
const preparedValue =
|
||||
column?.type === 'datetime' && typeof value === 'string' && value
|
||||
? new Date(value).toISOString()
|
||||
: value;
|
||||
addFilter(columnsArray, columnId, operatorKey, preparedValue, arrayValues);
|
||||
let preparedValue = value;
|
||||
|
||||
if (column?.type === 'datetime' && typeof value === 'string' && value) {
|
||||
preparedValue = new Date(value).toISOString();
|
||||
} else if (!schema) {
|
||||
const operatorTypes = operator?.types || [];
|
||||
preparedValue = coerceValueByOperatorType(value, operatorTypes);
|
||||
}
|
||||
|
||||
addFilter(columnsWithVirtual, columnId, operatorKey, preparedValue, arrayValues);
|
||||
}
|
||||
|
||||
columnId = null;
|
||||
@@ -135,19 +195,23 @@
|
||||
addFilterAndReset();
|
||||
}}>
|
||||
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
|
||||
<InputSelect
|
||||
id="column"
|
||||
options={columnOptions}
|
||||
placeholder="Select column"
|
||||
bind:value={columnId} />
|
||||
{#if schema}
|
||||
<InputSelect
|
||||
id="column"
|
||||
options={columnOptions}
|
||||
placeholder="Select column"
|
||||
bind:value={columnId} />
|
||||
{:else}
|
||||
<InputText id="column" placeholder="Enter attribute name" bind:value={columnId} />
|
||||
{/if}
|
||||
<InputSelect
|
||||
id="operator"
|
||||
disabled={!column}
|
||||
disabled={!column && schema}
|
||||
options={operatorsForColumn}
|
||||
placeholder="Select operator"
|
||||
bind:value={operatorKey} />
|
||||
</Layout.Stack>
|
||||
{#if column && operator && !operator?.hideInput}
|
||||
{#if (column || (!schema && columnId)) && operator && !operator?.hideInput}
|
||||
{#if column?.array}
|
||||
{#if column.format === 'enum'}
|
||||
<InputSelectCheckbox
|
||||
@@ -165,15 +229,15 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="u-margin-block-start-8">
|
||||
{#if column.format === 'enum'}
|
||||
{#if column?.format === 'enum'}
|
||||
<InputSelect
|
||||
id="value"
|
||||
bind:value
|
||||
placeholder="Select value"
|
||||
options={enumOptions} />
|
||||
{:else if column.type === 'integer' || column.type === 'double'}
|
||||
{:else if column?.type === 'integer' || column?.type === 'double'}
|
||||
<InputNumber id="value" bind:value placeholder="Enter value" />
|
||||
{:else if column.type === 'boolean'}
|
||||
{:else if column?.type === 'boolean'}
|
||||
<InputSelect
|
||||
id="value"
|
||||
placeholder="Select a value"
|
||||
@@ -183,11 +247,11 @@
|
||||
{ label: 'False', value: false }
|
||||
]}
|
||||
bind:value />
|
||||
{:else if column.type === 'datetime'}
|
||||
{:else if column?.type === 'datetime'}
|
||||
{#key value}
|
||||
<InputDateTime id="value" bind:value step={60} type="datetime-local" />
|
||||
{/key}
|
||||
{:else if column.type === 'point' || column.type === 'linestring' || column.type === 'polygon'}
|
||||
{:else if column?.type === 'point' || column?.type === 'linestring' || column?.type === 'polygon'}
|
||||
<InputPoint
|
||||
values={value || [0, 0]}
|
||||
onChangePoint={(index, newValue) => {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
export let enableApply = false;
|
||||
export let quickFilters = false;
|
||||
export let analyticsSource = '';
|
||||
export let schema = true;
|
||||
let displayQuickFilters = quickFilters;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -186,6 +187,7 @@
|
||||
bind:distanceValue
|
||||
bind:arrayValues
|
||||
{columns}
|
||||
{schema}
|
||||
{singleCondition}
|
||||
on:apply={afterApply}
|
||||
on:clear={() => (filtersAppliedCount = 0)} />
|
||||
@@ -243,6 +245,7 @@
|
||||
{:else}
|
||||
<Content
|
||||
{columns}
|
||||
{schema}
|
||||
bind:columnId={selectedColumn}
|
||||
bind:operatorKey
|
||||
bind:value
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
showAnyway?: boolean;
|
||||
disableButton?: boolean;
|
||||
onCustomOptionClick?: () => void;
|
||||
onPreferencesUpdated?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -36,7 +37,8 @@
|
||||
allowNoColumns = false,
|
||||
showAnyway = false,
|
||||
disableButton = false,
|
||||
onCustomOptionClick = null
|
||||
onCustomOptionClick = null,
|
||||
onPreferencesUpdated = null
|
||||
}: Props = $props();
|
||||
|
||||
let showCountBadge = $state(false);
|
||||
@@ -55,6 +57,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreferencesUpdated() {
|
||||
updateBadgeState();
|
||||
onPreferencesUpdated?.();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
showCountBadge = !onlyIcon || !!preferences.getKey(preferenceKey, false);
|
||||
});
|
||||
@@ -68,7 +75,7 @@
|
||||
{isCustomTable}
|
||||
{allowNoColumns}
|
||||
{onCustomOptionClick}
|
||||
onPreferencesUpdated={updateBadgeState}>
|
||||
onPreferencesUpdated={handlePreferencesUpdated}>
|
||||
{#snippet children(toggle, selectedColumnsNumber)}
|
||||
<Button.Button
|
||||
size="s"
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
snapThreshold = 3,
|
||||
disabled = false,
|
||||
id = ID.unique(),
|
||||
extraBlockStart = false,
|
||||
onchange
|
||||
}: {
|
||||
min?: number;
|
||||
@@ -29,6 +30,7 @@
|
||||
snapThreshold?: number;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
extraBlockStart?: boolean;
|
||||
onchange?: (value: number) => void;
|
||||
} = $props();
|
||||
|
||||
@@ -164,7 +166,12 @@
|
||||
</script>
|
||||
|
||||
<div class="seekbar">
|
||||
<div class="track" role="presentation" bind:this={trackRef} onclick={handleTrackClick}>
|
||||
<div
|
||||
class="track"
|
||||
class:extra-space={extraBlockStart}
|
||||
role="presentation"
|
||||
bind:this={trackRef}
|
||||
onclick={handleTrackClick}>
|
||||
{#if maxAllowed < max}
|
||||
<div
|
||||
class="disabled-area"
|
||||
@@ -238,12 +245,13 @@
|
||||
}
|
||||
|
||||
.breakpoints {
|
||||
position: absolute;
|
||||
top: calc(1.25rem + var(--seekbar-height) + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
position: absolute;
|
||||
margin-inline: 1px;
|
||||
pointer-events: none;
|
||||
top: calc(1.25rem + var(--seekbar-height) + 8px);
|
||||
}
|
||||
|
||||
.breakpoint {
|
||||
@@ -267,6 +275,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.extra-space {
|
||||
margin-block-start: 2px;
|
||||
}
|
||||
|
||||
.disabled-area {
|
||||
top: 0;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export let value = '';
|
||||
export let autofocus = true;
|
||||
export let required = false;
|
||||
export let disabled = false;
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
|
||||
let error = false;
|
||||
@@ -25,6 +26,7 @@
|
||||
{autofocus}
|
||||
{required}
|
||||
{leadingIcon}
|
||||
{disabled}
|
||||
id="id"
|
||||
placeholder="Enter ID"
|
||||
maxlength={36}
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
{readonly}
|
||||
{disabled}
|
||||
{required}
|
||||
{min}
|
||||
{max}
|
||||
min={min != null ? Number(min) : null}
|
||||
max={max != null ? Number(max) : null}
|
||||
{label}
|
||||
{step}
|
||||
{nullable}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export let value = '';
|
||||
export let autofocus = true;
|
||||
export let disabled = false;
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
|
||||
let error = false;
|
||||
@@ -24,6 +25,7 @@
|
||||
{pattern}
|
||||
{autofocus}
|
||||
{leadingIcon}
|
||||
{disabled}
|
||||
id="id"
|
||||
placeholder="Enter ID"
|
||||
maxlength={36}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export let helper: string | undefined = undefined;
|
||||
export let pattern: string | undefined = undefined;
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
export let max: number | undefined = undefined;
|
||||
|
||||
let value = '';
|
||||
let error: string;
|
||||
@@ -30,13 +31,29 @@
|
||||
$: if (value) {
|
||||
error = null;
|
||||
}
|
||||
|
||||
$: {
|
||||
// filter out empty or whitespace-only tags
|
||||
const cleaned = tags.filter((tag) => tag.trim().length > 0);
|
||||
if (cleaned.length !== tags.length) {
|
||||
tags = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
$: if (max !== undefined && tags.length > max) {
|
||||
error = `Maximum ${max} fields allowed`;
|
||||
} else if (max === undefined || tags.length <= max) {
|
||||
error = null;
|
||||
}
|
||||
|
||||
$: isDisabled = disabled || (max !== undefined && tags.length >= max);
|
||||
</script>
|
||||
|
||||
<Input.Tags
|
||||
{label}
|
||||
{id}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
disabled={isDisabled}
|
||||
{pattern}
|
||||
{required}
|
||||
{leadingIcon}
|
||||
@@ -44,3 +61,11 @@
|
||||
helper={error || helper}
|
||||
on:invalid={handleInvalid}
|
||||
state={error ? 'error' : 'default'}><slot name="info" slot="info" /></Input.Tags>
|
||||
|
||||
<style>
|
||||
/* hotfix due to root styles increasing block-size */
|
||||
:global(.tag) {
|
||||
border: none;
|
||||
block-size: unset;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -79,11 +79,11 @@ export async function generateFields(
|
||||
/**
|
||||
* Schema-less database types that don't require individual field creation:
|
||||
* - documentsdb: Flexible document structure without predefined schema
|
||||
* - vectordb: Vector embeddings and metadata are defined at collection creation
|
||||
* - vectorsdb: Vector embeddings and metadata are defined at collection creation
|
||||
* @returns Empty array since no individual field creation is needed
|
||||
*/
|
||||
case 'documentsdb':
|
||||
case 'vectordb': {
|
||||
case 'vectorsdb': {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
import type { BeforeNavigate } from '@sveltejs/kit';
|
||||
|
||||
type UnsavedChangesGuardOptions = {
|
||||
@@ -6,13 +6,15 @@ type UnsavedChangesGuardOptions = {
|
||||
hasUnsavedChanges: () => boolean;
|
||||
onConfirmNavigate?: () => void;
|
||||
shouldBlockNavigation?: (navigation: BeforeNavigate) => boolean;
|
||||
onShowConfirmModal?: (url: string, onConfirm: () => void | Promise<void>) => void;
|
||||
};
|
||||
|
||||
export const setupUnsavedChangesGuard = ({
|
||||
message,
|
||||
hasUnsavedChanges,
|
||||
onConfirmNavigate,
|
||||
shouldBlockNavigation
|
||||
shouldBlockNavigation,
|
||||
onShowConfirmModal
|
||||
}: UnsavedChangesGuardOptions) => {
|
||||
message = message ?? 'You have unsaved changes. Are you sure you want to leave?';
|
||||
|
||||
@@ -27,6 +29,22 @@ export const setupUnsavedChangesGuard = ({
|
||||
if (!hasUnsavedChanges()) return;
|
||||
if (shouldBlockNavigation && !shouldBlockNavigation(navigation)) return;
|
||||
|
||||
// If custom modal handler is provided, use it
|
||||
if (onShowConfirmModal && navigation.to?.url) {
|
||||
navigation.cancel();
|
||||
const targetUrl = navigation.to.url.href;
|
||||
const handleConfirm = async () => {
|
||||
onConfirmNavigate?.();
|
||||
|
||||
// eslint-disable-next-line
|
||||
await goto(targetUrl);
|
||||
};
|
||||
|
||||
onShowConfirmModal(targetUrl, handleConfirm);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to native confirm dialog
|
||||
if (!confirm(message)) {
|
||||
navigation.cancel();
|
||||
return;
|
||||
|
||||
@@ -74,6 +74,10 @@
|
||||
|
||||
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
beforeNavigate((nav) => {
|
||||
if (running) {
|
||||
complete();
|
||||
}
|
||||
|
||||
if (progressBarStartTimeout) {
|
||||
clearTimeout(progressBarStartTimeout);
|
||||
progressBarStartTimeout = null;
|
||||
|
||||
@@ -15,7 +15,7 @@ export type MigrationResource =
|
||||
|
||||
// Appwrite enum is the superset of all provider resources — used as a
|
||||
// provider-agnostic reference. The addResource guard filters by provider.
|
||||
export const ResourceType = AppwriteMigrationResource;
|
||||
export const MigrationResources = AppwriteMigrationResource;
|
||||
|
||||
type ProviderResourceMap = {
|
||||
appwrite: AppwriteMigrationResource[];
|
||||
@@ -107,46 +107,46 @@ export const migrationFormToResources = <P extends Provider>(
|
||||
};
|
||||
|
||||
if (formData.users.root) {
|
||||
addResource(ResourceType.User);
|
||||
addResource(MigrationResources.User);
|
||||
if (formData.users.teams) {
|
||||
addResource(ResourceType.Team);
|
||||
addResource(ResourceType.Membership);
|
||||
addResource(MigrationResources.Team);
|
||||
addResource(MigrationResources.Membership);
|
||||
}
|
||||
}
|
||||
if (formData.databases.root) {
|
||||
addResource(ResourceType.Database);
|
||||
addResource(ResourceType.Table);
|
||||
addResource(ResourceType.Column);
|
||||
addResource(ResourceType.Index);
|
||||
addResource(MigrationResources.Database);
|
||||
addResource(MigrationResources.Table);
|
||||
addResource(MigrationResources.Column);
|
||||
addResource(MigrationResources.Index);
|
||||
}
|
||||
if (formData.databases.rows) {
|
||||
addResource(ResourceType.Row);
|
||||
addResource(MigrationResources.Row);
|
||||
}
|
||||
if (formData.storage.root) {
|
||||
addResource(ResourceType.Bucket);
|
||||
addResource(ResourceType.File);
|
||||
addResource(MigrationResources.Bucket);
|
||||
addResource(MigrationResources.File);
|
||||
}
|
||||
if (formData.functions.root) {
|
||||
addResource(ResourceType.Function);
|
||||
addResource(ResourceType.Environmentvariable);
|
||||
addResource(MigrationResources.Function);
|
||||
addResource(MigrationResources.Environmentvariable);
|
||||
if (formData.functions.deploymentInactive) {
|
||||
addResource(ResourceType.Deployment);
|
||||
addResource(MigrationResources.Deployment);
|
||||
}
|
||||
}
|
||||
if (formData.sites.root) {
|
||||
addResource(ResourceType.Site);
|
||||
addResource(ResourceType.Sitevariable);
|
||||
addResource(MigrationResources.Site);
|
||||
addResource(MigrationResources.Sitevariable);
|
||||
if (formData.sites.deploymentInactive) {
|
||||
addResource(ResourceType.Sitedeployment);
|
||||
addResource(MigrationResources.Sitedeployment);
|
||||
}
|
||||
}
|
||||
if (formData.messaging.root) {
|
||||
addResource(ResourceType.Provider);
|
||||
addResource(ResourceType.Topic);
|
||||
addResource(ResourceType.Subscriber);
|
||||
addResource(MigrationResources.Provider);
|
||||
addResource(MigrationResources.Topic);
|
||||
addResource(MigrationResources.Subscriber);
|
||||
}
|
||||
if (formData.messaging.messages) {
|
||||
addResource(ResourceType.Message);
|
||||
addResource(MigrationResources.Message);
|
||||
}
|
||||
|
||||
return resources as ProviderResourceMap[P];
|
||||
@@ -175,45 +175,53 @@ export const isVersionAtLeast = (version: string, atLeast: string) => {
|
||||
|
||||
export const resourcesToMigrationForm = (resources: MigrationResource[]): MigrationFormData => {
|
||||
const formData = { ...initialFormData };
|
||||
if (resources.includes(ResourceType.User)) {
|
||||
if (resources.includes(MigrationResources.User)) {
|
||||
formData.users.root = true;
|
||||
}
|
||||
if (
|
||||
includesAll(resources, [ResourceType.Team, ResourceType.Membership] as MigrationResource[])
|
||||
includesAll(resources, [
|
||||
MigrationResources.Team,
|
||||
MigrationResources.Membership
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.users.teams = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Database)) {
|
||||
if (resources.includes(MigrationResources.Database)) {
|
||||
formData.databases.root = true;
|
||||
}
|
||||
if (
|
||||
includesAll(resources, [
|
||||
ResourceType.Table,
|
||||
ResourceType.Column,
|
||||
ResourceType.Row
|
||||
MigrationResources.Table,
|
||||
MigrationResources.Column,
|
||||
MigrationResources.Row
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.databases.rows = true;
|
||||
}
|
||||
if (includesAll(resources, [ResourceType.Bucket, ResourceType.File] as MigrationResource[])) {
|
||||
if (
|
||||
includesAll(resources, [
|
||||
MigrationResources.Bucket,
|
||||
MigrationResources.File
|
||||
] as MigrationResource[])
|
||||
) {
|
||||
formData.storage.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Function)) {
|
||||
if (resources.includes(MigrationResources.Function)) {
|
||||
formData.functions.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Deployment)) {
|
||||
if (resources.includes(MigrationResources.Deployment)) {
|
||||
formData.functions.deploymentInactive = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Site)) {
|
||||
if (resources.includes(MigrationResources.Site)) {
|
||||
formData.sites.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Sitedeployment)) {
|
||||
if (resources.includes(MigrationResources.Sitedeployment)) {
|
||||
formData.sites.deploymentInactive = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Provider)) {
|
||||
if (resources.includes(MigrationResources.Provider)) {
|
||||
formData.messaging.root = true;
|
||||
}
|
||||
if (resources.includes(ResourceType.Message)) {
|
||||
if (resources.includes(MigrationResources.Message)) {
|
||||
formData.messaging.messages = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Pathname, RouteId, RouteParams } from '$app/types';
|
||||
import type { Pathname, ResolvedPathname, RouteId, RouteParams } from '$app/types';
|
||||
|
||||
// taken directly from svelte's source!
|
||||
type ResolveArgs<T extends RouteId | Pathname> = T extends RouteId
|
||||
@@ -20,7 +20,10 @@ export function withPath(base: string, ...parts: string[]) {
|
||||
return [normalizedBase, ...normalizedParts].join('/');
|
||||
}
|
||||
|
||||
export function resolveRoute<T extends RouteId>(route: T, params?: Record<string, string>) {
|
||||
export function resolveRoute<T extends RouteId>(
|
||||
route: T,
|
||||
params?: Record<string, string>
|
||||
): ResolvedPathname {
|
||||
// type cast is necessary here!
|
||||
const resolveArgs = params ? ([route, params] as [T, RouteParams<T>]) : [route];
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@ import {
|
||||
Tokens,
|
||||
TablesDB,
|
||||
Domains,
|
||||
Webhooks,
|
||||
DocumentsDB,
|
||||
Webhooks,
|
||||
Compute,
|
||||
Realtime,
|
||||
Organizations
|
||||
Organizations,
|
||||
VectorsDB
|
||||
} from '@appwrite.io/console';
|
||||
import { Sources } from '$lib/sdk/sources';
|
||||
import {
|
||||
@@ -143,8 +144,9 @@ const sdkForProject = {
|
||||
tablesDB: new TablesDB(clientProject),
|
||||
documentsDB: new DocumentsDB(clientProject),
|
||||
compute: new Compute(clientProject),
|
||||
console: new Console(clientProject), // for suggestions API
|
||||
webhooks: new Webhooks(clientProject)
|
||||
vectorsDB: new VectorsDB(clientProject),
|
||||
webhooks: new Webhooks(clientProject),
|
||||
console: new Console(clientProject) // for suggestions API
|
||||
};
|
||||
|
||||
export const realtime = {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
export const viewportWidth = writable(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
|
||||
export const isSmallViewport = writable(false);
|
||||
export const isTabletViewport = writable(false);
|
||||
@@ -6,6 +8,8 @@ export const isDesktopViewport = writable(false);
|
||||
|
||||
export function updateViewport() {
|
||||
if (typeof window !== 'undefined') {
|
||||
viewportWidth.set(window.innerWidth);
|
||||
|
||||
isSmallViewport.set(
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false
|
||||
);
|
||||
@@ -18,3 +22,7 @@ export function updateViewport() {
|
||||
isDesktopViewport.set(!get(isSmallViewport) && !get(isTabletViewport));
|
||||
}
|
||||
}
|
||||
|
||||
export function isViewPortWidthInRange(min: number, max: number) {
|
||||
return derived(viewportWidth, ($width) => $width >= min && $width <= max);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
type MigrationResource,
|
||||
providerResources,
|
||||
resourcesToMigrationForm,
|
||||
ResourceType
|
||||
MigrationResources
|
||||
} from '$lib/stores/migration';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { wizard } from '$lib/stores/wizard';
|
||||
@@ -97,24 +97,27 @@
|
||||
|
||||
const shouldRenderGroup = (groupKey: string): boolean => {
|
||||
if (groupKey === 'storage') {
|
||||
return resources.includes(ResourceType.Bucket) && resources.includes(ResourceType.File);
|
||||
return (
|
||||
resources.includes(MigrationResources.Bucket) &&
|
||||
resources.includes(MigrationResources.File)
|
||||
);
|
||||
}
|
||||
|
||||
if (groupKey === 'functions') {
|
||||
return resources.includes(ResourceType.Function);
|
||||
return resources.includes(MigrationResources.Function);
|
||||
}
|
||||
|
||||
if (groupKey === 'sites') {
|
||||
return resources.includes(ResourceType.Site);
|
||||
return resources.includes(MigrationResources.Site);
|
||||
}
|
||||
|
||||
if (groupKey === 'messaging') {
|
||||
return resources.includes(ResourceType.Provider);
|
||||
return resources.includes(MigrationResources.Provider);
|
||||
}
|
||||
|
||||
const groupToResource: Record<string, MigrationResource> = {
|
||||
users: ResourceType.User,
|
||||
databases: ResourceType.Database
|
||||
users: MigrationResources.User,
|
||||
databases: MigrationResources.Database
|
||||
};
|
||||
const resource = groupToResource[groupKey];
|
||||
return resource ? resources.includes(resource) : false;
|
||||
@@ -140,7 +143,7 @@
|
||||
|
||||
<Layout.Stack gap="l">
|
||||
{#if error}
|
||||
<Alert.Inline status="error" title="Couldn’t load resources">
|
||||
<Alert.Inline status="error" title="Couldn't load resources">
|
||||
{#if migrationType === 'provider'}
|
||||
Please double-check your credentials from the previous step and try again.
|
||||
{:else}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import ResourceForm from './resource-form.svelte';
|
||||
import { requestedMigration } from '$routes/store';
|
||||
import { formData, provider, selectedProject, selectedRegion } from '.';
|
||||
import { ID, type Models, Query } from '@appwrite.io/console';
|
||||
import { ID, type Models, Query, AppwriteMigrationResource } from '@appwrite.io/console';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
import {
|
||||
Button,
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
function getProjectName(): string {
|
||||
return isExisting ? currentSelectedProject.name : newProjName || 'Appwrite project';
|
||||
return isExisting ? currentSelectedProject.name : newProjName || 'New project';
|
||||
}
|
||||
|
||||
async function createNewProject() {
|
||||
@@ -114,7 +114,7 @@
|
||||
|
||||
try {
|
||||
await projectSdkInstance.migrations.createAppwriteMigration({
|
||||
resources,
|
||||
resources: resources as AppwriteMigrationResource[],
|
||||
endpoint: $provider.endpoint,
|
||||
projectId: $provider.projectID,
|
||||
apiKey: $provider.apiKey
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
let projectId = ID.unique();
|
||||
let projectRegion = Region.Fra;
|
||||
let projectName = 'Appwrite Project';
|
||||
let projectName = 'New Project';
|
||||
|
||||
const projectIdForLog = projectId;
|
||||
|
||||
|
||||
@@ -69,7 +69,11 @@
|
||||
?.$id;
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
previousPage = from?.url?.pathname || previousPage;
|
||||
if (from?.url) {
|
||||
const search = from.url.search;
|
||||
const pathname = from.url.pathname;
|
||||
previousPage = search ? `${pathname}${search}` : pathname;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
|
||||
@@ -286,5 +286,9 @@
|
||||
:global(main:has([data-side-sheet-visible='true']) .layout-level-progress-bars) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:global(body:has(section .wizard) .layout-level-progress-bars) {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
let { children } = $props();
|
||||
|
||||
const authProjectRoute = $derived.by(() => {
|
||||
return resolveRoute('/(console)/project-[region]-[project]/auth', {
|
||||
...page.params
|
||||
});
|
||||
return resolveRoute('/(console)/project-[region]-[project]/auth', page.params);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -25,11 +25,7 @@
|
||||
message: `${user.name ? user.name : 'User'} has been deleted`
|
||||
});
|
||||
trackEvent(Submit.UserDelete);
|
||||
await goto(
|
||||
resolveRoute('/(console)/project-[region]-[project]/auth', {
|
||||
...page.params
|
||||
})
|
||||
);
|
||||
await goto(resolveRoute('/(console)/project-[region]-[project]/auth', page.params));
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
trackError(e, Submit.UserDelete);
|
||||
|
||||
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 205 KiB |
@@ -1,27 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="82" height="20" viewBox="0 0 82 20" fill="none">
|
||||
<g clip-path="url(#clip0_2754_71636)">
|
||||
<path d="M6.12566 2.06075C5.32306 1.14006 4.63192 0.204996 4.49073 0.0107893C4.47586 -0.00359643 4.45356 -0.00359643 4.4387 0.0107893C4.2975 0.204996 3.60637 1.14006 2.80376 2.06075C-4.08526 10.5555 3.88877 16.2882 3.88877 16.2882L3.95565 16.3314C4.0151 17.216 4.16373 18.4892 4.16373 18.4892H4.461H4.75826C4.75826 18.4892 4.90689 17.2232 4.96634 16.3314L5.03322 16.281C5.04065 16.2882 13.0147 10.5555 6.12566 2.06075ZM4.461 16.1587C4.461 16.1587 4.10429 15.8638 4.00767 15.7128V15.6983L4.4387 6.44839C4.4387 6.41961 4.4833 6.41961 4.4833 6.44839L4.91432 15.6983V15.7128C4.81771 15.8638 4.461 16.1587 4.461 16.1587Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M19.3839 14.2711L16.061 6.4245L16.0536 6.40283H13.4683V6.93027H13.8855C14.0121 6.93027 14.1314 6.98085 14.2208 7.06755C14.3102 7.15425 14.3549 7.26988 14.3549 7.39269L14.2804 15.2899C14.2804 15.5355 14.0718 15.7378 13.8185 15.7451L13.3938 15.7523V16.2725H15.9121V15.7523L15.6513 15.7451C15.398 15.7378 15.1893 15.5355 15.1893 15.2899V7.84787L18.8103 16.2725C18.8624 16.3953 18.9816 16.4748 19.1157 16.4748C19.2498 16.4748 19.369 16.3953 19.4212 16.2725L22.9601 8.03571L23.0123 15.2899C23.0123 15.5428 22.8036 15.7451 22.5429 15.7523H22.2747V16.2725H25.225V15.7523H24.8227C24.5694 15.7523 24.3607 15.5428 24.3533 15.2971L24.331 7.39988C24.331 7.14703 24.5395 6.94472 24.7929 6.93749L25.225 6.93027V6.40283H22.7068L19.3839 14.2711Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M42.5528 15.6148C42.4706 15.5352 42.4258 15.4266 42.4258 15.2964V11.4169C42.4258 10.6787 42.2018 10.0997 41.7535 9.68714C41.3128 9.27455 40.7003 9.0647 39.9384 9.0647C38.8702 9.0647 38.0261 9.48448 37.436 10.3096C37.4286 10.324 37.4062 10.3313 37.3838 10.3313C37.3613 10.3313 37.3464 10.3168 37.3464 10.2951L37.07 9.26009H36.6068L35.4192 9.91873V10.2806H35.7255C35.8674 10.2806 35.9869 10.3168 36.0691 10.3892C36.1512 10.4616 36.196 10.5702 36.196 10.7221V15.2891C36.196 15.4194 36.1512 15.5279 36.0691 15.6076C35.9869 15.6871 35.8749 15.7306 35.7404 15.7306H35.4416V16.2589H38.1755V15.7306H37.8767C37.7423 15.7306 37.6302 15.6871 37.5481 15.6076C37.4659 15.5279 37.421 15.4194 37.421 15.2891V12.2638C37.421 11.8802 37.5107 11.4966 37.6751 11.1202C37.8468 10.7511 38.1008 10.4399 38.437 10.201C38.7731 9.96218 39.1765 9.84635 39.6396 9.84635C40.1625 9.84635 40.5584 10.0056 40.8048 10.3241C41.0514 10.6425 41.1784 11.0551 41.1784 11.5472V15.2819C41.1784 15.4121 41.1335 15.5207 41.0514 15.6003C40.9692 15.6799 40.8572 15.7233 40.7227 15.7233H40.4239V16.2517H43.1578V15.7233H42.8591C42.747 15.7379 42.6424 15.6944 42.5528 15.6148Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M67.5307 6.99362C66.775 6.60456 65.9304 6.40283 65.0191 6.40283H61.4629V6.92877H61.8111C61.9445 6.92877 62.063 6.97921 62.1815 7.09449C62.2927 7.20254 62.3519 7.32506 62.3519 7.4547V15.2071C62.3519 15.3367 62.2927 15.4592 62.1815 15.5673C62.0705 15.6754 61.9445 15.733 61.8111 15.733H61.4629V16.2589H65.0191C65.9304 16.2589 66.775 16.0572 67.5307 15.6682C68.2864 15.2791 68.9014 14.7027 69.3459 13.9679C69.7904 13.233 70.0201 12.3468 70.0201 11.3381C70.0201 10.3295 69.7904 9.45046 69.3459 8.70837C68.8939 7.95909 68.2864 7.38988 67.5307 6.99362ZM68.5902 11.3237C68.5902 12.2459 68.4198 13.024 68.0864 13.6508C67.753 14.2777 67.3084 14.7459 66.7602 15.0486C66.212 15.3512 65.6044 15.5025 64.9524 15.5025H64.2338C64.1004 15.5025 63.9819 15.452 63.8634 15.3367C63.7522 15.2287 63.693 15.1062 63.693 14.9765V7.6637C63.693 7.53398 63.7448 7.41873 63.8634 7.30341C63.9745 7.19535 64.1004 7.13772 64.2338 7.13772H64.9524C65.6044 7.13772 66.212 7.28902 66.7602 7.59161C67.3084 7.8942 67.753 8.36254 68.0864 8.98931C68.4198 9.62334 68.5902 10.4087 68.5902 11.3237Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M78.4261 11.8497C78.0972 11.4822 77.4625 11.1724 76.7161 11.0066C77.7467 10.5095 78.2765 9.81067 78.2765 8.9101C78.2765 8.42017 78.1426 7.98067 77.8732 7.606C77.6046 7.23139 77.2236 6.92877 76.7385 6.71983C76.2526 6.5109 75.6848 6.40283 75.0427 6.40283H71.0172V6.92877H71.3384C71.4728 6.92877 71.5923 6.97921 71.7118 7.09449C71.8238 7.20254 71.8836 7.32506 71.8836 7.4547V15.2071C71.8836 15.3367 71.8238 15.4592 71.7118 15.5673C71.5998 15.6754 71.4728 15.733 71.3384 15.733H70.9873V16.2589H75.3567C76.0212 16.2589 76.641 16.1509 77.2013 15.9347C77.7616 15.7186 78.2096 15.4016 78.531 14.9837C78.8592 14.5659 79.0236 14.0543 79.0236 13.4635C79.0162 12.8295 78.8219 12.2891 78.4261 11.8497ZM73.4146 15.3439C73.3026 15.2359 73.2428 15.1134 73.2428 14.9837V11.5038H75.3195C76.0509 11.5038 76.6112 11.6839 76.9996 12.0441C77.3881 12.4044 77.5823 12.8727 77.5823 13.4491C77.5823 13.7949 77.4922 14.1336 77.3285 14.4433C77.1566 14.7604 76.9022 15.0125 76.5591 15.2071C76.2228 15.4016 75.8046 15.5025 75.3195 15.5025H73.788C73.6536 15.5025 73.5341 15.452 73.4146 15.3439ZM73.2503 10.7617V7.67089C73.2503 7.54118 73.3026 7.42593 73.4221 7.3106C73.5341 7.20254 73.6611 7.14492 73.7955 7.14492H74.7815C75.4906 7.14492 76.0137 7.31787 76.3426 7.64923C76.6708 7.98787 76.8352 8.42017 76.8352 8.95334C76.8352 9.50089 76.6782 9.94039 76.3724 10.2718C76.0658 10.596 75.603 10.7617 74.9906 10.7617H73.2503Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M32.1451 9.52427C31.5744 9.22268 30.937 9.0647 30.2477 9.0647C29.5584 9.0647 28.9136 9.21549 28.3503 9.52427C27.7796 9.82578 27.3275 10.2638 26.994 10.8166C26.6605 11.3695 26.49 12.0158 26.49 12.7338C26.49 13.4517 26.6605 14.098 26.994 14.6509C27.3275 15.2038 27.7796 15.6417 28.3503 15.9433C28.921 16.2448 29.5584 16.4028 30.2477 16.4028C30.937 16.4028 31.5818 16.252 32.1451 15.9433C32.7158 15.6417 33.1679 15.2038 33.5014 14.6509C33.8349 14.098 34.0054 13.4517 34.0054 12.7338C34.0054 12.0158 33.8349 11.3695 33.5014 10.8166C33.1679 10.2638 32.7158 9.82578 32.1451 9.52427ZM32.6936 12.7338C32.6936 13.6169 32.4712 14.3349 32.0265 14.8519C31.5892 15.3689 30.9889 15.6346 30.2477 15.6346C29.5066 15.6346 28.9062 15.3689 28.4689 14.8519C28.0242 14.3349 27.8018 13.6169 27.8018 12.7338C27.8018 11.8506 28.0242 11.1326 28.4689 10.6156C28.9062 10.0987 29.5066 9.83297 30.2477 9.83297C30.9889 9.83297 31.5892 10.0987 32.0265 10.6156C32.4712 11.1326 32.6936 11.8506 32.6936 12.7338Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M58.4119 9.52427C57.8412 9.22268 57.2038 9.0647 56.5145 9.0647C55.8253 9.0647 55.1804 9.21549 54.6172 9.52427C54.0464 9.82578 53.5943 10.2638 53.2608 10.8166C52.9273 11.3695 52.7568 12.0158 52.7568 12.7338C52.7568 13.4517 52.9273 14.098 53.2608 14.6509C53.5943 15.2038 54.0464 15.6417 54.6172 15.9433C55.1879 16.2448 55.8253 16.4028 56.5145 16.4028C57.2038 16.4028 57.8487 16.252 58.4119 15.9433C58.9827 15.6417 59.4348 15.2038 59.7683 14.6509C60.1018 14.098 60.2723 13.4517 60.2723 12.7338C60.2723 12.0158 60.1018 11.3695 59.7683 10.8166C59.4348 10.2638 58.9752 9.82578 58.4119 9.52427ZM58.9604 12.7338C58.9604 13.6169 58.738 14.3349 58.2933 14.8519C57.856 15.3689 57.2557 15.6346 56.5145 15.6346C55.7733 15.6346 55.173 15.3689 54.7358 14.8519C54.291 14.3349 54.0687 13.6169 54.0687 12.7338C54.0687 11.8434 54.291 11.1326 54.7358 10.6156C55.173 10.0987 55.7733 9.83297 56.5145 9.83297C57.2557 9.83297 57.856 10.0987 58.2933 10.6156C58.7306 11.1326 58.9604 11.8506 58.9604 12.7338Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M47.819 9.0647C47.2218 9.0647 46.6769 9.18729 46.1843 9.43254C45.6916 9.67779 45.3035 10.0097 45.0273 10.4352C44.7511 10.8535 44.6093 11.3225 44.6093 11.8202C44.6093 12.2674 44.7138 12.6785 44.9302 13.0464C45.1393 13.3999 45.4229 13.6956 45.7812 13.9408L44.7138 15.3402C44.5794 15.5133 44.5645 15.7441 44.6615 15.9317C44.766 16.1264 44.9601 16.2419 45.184 16.2419H45.4901C45.1915 16.4366 44.9526 16.6674 44.7884 16.9415C44.5943 17.2517 44.4973 17.5763 44.4973 17.9081C44.4973 18.5284 44.781 19.0406 45.3408 19.4229C45.8932 19.8052 46.6694 19.9999 47.6472 19.9999C48.3265 19.9999 48.9759 19.8917 49.5656 19.6825C50.1628 19.4734 50.6479 19.1632 51.0062 18.7593C51.372 18.3553 51.5586 17.8648 51.5586 17.3022C51.5586 16.7107 51.3346 16.2923 50.8121 15.8884C50.3643 15.5494 49.6626 15.3691 48.7893 15.3691H45.8036C45.7961 15.3691 45.7886 15.3618 45.7886 15.3618C45.7886 15.3618 45.7812 15.3474 45.7886 15.3402L46.5649 14.3304C46.774 14.4241 46.968 14.4818 47.1397 14.5179C47.3188 14.554 47.5204 14.5684 47.7443 14.5684C48.3713 14.5684 48.9386 14.4458 49.4312 14.2005C49.9239 13.9553 50.3195 13.6235 50.6032 13.1979C50.8868 12.7795 51.0286 12.3107 51.0286 11.813C51.0286 11.2792 50.7599 10.3053 50.0284 9.80765C50.0284 9.80045 50.0359 9.80045 50.0359 9.80045L51.6407 9.97355V9.25945H49.0729C48.6699 9.13686 48.2519 9.0647 47.819 9.0647ZM48.7147 13.5802C48.431 13.7244 48.125 13.8038 47.819 13.8038C47.3189 13.8038 46.8784 13.6307 46.5052 13.2917C46.132 12.9526 45.9454 12.4549 45.9454 11.8202C45.9454 11.1854 46.132 10.6876 46.5052 10.3487C46.8784 10.0097 47.3189 9.8365 47.819 9.8365C48.1325 9.8365 48.431 9.90865 48.7147 10.0601C48.9983 10.2044 49.2297 10.428 49.4163 10.7238C49.5955 11.0195 49.6925 11.3874 49.6925 11.8202C49.6925 12.2602 49.6029 12.628 49.4163 12.9166C49.2372 13.2123 48.9983 13.4359 48.7147 13.5802ZM46.6918 16.2346H48.7147C49.2745 16.2346 49.6328 16.3428 49.8717 16.5737C50.1105 16.8045 50.2299 17.1146 50.2299 17.4753C50.2299 18.0019 50.0135 18.4347 49.5806 18.7593C49.1476 19.0839 48.5654 19.2498 47.8488 19.2498C47.2218 19.2498 46.6993 19.1128 46.3186 18.853C45.9379 18.5934 45.7438 18.1966 45.7438 17.6917C45.7438 17.3743 45.8334 17.0786 46.0126 16.8189C46.1917 16.5592 46.4082 16.3717 46.6918 16.2346Z"
|
||||
fill="#001E2B"/>
|
||||
<path d="M80.5379 16.1387C80.3906 16.0607 80.2797 15.9474 80.1912 15.8129C80.1101 15.6713 80.0654 15.5226 80.0654 15.3597C80.0654 15.1969 80.1101 15.0411 80.1912 14.9066C80.2723 14.7649 80.3906 14.6587 80.5379 14.5808C80.686 14.503 80.8482 14.4604 81.0328 14.4604C81.2173 14.4604 81.3795 14.503 81.5276 14.5808C81.6749 14.6587 81.7858 14.772 81.8743 14.9066C81.9555 15.0481 82.0001 15.1969 82.0001 15.3597C82.0001 15.5226 81.9555 15.6784 81.8743 15.8129C81.7932 15.9545 81.6749 16.0607 81.5276 16.1387C81.3795 16.2165 81.2173 16.259 81.0328 16.259C80.8557 16.259 80.686 16.2236 80.5379 16.1387ZM81.4465 16.0395C81.5715 15.9758 81.6608 15.8766 81.7345 15.7633C81.8007 15.643 81.8379 15.5084 81.8379 15.3597C81.8379 15.211 81.8007 15.0765 81.7345 14.9561C81.6675 14.8358 81.5715 14.7437 81.4465 14.6799C81.3207 14.6162 81.1875 14.5808 81.0328 14.5808C80.878 14.5808 80.7448 14.6162 80.619 14.6799C80.494 14.7437 80.4047 14.8428 80.3311 14.9561C80.2648 15.0765 80.2276 15.211 80.2276 15.3597C80.2276 15.5084 80.2648 15.643 80.3311 15.7633C80.398 15.8837 80.494 15.9758 80.619 16.0395C80.7448 16.1033 80.878 16.1387 81.0328 16.1387C81.1875 16.1387 81.3282 16.1033 81.4465 16.0395ZM80.6339 15.7917V15.7279L80.6488 15.7209H80.6934C80.7076 15.7209 80.7225 15.7138 80.7299 15.7067C80.7448 15.6925 80.7448 15.6855 80.7448 15.6713V15.0128C80.7448 14.9986 80.7374 14.9845 80.7299 14.9774C80.715 14.9632 80.7076 14.9632 80.6934 14.9632H80.6488L80.6339 14.9561V14.8924L80.6488 14.8853H81.0328C81.1436 14.8853 81.2247 14.9066 81.291 14.9561C81.3579 15.0057 81.387 15.0694 81.387 15.1544C81.387 15.2181 81.3654 15.2818 81.3133 15.3243C81.2619 15.3739 81.2024 15.4022 81.1288 15.4093L81.2173 15.4376L81.387 15.6996C81.4018 15.7209 81.4167 15.7279 81.439 15.7279H81.4829L81.4904 15.7351V15.7987L81.4829 15.8058H81.2545L81.2396 15.7987L81.003 15.4163H80.9442V15.6713C80.9442 15.6855 80.9517 15.6996 80.9591 15.7067C80.974 15.7209 80.9814 15.7209 80.9956 15.7209H81.0402L81.0551 15.7279V15.7917L81.0402 15.7987H80.6488L80.6339 15.7917ZM81.0104 15.3243C81.07 15.3243 81.1213 15.3102 81.1511 15.2748C81.1801 15.2464 81.2024 15.1969 81.2024 15.1402C81.2024 15.0835 81.1875 15.0411 81.1585 15.0057C81.1287 14.9703 81.0841 14.9561 81.0328 14.9561H81.003C80.9881 14.9561 80.974 14.9632 80.9665 14.9703C80.9517 14.9844 80.9517 14.9915 80.9517 15.0057V15.3243H81.0104Z"
|
||||
fill="#001E2B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2754_71636">
|
||||
<rect width="82" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { app } from '$lib/stores/app';
|
||||
import MongoDB from './mongo-db.svg';
|
||||
import MongoDBDark from './dark/mongo-db.svg';
|
||||
|
||||
$: isDark = $app.themeInUse === 'dark';
|
||||
$: mongoDbImage = isDark ? MongoDBDark : MongoDB;
|
||||
</script>
|
||||
|
||||
<!-- cannot use a tag as it has hover state which isn't needed here -->
|
||||
<div class="custom-tag">
|
||||
<img src={mongoDbImage} alt="mongo-db artwork" style="width: 66px; height: 16px;" />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.custom-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: min-content;
|
||||
width: max-content;
|
||||
padding: var(--space-1, 2px) var(--space-3, 6px);
|
||||
|
||||
border-radius: var(--border-radius-xs, 6px);
|
||||
background: var(--bgcolor-neutral-primary);
|
||||
border: var(--border-width-s, 1px) solid var(--border-neutral);
|
||||
}
|
||||
</style>
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 3.8 MiB |
@@ -8,9 +8,7 @@
|
||||
let { children } = $props();
|
||||
|
||||
const databasesProjectRoute = $derived.by(() => {
|
||||
return resolveRoute('/(console)/project-[region]-[project]/databases', {
|
||||
...page.params
|
||||
});
|
||||
return resolveRoute('/(console)/project-[region]-[project]/databases', page.params);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
import { page } from '$app/state';
|
||||
import { PaginationWithLimit } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { Container, ResponsiveContainerHeader } from '$lib/layout';
|
||||
import { Container } from '$lib/layout';
|
||||
|
||||
import Grid from './grid.svelte';
|
||||
import { columns } from './store';
|
||||
import Table from './table.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import { Icon, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { registerCommands } from '$lib/commandCenter';
|
||||
import { canWriteDatabases } from '$lib/stores/roles';
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
@@ -48,31 +46,6 @@
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<ResponsiveContainerHeader
|
||||
hasSearch
|
||||
{columns}
|
||||
view={data.view}
|
||||
searchPlaceholder="Search by name or ID">
|
||||
{#if $canWriteDatabases}
|
||||
<Tooltip disabled={!isLimited}>
|
||||
<div>
|
||||
<Button
|
||||
disabled={isLimited}
|
||||
event="create_database"
|
||||
on:click={goToCreateDatabaseWizard}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Create database
|
||||
</Button>
|
||||
</div>
|
||||
<svelte:fragment slot="tooltip">
|
||||
<div style="white-space: pre-line;">
|
||||
You have reached the maximum number of databases for your plan.
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</ResponsiveContainerHeader>
|
||||
|
||||
{#if data.databases.total}
|
||||
{#if data.view === 'grid'}
|
||||
<Grid {data} onCreateDatabaseClick={goToCreateDatabaseWizard} />
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { CustomId, Modal } from '$lib/components';
|
||||
import { Button, InputText } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { BackupServices, ID, type Models } from '@appwrite.io/console';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import { getChangePlanUrl } from '$lib/stores/billing';
|
||||
import CreatePolicy from './database-[database]/backups/createPolicy.svelte';
|
||||
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
|
||||
import { Alert, Icon, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
export let showCreate = false;
|
||||
export let project: Models.Project;
|
||||
|
||||
let totalPolicies: UserBackupPolicy[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let name = '';
|
||||
let id: string = null;
|
||||
let showCustomId = false;
|
||||
|
||||
const trackEvents = (policies: UserBackupPolicy[]) => {
|
||||
policies.forEach((policy: UserBackupPolicy) => {
|
||||
let actualDay = null;
|
||||
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
|
||||
switch (monthlyBackupFrequency) {
|
||||
case 'first':
|
||||
actualDay = '1st';
|
||||
break;
|
||||
case 'middle':
|
||||
actualDay = '15th';
|
||||
break;
|
||||
case 'end':
|
||||
default:
|
||||
actualDay = '28th';
|
||||
break;
|
||||
}
|
||||
|
||||
const message = {
|
||||
keepFor: `${policy.retained} days`,
|
||||
frequency: policy.plainTextFrequency,
|
||||
policy: policy.default ? 'preset' : 'custom'
|
||||
};
|
||||
|
||||
if (actualDay) {
|
||||
message['monthlyInterval'] = actualDay;
|
||||
}
|
||||
|
||||
trackEvent('submit_policy_submit', message);
|
||||
});
|
||||
};
|
||||
|
||||
const createPolicies = async (resourceId: string) => {
|
||||
if (!totalPolicies.length) return;
|
||||
|
||||
const totalPoliciesPromise = totalPolicies.map((policy) => {
|
||||
cronExpression(policy);
|
||||
|
||||
return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({
|
||||
policyId: ID.unique(),
|
||||
services: [BackupServices.Databases],
|
||||
retention: policy.retained,
|
||||
schedule: policy.schedule,
|
||||
name: policy.label,
|
||||
resourceId
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(totalPoliciesPromise);
|
||||
trackEvents(totalPolicies);
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
try {
|
||||
const databaseId = id ? id : ID.unique();
|
||||
const database = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.tablesDB.create({
|
||||
databaseId,
|
||||
name
|
||||
});
|
||||
|
||||
await createPolicies(databaseId);
|
||||
|
||||
showCreate = false;
|
||||
dispatch('created', database);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${name} has been created`
|
||||
});
|
||||
trackEvent(Submit.DatabaseCreate, {
|
||||
customId: !!id
|
||||
});
|
||||
name = id = null;
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.DatabaseCreate);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title="Create database" onSubmit={create} bind:show={showCreate}>
|
||||
<InputText
|
||||
id="name"
|
||||
label="Name"
|
||||
placeholder="Enter database name"
|
||||
bind:value={name}
|
||||
autofocus
|
||||
required />
|
||||
|
||||
{#if !showCustomId}
|
||||
<div>
|
||||
<Tag
|
||||
size="s"
|
||||
on:click={() => {
|
||||
showCustomId = true;
|
||||
}}><Icon icon={IconPencil} /> Database ID</Tag>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CustomId bind:show={showCustomId} name="Database" bind:id autofocus={false} />
|
||||
|
||||
{#if isCloud}
|
||||
{#if !$currentPlan?.backupsEnabled}
|
||||
<Alert.Inline title="This database won't be backed up" status="warning">
|
||||
Upgrade your plan to ensure your data stays safe and backed up.
|
||||
<svelte:fragment slot="actions">
|
||||
<Button compact href={getChangePlanUrl(project.teamId)}>Upgrade plan</Button>
|
||||
</svelte:fragment>
|
||||
</Alert.Inline>
|
||||
{:else}
|
||||
<CreatePolicy
|
||||
{project}
|
||||
bind:totalPolicies
|
||||
bind:isShowing={showCreate}
|
||||
title="Backup policies"
|
||||
subtitle="Protect your data and ensure quick recovery by adding backup policies." />
|
||||
{/if}
|
||||
{/if}
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
|
||||
<Button submit>Create</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
@@ -2,23 +2,13 @@
|
||||
import { Wizard } from '$lib/layout';
|
||||
import { writable } from 'svelte/store';
|
||||
import { Form, InputText, InputSelect, InputCheckbox, Button } from '$lib/elements/forms';
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Divider,
|
||||
Fieldset,
|
||||
Icon,
|
||||
Layout,
|
||||
Tag,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Alert, Card, Divider, Fieldset, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { resolveRoute } from '$lib/stores/navigation';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import { CustomId } from '$lib/components';
|
||||
import { page } from '$app/state';
|
||||
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { ID, type Models } from '@appwrite.io/console';
|
||||
import { BackupServices, ID, type Models } from '@appwrite.io/console';
|
||||
import {
|
||||
type DatabaseType,
|
||||
type DedicatedDatabaseParams,
|
||||
@@ -36,76 +26,33 @@
|
||||
import CreatePolicy from '$database/backups/createPolicy.svelte';
|
||||
import { cronExpression, type UserBackupPolicy } from '$lib/helpers/backups';
|
||||
|
||||
import Mongo from '../(assets)/mongo.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import { createDatabaseStore } from './store';
|
||||
import { databaseTypes } from '../store';
|
||||
import { isTabletViewport } from '$lib/stores/viewport';
|
||||
import { filterRegions } from '$lib/helpers/regions';
|
||||
import { regions as regionsStore } from '$lib/stores/organization';
|
||||
import { backupRetainingOptions } from '$database/store';
|
||||
import PolicyPresets from '$database/backups/policyPresets.svelte';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
let formComponent: Form;
|
||||
|
||||
const params = page.url.searchParams;
|
||||
const typeFromParams = params.get('type') ?? (null as DatabaseType);
|
||||
|
||||
let databaseId = $state(params.get('id') ?? null);
|
||||
let databaseName = $state(params.get('name') ?? null);
|
||||
|
||||
let showCreatePolicies = $state(false);
|
||||
let totalPolicies: UserBackupPolicy[] = $state([]);
|
||||
|
||||
let showCustomId = $state(false);
|
||||
let showExitModal = $state(false);
|
||||
let isSubmitting = $state(writable(false));
|
||||
let previousPage: string = $state(resolveRoute('/'));
|
||||
let type = $state(typeFromParams ?? 'tablesdb') as DatabaseType;
|
||||
let type = $state(typeFromParams ?? 'dedicateddb') as DatabaseType;
|
||||
|
||||
const isDark = $derived($app.themeInUse === 'dark');
|
||||
const backupsImg = $derived(isDark ? EmptyDarkMobile : EmptyLightMobile);
|
||||
|
||||
// Free tier limits for shared databases
|
||||
const sharedTierLimits = {
|
||||
ram: '128 MB',
|
||||
cpu: '0.125 vCPU',
|
||||
storage: '1 GB',
|
||||
maxConnections: 10,
|
||||
queryTimeout: '15s',
|
||||
idleTimeout: '15 min'
|
||||
};
|
||||
|
||||
const databaseTypes: Array<{
|
||||
type: DatabaseType;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon?: typeof Mongo;
|
||||
}> = [
|
||||
{
|
||||
type: 'tablesdb',
|
||||
title: 'TablesDB',
|
||||
subtitle:
|
||||
'Structure your data in rows and columns. Best for relational data and advanced querying.'
|
||||
},
|
||||
{
|
||||
type: 'documentsdb',
|
||||
title: 'DocumentsDB',
|
||||
subtitle:
|
||||
'Store flexible data without a fixed schema. Best for unstructured data and simple querying.',
|
||||
icon: Mongo
|
||||
},
|
||||
{
|
||||
type: 'shared',
|
||||
title: 'Shared (Free)',
|
||||
subtitle:
|
||||
'Free serverless PostgreSQL that scales to zero when idle. Great for prototyping and small projects.'
|
||||
},
|
||||
{
|
||||
type: 'dedicated',
|
||||
title: 'DedicatedDB',
|
||||
subtitle:
|
||||
'Always-on dedicated instances with high availability. Best for production workloads.'
|
||||
}
|
||||
];
|
||||
|
||||
// Dedicated DB specific options
|
||||
const engineOptions = [
|
||||
{ value: 'postgres', label: 'PostgreSQL' },
|
||||
@@ -117,7 +64,7 @@
|
||||
const regionOptions = $derived(filterRegions($regionsStore.regions || []));
|
||||
|
||||
const tiers: Record<string, { label: string; price: number }> = {
|
||||
'free': { label: 'Free - 0.125 vCPU, 128MB RAM', price: 0 },
|
||||
free: { label: 'Free - 0.125 vCPU, 128MB RAM', price: 0 },
|
||||
's-1vcpu-1gb': { label: 'Starter - 1 vCPU, 1GB RAM', price: 15 },
|
||||
's-2vcpu-2gb': { label: 'Standard - 2 vCPU, 2GB RAM', price: 30 },
|
||||
's-2vcpu-4gb': { label: 'Standard Plus - 2 vCPU, 4GB RAM', price: 60 },
|
||||
@@ -148,10 +95,9 @@
|
||||
let highAvailability = $state(params.get('ha') === 'true');
|
||||
|
||||
// Helper to check database type capabilities
|
||||
const showRegionSelect = $derived(type === 'dedicated' || type === 'shared');
|
||||
const showTierSelect = $derived(type === 'dedicated');
|
||||
const showEngineSelect = $derived(type === 'dedicated');
|
||||
const isSharedType = $derived(type === 'shared');
|
||||
const showRegionSelect = $derived(type === 'dedicateddb');
|
||||
const showTierSelect = $derived(type === 'dedicateddb');
|
||||
const showEngineSelect = $derived(type === 'dedicateddb');
|
||||
const isFreeTier = $derived(selectedTier === 'free');
|
||||
|
||||
const tierPrice = $derived(tiers[selectedTier]?.price ?? 0);
|
||||
@@ -163,9 +109,7 @@
|
||||
case 'tablesdb':
|
||||
case 'documentsdb':
|
||||
return 'appwrite';
|
||||
case 'shared':
|
||||
return 'shared';
|
||||
case 'dedicated':
|
||||
case 'dedicateddb':
|
||||
return 'dedicated';
|
||||
default:
|
||||
return 'appwrite';
|
||||
@@ -264,7 +208,7 @@
|
||||
|
||||
return sdk.forProject(page.params.region, page.params.project).backups.createPolicy({
|
||||
policyId: ID.unique(),
|
||||
services: ['databases'],
|
||||
services: [BackupServices.Databases],
|
||||
retention: policy.retained,
|
||||
schedule: policy.schedule,
|
||||
name: policy.label,
|
||||
@@ -278,21 +222,15 @@
|
||||
|
||||
async function createDatabase() {
|
||||
try {
|
||||
databaseId ??= ID.unique();
|
||||
const databaseId = $createDatabaseStore.id ?? ID.unique();
|
||||
|
||||
let database: Models.Database;
|
||||
const databaseSdk = useDatabaseSdk(page.params.region, page.params.project);
|
||||
|
||||
if (type === 'shared') {
|
||||
if (type === 'dedicateddb') {
|
||||
database = await databaseSdk.create(type, {
|
||||
databaseId,
|
||||
name: databaseName,
|
||||
region: selectedRegion
|
||||
} as DedicatedDatabaseParams);
|
||||
} else if (type === 'dedicated') {
|
||||
database = await databaseSdk.create(type, {
|
||||
databaseId,
|
||||
name: databaseName,
|
||||
name: $createDatabaseStore.name,
|
||||
engine: selectedEngine,
|
||||
region: selectedRegion,
|
||||
tier: selectedTier,
|
||||
@@ -306,9 +244,8 @@
|
||||
} else {
|
||||
database = await databaseSdk.create(type, {
|
||||
databaseId,
|
||||
name: databaseName
|
||||
name: $createDatabaseStore.name
|
||||
});
|
||||
// Create Appwrite backup policies for TablesDB/DocumentsDB
|
||||
await createPolicies(database.$id);
|
||||
}
|
||||
|
||||
@@ -327,6 +264,8 @@
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
resetCreateDatabaseStore();
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -335,6 +274,11 @@
|
||||
trackError(error, Submit.DatabaseCreate);
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateDatabaseStore() {
|
||||
$createDatabaseStore.id = '';
|
||||
$createDatabaseStore.name = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Wizard
|
||||
@@ -343,12 +287,13 @@
|
||||
bind:showExitModal
|
||||
confirmExit
|
||||
column
|
||||
columnSize="s">
|
||||
columnSize="s"
|
||||
onExit={resetCreateDatabaseStore}>
|
||||
<Form bind:this={formComponent} onSubmit={createDatabase} bind:isSubmitting>
|
||||
<Layout.Stack gap="xxl">
|
||||
{#if typeFromParams === null}
|
||||
<Fieldset legend="Database type">
|
||||
{@render selectDatabaseType()}
|
||||
{@render selectDatabaseType($isSubmitting)}
|
||||
</Fieldset>
|
||||
{/if}
|
||||
|
||||
@@ -359,19 +304,18 @@
|
||||
id="name"
|
||||
autofocus
|
||||
label="Name"
|
||||
bind:value={databaseName}
|
||||
disabled={$isSubmitting}
|
||||
bind:value={$createDatabaseStore.name}
|
||||
placeholder="Enter database name" />
|
||||
|
||||
{#if !showCustomId}
|
||||
<div>
|
||||
<Tag size="s" on:click={() => (showCustomId = true)}>
|
||||
<Icon icon={IconPencil} slot="start" size="s" />
|
||||
Database ID
|
||||
</Tag>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CustomId bind:show={showCustomId} name="Database" bind:id={databaseId} />
|
||||
<CustomId
|
||||
show
|
||||
name="Database"
|
||||
required={false}
|
||||
autofocus={false}
|
||||
disabled={$isSubmitting}
|
||||
bind:id={$createDatabaseStore.id}
|
||||
syncFrom={$createDatabaseStore.name} />
|
||||
</Layout.Stack>
|
||||
</Fieldset>
|
||||
|
||||
@@ -442,82 +386,21 @@
|
||||
</Layout.Stack>
|
||||
|
||||
<Typography.Text>
|
||||
You'll be charged <b>{formatCurrency(estimatedMonthly)}</b> every
|
||||
30 days. Costs may vary with storage and network usage.
|
||||
You'll be charged <b>{formatCurrency(estimatedMonthly)}</b> every 30 days.
|
||||
Costs may vary with storage and network usage.
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
</Fieldset>
|
||||
{/if}
|
||||
|
||||
{#if isSharedType}
|
||||
<Fieldset legend="Free tier limits">
|
||||
<Alert.Inline status="info" title="Shared database limits">
|
||||
Shared databases are free and scale to zero when idle. The following
|
||||
limits apply:
|
||||
</Alert.Inline>
|
||||
<Layout.Grid columns={2} columnsS={1} gap="l">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
RAM
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{sharedTierLimits.ram}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
CPU
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{sharedTierLimits.cpu}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Storage
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{sharedTierLimits.storage}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Max Connections
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{sharedTierLimits.maxConnections}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Query Timeout
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{sharedTierLimits.queryTimeout}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Idle Timeout
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{sharedTierLimits.idleTimeout} (scales to zero)
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
</Layout.Grid>
|
||||
</Fieldset>
|
||||
{/if}
|
||||
|
||||
<Fieldset legend="Backups">
|
||||
{#if backupSystem === 'appwrite'}
|
||||
{#if isCloud}
|
||||
{@render cloudBackupOptions()}
|
||||
{@render cloudBackupOptions($isSubmitting)}
|
||||
{:else}
|
||||
{@render selfHostedBackupOptions()}
|
||||
{/if}
|
||||
{:else if backupSystem === 'shared'}
|
||||
{@render sharedBackupOptions()}
|
||||
{:else if backupSystem === 'dedicated'}
|
||||
{@render dedicatedBackupOptions()}
|
||||
{/if}
|
||||
@@ -526,37 +409,35 @@
|
||||
</Form>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
secondary
|
||||
fullWidthMobile
|
||||
disabled={$isSubmitting}
|
||||
on:click={() => (showExitModal = true)}>
|
||||
<Button secondary disabled={$isSubmitting} on:click={() => (showExitModal = true)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
submissionLoader
|
||||
disabled={$isSubmitting}
|
||||
forceShowLoader={$isSubmitting}
|
||||
on:click={() => formComponent.triggerSubmit()}>
|
||||
{type === 'dedicated' ? `Create - ${formatCurrency(estimatedMonthly)}/mo` : 'Create'}
|
||||
{type === 'dedicateddb' ? `Create - ${formatCurrency(estimatedMonthly)}/mo` : 'Create'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Wizard>
|
||||
|
||||
{#snippet cloudBackupOptions()}
|
||||
{#snippet cloudBackupOptions(disabled = false)}
|
||||
{#if $currentPlan?.backupsEnabled}
|
||||
<div style:width="100%">
|
||||
<CreatePolicy
|
||||
{disabled}
|
||||
bind:totalPolicies
|
||||
title="Backup policies"
|
||||
project={data.project}
|
||||
bind:isShowing={showCreatePolicies}
|
||||
subtitle="Protect your data and ensure quick recovery by adding backup policies."
|
||||
project={page.data.project} />
|
||||
subtitle="Protect your data and ensure quick recovery by adding backup policies." />
|
||||
</div>
|
||||
{:else}
|
||||
<Alert.Inline title="This database won't be backed up" status="warning">
|
||||
Upgrade your plan to ensure your data stays safe and backed up.
|
||||
<svelte:fragment slot="actions">
|
||||
<Button compact href={getChangePlanUrl()}>Upgrade plan</Button>
|
||||
<Button compact href={getChangePlanUrl(data?.project?.teamId)}>Upgrade plan</Button>
|
||||
</svelte:fragment>
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
@@ -593,15 +474,6 @@
|
||||
</Layout.Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet sharedBackupOptions()}
|
||||
<Layout.Stack gap="l">
|
||||
<Alert.Inline status="info" title="No backups on free tier">
|
||||
Shared databases on the free tier do not include automatic backups. Upgrade to a
|
||||
dedicated database for configurable backup and point-in-time recovery options.
|
||||
</Alert.Inline>
|
||||
</Layout.Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet dedicatedBackupOptions()}
|
||||
<Layout.Stack gap="l">
|
||||
{#if isFreeTier}
|
||||
@@ -653,19 +525,19 @@
|
||||
</Layout.Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet selectDatabaseType()}
|
||||
<Layout.Grid columns={2} columnsS={1}>
|
||||
{#snippet selectDatabaseType(disabled = false)}
|
||||
<Layout.Grid columns={4} columnsS={2} columnsXS={1}>
|
||||
{#each databaseTypes as databaseType}
|
||||
<div class="card-selector">
|
||||
<Card.Selector
|
||||
{disabled}
|
||||
variant="secondary"
|
||||
bind:group={type}
|
||||
name={databaseType.type}
|
||||
id={databaseType.type}
|
||||
value={databaseType.type}
|
||||
title={databaseType.title}
|
||||
imageRadius="s"
|
||||
icon={databaseType.icon}>
|
||||
imageRadius="s">
|
||||
{databaseType.subtitle}
|
||||
</Card.Selector>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
type CreateDatabase = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const createDatabaseStore = writable<CreateDatabase>({
|
||||
id: '',
|
||||
name: ''
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from './sdk';
|
||||
export * from './init';
|
||||
export * from './types';
|
||||
export * from './analytics';
|
||||
export * from './terminology';
|
||||
export * from './dependencies';
|
||||
|
||||
/* `.svelte.ts` for runes */
|
||||
export * from './init.svelte';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
type AnalyticsResult,
|
||||
type DatabaseSdkResult,
|
||||
type DependenciesResult,
|
||||
type TerminologyResult,
|
||||
useAnalytics,
|
||||
useDependencies,
|
||||
useTerminology,
|
||||
useDatabaseSdk
|
||||
} from '$database/(entity)';
|
||||
|
||||
export type Terminologies = {
|
||||
analytics: AnalyticsResult;
|
||||
terminology: TerminologyResult;
|
||||
dependencies: DependenciesResult;
|
||||
databaseSdk: DatabaseSdkResult;
|
||||
};
|
||||
|
||||
const terminologies = $derived.by((): Terminologies => {
|
||||
const terminology = useTerminology(page);
|
||||
return {
|
||||
terminology,
|
||||
analytics: useAnalytics(terminology),
|
||||
dependencies: useDependencies(terminology),
|
||||
databaseSdk: useDatabaseSdk(page, terminology)
|
||||
};
|
||||
});
|
||||
|
||||
export function getTerminologies(): Terminologies {
|
||||
return terminologies;
|
||||
}
|
||||
@@ -5,12 +5,24 @@ import {
|
||||
type DatabaseType,
|
||||
type Entity,
|
||||
type EntityList,
|
||||
type Index,
|
||||
type Record,
|
||||
type RecordList,
|
||||
toSupportiveEntity,
|
||||
toSupportiveRecord
|
||||
toSupportiveRecord,
|
||||
toSupportiveIndex
|
||||
} from './terminology';
|
||||
import { Backend, Engine, type Models } from '@appwrite.io/console';
|
||||
|
||||
import {
|
||||
Backend,
|
||||
Engine,
|
||||
Region,
|
||||
type Models,
|
||||
type OrderBy,
|
||||
type TablesDBIndexType,
|
||||
type DocumentsDBIndexType,
|
||||
type VectorsDBIndexType
|
||||
} from '@appwrite.io/console';
|
||||
|
||||
export type DedicatedDatabaseParams = {
|
||||
databaseId: string;
|
||||
@@ -44,6 +56,7 @@ export type DatabaseSdkResult = {
|
||||
entityId: string;
|
||||
name: string;
|
||||
databaseType?: DatabaseType;
|
||||
dimension?: number /* vectorsDB specific */;
|
||||
}) => Promise<Entity>;
|
||||
getEntity: (params: {
|
||||
databaseId: string;
|
||||
@@ -62,6 +75,15 @@ export type DatabaseSdkResult = {
|
||||
entityId: string;
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<{}>;
|
||||
updateEntity: (params: {
|
||||
databaseId: string;
|
||||
entityId: string;
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
documentSecurity?: boolean;
|
||||
enabled?: boolean;
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<Entity>;
|
||||
createRecord: (params: {
|
||||
databaseId: string;
|
||||
entityId: string;
|
||||
@@ -88,7 +110,7 @@ export type DatabaseSdkResult = {
|
||||
deleteRecord: (params: {
|
||||
databaseId: string;
|
||||
entityId: string;
|
||||
recordId?: string;
|
||||
recordId: string;
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<Record>;
|
||||
deleteRecords: (params: {
|
||||
@@ -97,8 +119,40 @@ export type DatabaseSdkResult = {
|
||||
queries?: string[];
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<RecordList>;
|
||||
createIndex: (params: {
|
||||
databaseId: string;
|
||||
entityId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
attributes: string[];
|
||||
lengths?: number[];
|
||||
orders?: OrderBy[];
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<Index>;
|
||||
deleteIndex: (params: {
|
||||
databaseId: string;
|
||||
entityId: string;
|
||||
key: string;
|
||||
databaseType?: DatabaseType;
|
||||
}) => Promise<{}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the raw DocumentsDB or VectorsDB SDK service for a given database type.
|
||||
* Use in load functions (.ts) where Svelte runes aren't available.
|
||||
*/
|
||||
export function getCollectionService(region: string, project: string, type: DatabaseType) {
|
||||
const projectSdk = sdk.forProject(region, project);
|
||||
switch (type) {
|
||||
case 'documentsdb':
|
||||
return projectSdk.documentsDB;
|
||||
case 'vectorsdb':
|
||||
return projectSdk.vectorsDB;
|
||||
default:
|
||||
throw new Error(`Unsupported collection database type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function useDatabaseSdk(
|
||||
regionOrPage: string | Page,
|
||||
projectOrTerminology: string | TerminologyResult,
|
||||
@@ -130,25 +184,17 @@ export function useDatabaseSdk(
|
||||
case 'documentsdb': {
|
||||
return await baseSdk.documentsDB.create(params);
|
||||
}
|
||||
case 'shared': {
|
||||
const sharedParams = params as DedicatedDatabaseParams;
|
||||
return (await baseSdk.compute.createDatabase({
|
||||
databaseId: sharedParams.databaseId,
|
||||
name: sharedParams.name,
|
||||
backend: Backend.Edge,
|
||||
engine: Engine.Postgres,
|
||||
region: sharedParams.region as any,
|
||||
type: 'shared' as any
|
||||
})) as unknown as Models.Database;
|
||||
case 'vectorsdb': {
|
||||
return await baseSdk.vectorsDB.create(params);
|
||||
}
|
||||
case 'dedicated': {
|
||||
case 'dedicateddb': {
|
||||
const dedicatedParams = params as DedicatedDatabaseParams;
|
||||
return (await baseSdk.compute.createDatabase({
|
||||
databaseId: dedicatedParams.databaseId,
|
||||
name: dedicatedParams.name,
|
||||
backend: Backend.Edge,
|
||||
engine: dedicatedParams.engine,
|
||||
region: dedicatedParams.region as any,
|
||||
engine: dedicatedParams.engine ?? Engine.Postgres,
|
||||
region: dedicatedParams.region as Region,
|
||||
tier: dedicatedParams.tier,
|
||||
highAvailability: dedicatedParams.highAvailability,
|
||||
backupEnabled: dedicatedParams.backupEnabled,
|
||||
@@ -158,18 +204,22 @@ export function useDatabaseSdk(
|
||||
pitrRetentionDays: dedicatedParams.pitrRetentionDays
|
||||
})) as unknown as Models.Database;
|
||||
}
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
default:
|
||||
throw new Error('Unknown database type');
|
||||
}
|
||||
},
|
||||
|
||||
async list(params): Promise<Models.DatabaseList> {
|
||||
const [tablesResult, dedicatedResult] = await Promise.all([
|
||||
baseSdk.tablesDB.list(params),
|
||||
baseSdk.compute.listDatabases({ queries: params.queries, search: params.search })
|
||||
]);
|
||||
const [tablesResult, documentsResult, vectorsResult, dedicatedResult] =
|
||||
await Promise.all([
|
||||
baseSdk.tablesDB.list(params),
|
||||
baseSdk.documentsDB.list(params),
|
||||
baseSdk.vectorsDB.list(params),
|
||||
baseSdk.compute.listDatabases({
|
||||
queries: params.queries,
|
||||
search: params.search
|
||||
})
|
||||
]);
|
||||
|
||||
const dedicatedAsDatabases = (dedicatedResult.databases ?? []).map(
|
||||
(db) =>
|
||||
@@ -186,8 +236,17 @@ export function useDatabaseSdk(
|
||||
);
|
||||
|
||||
return {
|
||||
total: tablesResult.total + (dedicatedResult.total ?? 0),
|
||||
databases: [...tablesResult.databases, ...dedicatedAsDatabases]
|
||||
total:
|
||||
tablesResult.total +
|
||||
documentsResult.total +
|
||||
vectorsResult.total +
|
||||
(dedicatedResult.total ?? 0),
|
||||
databases: [
|
||||
...tablesResult.databases,
|
||||
...documentsResult.databases,
|
||||
...vectorsResult.databases,
|
||||
...dedicatedAsDatabases
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
@@ -201,9 +260,8 @@ export function useDatabaseSdk(
|
||||
});
|
||||
return toSupportiveEntity(table);
|
||||
}
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support entity creation via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support entity creation via Appwrite');
|
||||
case 'documentsdb': {
|
||||
const table = await baseSdk.documentsDB.createCollection({
|
||||
...params,
|
||||
@@ -212,8 +270,15 @@ export function useDatabaseSdk(
|
||||
|
||||
return toSupportiveEntity(table);
|
||||
}
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb': {
|
||||
const collection = await baseSdk.vectorsDB.createCollection({
|
||||
...params,
|
||||
dimension: params.dimension,
|
||||
collectionId: params.entityId
|
||||
});
|
||||
|
||||
return toSupportiveEntity(collection);
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown database type');
|
||||
}
|
||||
@@ -226,9 +291,7 @@ export function useDatabaseSdk(
|
||||
const { total, tables } = await baseSdk.tablesDB.listTables(params);
|
||||
return { total, entities: tables.map(toSupportiveEntity) };
|
||||
}
|
||||
case 'shared':
|
||||
case 'dedicated': {
|
||||
// External databases don't have entities managed by Appwrite
|
||||
case 'dedicateddb': {
|
||||
return { total: 0, entities: [] };
|
||||
}
|
||||
case 'documentsdb': {
|
||||
@@ -236,12 +299,12 @@ export function useDatabaseSdk(
|
||||
await baseSdk.documentsDB.listCollections(params);
|
||||
return { total, entities: collections.map(toSupportiveEntity) };
|
||||
}
|
||||
case 'vectorsdb':
|
||||
const { total, collections } =
|
||||
await baseSdk.vectorsDB.listCollections(params);
|
||||
case 'vectorsdb': {
|
||||
const { total, collections } = await baseSdk.vectorsDB.listCollections(params);
|
||||
return { total, entities: collections.map(toSupportiveEntity) };
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown database type');
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -255,22 +318,24 @@ export function useDatabaseSdk(
|
||||
});
|
||||
return toSupportiveEntity(table);
|
||||
}
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support entity retrieval via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support entity retrieval via Appwrite');
|
||||
case 'documentsdb': {
|
||||
const table = await baseSdk.documentsDB.getCollection({
|
||||
const collection = await baseSdk.documentsDB.getCollection({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId
|
||||
});
|
||||
return toSupportiveEntity(table);
|
||||
|
||||
return toSupportiveEntity(collection);
|
||||
}
|
||||
case 'vectordb':
|
||||
const table = await baseSdk.vectorsDB.getCollection({
|
||||
case 'vectorsdb': {
|
||||
const collection = await baseSdk.vectorsDB.getCollection({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId
|
||||
});
|
||||
return toSupportiveEntity(table);
|
||||
|
||||
return toSupportiveEntity(collection);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -283,12 +348,11 @@ export function useDatabaseSdk(
|
||||
return await baseSdk.tablesDB.delete(params);
|
||||
case 'documentsdb':
|
||||
return await baseSdk.documentsDB.delete(params);
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
case 'dedicateddb':
|
||||
await baseSdk.compute.deleteDatabase(params);
|
||||
return {};
|
||||
case 'vectordb':
|
||||
return await baseSdk.vectorsDB.delete(params);
|
||||
case 'vectorsdb':
|
||||
return await baseSdk.vectorsDB.delete(params);
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -302,16 +366,59 @@ export function useDatabaseSdk(
|
||||
databaseId: params.databaseId,
|
||||
tableId: params.entityId
|
||||
});
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support entity deletion via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support entity deletion via Appwrite');
|
||||
case 'documentsdb':
|
||||
return await baseSdk.documentsDB.deleteCollection({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId
|
||||
});
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb':
|
||||
return await baseSdk.vectorsDB.deleteCollection({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
},
|
||||
|
||||
async updateEntity(params) {
|
||||
switch (type ?? params.databaseType) {
|
||||
case 'legacy': /* databases api */
|
||||
case 'tablesdb':
|
||||
return toSupportiveEntity(
|
||||
await baseSdk.tablesDB.updateTable({
|
||||
databaseId: params.databaseId,
|
||||
tableId: params.entityId,
|
||||
name: params.name,
|
||||
permissions: params.permissions,
|
||||
rowSecurity: params.documentSecurity,
|
||||
enabled: params.enabled
|
||||
})
|
||||
);
|
||||
case 'documentsdb':
|
||||
return toSupportiveEntity(
|
||||
await baseSdk.documentsDB.updateCollection({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
name: params.name,
|
||||
permissions: params.permissions,
|
||||
documentSecurity: params.documentSecurity,
|
||||
enabled: params.enabled
|
||||
})
|
||||
);
|
||||
case 'vectorsdb':
|
||||
return toSupportiveEntity(
|
||||
await baseSdk.vectorsDB.updateCollection({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
name: params.name,
|
||||
permissions: params.permissions,
|
||||
documentSecurity: params.documentSecurity,
|
||||
enabled: params.enabled
|
||||
})
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -328,9 +435,8 @@ export function useDatabaseSdk(
|
||||
data: params.data,
|
||||
permissions: params.permissions
|
||||
});
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support record creation via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support record creation via Appwrite');
|
||||
case 'documentsdb':
|
||||
return await baseSdk.documentsDB.createDocument({
|
||||
databaseId: params.databaseId,
|
||||
@@ -339,8 +445,15 @@ export function useDatabaseSdk(
|
||||
data: params.data,
|
||||
permissions: params.permissions
|
||||
});
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb': {
|
||||
return await baseSdk.vectorsDB.createDocument({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
documentId: params.recordId,
|
||||
data: params.data,
|
||||
permissions: params.permissions
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -357,9 +470,8 @@ export function useDatabaseSdk(
|
||||
data: params.data,
|
||||
permissions: params.permissions
|
||||
});
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support record updates via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support record updates via Appwrite');
|
||||
case 'documentsdb':
|
||||
return await baseSdk.documentsDB.upsertDocument({
|
||||
databaseId: params.databaseId,
|
||||
@@ -368,8 +480,15 @@ export function useDatabaseSdk(
|
||||
data: params.data,
|
||||
permissions: params.permissions
|
||||
});
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb': {
|
||||
return await baseSdk.vectorsDB.upsertDocument({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
documentId: params.recordId,
|
||||
data: params.data,
|
||||
permissions: params.permissions
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -385,9 +504,8 @@ export function useDatabaseSdk(
|
||||
rowId: params.recordId,
|
||||
permissions: params.permissions
|
||||
});
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support permission updates via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support permission updates via Appwrite');
|
||||
case 'documentsdb':
|
||||
return await baseSdk.documentsDB.upsertDocument({
|
||||
databaseId: params.databaseId,
|
||||
@@ -395,8 +513,14 @@ export function useDatabaseSdk(
|
||||
documentId: params.recordId,
|
||||
permissions: params.permissions
|
||||
});
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb': {
|
||||
return await baseSdk.vectorsDB.upsertDocument({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
documentId: params.recordId,
|
||||
permissions: params.permissions
|
||||
});
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -413,9 +537,8 @@ export function useDatabaseSdk(
|
||||
});
|
||||
return toSupportiveRecord(row);
|
||||
}
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support record deletion via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error('DedicatedDB does not support record deletion via Appwrite');
|
||||
case 'documentsdb': {
|
||||
const document = await baseSdk.documentsDB.deleteDocument({
|
||||
databaseId: params.databaseId,
|
||||
@@ -424,8 +547,19 @@ export function useDatabaseSdk(
|
||||
});
|
||||
return toSupportiveRecord(document);
|
||||
}
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb': {
|
||||
if (!params.recordId) {
|
||||
throw new Error('Record ID is required to delete a VectorsDB document');
|
||||
}
|
||||
|
||||
const document = await baseSdk.vectorsDB.deleteDocument({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
documentId: params.recordId
|
||||
});
|
||||
|
||||
return toSupportiveRecord(document);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
@@ -442,9 +576,10 @@ export function useDatabaseSdk(
|
||||
});
|
||||
return { total, records: rows.map(toSupportiveRecord) };
|
||||
}
|
||||
case 'shared':
|
||||
case 'dedicated':
|
||||
throw new Error('External databases do not support bulk record deletion via Appwrite');
|
||||
case 'dedicateddb':
|
||||
throw new Error(
|
||||
'DedicatedDB does not support bulk record deletion via Appwrite'
|
||||
);
|
||||
case 'documentsdb': {
|
||||
const { total, documents } = await baseSdk.documentsDB.deleteDocuments({
|
||||
databaseId: params.databaseId,
|
||||
@@ -453,8 +588,86 @@ export function useDatabaseSdk(
|
||||
});
|
||||
return { total, records: documents.map(toSupportiveRecord) };
|
||||
}
|
||||
case 'vectordb':
|
||||
throw new Error('Database type not supported yet');
|
||||
case 'vectorsdb': {
|
||||
const { total, documents } = await baseSdk.vectorsDB.deleteDocuments({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
queries: params.queries
|
||||
});
|
||||
|
||||
return { total, records: documents.map(toSupportiveRecord) };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
},
|
||||
|
||||
async createIndex(params) {
|
||||
switch (type ?? params.databaseType) {
|
||||
case 'legacy': /* databases api */
|
||||
case 'tablesdb': {
|
||||
const index = await baseSdk.tablesDB.createIndex({
|
||||
databaseId: params.databaseId,
|
||||
tableId: params.entityId,
|
||||
key: params.key,
|
||||
type: params.type as TablesDBIndexType,
|
||||
columns: params.attributes,
|
||||
lengths: params.lengths,
|
||||
orders: params.orders
|
||||
});
|
||||
return toSupportiveIndex(index);
|
||||
}
|
||||
case 'documentsdb': {
|
||||
const index = await baseSdk.documentsDB.createIndex({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
key: params.key,
|
||||
type: params.type as DocumentsDBIndexType,
|
||||
attributes: params.attributes,
|
||||
lengths: params.lengths,
|
||||
orders: params.orders
|
||||
});
|
||||
return toSupportiveIndex(index);
|
||||
}
|
||||
case 'vectorsdb': {
|
||||
const index = await baseSdk.vectorsDB.createIndex({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
key: params.key,
|
||||
type: params.type as VectorsDBIndexType,
|
||||
attributes: params.attributes,
|
||||
lengths: params.lengths,
|
||||
orders: params.orders
|
||||
});
|
||||
|
||||
return toSupportiveIndex(index);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteIndex(params) {
|
||||
switch (type ?? params.databaseType) {
|
||||
case 'legacy': /* databases api */
|
||||
case 'tablesdb':
|
||||
return await baseSdk.tablesDB.deleteIndex({
|
||||
databaseId: params.databaseId,
|
||||
tableId: params.entityId,
|
||||
key: params.key
|
||||
});
|
||||
case 'documentsdb':
|
||||
return await baseSdk.documentsDB.deleteIndex({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
key: params.key
|
||||
});
|
||||
case 'vectorsdb':
|
||||
return await baseSdk.vectorsDB.deleteIndex({
|
||||
databaseId: params.databaseId,
|
||||
collectionId: params.entityId,
|
||||
key: params.key
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unknown database type`);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import type { Page } from '@sveltejs/kit';
|
||||
|
||||
import { capitalize, plural } from '$lib/helpers/string';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { type TablesDBIndexType, type Models } from '@appwrite.io/console';
|
||||
import type { Attributes, Collection, Columns, Table } from '$database/store';
|
||||
import type { Term, TerminologyResult, TerminologyShape } from '$database/(entity)/helpers/types';
|
||||
|
||||
type BaseTerminology = typeof baseTerminology;
|
||||
type ImplementedDBTypes = Omit<BaseTerminology, 'vectordb' | 'legacy'>;
|
||||
type ImplementedDBTypes = Omit<BaseTerminology, 'legacy'>;
|
||||
|
||||
export type DatabaseType =
|
||||
| 'legacy'
|
||||
| 'tablesdb'
|
||||
| 'documentsdb'
|
||||
| 'vectordb'
|
||||
| 'shared'
|
||||
| 'dedicated';
|
||||
export type DatabaseType = 'legacy' | 'tablesdb' | 'documentsdb' | 'vectorsdb' | 'dedicateddb';
|
||||
export type CollectionDatabaseType = Extract<DatabaseType, 'documentsdb' | 'vectorsdb'>;
|
||||
|
||||
export const DEFAULT_VECTOR_DIMENSION = 768;
|
||||
|
||||
export type RecordType = ImplementedDBTypes[keyof ImplementedDBTypes]['record'];
|
||||
|
||||
@@ -23,6 +20,7 @@ export type Entity = Partial<Collection | Table> & {
|
||||
indexes?: Index[];
|
||||
fields?: (Attributes | Columns)[];
|
||||
recordSecurity?: Models.Collection['documentSecurity'] | Models.Table['rowSecurity'];
|
||||
dimension?: number;
|
||||
};
|
||||
|
||||
export type Field = Partial<Attributes> | Partial<Columns>;
|
||||
@@ -33,6 +31,7 @@ export type Record = Partial<Models.Document | Models.Row> & {
|
||||
|
||||
export type Index = Partial<Models.Index | Models.ColumnIndex> & {
|
||||
fields: Models.Index['attributes'] | Models.ColumnIndex['columns'];
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type EntityList = {
|
||||
@@ -66,17 +65,12 @@ export const baseTerminology = {
|
||||
field: 'attribute',
|
||||
record: 'document'
|
||||
},
|
||||
vectordb: {
|
||||
vectorsdb: {
|
||||
entity: 'collection',
|
||||
field: 'attribute',
|
||||
record: 'document'
|
||||
},
|
||||
shared: {
|
||||
entity: 'table',
|
||||
field: 'column',
|
||||
record: 'row'
|
||||
},
|
||||
dedicated: {
|
||||
dedicateddb: {
|
||||
entity: 'table',
|
||||
field: 'column',
|
||||
record: 'row'
|
||||
@@ -110,17 +104,20 @@ const terminologyData = Object.fromEntries(
|
||||
])
|
||||
);
|
||||
|
||||
const toIndex = (index: Models.Index | Models.ColumnIndex): Index => ({
|
||||
...index,
|
||||
fields: (index as Models.Index).attributes ?? (index as Models.ColumnIndex).columns ?? []
|
||||
});
|
||||
export function toSupportiveIndex(index: Models.Index | Models.ColumnIndex): Index {
|
||||
return {
|
||||
...index,
|
||||
type: index.type as TablesDBIndexType,
|
||||
fields: (index as Models.Index).attributes ?? (index as Models.ColumnIndex).columns ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a raw `Collection` / `Table` model to normalized `Entity`.
|
||||
*/
|
||||
export function toSupportiveEntity(raw: Models.Collection | Models.Table): Entity {
|
||||
const isTable = 'columns' in raw;
|
||||
const indexes = raw.indexes?.map(toIndex) ?? [];
|
||||
const indexes = raw.indexes?.map(toSupportiveIndex) ?? [];
|
||||
|
||||
const fields = isTable ? raw.columns : raw.attributes;
|
||||
const recordSecurity = isTable ? raw.rowSecurity : raw.documentSecurity;
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
import { Modal, CustomId } from '$lib/components';
|
||||
import { subNavigation } from '$lib/stores/database';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { Button, InputText } from '$lib/elements/forms';
|
||||
import { Button, InputNumber, InputText } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import {
|
||||
Input as SuggestionsInput,
|
||||
entityColumnSuggestions
|
||||
} from '$database/(suggestions)/index';
|
||||
|
||||
import { getTerminologies } from '../helpers';
|
||||
import { getTerminologies, DEFAULT_VECTOR_DIMENSION } from '$database/(entity)';
|
||||
import { resetSampleFieldsConfig } from '$database/store';
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
@@ -20,7 +21,7 @@
|
||||
}: {
|
||||
show: boolean;
|
||||
useSuggestions?: boolean;
|
||||
onCreateEntity: (id: string, name: string) => Promise<void>;
|
||||
onCreateEntity: (id: string, name: string, dimension?: number) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const { analytics, terminology } = getTerminologies();
|
||||
@@ -28,14 +29,15 @@
|
||||
const lower = terminology.entity.lower.singular;
|
||||
const title = terminology.entity.title.singular;
|
||||
const analyticsCreateSubmit = analytics.submit.entity('Create');
|
||||
const isVectorsDb = terminology.type === 'vectorsdb';
|
||||
|
||||
// example - `table-[table]`, `collection-[collection]`
|
||||
const isOnEntitiesPage = $derived(page.route?.id.endsWith(`${lower}-[${lower}]`));
|
||||
|
||||
let name = $state('');
|
||||
let id = $state(null);
|
||||
let dimension = $state(DEFAULT_VECTOR_DIMENSION);
|
||||
let error = $state(null);
|
||||
let touchedId = $state(false);
|
||||
let creatingEntity = $state(false);
|
||||
|
||||
function enableThinkingModeForSuggestions(id: string, name: string) {
|
||||
@@ -64,7 +66,7 @@
|
||||
enableThinkingModeForSuggestions(finalId, name);
|
||||
|
||||
// create entity.
|
||||
await onCreateEntity(finalId, name);
|
||||
await onCreateEntity(finalId, name, isVectorsDb ? dimension : undefined);
|
||||
|
||||
// cleanup
|
||||
updateAndCleanup();
|
||||
@@ -73,6 +75,7 @@
|
||||
trackError(e, analyticsCreateSubmit);
|
||||
} finally {
|
||||
creatingEntity = false;
|
||||
resetSampleFieldsConfig();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,34 +94,10 @@
|
||||
show = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts string to valid Appwrite ID format matching backend rules:
|
||||
* - Lowercase alphanumeric characters, hyphens, underscores, and dots only
|
||||
* - Cannot start with a hyphen
|
||||
* - Cannot end with a dot
|
||||
* - Consecutive underscores collapsed to single underscore
|
||||
* - Maximum 36 characters
|
||||
*/
|
||||
function toIdFormat(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\-_. ]+/g, '')
|
||||
.replace(/ /g, '_')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/\.+$/, '')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.slice(0, 36);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!touchedId && name) {
|
||||
id = toIdFormat(name);
|
||||
}
|
||||
|
||||
if (!show) {
|
||||
id = null;
|
||||
error = null;
|
||||
touchedId = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -140,27 +119,22 @@
|
||||
placeholder="Enter {lower} name"
|
||||
bind:value={name}
|
||||
autofocus
|
||||
required
|
||||
on:input={() => {
|
||||
if (!touchedId) {
|
||||
id = toIdFormat(name);
|
||||
}
|
||||
}} />
|
||||
required />
|
||||
|
||||
<CustomId
|
||||
show
|
||||
bind:id
|
||||
required={false}
|
||||
autofocus={false}
|
||||
name={title}
|
||||
on:input={() => {
|
||||
if (!touchedId) {
|
||||
touchedId = true;
|
||||
}
|
||||
}} />
|
||||
<CustomId show bind:id required={false} autofocus={false} name={title} syncFrom={name} />
|
||||
|
||||
{#if isVectorsDb}
|
||||
<InputNumber
|
||||
id="dimension"
|
||||
label="Vector dimension"
|
||||
bind:value={dimension}
|
||||
min={1}
|
||||
max={4096}
|
||||
required />
|
||||
{/if}
|
||||
|
||||
{#if useSuggestions}
|
||||
<SuggestionsInput showSampleCountPicker={terminology.type === 'documentsdb'} />
|
||||
<SuggestionsInput showSampleCountPicker={!terminology.schema} />
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Skeleton } from '@appwrite.io/pink-svelte';
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { getTerminologies, type Record, toSupportiveRecord } from '$database/(entity)';
|
||||
import { getCollectionService } from '$database/(entity)/helpers/sdk';
|
||||
|
||||
const {
|
||||
record
|
||||
@@ -32,15 +33,18 @@
|
||||
|
||||
const { $databaseId: databaseId, entityId, $id: recordId } = toSupportiveRecord(record);
|
||||
|
||||
if (terminology.type === 'documentsdb') {
|
||||
recordActivityLogs = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.listDocumentLogs({
|
||||
databaseId: databaseId,
|
||||
collectionId: entityId,
|
||||
documentId: recordId,
|
||||
queries: [Query.limit(limit), Query.offset(offset)]
|
||||
});
|
||||
if (terminology.type === 'documentsdb' || terminology.type === 'vectorsdb') {
|
||||
const collectionService = getCollectionService(
|
||||
page.params.region,
|
||||
page.params.project,
|
||||
terminology.type
|
||||
);
|
||||
recordActivityLogs = await collectionService.listDocumentLogs({
|
||||
databaseId: databaseId,
|
||||
collectionId: entityId,
|
||||
documentId: recordId,
|
||||
queries: [Query.limit(limit), Query.offset(offset)]
|
||||
});
|
||||
} else {
|
||||
recordActivityLogs = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { TablesDBIndexType, OrderBy } from '@appwrite.io/console';
|
||||
export type CreateIndexesCallbackType = {
|
||||
key: string;
|
||||
type: TablesDBIndexType;
|
||||
type: string;
|
||||
fields: string[];
|
||||
lengths: (number | null)[];
|
||||
orders: OrderBy[];
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
subtitle,
|
||||
showActions = true,
|
||||
customColumns = [],
|
||||
onOpenCreateColumn
|
||||
onOpenCreateColumn,
|
||||
showNoSqlEditor = false
|
||||
}: {
|
||||
type?: DatabaseType;
|
||||
mode: Mode;
|
||||
@@ -36,6 +37,7 @@
|
||||
actions?: Snippet;
|
||||
showActions?: boolean;
|
||||
customColumns?: Column[];
|
||||
showNoSqlEditor?: boolean;
|
||||
onOpenCreateColumn?: () => Promise<void> | void;
|
||||
} = $props();
|
||||
|
||||
@@ -221,7 +223,6 @@
|
||||
return columns;
|
||||
};
|
||||
|
||||
// TODO: @itznotabug - we probably don't need `hasCustomColumns` but check please.
|
||||
const getDocumentsDbColumns = (): Column[] => [
|
||||
{
|
||||
id: '$id',
|
||||
@@ -291,7 +292,7 @@
|
||||
|
||||
const spreadsheetColumns = $derived.by(() => {
|
||||
return isRecordMode
|
||||
? type !== 'documentsdb'
|
||||
? type === 'tablesdb' || type === 'legacy'
|
||||
? getRowColumns()
|
||||
: getDocumentsDbColumns()
|
||||
: getIndexesColumns();
|
||||
@@ -380,8 +381,10 @@
|
||||
</Spreadsheet.Root>
|
||||
|
||||
{#snippet noSqlEditor()}
|
||||
{#if type === 'documentsdb' && mode === 'records'}
|
||||
<NoSqlEditor loading />
|
||||
{#if showNoSqlEditor}
|
||||
{#if (type === 'documentsdb' || type === 'vectorsdb') && mode === 'records'}
|
||||
<NoSqlEditor loading />
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SpreadsheetContainer>
|
||||
@@ -408,7 +411,8 @@
|
||||
</Layout.Stack>
|
||||
|
||||
{#if showActions && actions}
|
||||
{@const isOnlyIndexes = mode === 'indexes' && type === 'documentsdb'}
|
||||
{@const isOnlyIndexes =
|
||||
mode === 'indexes' && (type === 'documentsdb' || type === 'vectorsdb')}
|
||||
{@const inline = mode === 'records-filtered' || isOnlyIndexes}
|
||||
<div class="controlled-width" class:single-mode={isOnlyIndexes}>
|
||||
<Layout.Stack
|
||||
@@ -501,7 +505,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
&[data-mode='records'][data-type='documentsdb'] {
|
||||
&[data-mode='records'][data-type='documentsdb'],
|
||||
&[data-mode='records'][data-type='vectorsdb'] {
|
||||
position: unset;
|
||||
// disable animation when not loading!
|
||||
&[data-loading='false'] :global(.skeleton) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { onMount, onDestroy, type Snippet, tick } from 'svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { SideSheet } from '$database/(entity)';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let {
|
||||
children,
|
||||
@@ -42,7 +43,7 @@
|
||||
let mutationObserver: MutationObserver;
|
||||
|
||||
/** to avoid querySelector for perf! */
|
||||
let cachedElements = new Set<Element>();
|
||||
let cachedElements = new SvelteSet<Element>();
|
||||
|
||||
/** writable store to prevent jumps when changing views */
|
||||
let spreadsheetHeight = $state($sheetHeightStore);
|
||||
@@ -156,6 +157,9 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- in some cases, its window! -->
|
||||
<svelte:window on:resize={handleResize} />
|
||||
|
||||
<div
|
||||
bind:this={spreadsheetWrapper}
|
||||
class="spreadsheet-wrapper"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { expandTabs } from '../store';
|
||||
import { SpreadsheetContainer } from '$database/(entity)';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { Columns } from '$database/store';
|
||||
import { sdk, realtime, type RealtimeResponse } from '$lib/stores/sdk';
|
||||
import { page } from '$app/state';
|
||||
import { setupColumnObserver } from '../(observer)/columnObserver';
|
||||
@@ -37,7 +38,6 @@
|
||||
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, getSupportedColumns } from '../table-[table]/columns/store';
|
||||
import Options from './options.svelte';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Modal } from '$lib/components';
|
||||
import { type Entity, SideSheet } from '$database/(entity)';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { TablesDBIndexType, OrderBy } from '@appwrite.io/console';
|
||||
import { OrderBy, TablesDBIndexType } from '@appwrite.io/console';
|
||||
import { capitalize } from '$lib/helpers/string';
|
||||
import type { Columns } from '$database/store';
|
||||
import { isRelationship } from '../table-[table]/rows/store';
|
||||
@@ -240,7 +240,7 @@
|
||||
databaseId: table.databaseId,
|
||||
tableId: table.$id,
|
||||
key: uniqueIndexKey,
|
||||
type: index.type,
|
||||
type: index.type as TablesDBIndexType,
|
||||
columns: index.fields,
|
||||
lengths,
|
||||
...(orders.length ? { orders } : {})
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
|
||||
const {
|
||||
isModal = false,
|
||||
required = false,
|
||||
context = 'suggestions',
|
||||
showSampleCountPicker = false
|
||||
}: {
|
||||
isModal?: boolean;
|
||||
required?: boolean;
|
||||
context?: 'suggestions' | 'data';
|
||||
showSampleCountPicker?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -31,6 +35,8 @@
|
||||
const record = terminology.record.lower;
|
||||
const entity = terminology.entity.lower.singular;
|
||||
|
||||
const isSchemaless = type === 'documentsdb' || type === 'vectorsdb';
|
||||
|
||||
const title = $derived.by(() => {
|
||||
switch (type) {
|
||||
default:
|
||||
@@ -41,48 +47,61 @@
|
||||
: `Smart ${field.singular} suggestions available on Cloud`;
|
||||
|
||||
case 'documentsdb':
|
||||
case 'vectorsdb':
|
||||
return featureActive ? `Sample Data` : `Sample Data available on Cloud`;
|
||||
}
|
||||
});
|
||||
|
||||
const subtitle = $derived.by(() => {
|
||||
const isDocs = type === 'documentsdb';
|
||||
|
||||
if (featureActive) {
|
||||
return isDocs
|
||||
? `Enable AI to generate sample ${record.plural} based on your ${entity} name`
|
||||
return isSchemaless
|
||||
? `Generate sample ${record.plural} based on your ${entity} name`
|
||||
: `Enable AI to suggest useful ${field.plural} based on your ${entity} name`;
|
||||
}
|
||||
|
||||
return isDocs
|
||||
return isSchemaless
|
||||
? `Sign up for Cloud to generate sample documents based on your ${entity} name`
|
||||
: `Sign up for Cloud to generate ${field.plural} based on your ${entity} name`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Base variant="secondary" radius="s" padding="xs">
|
||||
<Layout.Stack gap={featureActive ? 'm' : 'l'}>
|
||||
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
|
||||
<IconAI />
|
||||
{#if !required}
|
||||
<Card.Base variant="secondary" radius="s" padding="xs">
|
||||
{@render contextAndSeekbar(false)}
|
||||
</Card.Base>
|
||||
{:else}
|
||||
{@render contextAndSeekbar(true, 'Context', context)}
|
||||
{/if}
|
||||
|
||||
<Layout.Stack direction="column" gap="none">
|
||||
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
|
||||
>{title}</Typography.Text>
|
||||
{#snippet contextAndSeekbar(
|
||||
required = false,
|
||||
contextLabel = undefined,
|
||||
contextType = 'suggestions'
|
||||
)}
|
||||
<Layout.Stack gap={featureActive ? 'm' : 'l'} style="padding-block-end: var(--gap-m);">
|
||||
{#if !required}
|
||||
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
|
||||
<IconAI />
|
||||
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
<Layout.Stack direction="column" gap="none">
|
||||
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
|
||||
>{title}</Typography.Text>
|
||||
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if featureActive && !isModal}
|
||||
<div class="suggestions-switch">
|
||||
<Selector.Switch
|
||||
id="suggestions"
|
||||
label={undefined}
|
||||
bind:checked={$entityColumnSuggestions.enabled} />
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if featureActive && !isModal}
|
||||
<div class="suggestions-switch">
|
||||
<Selector.Switch
|
||||
id="suggestions"
|
||||
label={undefined}
|
||||
bind:checked={$entityColumnSuggestions.enabled} />
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
{#if !featureActive}
|
||||
<Layout.Stack>
|
||||
@@ -94,23 +113,25 @@
|
||||
|
||||
<!-- just being safe with extra guard! -->
|
||||
{#if $entityColumnSuggestions.enabled && featureActive}
|
||||
<div class="context-input" transition:slide={{ duration: 200 }}>
|
||||
<Layout.Stack gap="xl">
|
||||
<div class="context-input" class:required transition:slide={{ duration: 200 }}>
|
||||
<Layout.Stack gap="l">
|
||||
<InputTextarea
|
||||
id="context"
|
||||
rows={3}
|
||||
maxlength={255}
|
||||
label={contextLabel}
|
||||
bind:value={$entityColumnSuggestions.context}
|
||||
placeholder="Optional: Add context to improve suggestions" />
|
||||
placeholder="Optional: Add context to improve {contextType}" />
|
||||
|
||||
{#if showSampleCountPicker}
|
||||
<Layout.Stack gap="m" style="margin-block-end: var(--space-4, 8px);">
|
||||
<Layout.Stack gap="xl" style="padding-inline: var(--space-4, 8px);">
|
||||
<Typography.Text>
|
||||
Select how many random documents to generate for testing.
|
||||
</Typography.Text>
|
||||
|
||||
<Seekbar
|
||||
max={100}
|
||||
extraBlockStart
|
||||
breakpointCount={5}
|
||||
bind:value={$randomDataModalState.value} />
|
||||
</Layout.Stack>
|
||||
@@ -119,7 +140,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
{/snippet}
|
||||
|
||||
<style lang="scss">
|
||||
.suggestions-switch :global(button):not(:disabled) {
|
||||
@@ -129,4 +150,8 @@
|
||||
.context-input :global(.input) {
|
||||
background: var(--bgcolor-neutral-primary);
|
||||
}
|
||||
|
||||
.context-input.required :global(.input) {
|
||||
background: var(--bgcolor-neutral-default);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { TablesDBIndexType, OrderBy } from '@appwrite.io/console';
|
||||
import { OrderBy, type TablesDBIndexType } from '@appwrite.io/console';
|
||||
import { columnOptions } from '../table-[table]/columns/store';
|
||||
|
||||
export type EntityColumnSuggestions = {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
} from '$lib/commandCenter';
|
||||
import { tablesSearcher } from '$lib/commandCenter/searchers';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { showCreateEntity, randomDataModalState } from './store';
|
||||
import { showCreateEntity, randomDataModalState, resetSampleFieldsConfig } from './store';
|
||||
import { entityColumnSuggestions } from './(suggestions)/store';
|
||||
import { TablesPanel } from '$lib/commandCenter/panels';
|
||||
import { canWriteTables, canWriteDatabases } from '$lib/stores/roles';
|
||||
import { showCreateBackup, showCreatePolicy } from './backups/store';
|
||||
@@ -18,26 +19,32 @@
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { noWidthTransition } from '$lib/stores/sidebar';
|
||||
import { CreateEntity, getTerminologies, setTerminologies } from '$database/(entity)';
|
||||
import { CreateEntity, getTerminologies } from '$database/(entity)';
|
||||
import { resolveRoute, withPath } from '$lib/stores/navigation';
|
||||
import { Dialog, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
import { Button, Seekbar } from '$lib/elements/forms';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Input as SuggestionsInput } from '$database/(suggestions)/index';
|
||||
import { Modal } from '$lib/components';
|
||||
import { realtime } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { getProjectId } from '$lib/helpers/project';
|
||||
|
||||
setTerminologies(page);
|
||||
let {
|
||||
children
|
||||
}: {
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
const project = page.params.project;
|
||||
const databaseId = page.params.database;
|
||||
|
||||
const { databaseSdk, terminology } = getTerminologies();
|
||||
|
||||
// Check if this is a dedicated database type
|
||||
$: isDedicatedType =
|
||||
terminology.type === 'dedicated' || terminology.type === 'shared';
|
||||
const isDedicatedType = terminology.type === 'dedicateddb';
|
||||
|
||||
$noWidthTransition = true;
|
||||
|
||||
// Auto-reload dedicated database on realtime events (status changes, credentials ready, etc.)
|
||||
onMount(() => {
|
||||
if (!isDedicatedType) return;
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
@@ -48,145 +55,17 @@
|
||||
});
|
||||
});
|
||||
|
||||
$: $registerCommands([
|
||||
{
|
||||
label: 'Create table',
|
||||
callback() {
|
||||
$showCreateEntity = true;
|
||||
if (!page.url.pathname.endsWith(databaseId)) {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}`
|
||||
);
|
||||
}
|
||||
},
|
||||
keys: page.url.pathname.endsWith(databaseId) ? ['c'] : ['c', 'c'],
|
||||
// Disable for dedicated databases - they don't have tables/collections
|
||||
disabled: page.url.pathname.includes('table-') || !$canWriteTables || isDedicatedType,
|
||||
group: 'databases',
|
||||
icon: IconPlus
|
||||
},
|
||||
{
|
||||
label: 'Create backup policy',
|
||||
callback: async () => {
|
||||
if (!page.url.pathname.endsWith('backups')) {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/backups`
|
||||
);
|
||||
}
|
||||
showCreatePolicy.set(true);
|
||||
},
|
||||
keys: page.url.pathname.endsWith('backups') ? ['c'] : ['c', 'p'],
|
||||
group: 'databases',
|
||||
icon: IconPlus,
|
||||
rank: page.url.pathname.endsWith('backups') ? 10 : 0,
|
||||
disabled: !isCloud || !$currentPlan?.backupsEnabled
|
||||
},
|
||||
{
|
||||
label: 'Create manual backup',
|
||||
callback: async () => {
|
||||
if (!page.url.pathname.endsWith('backups')) {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/backups`
|
||||
);
|
||||
}
|
||||
showCreateBackup.set(true);
|
||||
},
|
||||
keys: page.url.pathname.endsWith('backups') ? ['c'] : ['c', 'b'],
|
||||
group: 'databases',
|
||||
icon: IconPlus,
|
||||
rank: page.url.pathname.endsWith('backups') ? 10 : 0,
|
||||
disabled: !isCloud || !$currentPlan?.backupsEnabled
|
||||
},
|
||||
{
|
||||
// For dedicated DBs, show "Go to overview" instead of "Go to tables"
|
||||
label: isDedicatedType ? 'Go to overview' : 'Go to tables',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.endsWith(databaseId) || page.url.pathname.includes('table-'),
|
||||
keys: ['g', 'c'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to usage',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/usage`
|
||||
);
|
||||
},
|
||||
disabled: page.url.pathname.includes('/usage') || page.url.pathname.includes('table-'),
|
||||
keys: ['g', 'u'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to backups',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/backups`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.includes('/backups') || page.url.pathname.includes('table-'),
|
||||
keys: ['g', 'b'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to monitoring',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/monitoring`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.includes('/monitoring') ||
|
||||
page.url.pathname.includes('table-') ||
|
||||
!isDedicatedType,
|
||||
keys: ['g', 'm'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to settings',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/settings`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.includes('/settings') ||
|
||||
page.url.pathname.includes('table-') ||
|
||||
!$canWriteDatabases,
|
||||
keys: ['g', 's'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Find tables',
|
||||
callback: () => {
|
||||
addSubPanel(TablesPanel);
|
||||
},
|
||||
group: 'databases',
|
||||
rank: -1,
|
||||
// Disable for dedicated databases
|
||||
disabled: isDedicatedType
|
||||
}
|
||||
]);
|
||||
|
||||
// Only register table searcher for non-dedicated databases
|
||||
if (!isDedicatedType) {
|
||||
$registerSearchers(tablesSearcher);
|
||||
}
|
||||
|
||||
$: $updateCommandGroupRanks({ tables: 10 });
|
||||
|
||||
$noWidthTransition = true;
|
||||
|
||||
async function createEntity(entityId: string, name: string) {
|
||||
async function createEntity(entityId: string, name: string, dimension?: number) {
|
||||
const entity = await databaseSdk.createEntity({
|
||||
databaseId,
|
||||
entityId,
|
||||
name
|
||||
name,
|
||||
dimension
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.DATABASE);
|
||||
@@ -199,7 +78,128 @@
|
||||
`/${terminology.entity.lower.singular}-${entity.$id}`
|
||||
)
|
||||
);
|
||||
|
||||
if ($entityColumnSuggestions.enabled) {
|
||||
$randomDataModalState.columns = true;
|
||||
await $randomDataModalState.onSubmit?.();
|
||||
}
|
||||
|
||||
resetSampleFieldsConfig();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
$registerCommands([
|
||||
{
|
||||
label: 'Create table',
|
||||
callback() {
|
||||
$showCreateEntity = true;
|
||||
if (!page.url.pathname.endsWith(databaseId)) {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}`
|
||||
);
|
||||
}
|
||||
},
|
||||
keys: page.url.pathname.endsWith(databaseId) ? ['c'] : ['c', 'c'],
|
||||
disabled: page.route.id?.includes('table-') || !$canWriteTables,
|
||||
group: 'databases',
|
||||
icon: IconPlus
|
||||
},
|
||||
{
|
||||
label: 'Create backup policy',
|
||||
callback: async () => {
|
||||
if (!page.url.pathname.endsWith('backups')) {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/backups`
|
||||
);
|
||||
}
|
||||
showCreatePolicy.set(true);
|
||||
},
|
||||
keys: page.url.pathname.endsWith('backups') ? ['c'] : ['c', 'p'],
|
||||
group: 'databases',
|
||||
icon: IconPlus,
|
||||
rank: page.url.pathname.endsWith('backups') ? 10 : 0,
|
||||
disabled: !isCloud || !$currentPlan?.backupsEnabled
|
||||
},
|
||||
{
|
||||
label: 'Create manual backup',
|
||||
callback: async () => {
|
||||
if (!page.url.pathname.endsWith('backups')) {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/backups`
|
||||
);
|
||||
}
|
||||
showCreateBackup.set(true);
|
||||
},
|
||||
keys: page.url.pathname.endsWith('backups') ? ['c'] : ['c', 'b'],
|
||||
group: 'databases',
|
||||
icon: IconPlus,
|
||||
rank: page.url.pathname.endsWith('backups') ? 10 : 0,
|
||||
disabled: !isCloud || !$currentPlan?.backupsEnabled
|
||||
},
|
||||
{
|
||||
label: 'Go to tables',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.endsWith(databaseId) || page.url.pathname.includes('table-'),
|
||||
keys: ['g', 'c'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to usage',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/usage`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.includes('/usage') || page.url.pathname.includes('table-'),
|
||||
keys: ['g', 'u'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to backups',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/backups`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.includes('/backups') || page.url.pathname.includes('table-'),
|
||||
keys: ['g', 'b'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Go to settings',
|
||||
callback() {
|
||||
goto(
|
||||
`${base}/project-${page.params.region}-${project}/databases/database-${databaseId}/settings`
|
||||
);
|
||||
},
|
||||
disabled:
|
||||
page.url.pathname.includes('/settings') ||
|
||||
page.url.pathname.includes('table-') ||
|
||||
!$canWriteDatabases,
|
||||
keys: ['g', 's'],
|
||||
group: 'databases'
|
||||
},
|
||||
{
|
||||
label: 'Find tables',
|
||||
callback: () => {
|
||||
addSubPanel(TablesPanel);
|
||||
},
|
||||
group: 'databases',
|
||||
rank: -1
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$updateCommandGroupRanks({ tables: 10 });
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -209,28 +209,36 @@
|
||||
{/key}
|
||||
</svelte:head>
|
||||
|
||||
<slot />
|
||||
{@render children()}
|
||||
|
||||
<!-- Only show entity creation dialog for non-dedicated databases -->
|
||||
{#if !isDedicatedType}
|
||||
<CreateEntity bind:show={$showCreateEntity} onCreateEntity={createEntity} />
|
||||
{/if}
|
||||
|
||||
<Dialog title="Generate sample data" bind:open={$randomDataModalState.show}>
|
||||
{@const records = terminology.record.lower.singular}
|
||||
{#if !$randomDataModalState.managed}
|
||||
<Modal title="Generate sample data" bind:show={$randomDataModalState.show}>
|
||||
<Layout.Stack style="gap: 28px;">
|
||||
<Typography.Text>
|
||||
Select how many sample {records} to generate for testing. This won't delete or replace
|
||||
any existing {records}.
|
||||
</Typography.Text>
|
||||
|
||||
<Seekbar max={100} breakpointCount={5} bind:value={$randomDataModalState.value} />
|
||||
{#if $randomDataModalState.columns}
|
||||
<SuggestionsInput
|
||||
required
|
||||
context="data"
|
||||
showSampleCountPicker={!terminology.schema} />
|
||||
{:else}
|
||||
<Seekbar max={100} breakpointCount={5} bind:value={$randomDataModalState.value} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" gap="s" justifyContent="flex-end">
|
||||
<Button text on:click={() => ($randomDataModalState.show = false)}>Cancel</Button>
|
||||
<Button on:click={() => $randomDataModalState.onSubmit?.()}>Create</Button>
|
||||
<Button text on:click={() => resetSampleFieldsConfig()}>Cancel</Button>
|
||||
<Button
|
||||
on:click={async () => {
|
||||
$randomDataModalState.show = false;
|
||||
await $randomDataModalState.onSubmit?.();
|
||||
resetSampleFieldsConfig();
|
||||
}}>Create</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@ type DatabaseWithType = Models.Database & {
|
||||
};
|
||||
|
||||
function isDedicatedDatabaseType(type: string | undefined): boolean {
|
||||
return type === 'dedicated' || type === 'shared';
|
||||
return type === 'dedicateddb';
|
||||
}
|
||||
|
||||
export const load: LayoutLoad = async ({ params, depends }) => {
|
||||
@@ -34,7 +34,9 @@ export const load: LayoutLoad = async ({ params, depends }) => {
|
||||
const dbType = database.type as string | undefined;
|
||||
if (isDedicatedDatabaseType(dbType) && !dedicatedDatabase) {
|
||||
try {
|
||||
dedicatedDatabase = await projectSdk.compute.getDatabase({ databaseId: params.database });
|
||||
dedicatedDatabase = await projectSdk.compute.getDatabase({
|
||||
databaseId: params.database
|
||||
});
|
||||
} catch {
|
||||
// Fallback - dedicated details not available
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
/**
|
||||
* init update because `getContext`
|
||||
* doesn't work on typescript context!
|
||||
* doesn't work on TypeScript context!
|
||||
*/
|
||||
tableViewColumns.update((columns) => {
|
||||
/* $id */
|
||||
@@ -32,10 +32,11 @@
|
||||
return columns;
|
||||
});
|
||||
|
||||
// TODO: get proper images for documentsDB
|
||||
function getImageRoute(type: 'light' | 'dark'): string {
|
||||
const base = terminology.type === 'documentsdb' ? 'empty-documents-db' : 'empty-database';
|
||||
return withPath(resolveRoute('/'), `/images/${base}-${type}.svg`);
|
||||
return withPath(
|
||||
resolveRoute('/'),
|
||||
`/images/databases/empty-${terminology.type}-${type}.svg`
|
||||
);
|
||||
}
|
||||
|
||||
const emptyPageText = $derived.by(() => {
|
||||
@@ -45,7 +46,7 @@
|
||||
case 'tablesdb':
|
||||
return `Create, organize, and query structured data with ${entityTitle.plural}.`;
|
||||
case 'documentsdb':
|
||||
return `Create, organize, and query flexible data with ${entityTitle.plural}.`;
|
||||
return `Store, manage, and query unstructured data with ${entityTitle.plural}.`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const load: PageLoad = async ({ params, url, route, depends, parent }) =>
|
||||
const databaseType = database.type as DatabaseType;
|
||||
|
||||
// For dedicated databases, we don't fetch entities (tables/collections)
|
||||
const isDedicatedType = databaseType === 'dedicated';
|
||||
const isDedicatedType = databaseType === 'dedicateddb';
|
||||
|
||||
if (isDedicatedType) {
|
||||
return {
|
||||
|
||||
@@ -29,10 +29,7 @@
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
const isDedicatedType = $derived(
|
||||
data.database?.type === 'dedicated' ||
|
||||
data.database?.type === 'shared'
|
||||
);
|
||||
const isDedicatedType = $derived(data.database?.type === 'dedicateddb');
|
||||
|
||||
let policyCreateError: string | null = $state(null);
|
||||
let totalPolicies: UserBackupPolicy[] = $state([]);
|
||||
@@ -180,70 +177,70 @@
|
||||
{#if isDedicatedType && data.dedicatedDatabase}
|
||||
<DedicatedBackups database={data.dedicatedDatabase as Models.DedicatedDatabase} />
|
||||
{:else}
|
||||
<Container size="xxl" databasesMainScreen>
|
||||
<div class="backups-page u-flex u-gap-32 u-flex-vertical-mobile">
|
||||
{#if !isDisabled}
|
||||
<div class="u-flex-vertical u-gap-16 policies-holder-card">
|
||||
<ContainerHeader
|
||||
title="Policies"
|
||||
buttonText="Create policy"
|
||||
buttonEvent="create_backup"
|
||||
buttonType="secondary"
|
||||
project={data.project}
|
||||
buttonDisabled={isDisabled}
|
||||
policiesCreated={data.policies.total}
|
||||
maxPolicies={$currentPlan.backupPolicies}
|
||||
buttonMethod={() => {
|
||||
$showCreatePolicy = true;
|
||||
trackEvent('click_policy_create');
|
||||
}} />
|
||||
<Container size="xxl" databasesMainScreen>
|
||||
<div class="backups-page u-flex u-gap-32 u-flex-vertical-mobile">
|
||||
{#if !isDisabled}
|
||||
<div class="u-flex-vertical u-gap-16 policies-holder-card">
|
||||
<ContainerHeader
|
||||
title="Policies"
|
||||
buttonText="Create policy"
|
||||
buttonEvent="create_backup"
|
||||
buttonType="secondary"
|
||||
project={data.project}
|
||||
buttonDisabled={isDisabled}
|
||||
policiesCreated={data.policies.total}
|
||||
maxPolicies={$currentPlan.backupPolicies}
|
||||
buttonMethod={() => {
|
||||
$showCreatePolicy = true;
|
||||
trackEvent('click_policy_create');
|
||||
}} />
|
||||
|
||||
<BackupPolicy
|
||||
bind:showCreatePolicy={$showCreatePolicy}
|
||||
policies={data.policies}
|
||||
lastBackupDates={data.lastBackupDates} />
|
||||
</div>
|
||||
<BackupPolicy
|
||||
bind:showCreatePolicy={$showCreatePolicy}
|
||||
policies={data.policies}
|
||||
lastBackupDates={data.lastBackupDates} />
|
||||
</div>
|
||||
|
||||
<div class="u-flex-vertical u-gap-16 u-width-full-line u-overflow-x-auto">
|
||||
<ContainerHeader
|
||||
title="Backups"
|
||||
buttonText="Manual backup"
|
||||
buttonEvent="create_backup"
|
||||
buttonType="secondary"
|
||||
project={data.project}
|
||||
buttonDisabled={isDisabled}
|
||||
buttonMethod={() => {
|
||||
$showCreateBackup = true;
|
||||
trackEvent('click_manual_create');
|
||||
}} />
|
||||
<div class="u-flex-vertical u-gap-16 u-width-full-line u-overflow-x-auto">
|
||||
<ContainerHeader
|
||||
title="Backups"
|
||||
buttonText="Manual backup"
|
||||
buttonEvent="create_backup"
|
||||
buttonType="secondary"
|
||||
project={data.project}
|
||||
buttonDisabled={isDisabled}
|
||||
buttonMethod={() => {
|
||||
$showCreateBackup = true;
|
||||
trackEvent('click_manual_create');
|
||||
}} />
|
||||
|
||||
{#if data.backups.total}
|
||||
<Layout.Stack gap="xxl">
|
||||
<Table {data} />
|
||||
{#if data.backups.total}
|
||||
<Layout.Stack gap="xxl">
|
||||
<Table {data} />
|
||||
|
||||
{#if data.backups.total > 6}
|
||||
<PaginationWithLimit
|
||||
name="Backups"
|
||||
limit={data.limit}
|
||||
offset={data.offset}
|
||||
total={data.backups.total} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<div class="u-flex u-flex-vertical u-gap-16">
|
||||
<article class="empty card u-width-full-line common-section">
|
||||
No backups yet
|
||||
</article>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="u-flex-vertical u-gap-32">
|
||||
<LockedCard project={data.project} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
{#if data.backups.total > 6}
|
||||
<PaginationWithLimit
|
||||
name="Backups"
|
||||
limit={data.limit}
|
||||
offset={data.offset}
|
||||
total={data.backups.total} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<div class="u-flex u-flex-vertical u-gap-16">
|
||||
<article class="empty card u-width-full-line common-section">
|
||||
No backups yet
|
||||
</article>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="u-flex-vertical u-gap-32">
|
||||
<LockedCard project={data.project} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getChangePlanUrl } from '$lib/stores/billing';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let isShowing: boolean;
|
||||
export let disabled: boolean = false;
|
||||
export let isFromBackupsTab: boolean = false;
|
||||
export let title: string | undefined = undefined;
|
||||
export let subtitle: string | undefined = undefined;
|
||||
@@ -98,14 +98,12 @@
|
||||
|
||||
listOfCustomPolicies = [...listOfCustomPolicies, userBackupPolicy];
|
||||
|
||||
selectedPolicyGroup = 'custom';
|
||||
|
||||
resetFormVariables();
|
||||
showCustomPolicy = false;
|
||||
};
|
||||
|
||||
const markPolicyChecked = (event: CustomEvent, policy: UserBackupPolicy) => {
|
||||
const isChecked = event.detail as boolean;
|
||||
const markPolicyChecked = (event: CustomEvent<boolean>, policy: UserBackupPolicy) => {
|
||||
const isChecked = event.detail;
|
||||
presetPolicies.update((all) => {
|
||||
return all.map((p) => {
|
||||
if (p.label === policy.label) {
|
||||
@@ -136,6 +134,10 @@
|
||||
);
|
||||
};
|
||||
|
||||
const getPolicyById = (id: string) => {
|
||||
return $presetPolicies.find((p) => p.id === id);
|
||||
};
|
||||
|
||||
$: if (showCustomPolicy) {
|
||||
customPolicySection?.scrollIntoView({ behavior: 'auto' });
|
||||
}
|
||||
@@ -178,7 +180,7 @@
|
||||
label: freq.charAt(0).toUpperCase() + freq.slice(1)
|
||||
}));
|
||||
|
||||
let selectedPolicyGroup: string = null;
|
||||
let selectedPolicyGroup: null | string = null;
|
||||
$: if (selectedPolicyGroup) {
|
||||
if (selectedPolicyGroup === 'custom') {
|
||||
if (listOfCustomPolicies.length === 0) {
|
||||
@@ -204,15 +206,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
$: filteredPresetPolicies = $presetPolicies.filter((policy) => {
|
||||
if (isFromBackupsTab) {
|
||||
presetPolicies.update((preset) => {
|
||||
return preset.filter((policy) => policy.id !== 'none');
|
||||
});
|
||||
return policy.id !== 'none';
|
||||
} else {
|
||||
presetPolicies.update((preset) => {
|
||||
return preset.filter((policy) => policy.id !== 'hourly');
|
||||
});
|
||||
return policy.id !== 'hourly';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -238,7 +236,7 @@
|
||||
|
||||
<!-- because we show a set of pre-defined ones -->
|
||||
{#if $currentPlan?.backupPolicies === 1}
|
||||
{@const dailyPolicy = $presetPolicies[1]}
|
||||
{@const dailyPolicy = getPolicyById('dank')}
|
||||
|
||||
{#if isFromBackupsTab}
|
||||
<Layout.Stack gap="m">
|
||||
@@ -263,6 +261,7 @@
|
||||
{:else}
|
||||
<Layout.Stack gap="m">
|
||||
<InputSwitch
|
||||
{disabled}
|
||||
id="daily_backup"
|
||||
label="Daily backups"
|
||||
on:change={(event) => markPolicyChecked(event, dailyPolicy)}>
|
||||
@@ -284,7 +283,7 @@
|
||||
<Layout.Stack gap="m">
|
||||
<Layout.Grid columns={isFromBackupsTab ? 2 : 3} columnsS={1}>
|
||||
{#if isFromBackupsTab}
|
||||
{#each $presetPolicies as policy, index (index)}
|
||||
{#each filteredPresetPolicies as policy, index (index)}
|
||||
<label for={index.toString()} class="card preset-label-card is-allow-focus">
|
||||
<Layout.Stack gap="s" direction="row">
|
||||
<InputCheckbox
|
||||
@@ -300,8 +299,8 @@
|
||||
</label>
|
||||
{/each}
|
||||
{:else}
|
||||
{@const none = $presetPolicies[1]}
|
||||
{@const dailPreset = $presetPolicies[0]}
|
||||
{@const none = getPolicyById('none')}
|
||||
{@const dailPreset = getPolicyById('daily')}
|
||||
<Card.Selector
|
||||
variant="secondary"
|
||||
imageRadius="s"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Type, type Models } from '@appwrite.io/console';
|
||||
import {
|
||||
ActionMenu,
|
||||
Alert,
|
||||
@@ -21,11 +21,7 @@
|
||||
Tabs,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
IconDotsHorizontal,
|
||||
IconRefresh,
|
||||
IconTrash
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { IconDotsHorizontal, IconRefresh, IconTrash } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
const {
|
||||
database
|
||||
@@ -34,7 +30,10 @@
|
||||
} = $props();
|
||||
|
||||
let backups = $state<Models.DedicatedDatabaseBackupList>({ total: 0, backups: [] });
|
||||
let restorations = $state<Models.DedicatedDatabaseRestorationList>({ total: 0, restorations: [] });
|
||||
let restorations = $state<Models.DedicatedDatabaseRestorationList>({
|
||||
total: 0,
|
||||
restorations: []
|
||||
});
|
||||
let pitrWindows = $state<Models.DedicatedDatabasePITRWindows | null>(null);
|
||||
|
||||
let isLoadingBackups = $state(true);
|
||||
@@ -53,9 +52,7 @@
|
||||
|
||||
let activeTab = $state<'backups' | 'restorations'>('backups');
|
||||
|
||||
const computeSdk = $derived(
|
||||
sdk.forProject(page.params.region, page.params.project).compute
|
||||
);
|
||||
const computeSdk = $derived(sdk.forProject(page.params.region, page.params.project).compute);
|
||||
|
||||
function mapBackupStatus(
|
||||
status: string
|
||||
@@ -176,7 +173,10 @@
|
||||
async function handleDeleteBackup() {
|
||||
if (!selectedBackup) return;
|
||||
try {
|
||||
await computeSdk.deleteDatabaseBackup({ databaseId: database.$id, backupId: selectedBackup.$id });
|
||||
await computeSdk.deleteDatabaseBackup({
|
||||
databaseId: database.$id,
|
||||
backupId: selectedBackup.$id
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Backup deleted'
|
||||
@@ -197,7 +197,11 @@
|
||||
async function handleRestoreBackup() {
|
||||
if (!restoreBackup) return;
|
||||
try {
|
||||
await computeSdk.createDatabaseRestoration({ databaseId: database.$id, type: 'backup' as any, backupId: restoreBackup.$id });
|
||||
await computeSdk.createDatabaseRestoration({
|
||||
databaseId: database.$id,
|
||||
type: Type.Backup,
|
||||
backupId: restoreBackup.$id
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Restoration started from backup'
|
||||
@@ -219,7 +223,11 @@
|
||||
if (!pitrTargetDateTime) return;
|
||||
try {
|
||||
const targetTime = Math.floor(new Date(pitrTargetDateTime).getTime() / 1000);
|
||||
await computeSdk.createDatabaseRestoration({ databaseId: database.$id, type: 'pitr' as any, targetTime });
|
||||
await computeSdk.createDatabaseRestoration({
|
||||
databaseId: database.$id,
|
||||
type: Type.Pitr,
|
||||
targetTime
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Point-in-time restoration started'
|
||||
@@ -472,80 +480,73 @@
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/if}
|
||||
{:else if isLoadingRestorations}
|
||||
<article class="empty card u-width-full-line common-section">
|
||||
Loading restorations...
|
||||
</article>
|
||||
{:else if restorations.total === 0}
|
||||
<article class="empty card u-width-full-line common-section">
|
||||
No restorations yet.
|
||||
</article>
|
||||
{:else}
|
||||
{#if isLoadingRestorations}
|
||||
<article class="empty card u-width-full-line common-section">
|
||||
Loading restorations...
|
||||
</article>
|
||||
{:else if restorations.total === 0}
|
||||
<article class="empty card u-width-full-line common-section">
|
||||
No restorations yet.
|
||||
</article>
|
||||
{:else}
|
||||
<Table.Root
|
||||
columns={[
|
||||
{ id: 'id', width: { min: 120 } },
|
||||
{ id: 'type', width: { min: 120 } },
|
||||
{ id: 'status', width: { min: 120 } },
|
||||
{ id: 'backupId', width: { min: 120 } },
|
||||
{ id: 'targetTime', width: { min: 140 } },
|
||||
{ id: 'started', width: { min: 140 } },
|
||||
{ id: 'completed', width: { min: 140 } }
|
||||
]}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="id" {root}>ID</Table.Header.Cell>
|
||||
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
|
||||
<Table.Header.Cell column="status" {root}>Status</Table.Header.Cell>
|
||||
<Table.Header.Cell column="backupId" {root}>Backup ID</Table.Header.Cell>
|
||||
<Table.Header.Cell column="targetTime" {root}>Target Time</Table.Header.Cell>
|
||||
<Table.Header.Cell column="started" {root}>Started</Table.Header.Cell>
|
||||
<Table.Header.Cell column="completed" {root}>Completed</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each restorations.restorations as restoration}
|
||||
<Table.Row.Base id={restoration.$id} {root}>
|
||||
<Table.Cell column="id" {root}>
|
||||
<Typography.Text variant="m-400">
|
||||
{restoration.$id.substring(0, 8)}...
|
||||
</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="type" {root}>
|
||||
{restoration.type === 'pitr' ? 'Point-in-Time' : 'Backup'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="status" {root}>
|
||||
<Status
|
||||
status={mapRestorationStatus(restoration.status)}
|
||||
label={restoration.status} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="backupId" {root}>
|
||||
{restoration.backupId
|
||||
? restoration.backupId.substring(0, 8) + '...'
|
||||
: '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="targetTime" {root}>
|
||||
{restoration.targetTime
|
||||
? formatTimestamp(restoration.targetTime)
|
||||
: '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="started" {root}>
|
||||
{formatTimestamp(restoration.startedAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="completed" {root}>
|
||||
{formatTimestamp(restoration.completedAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/if}
|
||||
<Table.Root
|
||||
columns={[
|
||||
{ id: 'id', width: { min: 120 } },
|
||||
{ id: 'type', width: { min: 120 } },
|
||||
{ id: 'status', width: { min: 120 } },
|
||||
{ id: 'backupId', width: { min: 120 } },
|
||||
{ id: 'targetTime', width: { min: 140 } },
|
||||
{ id: 'started', width: { min: 140 } },
|
||||
{ id: 'completed', width: { min: 140 } }
|
||||
]}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="id" {root}>ID</Table.Header.Cell>
|
||||
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
|
||||
<Table.Header.Cell column="status" {root}>Status</Table.Header.Cell>
|
||||
<Table.Header.Cell column="backupId" {root}>Backup ID</Table.Header.Cell>
|
||||
<Table.Header.Cell column="targetTime" {root}>Target Time</Table.Header.Cell>
|
||||
<Table.Header.Cell column="started" {root}>Started</Table.Header.Cell>
|
||||
<Table.Header.Cell column="completed" {root}>Completed</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each restorations.restorations as restoration}
|
||||
<Table.Row.Base id={restoration.$id} {root}>
|
||||
<Table.Cell column="id" {root}>
|
||||
<Typography.Text variant="m-400">
|
||||
{restoration.$id.substring(0, 8)}...
|
||||
</Typography.Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="type" {root}>
|
||||
{restoration.type === 'pitr' ? 'Point-in-Time' : 'Backup'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="status" {root}>
|
||||
<Status
|
||||
status={mapRestorationStatus(restoration.status)}
|
||||
label={restoration.status} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="backupId" {root}>
|
||||
{restoration.backupId
|
||||
? restoration.backupId.substring(0, 8) + '...'
|
||||
: '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="targetTime" {root}>
|
||||
{restoration.targetTime ? formatTimestamp(restoration.targetTime) : '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="started" {root}>
|
||||
{formatTimestamp(restoration.startedAt)}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="completed" {root}>
|
||||
{formatTimestamp(restoration.completedAt)}
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Container>
|
||||
|
||||
<!-- Delete Backup Confirmation -->
|
||||
<Confirm
|
||||
title="Delete backup"
|
||||
bind:open={showDeleteConfirm}
|
||||
onSubmit={handleDeleteBackup}>
|
||||
<Confirm title="Delete backup" bind:open={showDeleteConfirm} onSubmit={handleDeleteBackup}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete this backup? This action is irreversible.
|
||||
</Typography.Text>
|
||||
@@ -557,10 +558,7 @@
|
||||
</Confirm>
|
||||
|
||||
<!-- Restore from Backup Confirmation -->
|
||||
<Modal
|
||||
title="Restore from backup"
|
||||
bind:show={showRestoreConfirm}
|
||||
onSubmit={handleRestoreBackup}>
|
||||
<Modal title="Restore from backup" bind:show={showRestoreConfirm} onSubmit={handleRestoreBackup}>
|
||||
<Layout.Stack gap="l">
|
||||
<Typography.Text>
|
||||
This will restore your database from the selected backup. Your database will be
|
||||
@@ -587,9 +585,7 @@
|
||||
Size
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{restoreBackup.sizeBytes
|
||||
? calculateSize(restoreBackup.sizeBytes)
|
||||
: '-'}
|
||||
{restoreBackup.sizeBytes ? calculateSize(restoreBackup.sizeBytes) : '-'}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
@@ -603,8 +599,8 @@
|
||||
</Layout.Grid>
|
||||
{/if}
|
||||
<Alert.Inline status="warning" title="Warning">
|
||||
The database will enter a restoring state and will be unavailable until the
|
||||
restoration completes.
|
||||
The database will enter a restoring state and will be unavailable until the restoration
|
||||
completes.
|
||||
</Alert.Inline>
|
||||
</Layout.Stack>
|
||||
<svelte:fragment slot="footer">
|
||||
@@ -614,14 +610,11 @@
|
||||
</Modal>
|
||||
|
||||
<!-- PITR Restore Modal -->
|
||||
<Modal
|
||||
title="Point-in-Time Recovery"
|
||||
bind:show={showPitrRestore}
|
||||
onSubmit={handlePitrRestore}>
|
||||
<Modal title="Point-in-Time Recovery" bind:show={showPitrRestore} onSubmit={handlePitrRestore}>
|
||||
<Layout.Stack gap="l">
|
||||
<Typography.Text>
|
||||
Select a target date and time to restore your database to. The target must be within
|
||||
the available recovery window.
|
||||
Select a target date and time to restore your database to. The target must be within the
|
||||
available recovery window.
|
||||
</Typography.Text>
|
||||
{#if pitrWindows}
|
||||
<Layout.Grid columns={2} columnsS={1} gap="m">
|
||||
@@ -656,8 +649,8 @@
|
||||
required />
|
||||
</Layout.Stack>
|
||||
<Alert.Inline status="warning" title="Warning">
|
||||
The database will enter a restoring state and will be unavailable until the
|
||||
restoration completes. All data after the selected point in time will be lost.
|
||||
The database will enter a restoring state and will be unavailable until the restoration
|
||||
completes. All data after the selected point in time will be lost.
|
||||
</Alert.Inline>
|
||||
</Layout.Stack>
|
||||
<svelte:fragment slot="footer">
|
||||
|
||||
@@ -5,18 +5,6 @@ import type { UserBackupPolicy } from '$lib/helpers/backups';
|
||||
export const showCreatePolicy = writable(false);
|
||||
export const showCreateBackup = writable(false);
|
||||
|
||||
export const dailyPolicy: UserBackupPolicy = {
|
||||
id: 'daily',
|
||||
label: 'Daily',
|
||||
retained: 7,
|
||||
default: true,
|
||||
checked: false,
|
||||
schedule: '{time} * * *',
|
||||
selectedTime: '00:00',
|
||||
plainTextFrequency: 'daily',
|
||||
description: 'Runs every day and is retained for 7 days'
|
||||
};
|
||||
|
||||
export const presetPolicies = writable<UserBackupPolicy[]>([
|
||||
{
|
||||
id: 'hourly',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Modal } from '$lib/components';
|
||||
import { Button, InputTextarea } from '$lib/elements/forms';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Layout, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
onGenerate
|
||||
}: {
|
||||
show: boolean;
|
||||
onGenerate: (embeddings: number[]) => void;
|
||||
} = $props();
|
||||
|
||||
let content = $state('');
|
||||
let generating = $state(false);
|
||||
|
||||
const MAX_LENGTH = 25000;
|
||||
|
||||
async function generate() {
|
||||
if (!content.trim()) return;
|
||||
|
||||
generating = true;
|
||||
try {
|
||||
const response = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.vectorsDB.createTextEmbeddings({ texts: [content.trim()] });
|
||||
|
||||
const embedding = response?.embeddings?.[0]?.embedding;
|
||||
if (embedding?.length) {
|
||||
onGenerate(embedding);
|
||||
content = '';
|
||||
show = false;
|
||||
} else {
|
||||
const error = response?.embeddings?.[0]?.error;
|
||||
throw new Error(error || 'Failed to generate embeddings');
|
||||
}
|
||||
} catch (e) {
|
||||
addNotification({ type: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!show) {
|
||||
content = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal size="m" bind:show title="Text for embedding" onSubmit={generate}>
|
||||
<p class="u-margin-block-end-8">
|
||||
Enter the content you want to convert into a vector. This will allow semantic search and
|
||||
similarity matching.
|
||||
</p>
|
||||
|
||||
<InputTextarea
|
||||
id="embedding-content"
|
||||
label="Content to embed"
|
||||
placeholder="Paste or type your text here..."
|
||||
bind:value={content}
|
||||
maxlength={MAX_LENGTH}
|
||||
autofocus
|
||||
required />
|
||||
|
||||
<Layout.Stack direction="row" gap="xs" alignItems="center">
|
||||
<Icon icon={IconInfo} size="s" />
|
||||
<span class="u-color-text-offline"
|
||||
>Embeddings are generated using Embedding Gemma model</span>
|
||||
</Layout.Stack>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button secondary disabled={generating} on:click={() => (show = false)}>Cancel</Button>
|
||||
<Button
|
||||
submit
|
||||
disabled={generating || !content.trim()}
|
||||
submissionLoader
|
||||
forceShowLoader={generating}>Generate</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -2,12 +2,83 @@ import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
gutterLineClass,
|
||||
type ViewUpdate,
|
||||
ViewPlugin
|
||||
} from '@codemirror/view';
|
||||
import { Range, type Extension } from '@codemirror/state';
|
||||
import { Range, RangeSet, RangeSetBuilder, StateField, type Extension } from '@codemirror/state';
|
||||
import { forEachDiagnostic, setDiagnosticsEffect } from '@codemirror/lint';
|
||||
import { NESTED_KEY_REGEX } from '../helpers/constants';
|
||||
|
||||
export const SYSTEM_FIELD_ID = '$id' as const;
|
||||
export const SYSTEM_FIELD_CREATED_AT = '$createdAt' as const;
|
||||
export const SYSTEM_FIELD_UPDATED_AT = '$updatedAt' as const;
|
||||
export const SYSTEM_FIELDS = [
|
||||
SYSTEM_FIELD_ID,
|
||||
SYSTEM_FIELD_CREATED_AT,
|
||||
SYSTEM_FIELD_UPDATED_AT
|
||||
] as const;
|
||||
|
||||
export type SystemFieldKey = (typeof SYSTEM_FIELDS)[number];
|
||||
|
||||
function escapeRegExp(source: string): string {
|
||||
return source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Match a system field key at the start of a line, either quoted ("$id") or unquoted ($id).
|
||||
const SYSTEM_FIELDS_SOURCE = SYSTEM_FIELDS.map(escapeRegExp).join('|');
|
||||
const SYSTEM_FIELD_KEY_LINE_PATTERN = new RegExp(
|
||||
`^\\s*("(?:${SYSTEM_FIELDS_SOURCE})"|(?:${SYSTEM_FIELDS_SOURCE}))\\s*:`
|
||||
);
|
||||
|
||||
function skipInlineWhitespace(text: string, from: number): number {
|
||||
let i = from;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
// Don't cross lines; values for the system fields we style are expected to be on the same line.
|
||||
if (ch !== ' ' && ch !== '\t') break;
|
||||
i += 1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function findSingleLineValueEnd(text: string, from: number): number {
|
||||
if (from >= text.length) return from;
|
||||
|
||||
const quote = text[from];
|
||||
if (quote === '"' || quote === "'") {
|
||||
let escaped = false;
|
||||
for (let i = from + 1; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) {
|
||||
return i + 1;
|
||||
}
|
||||
if (ch === '\n' || ch === '\r') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
// Scalar token: read until comma or newline.
|
||||
for (let i = from; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch === ',' || ch === '\n' || ch === '\r') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
// ViewPlugin to highlight nested keys (4+ spaces) only in visible ranges
|
||||
export function createNestedKeyPlugin(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
@@ -53,6 +124,8 @@ export function createNestedKeyPlugin(): Extension {
|
||||
|
||||
// ViewPlugin to apply muted styling to system fields ($id, $createdAt, $updatedAt)
|
||||
export function createSystemFieldStylePlugin(getShouldStyle: () => boolean): Extension {
|
||||
const mutedMark = Decoration.mark({ class: 'cm-system-field-muted' });
|
||||
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
@@ -74,32 +147,24 @@ export function createSystemFieldStylePlugin(getShouldStyle: () => boolean): Ext
|
||||
|
||||
const doc = view.state.doc;
|
||||
const text = doc.toString();
|
||||
const systemFields = ['$id', '$createdAt', '$updatedAt'];
|
||||
const decos: Range<Decoration>[] = [];
|
||||
|
||||
// Find all occurrences of system field keys
|
||||
for (const field of systemFields) {
|
||||
// Match the key in format: "$id": or $id: (with or without quotes)
|
||||
const quotedPattern = new RegExp(`"${field.replace('$', '\\$')}"\\s*:`, 'g');
|
||||
const unquotedPattern = new RegExp(`${field.replace('$', '\\$')}\\s*:`, 'g');
|
||||
for (let ln = 1; ln <= doc.lines; ln += 1) {
|
||||
const line = doc.line(ln);
|
||||
const match = SYSTEM_FIELD_KEY_LINE_PATTERN.exec(line.text);
|
||||
if (!match) continue;
|
||||
|
||||
let match: RegExpExecArray;
|
||||
// Check quoted format
|
||||
while ((match = quotedPattern.exec(text)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + field.length + 2; // +2 for quotes
|
||||
decos.push(
|
||||
Decoration.mark({ class: 'cm-system-field-muted' }).range(from, to)
|
||||
);
|
||||
}
|
||||
const keyToken = match[1]; // either "$id" or $id
|
||||
const keyOffset = match[0].indexOf(keyToken);
|
||||
const from = line.from + keyOffset;
|
||||
const to = line.from + match[0].length;
|
||||
|
||||
// Check unquoted format
|
||||
while ((match = unquotedPattern.exec(text)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + field.length;
|
||||
decos.push(
|
||||
Decoration.mark({ class: 'cm-system-field-muted' }).range(from, to)
|
||||
);
|
||||
decos.push(mutedMark.range(from, to));
|
||||
|
||||
const valueFrom = skipInlineWhitespace(text, to);
|
||||
const valueTo = findSingleLineValueEnd(text, valueFrom);
|
||||
if (valueTo > valueFrom) {
|
||||
decos.push(mutedMark.range(valueFrom, valueTo));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,3 +174,63 @@ export function createSystemFieldStylePlugin(getShouldStyle: () => boolean): Ext
|
||||
{ decorations: (v) => v.decorations }
|
||||
);
|
||||
}
|
||||
|
||||
export function createErrorLineHighlight(): Extension {
|
||||
const errorLineDecoration = Decoration.line({
|
||||
attributes: { class: 'cm-error-line' }
|
||||
});
|
||||
|
||||
class ErrorLineGutterMarker extends GutterMarker {
|
||||
elementClass = 'cm-error-lineGutter';
|
||||
}
|
||||
|
||||
const errorLineGutterMarker = new ErrorLineGutterMarker();
|
||||
|
||||
type ErrorLineDecorations = {
|
||||
line: DecorationSet;
|
||||
gutter: RangeSet<GutterMarker>;
|
||||
};
|
||||
|
||||
const buildDecorations = (state: import('@codemirror/state').EditorState) => {
|
||||
const lineBuilder = new RangeSetBuilder<Decoration>();
|
||||
const gutterBuilder = new RangeSetBuilder<GutterMarker>();
|
||||
const seenLines = new Set<number>();
|
||||
|
||||
forEachDiagnostic(state, (diagnostic, from) => {
|
||||
if (diagnostic.severity !== 'error') return;
|
||||
try {
|
||||
const line = state.doc.lineAt(from);
|
||||
if (seenLines.has(line.from)) return;
|
||||
seenLines.add(line.from);
|
||||
lineBuilder.add(line.from, line.from, errorLineDecoration);
|
||||
gutterBuilder.add(line.from, line.from, errorLineGutterMarker);
|
||||
} catch {
|
||||
// line might not exist
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
line: lineBuilder.finish(),
|
||||
gutter: gutterBuilder.finish()
|
||||
};
|
||||
};
|
||||
|
||||
return StateField.define<ErrorLineDecorations>({
|
||||
create(state) {
|
||||
return buildDecorations(state);
|
||||
},
|
||||
update(decorations, tr) {
|
||||
const hasDiagnosticsUpdate = tr.effects.some((effect) =>
|
||||
effect.is(setDiagnosticsEffect)
|
||||
);
|
||||
if (!tr.docChanged && !hasDiagnosticsUpdate) {
|
||||
return decorations;
|
||||
}
|
||||
return buildDecorations(tr.state);
|
||||
},
|
||||
provide: (f) => [
|
||||
EditorView.decorations.compute([f], (state) => state.field(f).line),
|
||||
gutterLineClass.compute([f], (state) => state.field(f).gutter)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { StateEffect, StateField, type Extension, RangeSet, Transaction } from '@codemirror/state';
|
||||
import { Decoration, EditorView, GutterMarker, gutterLineClass } from '@codemirror/view';
|
||||
|
||||
class HoveredLineGutterMarker extends GutterMarker {
|
||||
elementClass = 'cm-hovered-lineGutter';
|
||||
}
|
||||
|
||||
const hoveredGutterMarker = new HoveredLineGutterMarker();
|
||||
|
||||
const setHoveredLine = StateEffect.define<number | null>();
|
||||
|
||||
const hoveredLineField = StateField.define<number | null>({
|
||||
create: () => null,
|
||||
update: (value, tr) => {
|
||||
for (const e of tr.effects) if (e.is(setHoveredLine)) return e.value;
|
||||
if (value === null) return null;
|
||||
if (tr.docChanged) {
|
||||
return tr.changes.mapPos(value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
provide: (f) => [
|
||||
gutterLineClass.compute([f], (state) => {
|
||||
const linePos = state.field(f);
|
||||
if (linePos === null) return RangeSet.empty;
|
||||
try {
|
||||
return RangeSet.of([hoveredGutterMarker.range(state.doc.lineAt(linePos).from)]);
|
||||
} catch {
|
||||
return RangeSet.empty;
|
||||
}
|
||||
}),
|
||||
EditorView.decorations.compute([f], (state) => {
|
||||
const linePos = state.field(f);
|
||||
if (linePos === null) return Decoration.none;
|
||||
try {
|
||||
return Decoration.set([
|
||||
Decoration.line({ class: 'cm-hovered-line' }).range(
|
||||
state.doc.lineAt(linePos).from
|
||||
)
|
||||
]);
|
||||
} catch {
|
||||
return Decoration.none;
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
export function createLineHoverPlugin(): Extension {
|
||||
return [
|
||||
hoveredLineField,
|
||||
EditorView.domEventHandlers({
|
||||
mousemove(e, view) {
|
||||
const currentHovered = view.state.field(hoveredLineField);
|
||||
const pos =
|
||||
view.posAtCoords({ x: e.clientX, y: e.clientY }) ??
|
||||
(() => {
|
||||
const rect = view.contentDOM.getBoundingClientRect();
|
||||
if (e.clientY < rect.top || e.clientY > rect.bottom) {
|
||||
return null;
|
||||
}
|
||||
return view.posAtCoords({ x: rect.left + 2, y: e.clientY });
|
||||
})();
|
||||
|
||||
if (pos === null) {
|
||||
if (currentHovered !== null) {
|
||||
view.dispatch({
|
||||
effects: setHoveredLine.of(null),
|
||||
annotations: Transaction.userEvent.of('appwrite:hover')
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const linePos = view.state.doc.lineAt(pos).from;
|
||||
if (currentHovered !== linePos) {
|
||||
view.dispatch({
|
||||
effects: setHoveredLine.of(linePos),
|
||||
annotations: Transaction.userEvent.of('appwrite:hover')
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
mouseleave(_, view) {
|
||||
if (view.state.field(hoveredLineField) !== null) {
|
||||
view.dispatch({
|
||||
effects: setHoveredLine.of(null),
|
||||
annotations: Transaction.userEvent.of('appwrite:hover')
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.viewportChanged) return;
|
||||
if (update.state.field(hoveredLineField) === null) return;
|
||||
update.view.dispatch({
|
||||
effects: setHoveredLine.of(null),
|
||||
annotations: Transaction.userEvent.of('appwrite:hover')
|
||||
});
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -6,4 +6,14 @@ export {
|
||||
} from './readonly';
|
||||
|
||||
export { createDuplicateKeyLinter } from './duplicates';
|
||||
export { createNestedKeyPlugin, createSystemFieldStylePlugin } from './highlighting';
|
||||
export {
|
||||
createNestedKeyPlugin,
|
||||
createSystemFieldStylePlugin,
|
||||
createErrorLineHighlight,
|
||||
SYSTEM_FIELD_ID,
|
||||
SYSTEM_FIELD_CREATED_AT,
|
||||
SYSTEM_FIELD_UPDATED_AT,
|
||||
SYSTEM_FIELDS,
|
||||
type SystemFieldKey
|
||||
} from './highlighting';
|
||||
export { createLineHoverPlugin } from './hover';
|
||||
|
||||
@@ -5,8 +5,7 @@ export const SYSTEM_KEYS = new Set(['$id:', '$createdAt:', '$updatedAt:']);
|
||||
// timing constants
|
||||
export const LINTER_DELAY = 250;
|
||||
export const DEBOUNCE_DELAY = 200;
|
||||
export const AUTOSAVE_DELAY = 2000;
|
||||
export const SUGGESTIONS_HIDE_DELAY = 3000;
|
||||
export const SAVE_UNDO_TOOLBAR_TIMEOUT = 8000;
|
||||
|
||||
// regex patterns
|
||||
/* export const UNQUOTED_KEY_REGEX = /([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g; */
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import type { EditorState } from '@codemirror/state';
|
||||
import type { Diagnostic } from '@codemirror/lint';
|
||||
|
||||
type SmartError = {
|
||||
message: string;
|
||||
hint?: string;
|
||||
range?: { from: number; to: number };
|
||||
};
|
||||
|
||||
const MAX_VALUE_TOKEN_LENGTH = 18;
|
||||
const ALLOWED_LITERALS = new Set(['true', 'false', 'null']);
|
||||
|
||||
type ErrorContext = {
|
||||
raw: string;
|
||||
pos: number;
|
||||
line: { from: number; to: number; number: number; text: string };
|
||||
lineText: string;
|
||||
col: number;
|
||||
charAtError: string;
|
||||
colonIndex: number;
|
||||
prevLine: { number: number; text: string } | null;
|
||||
nextLine: { number: number; text: string } | null;
|
||||
currentKey: string | null;
|
||||
keyPart: string | null;
|
||||
insideArray: boolean;
|
||||
braceDepth: number;
|
||||
bracketDepth: number;
|
||||
braceDepthAtPos: number;
|
||||
bracketDepthAtPos: number;
|
||||
openQuoteBeforeLine: { quote: '"' | "'"; pos: number } | null;
|
||||
};
|
||||
|
||||
function stripJson5Prefix(message: string): string {
|
||||
return message.replace(/^JSON5:\s*/i, '').trim();
|
||||
}
|
||||
|
||||
function getKeyName(lineText: string, colonIndex: number): string | null {
|
||||
const keyPart = lineText.slice(0, colonIndex).trim();
|
||||
const match = keyPart.match(/^(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([A-Za-z_$][\w$]*))$/);
|
||||
return match ? match[1] || match[2] || match[3] || null : null;
|
||||
}
|
||||
|
||||
function getValueToken(lineText: string, colonIndex: number): string | null {
|
||||
const rest = lineText.slice(colonIndex + 1);
|
||||
const leading = rest.match(/^\s*/)?.[0].length ?? 0;
|
||||
const trimmed = rest.slice(leading);
|
||||
const valueMatch = trimmed.match(/^([^\s,}]+)/);
|
||||
return valueMatch ? valueMatch[1] : null;
|
||||
}
|
||||
|
||||
function getValueRange(
|
||||
lineText: string,
|
||||
lineFrom: number,
|
||||
colonIndex: number
|
||||
): { from: number; to: number } | null {
|
||||
if (colonIndex === -1) return null;
|
||||
let startOffset = colonIndex + 1;
|
||||
while (startOffset < lineText.length && /\s/.test(lineText[startOffset])) {
|
||||
startOffset += 1;
|
||||
}
|
||||
let endOffset = lineText.length;
|
||||
while (endOffset > startOffset && /\s/.test(lineText[endOffset - 1])) {
|
||||
endOffset -= 1;
|
||||
}
|
||||
if (endOffset <= startOffset) return null;
|
||||
return { from: lineFrom + startOffset, to: lineFrom + endOffset };
|
||||
}
|
||||
|
||||
function getMissingCommaMessage(prevKey: string | null, prevValueToken: string | null): string {
|
||||
if (
|
||||
prevValueToken &&
|
||||
prevValueToken.length <= MAX_VALUE_TOKEN_LENGTH &&
|
||||
!prevValueToken.startsWith('{') &&
|
||||
!prevValueToken.startsWith('[')
|
||||
) {
|
||||
return `Expected ',' after ${prevValueToken}`;
|
||||
}
|
||||
if (prevKey) {
|
||||
return `Expected ',' after value for "${prevKey}"`;
|
||||
}
|
||||
return "Expected ',' after previous value";
|
||||
}
|
||||
|
||||
function isNumberToken(token: string): boolean {
|
||||
return /^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(token);
|
||||
}
|
||||
|
||||
function isBareWord(token: string): boolean {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(token);
|
||||
}
|
||||
|
||||
function looksLikePropertyLine(text: string): boolean {
|
||||
return /^\s*(?:"[^"]+"|'[^']+'|[A-Za-z_$][\w$]*)\s*:/.test(text);
|
||||
}
|
||||
|
||||
function nextNonEmptyLine(
|
||||
state: EditorState,
|
||||
lineNumber: number
|
||||
): { number: number; text: string } | null {
|
||||
for (let i = lineNumber + 1; i <= state.doc.lines; i += 1) {
|
||||
const text = state.doc.line(i).text;
|
||||
if (text.trim().length) {
|
||||
return { number: i, text };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prevNonEmptyLine(
|
||||
state: EditorState,
|
||||
lineNumber: number
|
||||
): { number: number; text: string } | null {
|
||||
for (let i = lineNumber - 1; i >= 1; i -= 1) {
|
||||
const text = state.doc.line(i).text;
|
||||
if (text.trim().length) {
|
||||
return { number: i, text };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getIndentLength(text: string): number {
|
||||
return text.match(/^\s*/)?.[0].length ?? 0;
|
||||
}
|
||||
|
||||
function prevNonSpaceChar(text: string, fromIndex: number): string | null {
|
||||
for (let i = fromIndex; i >= 0; i -= 1) {
|
||||
const ch = text[i];
|
||||
if (!/\s/.test(ch)) return ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextNonSpaceChar(text: string, fromIndex: number): string | null {
|
||||
for (let i = fromIndex; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (!/\s/.test(ch)) return ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isValueStartChar(ch: string): boolean {
|
||||
return /["'{[\d\-+.A-Za-z_$]/.test(ch);
|
||||
}
|
||||
|
||||
function extractNumberToken(
|
||||
lineText: string,
|
||||
col: number
|
||||
): { token: string; start: number; end: number } | null {
|
||||
const allowed = /[0-9.+-]/;
|
||||
let start = col;
|
||||
while (start > 0 && allowed.test(lineText[start - 1])) {
|
||||
start -= 1;
|
||||
}
|
||||
let end = col;
|
||||
while (end < lineText.length && allowed.test(lineText[end])) {
|
||||
end += 1;
|
||||
}
|
||||
if (start === end) return null;
|
||||
const token = lineText.slice(start, end);
|
||||
if (!/[0-9]/.test(token)) return null;
|
||||
return { token, start, end };
|
||||
}
|
||||
|
||||
function hasUnclosedQuote(lineText: string, quote: '"' | "'"): boolean {
|
||||
let count = 0;
|
||||
let escaped = false;
|
||||
for (const ch of lineText) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) count += 1;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
}
|
||||
|
||||
function scanDocument(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
lineStart: number
|
||||
): {
|
||||
braceDepth: number;
|
||||
bracketDepth: number;
|
||||
braceDepthAtPos: number;
|
||||
bracketDepthAtPos: number;
|
||||
openQuoteBeforeLine: { quote: '"' | "'"; pos: number } | null;
|
||||
} {
|
||||
const text = state.doc.toString();
|
||||
let inQuote: '"' | "'" | null = null;
|
||||
let openPos = -1;
|
||||
let escaped = false;
|
||||
let braceDepth = 0;
|
||||
let bracketDepth = 0;
|
||||
let braceDepthAtPos = 0;
|
||||
let bracketDepthAtPos = 0;
|
||||
let openQuoteBeforeLine: { quote: '"' | "'"; pos: number } | null = null;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
|
||||
if (inQuote) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === inQuote) {
|
||||
inQuote = null;
|
||||
openPos = -1;
|
||||
}
|
||||
} else {
|
||||
if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
openPos = i;
|
||||
} else if (ch === '{') {
|
||||
braceDepth += 1;
|
||||
} else if (ch === '}') {
|
||||
braceDepth -= 1;
|
||||
} else if (ch === '[') {
|
||||
bracketDepth += 1;
|
||||
} else if (ch === ']') {
|
||||
bracketDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (i === lineStart - 1) {
|
||||
openQuoteBeforeLine = inQuote && openPos >= 0 ? { quote: inQuote, pos: openPos } : null;
|
||||
}
|
||||
|
||||
if (i === pos) {
|
||||
braceDepthAtPos = braceDepth;
|
||||
bracketDepthAtPos = bracketDepth;
|
||||
}
|
||||
}
|
||||
|
||||
if (pos >= text.length) {
|
||||
braceDepthAtPos = braceDepth;
|
||||
bracketDepthAtPos = bracketDepth;
|
||||
}
|
||||
|
||||
if (lineStart <= 0) {
|
||||
openQuoteBeforeLine = null;
|
||||
}
|
||||
|
||||
return {
|
||||
braceDepth,
|
||||
bracketDepth,
|
||||
braceDepthAtPos,
|
||||
bracketDepthAtPos,
|
||||
openQuoteBeforeLine
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(state: EditorState, diagnostic: Diagnostic): ErrorContext {
|
||||
const raw = stripJson5Prefix(diagnostic.message || 'Syntax error');
|
||||
const pos = diagnostic.from;
|
||||
const line = state.doc.lineAt(pos);
|
||||
const lineText = line.text;
|
||||
const col = pos - line.from;
|
||||
const charAtError = state.doc.sliceString(pos, pos + 1);
|
||||
|
||||
const colonIndex = lineText.indexOf(':');
|
||||
const prevLine = prevNonEmptyLine(state, line.number);
|
||||
const currentKey = colonIndex !== -1 ? getKeyName(lineText, colonIndex) : null;
|
||||
const keyPart = colonIndex !== -1 ? lineText.slice(0, colonIndex).trim() : null;
|
||||
const nextLine = nextNonEmptyLine(state, line.number);
|
||||
const scan = scanDocument(state, pos, line.from);
|
||||
const insideArray = scan.bracketDepthAtPos > 0;
|
||||
|
||||
return {
|
||||
raw,
|
||||
pos,
|
||||
line,
|
||||
lineText,
|
||||
col,
|
||||
charAtError,
|
||||
colonIndex,
|
||||
prevLine,
|
||||
nextLine,
|
||||
currentKey,
|
||||
keyPart,
|
||||
insideArray,
|
||||
braceDepth: scan.braceDepth,
|
||||
bracketDepth: scan.bracketDepth,
|
||||
braceDepthAtPos: scan.braceDepthAtPos,
|
||||
bracketDepthAtPos: scan.bracketDepthAtPos,
|
||||
openQuoteBeforeLine: scan.openQuoteBeforeLine
|
||||
};
|
||||
}
|
||||
|
||||
function detectMissingCommaPrevLine(state: EditorState, context: ErrorContext): SmartError | null {
|
||||
const { lineText, prevLine } = context;
|
||||
if (prevLine && looksLikePropertyLine(lineText)) {
|
||||
const prevIndent = getIndentLength(prevLine.text);
|
||||
const currentIndent = getIndentLength(lineText);
|
||||
const prevTrimmed = prevLine.text.trim();
|
||||
const prevEndsWithComma = prevTrimmed.endsWith(',');
|
||||
const prevEndsWithOpen = prevTrimmed.endsWith('{') || prevTrimmed.endsWith('[');
|
||||
const prevEndsWithColon = prevTrimmed.endsWith(':');
|
||||
|
||||
if (
|
||||
prevIndent === currentIndent &&
|
||||
!prevEndsWithComma &&
|
||||
!prevEndsWithOpen &&
|
||||
!prevEndsWithColon
|
||||
) {
|
||||
const prevColonIndex = prevLine.text.indexOf(':');
|
||||
const prevKey =
|
||||
prevColonIndex !== -1 ? getKeyName(prevLine.text, prevColonIndex) : null;
|
||||
const prevRange =
|
||||
prevColonIndex !== -1
|
||||
? getValueRange(
|
||||
prevLine.text,
|
||||
state.doc.line(prevLine.number).from,
|
||||
prevColonIndex
|
||||
)
|
||||
: null;
|
||||
const prevValueToken =
|
||||
prevColonIndex !== -1 ? getValueToken(prevLine.text, prevColonIndex) : null;
|
||||
return {
|
||||
message: getMissingCommaMessage(prevKey, prevValueToken),
|
||||
range: prevRange ?? undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectCommentToken(context: ErrorContext): SmartError | null {
|
||||
if (context.charAtError === '/') {
|
||||
return {
|
||||
message: 'Comments are not supported',
|
||||
hint: 'Remove // or /* */.'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectUnclosedStringAcrossLines(
|
||||
state: EditorState,
|
||||
context: ErrorContext
|
||||
): SmartError | null {
|
||||
const { line, openQuoteBeforeLine } = context;
|
||||
const open = openQuoteBeforeLine;
|
||||
if (!open) return null;
|
||||
const openLine = state.doc.lineAt(open.pos);
|
||||
if (openLine.number === line.number) return null;
|
||||
return {
|
||||
message: 'Unclosed string',
|
||||
hint: 'Add the closing quote.',
|
||||
range: { from: open.pos, to: openLine.to }
|
||||
};
|
||||
}
|
||||
|
||||
function detectInvalidPropertyName(context: ErrorContext): SmartError | null {
|
||||
const { colonIndex, keyPart, line } = context;
|
||||
if (colonIndex !== -1 && keyPart) {
|
||||
const validKey = /^(?:"[^"]+"|'[^']+'|[A-Za-z_$][\w$]*)$/.test(keyPart);
|
||||
if (!validKey) {
|
||||
return {
|
||||
message: 'Invalid property name',
|
||||
hint: 'Use quotes for keys with spaces or symbols.',
|
||||
range: { from: line.from, to: line.from + colonIndex }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectMissingColon(context: ErrorContext): SmartError | null {
|
||||
const { colonIndex, lineText, prevLine, line, nextLine } = context;
|
||||
if (colonIndex === -1) {
|
||||
const trimmed = lineText.trim();
|
||||
const keyMatch = trimmed.match(/^(?:"[^"]+"|'[^']+'|[A-Za-z_$][\w$]*)/);
|
||||
const hasValue = keyMatch ? trimmed.length > keyMatch[0].length : false;
|
||||
const prevLooksObject =
|
||||
!!prevLine &&
|
||||
(prevLine.text.trim().endsWith('{') || looksLikePropertyLine(prevLine.text));
|
||||
const nextLooksProperty = !!nextLine && looksLikePropertyLine(nextLine.text);
|
||||
|
||||
if (keyMatch && hasValue && (prevLooksObject || nextLooksProperty)) {
|
||||
return {
|
||||
message: 'Missing ":" after property name',
|
||||
hint: 'Insert ":" between the key and value.',
|
||||
range: { from: line.from, to: line.from + keyMatch[0].length }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectMissingValueAfterColon(context: ErrorContext): SmartError | null {
|
||||
const { colonIndex, lineText, line } = context;
|
||||
if (colonIndex !== -1) {
|
||||
const afterColon = lineText.slice(colonIndex + 1).trim();
|
||||
if (
|
||||
!afterColon ||
|
||||
afterColon.startsWith(',') ||
|
||||
afterColon.startsWith('}') ||
|
||||
afterColon.startsWith(']')
|
||||
) {
|
||||
return {
|
||||
message: 'Missing value after ":"',
|
||||
hint: 'Provide a value (string, number, object, or array).',
|
||||
range: { from: line.from + colonIndex, to: line.from + colonIndex + 1 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectInvalidValue(context: ErrorContext): SmartError | null {
|
||||
const { colonIndex, col, lineText } = context;
|
||||
if (colonIndex !== -1 && col > colonIndex) {
|
||||
const keyName = getKeyName(lineText, colonIndex);
|
||||
const valueToken = getValueToken(lineText, colonIndex);
|
||||
if (valueToken) {
|
||||
const isAllowed =
|
||||
valueToken.startsWith('"') ||
|
||||
valueToken.startsWith("'") ||
|
||||
valueToken.startsWith('{') ||
|
||||
valueToken.startsWith('[') ||
|
||||
isNumberToken(valueToken) ||
|
||||
ALLOWED_LITERALS.has(valueToken);
|
||||
if (!isAllowed && isBareWord(valueToken)) {
|
||||
return {
|
||||
message: keyName ? `Invalid value for ${keyName}` : 'Invalid value',
|
||||
hint: 'Strings must be quoted.'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectArrayCommaIssues(context: ErrorContext): SmartError | null {
|
||||
const { charAtError, lineText, col, pos, insideArray } = context;
|
||||
if (charAtError === ',') {
|
||||
const prevChar = prevNonSpaceChar(lineText, col - 1);
|
||||
const nextChar = nextNonSpaceChar(lineText, col + 1);
|
||||
if (insideArray && (prevChar === '[' || prevChar === ',')) {
|
||||
return {
|
||||
message: 'Empty array item',
|
||||
hint: 'Add a value before the comma.',
|
||||
range: { from: pos, to: pos + 1 }
|
||||
};
|
||||
}
|
||||
if (insideArray && nextChar === ',') {
|
||||
return {
|
||||
message: 'Empty array item',
|
||||
hint: 'Remove the extra comma or add a value.',
|
||||
range: { from: pos, to: pos + 1 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectMissingArrayComma(context: ErrorContext): SmartError | null {
|
||||
const { charAtError, lineText, col, pos, insideArray } = context;
|
||||
if (!charAtError || !isValueStartChar(charAtError)) return null;
|
||||
if (!insideArray) return null;
|
||||
const prevChar = prevNonSpaceChar(lineText, col - 1);
|
||||
if (prevChar && prevChar !== '[' && prevChar !== ',' && prevChar !== ':') {
|
||||
return {
|
||||
message: "Missing ',' between array values",
|
||||
hint: 'Add a comma between items.',
|
||||
range: { from: pos, to: pos + 1 }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectInvalidEscape(context: ErrorContext): SmartError | null {
|
||||
const raw = context.raw.toLowerCase();
|
||||
if (raw.includes('escape')) {
|
||||
return {
|
||||
message: 'Invalid escape sequence',
|
||||
hint: 'Use valid escapes like \\n, \\t, \\\\, or \\".'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectInvalidNumber(context: ErrorContext): SmartError | null {
|
||||
const raw = context.raw.toLowerCase();
|
||||
if (!raw.includes('number')) return null;
|
||||
|
||||
const tokenInfo = extractNumberToken(context.lineText, context.col);
|
||||
if (!tokenInfo) {
|
||||
return {
|
||||
message: 'Invalid number literal',
|
||||
hint: 'Only plain decimals are supported (no exponent or hex).'
|
||||
};
|
||||
}
|
||||
|
||||
const { token, start, end } = tokenInfo;
|
||||
const range = { from: context.line.from + start, to: context.line.from + end };
|
||||
if (token === '+' || token === '-' || token === '.') {
|
||||
return {
|
||||
message: 'Invalid number literal',
|
||||
hint: 'Add digits after the sign or decimal point.',
|
||||
range
|
||||
};
|
||||
}
|
||||
|
||||
if (token.includes('..')) {
|
||||
return {
|
||||
message: 'Invalid number literal',
|
||||
hint: 'A number can only have one decimal point.',
|
||||
range
|
||||
};
|
||||
}
|
||||
|
||||
if (!/^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(token)) {
|
||||
return {
|
||||
message: 'Invalid number literal',
|
||||
hint: 'Only plain decimals are supported (no exponent or hex).',
|
||||
range
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectUnexpectedCloser(_state: EditorState, context: ErrorContext): SmartError | null {
|
||||
const { charAtError, pos, braceDepthAtPos, bracketDepthAtPos } = context;
|
||||
if (charAtError === '}' || charAtError === ']') {
|
||||
const depthAtPos = charAtError === '}' ? braceDepthAtPos : bracketDepthAtPos;
|
||||
if (depthAtPos < 0) {
|
||||
return {
|
||||
message: `Unexpected '${charAtError}'`,
|
||||
hint: 'Remove the extra closing bracket.',
|
||||
range: { from: pos, to: pos + 1 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectMissingCommaNextLine(context: ErrorContext): SmartError | null {
|
||||
const { colonIndex, nextLine, lineText, line, currentKey } = context;
|
||||
const lineTrimmed = lineText.trim();
|
||||
const hasColon = colonIndex !== -1;
|
||||
const nextIsProperty = !!nextLine && looksLikePropertyLine(nextLine.text);
|
||||
const hasComma = lineTrimmed.endsWith(',');
|
||||
const endsWithColon = lineTrimmed.endsWith(':');
|
||||
const endsWithOpen = lineTrimmed.endsWith('{') || lineTrimmed.endsWith('[');
|
||||
|
||||
if (hasColon && nextIsProperty && !hasComma && !endsWithColon && !endsWithOpen) {
|
||||
const currentRange = getValueRange(lineText, line.from, colonIndex);
|
||||
const currentValueToken = getValueToken(lineText, colonIndex);
|
||||
return {
|
||||
message: getMissingCommaMessage(currentKey, currentValueToken),
|
||||
range: currentRange ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectUnclosedString(context: ErrorContext): SmartError | null {
|
||||
const { lineText } = context;
|
||||
if (hasUnclosedQuote(lineText, '"') || hasUnclosedQuote(lineText, "'")) {
|
||||
return { message: 'Unclosed string', hint: 'Add the closing quote.' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectUnexpectedEnd(_state: EditorState, context: ErrorContext): SmartError | null {
|
||||
const { raw, braceDepth, bracketDepth } = context;
|
||||
if (raw.toLowerCase().includes('end of input')) {
|
||||
if (braceDepth > 0) {
|
||||
return { message: 'Unexpected end of input', hint: 'Missing }.' };
|
||||
}
|
||||
if (bracketDepth > 0) {
|
||||
return { message: 'Unexpected end of input', hint: 'Missing ].' };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectInvalidCharacter(context: ErrorContext): SmartError | null {
|
||||
const { raw, charAtError } = context;
|
||||
if (raw.toLowerCase().includes('invalid character') && charAtError) {
|
||||
return {
|
||||
message: `Unexpected token '${charAtError}'`
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function makeErrorMessage(state: EditorState, diagnostic: Diagnostic): SmartError {
|
||||
const context = buildContext(state, diagnostic);
|
||||
const detectors: Array<(state: EditorState, context: ErrorContext) => SmartError | null> = [
|
||||
(_state, ctx) => detectCommentToken(ctx),
|
||||
detectUnclosedStringAcrossLines,
|
||||
detectMissingCommaPrevLine,
|
||||
(_state, ctx) => detectInvalidPropertyName(ctx),
|
||||
(_state, ctx) => detectMissingColon(ctx),
|
||||
(_state, ctx) => detectMissingValueAfterColon(ctx),
|
||||
(_state, ctx) => detectInvalidValue(ctx),
|
||||
(_state, ctx) => detectArrayCommaIssues(ctx),
|
||||
(_state, ctx) => detectMissingArrayComma(ctx),
|
||||
(_state, ctx) => detectInvalidEscape(ctx),
|
||||
(_state, ctx) => detectInvalidNumber(ctx),
|
||||
detectUnexpectedCloser,
|
||||
(_state, ctx) => detectMissingCommaNextLine(ctx),
|
||||
(_state, ctx) => detectUnclosedString(ctx),
|
||||
detectUnexpectedEnd,
|
||||
(_state, ctx) => detectInvalidCharacter(ctx)
|
||||
];
|
||||
|
||||
for (const detector of detectors) {
|
||||
const result = detector(state, context);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return { message: context.raw || 'Syntax error' };
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const customTheme = EditorView.theme({
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0',
|
||||
lineHeight: '1.6'
|
||||
lineHeight: '140%'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
type ViewUpdate
|
||||
} from '@codemirror/view';
|
||||
import { history } from '@codemirror/commands';
|
||||
import { history, undo } from '@codemirror/commands';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
@@ -41,15 +42,17 @@
|
||||
Compartment,
|
||||
type Extension
|
||||
} from '@codemirror/state';
|
||||
import type { Text } from '@codemirror/state';
|
||||
import type { Text as CmText } from '@codemirror/state';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Id, { truncateId } from '$lib/components/id.svelte';
|
||||
import { Icon, Layout, Skeleton, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Badge, Icon, Layout, Skeleton, Tooltip, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { copy } from '$lib/helpers/copy';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { isMac } from '$lib/helpers/platform';
|
||||
|
||||
import { makeErrorMessage } from './helpers/errorMessages';
|
||||
import { customTheme, customSyntaxHighlighting } from './helpers/theme';
|
||||
import { createEditorKeymaps, secondaryKeymaps } from './helpers/keymaps';
|
||||
import {
|
||||
@@ -58,27 +61,31 @@
|
||||
createReadOnlyRangesFilter,
|
||||
createNestedKeyPlugin,
|
||||
createDuplicateKeyLinter,
|
||||
createSystemFieldStylePlugin
|
||||
createSystemFieldStylePlugin,
|
||||
createLineHoverPlugin,
|
||||
createErrorLineHighlight,
|
||||
SYSTEM_FIELD_ID
|
||||
} from './extensions';
|
||||
import {
|
||||
ALLOWED_DOLLAR_PROPS,
|
||||
DEBOUNCE_DELAY,
|
||||
LINTER_DELAY,
|
||||
AUTOSAVE_DELAY,
|
||||
SUGGESTIONS_HIDE_DELAY,
|
||||
INDENT_REGEX,
|
||||
SCALAR_VALUE_REGEX,
|
||||
TRAILING_COMMA_REGEX,
|
||||
WHITESPACE_REGEX,
|
||||
WHITESPACE_ONLY_REGEX,
|
||||
SKELETON_LINES,
|
||||
getIndent
|
||||
getIndent,
|
||||
SAVE_UNDO_TOOLBAR_TIMEOUT
|
||||
} from './helpers/constants';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { Suggestions, Error as ErrorSonner, Save as SavingSonner } from '../sonners';
|
||||
import { sleep } from '$lib/helpers/promises';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { Suggestions, Error as ErrorSonner, Save as SavingSonner } from '../sonners';
|
||||
import HintBadge from '../hintBadge.svelte';
|
||||
import { json5, json5ParseCache, json5ParseLinter } from 'codemirror-json5';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
isNew?: boolean;
|
||||
@@ -87,7 +94,7 @@
|
||||
loading?: boolean;
|
||||
onChange?: (newData: JsonValue, hasChanged: boolean) => Promise<void> | void;
|
||||
onSave?: (newData: JsonValue) => Promise<void> | void;
|
||||
onCancel?: () => void;
|
||||
onDiscard?: () => void;
|
||||
readonly?: boolean;
|
||||
wrapLines?: boolean;
|
||||
errorInPlace?: boolean;
|
||||
@@ -95,6 +102,9 @@
|
||||
showHeaderActions?: boolean;
|
||||
showSuggestions?: boolean;
|
||||
suggestedAttributes?: string[];
|
||||
showMockSuggestions?: boolean;
|
||||
suggestedDefaults?: Record<string, unknown>;
|
||||
onGenerateEmbedding?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -102,7 +112,7 @@
|
||||
data = $bindable(),
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDiscard,
|
||||
isSaving = $bindable(false),
|
||||
loading = false,
|
||||
readonly = false,
|
||||
@@ -111,7 +121,10 @@
|
||||
ctrlSave = false,
|
||||
showHeaderActions = true,
|
||||
showSuggestions = false,
|
||||
suggestedAttributes = []
|
||||
suggestedAttributes = [],
|
||||
showMockSuggestions = false,
|
||||
suggestedDefaults,
|
||||
onGenerateEmbedding
|
||||
}: Props = $props();
|
||||
|
||||
let editorContainer: HTMLDivElement = $state(null);
|
||||
@@ -120,10 +133,7 @@
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let warningMessage = $state<string | null>(null);
|
||||
let changeTimer: ReturnType<typeof setTimeout> | null = null; // debounce timer for parse + onChange
|
||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null; // debounce timer for auto-save
|
||||
let saveGeneration = 0; // generation counter to detect stale closures in auto-save
|
||||
let tooltipTimer: ReturnType<typeof setTimeout> | null = null; // timer for tooltip message reset
|
||||
let suggestionsHideTimer: ReturnType<typeof setTimeout> | null = null; // timer to auto-hide suggestions
|
||||
let pendingCanonicalize = false; // set when a full-document replace (paste-all) occurs
|
||||
let lastExpectedContent = ''; // track latest serialized data to avoid spurious rewrites
|
||||
let lastDocId: string | null = null; // track current document identity for history reset
|
||||
@@ -140,8 +150,11 @@
|
||||
|
||||
let tooltipMessage = $state('Copy document');
|
||||
|
||||
// Cache embeddings values for the fold placeholder (survives fold state changes)
|
||||
let cachedEmbeddingsPreview = '';
|
||||
|
||||
// Store the original data to preserve system values
|
||||
let originalData = $state<JsonValue>($state.snapshot(data));
|
||||
let originalData = $state<JsonValue>(data);
|
||||
|
||||
// Check if the update is from editor
|
||||
let isUpdatingFromEditor = false;
|
||||
@@ -194,7 +207,7 @@
|
||||
|
||||
if (filteredEntries.length === 0) return '{}';
|
||||
|
||||
// Sort entries: $id first, user fields in middle, timestamps last
|
||||
// Sort entries: $id first, metadata before embeddings, timestamps last
|
||||
const sortedEntries = filteredEntries.sort(([keyA], [keyB]) => {
|
||||
// $id always comes first
|
||||
if (keyA === '$id') return -1;
|
||||
@@ -207,6 +220,10 @@
|
||||
if (isKeyATimestamp && !isKeyBTimestamp) return 1;
|
||||
if (!isKeyATimestamp && isKeyBTimestamp) return -1;
|
||||
|
||||
// metadata before embeddings
|
||||
if (keyA === 'metadata' && keyB === 'embeddings') return -1;
|
||||
if (keyA === 'embeddings' && keyB === 'metadata') return 1;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -216,11 +233,20 @@
|
||||
return `${indentStr} ${key}: ${formattedValue}${isLast ? '' : ','}`;
|
||||
});
|
||||
|
||||
if (isNew && !hasUserContent && sortedEntries[0]?.[0] === '$id') {
|
||||
props.splice(1, 0, `${indentStr} `);
|
||||
}
|
||||
|
||||
return `{\n${props.join('\n')}\n${indentStr}}`;
|
||||
} else if (type === 'array') {
|
||||
const items = value as JsonArray;
|
||||
if (items.length === 0) return '[]';
|
||||
|
||||
// Render embeddings as 3 lines: [ / values / ] — enables native fold gutter
|
||||
if (key === 'embeddings' && items.length > 0 && typeof items[0] === 'number') {
|
||||
return `[\n${items.join(',')}\n${indentStr}]`;
|
||||
}
|
||||
|
||||
const elements = items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
const formattedValue = dataToString(item, indent + 1);
|
||||
@@ -271,6 +297,54 @@
|
||||
return serialized;
|
||||
}
|
||||
|
||||
export function replaceData(newData: JsonValue) {
|
||||
if (!editorView) return;
|
||||
data = newData;
|
||||
const content = dataToString(newData);
|
||||
lastExpectedContent = content;
|
||||
lastSerializedData = null;
|
||||
isUpdatingFromEditor = true;
|
||||
const currentContent = editorView.state.doc.toString();
|
||||
editorView.dispatch({
|
||||
changes: { from: 0, to: currentContent.length, insert: content },
|
||||
annotations: [Transaction.addToHistory.of(false)]
|
||||
});
|
||||
editorView.requestMeasure({
|
||||
read() {},
|
||||
write(_measure, view) {
|
||||
foldEmbeddings(view);
|
||||
}
|
||||
});
|
||||
queueMicrotask(() => (isUpdatingFromEditor = false));
|
||||
}
|
||||
|
||||
function findNewDocCursorPos(state: EditorState): number | null {
|
||||
const maxLines = Math.min(state.doc.lines, 12);
|
||||
for (let ln = 1; ln <= maxLines; ln += 1) {
|
||||
const line = state.doc.line(ln);
|
||||
const text = line.text;
|
||||
let i = 0;
|
||||
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i += 1;
|
||||
if (text[i] === '"') {
|
||||
const quoted = `"${SYSTEM_FIELD_ID}"`;
|
||||
if (text.slice(i, i + quoted.length) !== quoted) continue;
|
||||
i += quoted.length;
|
||||
} else if (text.slice(i, i + SYSTEM_FIELD_ID.length) === SYSTEM_FIELD_ID) {
|
||||
i += SYSTEM_FIELD_ID.length;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i += 1;
|
||||
if (text[i] !== ':') continue;
|
||||
|
||||
if (ln + 1 > state.doc.lines) return null;
|
||||
const next = state.doc.line(ln + 1);
|
||||
if (next.text.trim() === '') return next.to; // after indentation on the blank line
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Preserve system key values when content changes
|
||||
function preserveSystemValues(parsed: JsonValue): JsonValue {
|
||||
if (
|
||||
@@ -416,7 +490,7 @@
|
||||
if (isPaste) return false;
|
||||
|
||||
update.changes.iterChanges(
|
||||
(fromA: number, toA: number, fromB: number, toB: number, inserted: Text) => {
|
||||
(fromA: number, toA: number, fromB: number, toB: number, inserted: CmText) => {
|
||||
if (did) return;
|
||||
|
||||
// Only trigger on insertion (not deletion)
|
||||
@@ -467,7 +541,7 @@
|
||||
function ensureTimestampsAtBottom(
|
||||
expectedFields: Array<{ key: '$id' | '$createdAt' | '$updatedAt'; text: string }>,
|
||||
hits: Map<string, Hit>,
|
||||
doc: Text,
|
||||
doc: CmText,
|
||||
content: string,
|
||||
closeIdx: number,
|
||||
indent: string
|
||||
@@ -593,7 +667,7 @@
|
||||
expectedFields: Array<{ key: '$id' | '$createdAt' | '$updatedAt'; text: string }>
|
||||
): boolean {
|
||||
const FAST_CHECK_LINES = 20;
|
||||
const foundValues = new Map<string, string>();
|
||||
const foundValues = new SvelteMap<string, string>();
|
||||
|
||||
// Check first `FAST_CHECK_LINES` lines after opening brace for $id
|
||||
let checkPos = openIdx + 1;
|
||||
@@ -659,7 +733,7 @@
|
||||
}
|
||||
|
||||
// In-place patch: ensure top-level $ system fields reflect originals without reformatting the whole doc
|
||||
function applySystemFieldsPatch(view: EditorView, doc: Text, parsed: JsonValue): boolean {
|
||||
function applySystemFieldsPatch(view: EditorView, doc: CmText, parsed: JsonValue): boolean {
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
const obj = parsed as JsonObject;
|
||||
const expectedFields: Array<{ key: '$id' | '$createdAt' | '$updatedAt'; text: string }> = [
|
||||
@@ -681,7 +755,7 @@
|
||||
}
|
||||
|
||||
// Values differ or not all found - proceed with full scan
|
||||
const hits = new Map<string, Hit>();
|
||||
const hits = new SvelteMap<string, Hit>();
|
||||
|
||||
// Scan lines between top-level braces to find existing $ fields at the start of a line
|
||||
let pos = openIdx + 1;
|
||||
@@ -828,6 +902,43 @@
|
||||
|
||||
const baseJson5Linter = json5ParseLinter();
|
||||
|
||||
function expandErrorRange(state: EditorState, diagnostic: Diagnostic): Diagnostic {
|
||||
if (diagnostic.severity !== 'error') return diagnostic;
|
||||
|
||||
const line = state.doc.lineAt(diagnostic.from);
|
||||
const text = line.text;
|
||||
const colonIndex = text.indexOf(':');
|
||||
|
||||
let from = line.from;
|
||||
let to = line.to;
|
||||
|
||||
if (colonIndex !== -1) {
|
||||
let startOffset = colonIndex + 1;
|
||||
while (startOffset < text.length && /\s/.test(text[startOffset])) {
|
||||
startOffset += 1;
|
||||
}
|
||||
|
||||
let endOffset = text.length;
|
||||
while (endOffset > 0 && /\s/.test(text[endOffset - 1])) {
|
||||
endOffset -= 1;
|
||||
}
|
||||
if (endOffset > 0 && text[endOffset - 1] === ',') {
|
||||
endOffset -= 1;
|
||||
}
|
||||
|
||||
if (endOffset > startOffset) {
|
||||
from = line.from + startOffset;
|
||||
to = line.from + endOffset;
|
||||
}
|
||||
}
|
||||
|
||||
if (to <= from) {
|
||||
return diagnostic;
|
||||
}
|
||||
|
||||
return { ...diagnostic, from, to };
|
||||
}
|
||||
|
||||
// JSON5 linter using the parse cache; preserves errorInPlace behavior.
|
||||
async function json5Linter(view: EditorView): Promise<Diagnostic[]> {
|
||||
if (isUpdatingFromEditor) return [];
|
||||
@@ -836,10 +947,28 @@
|
||||
errorMessage = null;
|
||||
return [];
|
||||
}
|
||||
const errorMsg = (result[0]?.message || 'Syntax error').replace(/^JSON5:\s*/i, '');
|
||||
|
||||
const smartError = makeErrorMessage(view.state, result[0]);
|
||||
const errorMsg = smartError.hint
|
||||
? `${smartError.message}: ${smartError.hint}`
|
||||
: smartError.message;
|
||||
errorMessage = errorMsg;
|
||||
if (errorInPlace) {
|
||||
return result;
|
||||
return result.map((diagnostic, index) => {
|
||||
const expanded = expandErrorRange(view.state, diagnostic);
|
||||
if (index === 0 && diagnostic.severity === 'error') {
|
||||
if (smartError.range) {
|
||||
return {
|
||||
...expanded,
|
||||
from: smartError.range.from,
|
||||
to: smartError.range.to,
|
||||
message: smartError.message
|
||||
};
|
||||
}
|
||||
return { ...expanded, message: smartError.message };
|
||||
}
|
||||
return expanded;
|
||||
});
|
||||
}
|
||||
// Fallback to full-doc underline
|
||||
return [{ from: 0, to: view.state.doc.length, severity: 'error', message: errorMsg }];
|
||||
@@ -851,10 +980,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an object with suggested attributes as empty strings
|
||||
const suggestedObject: Record<string, string> = {};
|
||||
// Create an object with suggested attributes
|
||||
const suggestedObject: Record<string, unknown> = {};
|
||||
for (const attr of suggestedAttributes) {
|
||||
suggestedObject[attr] = '';
|
||||
suggestedObject[attr] = suggestedDefaults?.[attr] ?? '';
|
||||
}
|
||||
|
||||
// System fields that should not be overwritten via spread
|
||||
@@ -891,6 +1020,7 @@
|
||||
|
||||
// Update the data
|
||||
data = updatedData;
|
||||
hasUserContent = true;
|
||||
|
||||
// Manually update the editor content
|
||||
const newContent = serializeData(updatedData);
|
||||
@@ -909,12 +1039,6 @@
|
||||
// Hide the suggestions bar after applying
|
||||
hasStartedEditing = false;
|
||||
hasSuggestionsBeenShown = true;
|
||||
|
||||
// Clear the auto-hide timer
|
||||
if (suggestionsHideTimer) {
|
||||
clearTimeout(suggestionsHideTimer);
|
||||
suggestionsHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle save logic - called from both button and keyboard shortcut
|
||||
@@ -937,11 +1061,50 @@
|
||||
await onSave?.(dataToSave);
|
||||
|
||||
// update after save completes
|
||||
originalData = $state.snapshot(data);
|
||||
originalData = data;
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
if (!editorView) return;
|
||||
undo(editorView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-fold the embeddings array. Uses multi-line format so the
|
||||
* native fold gutter handles expand/collapse with the same chevron as metadata.
|
||||
*/
|
||||
function foldEmbeddings(view: EditorView) {
|
||||
const doc = view.state.doc;
|
||||
for (let ln = 1; ln <= doc.lines; ln++) {
|
||||
const line = doc.line(ln);
|
||||
const match = line.text.match(/^(\s*embeddings:\s*\[)/);
|
||||
if (!match) continue;
|
||||
// Find closing ] line and cache values for fold placeholder
|
||||
for (let endLn = ln + 1; endLn <= doc.lines; endLn++) {
|
||||
const endLine = doc.line(endLn);
|
||||
if (endLine.text.trim().startsWith(']')) {
|
||||
// Cache the values line before folding hides it
|
||||
const valuesLn = ln + 1;
|
||||
if (valuesLn <= doc.lines && valuesLn < endLn) {
|
||||
cachedEmbeddingsPreview = doc.line(valuesLn).text.trim();
|
||||
}
|
||||
const from = line.from + match[1].length; // after [
|
||||
const to = endLine.from + endLine.text.indexOf(']'); // before ]
|
||||
if (to > from) {
|
||||
view.dispatch({
|
||||
effects: [foldEffect.of({ from, to })],
|
||||
annotations: [Transaction.addToHistory.of(false)]
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!editorContainer) return;
|
||||
|
||||
@@ -952,6 +1115,76 @@
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
// Replace embeddings fold placeholder with truncated value preview
|
||||
ViewPlugin.define((view) => {
|
||||
function updatePlaceholder() {
|
||||
if (!cachedEmbeddingsPreview) return;
|
||||
|
||||
// Get char width and styles from a number element, or fall back to cm-content
|
||||
const numberEl = view.dom.querySelector('.cm-number');
|
||||
const refEl = numberEl || view.dom.querySelector('.cm-content');
|
||||
if (!refEl) return;
|
||||
|
||||
const charWidth = numberEl
|
||||
? numberEl.getBoundingClientRect().width /
|
||||
(numberEl.textContent?.length || 1)
|
||||
: 7.8;
|
||||
const numberStyles = getComputedStyle(refEl);
|
||||
|
||||
for (const p of view.dom.querySelectorAll('.cm-foldPlaceholder')) {
|
||||
const lineEl = p.closest('.cm-line');
|
||||
if (!lineEl?.textContent?.match(/embeddings:\s*\[/)) continue;
|
||||
|
||||
const pos = view.posAtDOM(lineEl);
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
const match = line.text.match(/^(\s*embeddings:\s*\[)/);
|
||||
if (!match) continue;
|
||||
|
||||
const contentWidth =
|
||||
view.dom.querySelector('.cm-content')?.clientWidth ?? 600;
|
||||
const available =
|
||||
Math.floor(contentWidth / charWidth) - match[1].length - 8;
|
||||
if (available <= 10) continue;
|
||||
|
||||
const truncated = cachedEmbeddingsPreview.slice(0, available - 3);
|
||||
const lastComma = truncated.lastIndexOf(',');
|
||||
const el = p as HTMLElement;
|
||||
el.textContent =
|
||||
(lastComma > 0 ? truncated.slice(0, lastComma) : truncated) + '...';
|
||||
el.classList.add('cm-embeddings-fold');
|
||||
|
||||
Object.assign(el.style, {
|
||||
fontFamily: numberStyles.fontFamily,
|
||||
fontSize: numberStyles.fontSize,
|
||||
fontWeight: numberStyles.fontWeight,
|
||||
lineHeight: numberStyles.lineHeight,
|
||||
letterSpacing: numberStyles.letterSpacing
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
requestAnimationFrame(() => updatePlaceholder());
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(scheduleUpdate);
|
||||
observer.observe(view.dom);
|
||||
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.transactions.some((tr) =>
|
||||
tr.effects.some((e) => e.is(foldEffect))
|
||||
)
|
||||
) {
|
||||
scheduleUpdate();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
}),
|
||||
// Use fold gutter, hide default glyphs (we style via CSS)
|
||||
foldGutter({ openText: ' ', closedText: ' ' }),
|
||||
indentUnit.of(' '), // Use 2 spaces for indentation
|
||||
@@ -960,6 +1193,7 @@
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
linter(json5Linter, { delay: LINTER_DELAY }),
|
||||
createErrorLineHighlight(),
|
||||
readOnlyRangesField,
|
||||
EditorState.transactionFilter.of(
|
||||
createReadOnlyRangesFilter(readOnlyRangesField, readonly)
|
||||
@@ -968,6 +1202,7 @@
|
||||
createNestedKeyPlugin(),
|
||||
createDuplicateKeyLinter({ delay: LINTER_DELAY }),
|
||||
createSystemFieldStylePlugin(() => isNew && !hasUserContent),
|
||||
createLineHoverPlugin(),
|
||||
highlightSelectionMatches(),
|
||||
// Clear selection after fold/unfold to prevent split highlighting
|
||||
EditorView.updateListener.of((update) => {
|
||||
@@ -1012,16 +1247,23 @@
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'Mod-g',
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
if (onGenerateEmbedding) {
|
||||
onGenerateEmbedding();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: () => {
|
||||
if (showSuggestions && hasStartedEditing) {
|
||||
hasStartedEditing = false;
|
||||
hasSuggestionsBeenShown = true;
|
||||
if (suggestionsHideTimer) {
|
||||
clearTimeout(suggestionsHideTimer);
|
||||
suggestionsHideTimer = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -1032,31 +1274,41 @@
|
||||
json5(),
|
||||
customSyntaxHighlighting,
|
||||
customTheme,
|
||||
EditorView.domEventHandlers({
|
||||
mousedown: () => {
|
||||
if (isNew && showSuggestions && !hasSuggestionsBeenShown) {
|
||||
hasStartedEditing = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
wrapCompartment.of(wrapLines ? EditorView.lineWrapping : []),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged || update.transactions.some((tr) => tr.effects.length > 0)) {
|
||||
const hasNonHoverEffects = update.transactions.some((tr) => {
|
||||
if (!tr.effects.length) return false;
|
||||
return tr.annotation(Transaction.userEvent) !== 'appwrite:hover';
|
||||
});
|
||||
if (update.docChanged || hasNonHoverEffects) {
|
||||
const summary = getLintWarningSummary(update.state);
|
||||
warningMessage = summary.message;
|
||||
}
|
||||
if (!update.docChanged || readonly) return;
|
||||
|
||||
// Hide saved sonner when user starts typing
|
||||
if (saveSonnerState === 'saved') {
|
||||
saveSonnerState = null;
|
||||
}
|
||||
|
||||
// Check if this is manual typing (not paste, undo, or programmatic)
|
||||
const isPaste = isPasteUpdate(update);
|
||||
const isManualInput = !isPaste && !isUpdatingFromEditor;
|
||||
|
||||
if (isNew && isManualInput && !hasSuggestionsBeenShown) {
|
||||
hasStartedEditing = true;
|
||||
|
||||
if (showSuggestions) {
|
||||
if (suggestionsHideTimer) {
|
||||
clearTimeout(suggestionsHideTimer);
|
||||
}
|
||||
|
||||
suggestionsHideTimer = setTimeout(() => {
|
||||
hasStartedEditing = false;
|
||||
hasSuggestionsBeenShown = true;
|
||||
suggestionsHideTimer = null;
|
||||
}, SUGGESTIONS_HIDE_DELAY);
|
||||
if (hasStartedEditing) {
|
||||
hasStartedEditing = false;
|
||||
hasSuggestionsBeenShown = true;
|
||||
} else {
|
||||
hasStartedEditing = true;
|
||||
}
|
||||
|
||||
const parseCache = update.state.field(json5ParseCache, false);
|
||||
@@ -1089,12 +1341,6 @@
|
||||
changeTimer = null;
|
||||
}
|
||||
|
||||
// Clear auto-save timer when user starts typing again
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
|
||||
changeTimer = setTimeout(async () => {
|
||||
const state = update.view.state;
|
||||
const parseCache = state.field(json5ParseCache, false);
|
||||
@@ -1114,47 +1360,6 @@
|
||||
data = parsed;
|
||||
onChange?.(parsed, hasDataChanged);
|
||||
lastExpectedContent = serializeData(parsed);
|
||||
|
||||
// Check if this was a manual edit (not undo) and trigger auto-save
|
||||
const isUndoOrRedo = update.transactions.some(
|
||||
(tr) =>
|
||||
tr.annotation(Transaction.userEvent) === 'undo' ||
|
||||
tr.annotation(Transaction.userEvent) === 'redo'
|
||||
);
|
||||
|
||||
if (!isUndoOrRedo && !$isSmallViewport && hasDataChanged && onSave) {
|
||||
// Clear existing auto-save timer
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
|
||||
// Increment generation to track this save attempt
|
||||
saveGeneration++;
|
||||
const capturedGeneration = saveGeneration;
|
||||
|
||||
// Set new auto-save timer
|
||||
autoSaveTimer = setTimeout(() => {
|
||||
// Skip if a newer edit has occurred (stale closure detection)
|
||||
if (capturedGeneration !== saveGeneration) {
|
||||
autoSaveTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const parseCache = editorView?.state.field(json5ParseCache, false);
|
||||
if (parseCache?.err || errorMessage) {
|
||||
autoSaveTimer = null;
|
||||
return;
|
||||
}
|
||||
if (editorView && getLintWarningSummary(editorView.state).hasWarning) {
|
||||
autoSaveTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
handleSave();
|
||||
autoSaveTimer = null;
|
||||
}, AUTOSAVE_DELAY);
|
||||
}
|
||||
}, DEBOUNCE_DELAY);
|
||||
}),
|
||||
readOnlyCompartment.of(EditorState.readOnly.of(readonly))
|
||||
@@ -1169,6 +1374,8 @@
|
||||
state: startState,
|
||||
parent: editorContainer
|
||||
});
|
||||
|
||||
foldEmbeddings(editorView);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -1176,18 +1383,10 @@
|
||||
clearTimeout(changeTimer);
|
||||
changeTimer = null;
|
||||
}
|
||||
if (autoSaveTimer) {
|
||||
clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = null;
|
||||
}
|
||||
if (tooltipTimer) {
|
||||
clearTimeout(tooltipTimer);
|
||||
tooltipTimer = null;
|
||||
}
|
||||
if (suggestionsHideTimer) {
|
||||
clearTimeout(suggestionsHideTimer);
|
||||
suggestionsHideTimer = null;
|
||||
}
|
||||
lastSerializedData = null;
|
||||
lastSerializedText = '';
|
||||
editorView?.destroy();
|
||||
@@ -1197,7 +1396,7 @@
|
||||
// Reset originalData when transitioning to new document mode
|
||||
$effect(() => {
|
||||
if (isNew && !wasNew) {
|
||||
originalData = $state.snapshot(data);
|
||||
originalData = data;
|
||||
generatedId = ID.unique();
|
||||
hasStartedEditing = false; // Reset editing flag for new document
|
||||
hasUserContent = false;
|
||||
@@ -1219,8 +1418,13 @@
|
||||
hasUserContent = false;
|
||||
hasSuggestionsBeenShown = false; // Reset suggestions shown flag when switching documents
|
||||
|
||||
// Hide saved sonner when document is changed
|
||||
if (saveSonnerState === 'saved') {
|
||||
saveSonnerState = null;
|
||||
}
|
||||
|
||||
// Capture original data snapshot when switching documents
|
||||
originalData = $state.snapshot(data);
|
||||
originalData = data;
|
||||
|
||||
lastExpectedContent = expectedContent;
|
||||
|
||||
@@ -1238,6 +1442,23 @@
|
||||
extensions: baseExtensions
|
||||
});
|
||||
editorView.setState(newState);
|
||||
foldEmbeddings(editorView);
|
||||
|
||||
if (isNew && !readonly) {
|
||||
const pos = findNewDocCursorPos(editorView.state);
|
||||
if (pos !== null) {
|
||||
editorView.dispatch({
|
||||
selection: EditorSelection.cursor(pos),
|
||||
scrollIntoView: true,
|
||||
annotations: [Transaction.addToHistory.of(false)]
|
||||
});
|
||||
editorView.focus();
|
||||
|
||||
if (!hasSuggestionsBeenShown) {
|
||||
hasStartedEditing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
queueMicrotask(() => (isUpdatingFromEditor = false));
|
||||
return;
|
||||
}
|
||||
@@ -1257,6 +1478,7 @@
|
||||
changes: { from: 0, to: currentContent.length, insert: expectedContent },
|
||||
annotations: [Transaction.addToHistory.of(false)]
|
||||
});
|
||||
foldEmbeddings(editorView);
|
||||
queueMicrotask(() => (isUpdatingFromEditor = false));
|
||||
}
|
||||
});
|
||||
@@ -1288,7 +1510,7 @@
|
||||
saveSonnerState = 'saving';
|
||||
} else if (saveSonnerState === 'saving') {
|
||||
saveSonnerState = 'saved';
|
||||
sleep(AUTOSAVE_DELAY).then(() => {
|
||||
sleep(SAVE_UNDO_TOOLBAR_TIMEOUT).then(() => {
|
||||
if (saveSonnerState === 'saved') {
|
||||
saveSonnerState = null;
|
||||
}
|
||||
@@ -1314,22 +1536,16 @@
|
||||
|
||||
{#if documentId}
|
||||
<Layout.Stack direction="row" inline gap="s">
|
||||
{#if isNew && onCancel}
|
||||
<Tooltip placement="top">
|
||||
<Button
|
||||
icon
|
||||
secondary
|
||||
size="xs"
|
||||
class="icon-button"
|
||||
disabled={loading}
|
||||
on:click={onCancel}>
|
||||
<Icon icon={IconX} size="s" />
|
||||
</Button>
|
||||
|
||||
<span slot="tooltip">Cancel</span>
|
||||
</Tooltip>
|
||||
{#if isNew && onDiscard}
|
||||
<Button text size="xs" disabled={loading} on:click={onDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button secondary size="xs" disabled={!hasDataChanged} on:click={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Tooltip placement="top">
|
||||
<Button
|
||||
icon
|
||||
@@ -1384,11 +1600,37 @@
|
||||
|
||||
<div bind:this={editorContainer} class="cm-editor-wrapper" class:loading></div>
|
||||
|
||||
{#if showSuggestions && hasStartedEditing}
|
||||
<div
|
||||
class="suggestions-wrapper"
|
||||
style="position: absolute; top: 205px; left: 205px; z-index: 50;">
|
||||
<Suggestions show={showSuggestions} />
|
||||
{#if ($isSmallViewport && showSuggestions) || (showSuggestions && hasStartedEditing)}
|
||||
<Suggestions
|
||||
show={showSuggestions}
|
||||
showMock={showMockSuggestions}
|
||||
onMobileClick={() => {
|
||||
showSuggestions = false;
|
||||
applySuggestedAttributes();
|
||||
}} />
|
||||
{/if}
|
||||
|
||||
{#if onGenerateEmbedding && !$isSmallViewport}
|
||||
<div class="embedding-hint">
|
||||
<HintBadge>
|
||||
<Layout.Stack inline gap="xs" direction="row" alignItems="center">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
|
||||
Press
|
||||
</Typography.Caption>
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
inline
|
||||
gap="xxxs"
|
||||
alignItems="center"
|
||||
style="height: fit-content">
|
||||
<Badge content={isMac() ? '⌘' : 'Ctrl'} variant="secondary" size="xs" />
|
||||
<Badge content="G" variant="secondary" size="xs" />
|
||||
</Layout.Stack>
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
|
||||
to generate embeddings
|
||||
</Typography.Caption>
|
||||
</Layout.Stack>
|
||||
</HintBadge>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1399,7 +1641,7 @@
|
||||
severity={errorMessage ? 'error' : 'warning'} />
|
||||
{/if}
|
||||
|
||||
<SavingSonner state={saveSonnerState} />
|
||||
<SavingSonner state={saveSonnerState} onUndo={handleUndo} />
|
||||
|
||||
<style lang="scss">
|
||||
.editor-container {
|
||||
@@ -1544,6 +1786,22 @@
|
||||
background-color: var(--overlay-neutral-pressed) !important;
|
||||
}
|
||||
|
||||
:global(.cm-hovered-line) {
|
||||
background-color: var(--overlay-neutral-hover);
|
||||
}
|
||||
|
||||
:global(.cm-gutterElement.cm-hovered-lineGutter) {
|
||||
background-color: var(--overlay-neutral-hover);
|
||||
}
|
||||
|
||||
:global(.cm-line.cm-error-line) {
|
||||
background-color: var(--bgcolor-error-weak) !important;
|
||||
}
|
||||
|
||||
:global(.cm-gutterElement.cm-error-lineGutter) {
|
||||
background-color: var(--bgcolor-error-weak) !important;
|
||||
}
|
||||
|
||||
// Subtle indicator for read-only system-field lines
|
||||
:global(.cm-readOnlyLine) {
|
||||
cursor: not-allowed;
|
||||
@@ -1559,14 +1817,21 @@
|
||||
|
||||
// Syntax highlighting
|
||||
// Property names (keys) must use neutral color with priority
|
||||
:global(.cm-string),
|
||||
:global(.cm-number),
|
||||
:global(.cm-propertyName) {
|
||||
color: var(--fgcolor-neutral-primary) !important;
|
||||
font-weight: 500;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 140%; /* 16.8px */
|
||||
font-size: var(--font-size-xs, 12px);
|
||||
color: var(--fgcolor-neutral-primary);
|
||||
font-family: var(--font-family-code, 'Fira Code');
|
||||
}
|
||||
|
||||
// System fields muted styling (when suggestions are showing)
|
||||
// Must come after .cm-propertyName to override
|
||||
:global(.cm-system-field-muted),
|
||||
:global(.cm-system-field-muted *),
|
||||
:global(.cm-system-field-muted.cm-propertyName),
|
||||
:global(.cm-system-field-muted .cm-propertyName) {
|
||||
color: var(--fgcolor-neutral-tertiary, #97979b) !important;
|
||||
@@ -1629,17 +1894,19 @@
|
||||
|
||||
// Smooth curved underline for errors
|
||||
:global(.cm-lintRange-error) {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='3' viewBox='0 0 4 3'%3E%3Cpath d='M0 3 Q 1 0 2 3 Q 3 0 4 3' fill='none' stroke='%23f04438' stroke-width='0.6'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='4' viewBox='0 0 10 4' fill='none'%3E%3Cpath d='M0 2 C1.5 0.6 3.5 0.6 5 2 C6.5 3.4 8.5 3.4 10 2' stroke='%23f04438' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat-x;
|
||||
background-position: left bottom;
|
||||
padding-bottom: 2px;
|
||||
background-size: 10px 4px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
:global(.cm-lintRange-warning) {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='3' viewBox='0 0 4 3'%3E%3Cpath d='M0 3 Q 1 0 2 3 Q 3 0 4 3' fill='none' stroke='%23ffa500' stroke-width='0.6'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='4' viewBox='0 0 10 4' fill='none'%3E%3Cpath d='M0 2 C1.5 0.6 3.5 0.6 5 2 C6.5 3.4 8.5 3.4 10 2' stroke='%23ffa500' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: repeat-x;
|
||||
background-position: left bottom;
|
||||
padding-bottom: 2px;
|
||||
background-size: 10px 4px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
// Hide lint tooltip since we show errors in header
|
||||
@@ -1657,5 +1924,18 @@
|
||||
padding: 0 4px;
|
||||
background: var(--bgcolor-neutral-secondary);
|
||||
}
|
||||
|
||||
:global(.cm-foldPlaceholder.cm-embeddings-fold) {
|
||||
color: var(--brand-mint-600);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.embedding-hint {
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
z-index: 50;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="hint-badge">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hint-badge {
|
||||
height: 44px;
|
||||
width: max-content;
|
||||
gap: var(--gap-xxs);
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--border-radius-m);
|
||||
background: var(--bgcolor-neutral-primary);
|
||||
border: var(--border-width-s) solid var(--border-neutral);
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.03),
|
||||
0 4px 4px 0 rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,11 @@
|
||||
import { InputTags } from '$lib/elements/forms';
|
||||
import { symmetricDifference } from '$lib/helpers/array';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { Input, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { organization } from '$lib/stores/organization';
|
||||
|
||||
let {
|
||||
collectionId,
|
||||
databaseType,
|
||||
inModal = false,
|
||||
onSuccess = null,
|
||||
onFailure = null
|
||||
}: {
|
||||
@@ -59,18 +57,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Layout.Stack>
|
||||
<Layout.Stack gap="s">
|
||||
{#key names.length}
|
||||
<InputTags
|
||||
bind:tags={names}
|
||||
id="custom-columns-{collectionId}"
|
||||
placeholder="Enter fields"
|
||||
label={inModal ? null : 'Fields to display'} />
|
||||
{/key}
|
||||
|
||||
<Input.Helper state="default">
|
||||
ID, createdAt, and updatedAt are always included and cannot be modified
|
||||
</Input.Helper>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
<InputTags
|
||||
max={5}
|
||||
required
|
||||
bind:tags={names}
|
||||
id="custom-columns-{collectionId}"
|
||||
placeholder="Enter fields"
|
||||
label="Fields to display"
|
||||
helper="ID, createdAt, and updatedAt are always included and cannot be modified" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { FloatingActionBar, Icon, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
|
||||
import { IconExclamationCircle, IconExclamation } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
let {
|
||||
message,
|
||||
@@ -10,6 +10,7 @@
|
||||
severity?: 'error' | 'warning';
|
||||
} = $props();
|
||||
|
||||
const properIcon = $derived(severity === 'warning' ? IconExclamation : IconExclamationCircle);
|
||||
const iconColor = $derived(severity === 'warning' ? '--fgcolor-warning' : '--fgcolor-error');
|
||||
</script>
|
||||
|
||||
@@ -23,7 +24,7 @@
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
style="width: max-content;">
|
||||
<Icon icon={IconExclamationCircle} color={iconColor} />
|
||||
<Icon icon={properIcon} color={iconColor} />
|
||||
|
||||
<div class="sonner-message">
|
||||
{message}
|
||||
@@ -45,8 +46,10 @@
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
& :global(div:first-of-type) {
|
||||
height: 44px;
|
||||
& > :global(div:first-of-type) {
|
||||
bottom: 32px;
|
||||
max-width: 100%;
|
||||
min-height: 44px;
|
||||
width: fit-content;
|
||||
align-content: center;
|
||||
}
|
||||
@@ -55,9 +58,9 @@
|
||||
.sonner-message {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-family-code);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,18 +10,21 @@
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { sleep } from '$lib/helpers/promises';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { SAVE_UNDO_TOOLBAR_TIMEOUT } from '../editor/helpers/constants';
|
||||
|
||||
let {
|
||||
state = null
|
||||
state = null,
|
||||
onUndo = null
|
||||
}: {
|
||||
state: 'saving' | 'saved' | null;
|
||||
onUndo?: () => Promise<void> | void;
|
||||
} = $props();
|
||||
|
||||
let previousState = state;
|
||||
|
||||
$effect(() => {
|
||||
if (state === 'saved' && previousState !== 'saved') {
|
||||
sleep(3000).then(() => {
|
||||
sleep(SAVE_UNDO_TOOLBAR_TIMEOUT).then(() => {
|
||||
previousState = state;
|
||||
state = null;
|
||||
});
|
||||
@@ -53,7 +56,7 @@
|
||||
|
||||
<svelte:fragment slot="end">
|
||||
{#if state === 'saved' && !$isSmallViewport}
|
||||
<Button secondary size="xs" on:click={async () => {}}>
|
||||
<Button secondary size="xs" on:click={async () => onUndo?.()}>
|
||||
<Typography.Caption variant="500">Undo</Typography.Caption>
|
||||
|
||||
<Badge content="⌘Z" variant="secondary" size="xs" />
|
||||
@@ -77,6 +80,7 @@
|
||||
}
|
||||
|
||||
& :global(div:first-of-type) {
|
||||
bottom: 32px;
|
||||
max-width: 280px;
|
||||
transition: width 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { Badge, FloatingActionBar, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { Badge, FloatingActionBar, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import HintBadge from '../hintBadge.svelte';
|
||||
|
||||
let {
|
||||
show = true
|
||||
show = true,
|
||||
onMobileClick,
|
||||
showMock = false
|
||||
}: {
|
||||
show?: boolean;
|
||||
onMobileClick: () => Promise<void> | void;
|
||||
showMock?: boolean;
|
||||
} = $props();
|
||||
|
||||
const suffix = $derived(
|
||||
showMock ? 'to get started with example suggestions' : 'for suggestions'
|
||||
);
|
||||
const translateX = $derived(showMock ? '25%' : '40%');
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="floating-action-bar">
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Layout.Stack
|
||||
inline
|
||||
gap="xs"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
style="width: max-content;">
|
||||
Press
|
||||
{#if $isSmallViewport}
|
||||
<button type="button" class="floating-action-bar" onclick={onMobileClick}>
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Typography.Text variant="m-500">Tap to apply suggestions</Typography.Text>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="suggestions-wrapper" style:transform={`translateX(${translateX})`}>
|
||||
<HintBadge>
|
||||
<Layout.Stack inline gap="xs" direction="row" alignItems="center">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
|
||||
Press
|
||||
</Typography.Caption>
|
||||
|
||||
<Layout.Stack direction="row" inline gap="xxxs" style="height: fit-content">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
inline
|
||||
gap="xxxs"
|
||||
alignItems="center"
|
||||
style="height: fit-content">
|
||||
<Badge content="⌘" variant="secondary" size="xs" />
|
||||
|
||||
<Badge content="A" variant="secondary" size="xs" />
|
||||
</Layout.Stack>
|
||||
|
||||
for suggestions
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-secondary">
|
||||
{suffix}
|
||||
</Typography.Caption>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
</HintBadge>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.suggestions-wrapper {
|
||||
top: 15%;
|
||||
z-index: 50;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.floating-action-bar {
|
||||
max-width: 205px;
|
||||
transform: translateX(60%);
|
||||
left: 50%;
|
||||
bottom: 5%;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
left: 50%;
|
||||
bottom: 5%;
|
||||
position: absolute;
|
||||
}
|
||||
& > :global:first-child {
|
||||
display: unset; /* stack with flex-start in FAB */
|
||||
|
||||
& :global(div:first-of-type) {
|
||||
height: 44px;
|
||||
width: fit-content;
|
||||
align-content: center;
|
||||
& > :global:first-child {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -44,13 +44,19 @@
|
||||
import {
|
||||
documentActivitySheet,
|
||||
documentPermissionSheet,
|
||||
noSqlDocument
|
||||
noSqlDocument,
|
||||
showCreateIndexSheet
|
||||
} from '$database/collection-[collection]/store';
|
||||
import {
|
||||
SideSheet,
|
||||
EditRecordPermissions,
|
||||
RecordActivity,
|
||||
type Field
|
||||
CreateIndex,
|
||||
useDatabaseSdk,
|
||||
DEFAULT_VECTOR_DIMENSION,
|
||||
type DatabaseType,
|
||||
type Field,
|
||||
type Index
|
||||
} from '$database/(entity)';
|
||||
import {
|
||||
entityColumnSuggestions,
|
||||
@@ -60,6 +66,7 @@
|
||||
} from '$database/(suggestions)';
|
||||
import { VARS } from '$lib/system';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import type { OrderBy } from '@appwrite.io/console';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
@@ -69,6 +76,7 @@
|
||||
*/
|
||||
let isWaterfallFromFaker = false;
|
||||
|
||||
let createIndex: CreateIndex;
|
||||
let editRecordPermissions: EditRecordPermissions;
|
||||
|
||||
$: collection = data.collection;
|
||||
@@ -81,7 +89,7 @@
|
||||
expandTabs.set(preferences.getKey('entityHeaderExpanded', true));
|
||||
|
||||
// set faker method.
|
||||
$randomDataModalState.onSubmit = async () => await createFakeData();
|
||||
$randomDataModalState.onSubmit = async () => await createSampleDocuments();
|
||||
|
||||
if (
|
||||
$entityColumnSuggestions.enabled &&
|
||||
@@ -92,7 +100,10 @@
|
||||
}
|
||||
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
if (response.events.includes('documentsdb.*.collections.*.indexes.*')) {
|
||||
if (
|
||||
response.events.includes('documentsdb.*.collections.*.indexes.*') ||
|
||||
response.events.includes('vectorsdb.*.collections.*.indexes.*')
|
||||
) {
|
||||
if (!isWaterfallFromFaker && !$entityColumnSuggestions.entity) {
|
||||
invalidate(Dependencies.COLLECTION);
|
||||
}
|
||||
@@ -198,10 +209,31 @@
|
||||
indexes: 700
|
||||
});
|
||||
|
||||
async function handleCreateIndex(index: Index) {
|
||||
const databaseSdk = useDatabaseSdk(
|
||||
page.params.region,
|
||||
page.params.project,
|
||||
data.database.type as DatabaseType
|
||||
);
|
||||
|
||||
await databaseSdk.createIndex({
|
||||
databaseId: page.params.database,
|
||||
entityId: page.params.collection,
|
||||
key: index.key,
|
||||
type: index.type,
|
||||
attributes: index.fields,
|
||||
lengths: index.lengths,
|
||||
orders: index.orders as OrderBy[] /* TODO: @itznotabug: needs to be fixed at SDK level */
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.COLLECTION);
|
||||
}
|
||||
|
||||
async function createSampleDocuments() {
|
||||
$spreadsheetLoading = true;
|
||||
isWaterfallFromFaker = true;
|
||||
|
||||
let documentIds = [];
|
||||
let suggestedColumns: { total: number; columns: ColumnInput[] } = {
|
||||
total: 0,
|
||||
columns: []
|
||||
@@ -245,20 +277,36 @@
|
||||
status: 'available'
|
||||
})) as Field[];
|
||||
|
||||
const { rows } = generateFakeRecords($randomDataModalState.value, fields);
|
||||
const { rows, ids } = generateFakeRecords($randomDataModalState.value, fields);
|
||||
documentIds = ids;
|
||||
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.createDocuments({
|
||||
const dbType = data.database?.type;
|
||||
const isVectorsDb = dbType === 'vectorsdb';
|
||||
const dimension = collection?.dimension ?? DEFAULT_VECTOR_DIMENSION;
|
||||
|
||||
// For vectorsdb, wrap fields in metadata and add empty embeddings
|
||||
const documents = isVectorsDb
|
||||
? rows.map((row) => {
|
||||
const { $id, ...rest } = row;
|
||||
return { $id, metadata: rest, embeddings: new Array(dimension).fill(0) };
|
||||
})
|
||||
: rows;
|
||||
|
||||
const projectSdk = sdk.forProject(page.params.region, page.params.project);
|
||||
|
||||
if (isVectorsDb) {
|
||||
await projectSdk.vectorsDB.createDocuments({
|
||||
databaseId: page.params.database,
|
||||
collectionId: page.params.collection,
|
||||
documents: rows
|
||||
documents
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Sample data added successfully with AI-suggested attributes'
|
||||
});
|
||||
} else {
|
||||
await projectSdk.documentsDB.createDocuments({
|
||||
databaseId: page.params.database,
|
||||
collectionId: page.params.collection,
|
||||
documents
|
||||
});
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.DOCUMENTS);
|
||||
} catch (e) {
|
||||
@@ -277,50 +325,8 @@
|
||||
|
||||
$spreadsheetLoading = false;
|
||||
isWaterfallFromFaker = false;
|
||||
$randomDataModalState.columns = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createFakeData() {
|
||||
isWaterfallFromFaker = true;
|
||||
|
||||
$spreadsheetLoading = true;
|
||||
$randomDataModalState.show = false;
|
||||
|
||||
/* let the columns be processed! */
|
||||
await sleep(1250);
|
||||
|
||||
let documentIds = [];
|
||||
try {
|
||||
const { rows, ids } = generateFakeRecords($randomDataModalState.value);
|
||||
|
||||
documentIds = ids;
|
||||
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.createDocuments({
|
||||
databaseId: page.params.database,
|
||||
collectionId: page.params.collection,
|
||||
documents: rows
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Sample data added successfully'
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.DOCUMENTS);
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: e.message
|
||||
});
|
||||
} finally {
|
||||
// reset value to 25 default!
|
||||
$randomDataModalState.value = 25;
|
||||
}
|
||||
|
||||
$spreadsheetLoading = false;
|
||||
isWaterfallFromFaker = false;
|
||||
|
||||
spreadsheetRenderKey.set(hash(documentIds));
|
||||
}
|
||||
@@ -350,3 +356,21 @@
|
||||
<SideSheet title="Document activity" bind:show={$documentActivitySheet.show} closeOnBlur>
|
||||
<RecordActivity record={$documentActivitySheet.document} />
|
||||
</SideSheet>
|
||||
|
||||
<SideSheet
|
||||
closeOnBlur
|
||||
title="Create index"
|
||||
bind:show={$showCreateIndexSheet.show}
|
||||
submit={{
|
||||
text: 'Create',
|
||||
onClick: async () => {
|
||||
await createIndex.create();
|
||||
}
|
||||
}}>
|
||||
<CreateIndex
|
||||
entity={collection}
|
||||
bind:this={createIndex}
|
||||
bind:showCreateIndex={$showCreateIndexSheet.show}
|
||||
externalFieldKey={$showCreateIndexSheet.column}
|
||||
onCreateIndex={handleCreateIndex} />
|
||||
</SideSheet>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { hasPageQueries, queries } from '$lib/components/filters';
|
||||
import { Filters, hasPageQueries, queries } from '$lib/components/filters';
|
||||
import ViewSelector from '$lib/components/viewSelector.svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import type { Column, ColumnType } from '$lib/helpers/types';
|
||||
import { Container } from '$lib/layout';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
@@ -9,17 +10,27 @@
|
||||
import FilePicker from '$lib/components/filePicker.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlus,
|
||||
IconViewBoards
|
||||
IconViewBoards,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
IconDownload
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { type Models } from '@appwrite.io/console';
|
||||
import { expandTabs, randomDataModalState } from '$database/store';
|
||||
import { EmptySheet, EmptySheetCards } from '$database/(entity)';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Click } from '$lib/actions/analytics';
|
||||
import { expandTabs, randomDataModalState, spreadsheetRenderKey } from '$database/store';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { hash } from '$lib/helpers/string';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { EmptySheet, EmptySheetCards, type DatabaseType } from '$database/(entity)';
|
||||
import {
|
||||
isCollectionsJsonImportInProgress,
|
||||
noSqlDocument,
|
||||
@@ -30,9 +41,13 @@
|
||||
import ColumnDisplayNameInput from '$database/collection-[collection]/(components)/inputs/displayName.svelte';
|
||||
import { Modal } from '$lib/components';
|
||||
import { buildInitDoc } from './+layout.svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
const filterColumns = writable<Column[]>([]);
|
||||
|
||||
let isRefreshing = $state(false);
|
||||
let showImportJson = $state(false);
|
||||
let showCustomColumnsModal = $state(false);
|
||||
|
||||
@@ -40,20 +55,56 @@
|
||||
let spreadsheet: SpreadSheet | null = $state(null);
|
||||
let columnDisplayNameInput: ColumnDisplayNameInput | null = $state(null);
|
||||
|
||||
const disableCreateDocument = $derived(
|
||||
$noSqlDocument.isNew && ($noSqlDocument.hasDataChanged || $noSqlDocument.isDirty)
|
||||
);
|
||||
|
||||
function createFilterableColumns(): Column[] {
|
||||
return [
|
||||
{ id: '$id', title: '$id', type: 'string' as ColumnType },
|
||||
{ id: '$createdAt', title: '$createdAt', type: 'datetime' as ColumnType },
|
||||
{ id: '$updatedAt', title: '$updatedAt', type: 'datetime' as ColumnType }
|
||||
];
|
||||
}
|
||||
|
||||
function handleColumnToggle() {
|
||||
// Force spreadsheet re-render when columns are toggled
|
||||
spreadsheetRenderKey.set(hash(Date.now().toString()));
|
||||
}
|
||||
|
||||
function getExportUrl() {
|
||||
const queryParam = page.url.searchParams.get('query');
|
||||
const url = resolve(
|
||||
'/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]/export',
|
||||
{
|
||||
region: page.params.region,
|
||||
project: page.params.project,
|
||||
database: page.params.database,
|
||||
collection: page.params.collection
|
||||
}
|
||||
);
|
||||
return queryParam ? `${url}?query=${encodeURIComponent(queryParam)}` : url;
|
||||
}
|
||||
|
||||
async function onSelect(file: Models.File, localFile = false) {
|
||||
$isCollectionsJsonImportInProgress = true;
|
||||
|
||||
console.log(file, localFile);
|
||||
|
||||
try {
|
||||
/*await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.migrations.createJSONImport({
|
||||
bucketId: file.bucketId,
|
||||
fileId: file.$id,
|
||||
resourceId: `${page.params.database}:${page.params.collection}`,
|
||||
internalFile: localFile
|
||||
});*/
|
||||
await (
|
||||
sdk.forProject(page.params.region, page.params.project).migrations as unknown as {
|
||||
createJSONImport: (params: {
|
||||
bucketId: string;
|
||||
fileId: string;
|
||||
resourceId: string;
|
||||
internalFile: boolean;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
).createJSONImport({
|
||||
bucketId: file.bucketId,
|
||||
fileId: file.$id,
|
||||
resourceId: `${page.params.database}:${page.params.collection}`,
|
||||
internalFile: localFile
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -71,6 +122,10 @@
|
||||
$isCollectionsJsonImportInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
filterColumns.set(createFilterableColumns());
|
||||
});
|
||||
</script>
|
||||
|
||||
{#key page.params.collection}
|
||||
@@ -89,11 +144,23 @@
|
||||
view={data.view}
|
||||
columns={collectionColumns}
|
||||
disableButton={data.documents.total === 0}
|
||||
onPreferencesUpdated={handleColumnToggle}
|
||||
onCustomOptionClick={() => (showCustomColumnsModal = true)} />
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">Columns</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Filters
|
||||
onlyIcon
|
||||
query={data.query}
|
||||
columns={filterColumns}
|
||||
schema={false}
|
||||
analyticsSource="database_collections" />
|
||||
|
||||
<svelte:fragment slot="tooltip">Filters</svelte:fragment>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
@@ -105,25 +172,58 @@
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end">
|
||||
<Button
|
||||
secondary
|
||||
disabled
|
||||
event={Click.DatabaseImportJson}
|
||||
on:click={() => (showImportJson = true)}>
|
||||
Import JSON
|
||||
</Button>
|
||||
{#if !$isSmallViewport}
|
||||
<Button
|
||||
secondary
|
||||
event="create_document"
|
||||
on:click={() => {
|
||||
if (!$noSqlDocument.isNew) {
|
||||
noSqlDocument.create(buildInitDoc());
|
||||
}
|
||||
}}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Create document
|
||||
</Button>
|
||||
<Tooltip placement="top">
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
class="small-button-dimensions"
|
||||
on:click={() => (showImportJson = true)}>
|
||||
<Icon icon={IconUpload} size="s" />
|
||||
</Button>
|
||||
<svelte:fragment slot="tooltip">Import JSON</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip placement="top">
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
class="small-button-dimensions"
|
||||
disabled={!data.documents.total}
|
||||
on:click={() => {
|
||||
trackEvent(Click.DatabaseExportCsv);
|
||||
goto(getExportUrl());
|
||||
}}>
|
||||
<Icon icon={IconDownload} size="s" />
|
||||
</Button>
|
||||
<svelte:fragment slot="tooltip">Export JSON</svelte:fragment>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
maxWidth="210px"
|
||||
placement="bottom"
|
||||
disabled={!disableCreateDocument}>
|
||||
<div>
|
||||
<Button
|
||||
secondary
|
||||
event="create_document"
|
||||
disabled={disableCreateDocument}
|
||||
on:click={() => {
|
||||
if (disableCreateDocument) return;
|
||||
if (!$noSqlDocument.isNew) {
|
||||
noSqlDocument.create(buildInitDoc());
|
||||
}
|
||||
}}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Create document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">
|
||||
Save your current document before creating a new one
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
icon
|
||||
@@ -138,6 +238,28 @@
|
||||
icon={!$expandTabs ? IconChevronDown : IconChevronUp}
|
||||
size="s" />
|
||||
</Button>
|
||||
|
||||
<Tooltip
|
||||
disabled={isRefreshing || !data.documents.total}
|
||||
placement="top">
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
disabled={isRefreshing || !data.documents.total}
|
||||
class="small-button-dimensions"
|
||||
on:click={async () => {
|
||||
isRefreshing = true;
|
||||
await invalidate(Dependencies.COLLECTION);
|
||||
isRefreshing = false; /* too fast on local */
|
||||
}}>
|
||||
<div style:line-height="0px" class:rotating={isRefreshing}>
|
||||
<Icon icon={IconRefresh} size="s" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<svelte:fragment slot="tooltip">Refresh</svelte:fragment>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
@@ -183,22 +305,27 @@
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
{:else}
|
||||
<EmptySheet mode="records" type="documentsdb" showActions={$canWriteRows}>
|
||||
<EmptySheet
|
||||
mode="records"
|
||||
type={data.database.type as DatabaseType}
|
||||
showActions={$canWriteRows}>
|
||||
{#snippet actions()}
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create documents"
|
||||
subtitle="Create documents manually"
|
||||
onClick={() => {
|
||||
noSqlDocument.create(buildInitDoc());
|
||||
}} />
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconViewBoards}
|
||||
title="Generate sample data"
|
||||
subtitle="Generate data for testing"
|
||||
onClick={() => {
|
||||
$randomDataModalState.show = true;
|
||||
$randomDataModalState.columns = true;
|
||||
$randomDataModalState.managed = false;
|
||||
}} />
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create document"
|
||||
subtitle="Manually add documents"
|
||||
onClick={() => {
|
||||
noSqlDocument.create(buildInitDoc());
|
||||
}} />
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
@@ -232,7 +359,6 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<ColumnDisplayNameInput
|
||||
inModal
|
||||
bind:this={columnDisplayNameInput}
|
||||
databaseType={data.database.type}
|
||||
collectionId={page.params.collection}
|
||||
@@ -248,7 +374,8 @@
|
||||
<svelte:fragment slot="footer">
|
||||
<Button size="s" secondary on:click={() => (showCustomColumnsModal = false)}>Cancel</Button>
|
||||
|
||||
<Button size="s" submit disabled={columnDisplayNameInput?.hasChanged()}>Update</Button>
|
||||
<Button size="s" submit submissionLoader disabled={columnDisplayNameInput?.hasChanged()}
|
||||
>Update</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
@@ -257,4 +384,17 @@
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
:global(.rotating) {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Dependencies, SPREADSHEET_PAGE_LIMIT } from '$lib/constants';
|
||||
import { getLimit, getPage, getQuery, getView, pageToOffset, View } from '$lib/helpers/load';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
import { queries, queryParamToMap } from '$lib/components/filters';
|
||||
import { buildGridQueries, extractSortFromQueries } from '$database/store';
|
||||
import { getCollectionService, type DatabaseType } from '$database/(entity)';
|
||||
|
||||
export const load: PageLoad = async ({ params, depends, url, route, parent }) => {
|
||||
const { collection } = await parent();
|
||||
const { collection, database } = await parent();
|
||||
depends(Dependencies.DOCUMENTS);
|
||||
|
||||
const page = getPage(url);
|
||||
@@ -20,6 +20,11 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) =>
|
||||
queries.set(parsedQueries);
|
||||
|
||||
const currentSort = extractSortFromQueries(parsedQueries);
|
||||
const collectionSdk = getCollectionService(
|
||||
params.region,
|
||||
params.project,
|
||||
database.type as DatabaseType
|
||||
);
|
||||
|
||||
return {
|
||||
offset,
|
||||
@@ -28,7 +33,7 @@ export const load: PageLoad = async ({ params, depends, url, route, parent }) =>
|
||||
query,
|
||||
currentSort,
|
||||
parsedQueries,
|
||||
documents: await sdk.forProject(params.region, params.project).documentsDB.listDocuments({
|
||||
documents: await collectionSdk.listDocuments({
|
||||
databaseId: params.database,
|
||||
collectionId: params.collection,
|
||||
queries: buildGridQueries(limit, offset, parsedQueries, collection)
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
import { PAGE_LIMIT } from '$lib/constants';
|
||||
import { Query } from '@appwrite.io/console';
|
||||
import { getLimit, getPage, pageToOffset } from '$lib/helpers/load';
|
||||
import { getCollectionService, type DatabaseType } from '$database/(entity)';
|
||||
|
||||
export const load: PageLoad = async ({ params, url, route }) => {
|
||||
export const load: PageLoad = async ({ params, url, route, parent }) => {
|
||||
const { database } = await parent();
|
||||
const page = getPage(url);
|
||||
const limit = getLimit(url, route, PAGE_LIMIT);
|
||||
const offset = pageToOffset(page, limit);
|
||||
|
||||
const collectionSdk = getCollectionService(
|
||||
params.region,
|
||||
params.project,
|
||||
database.type as DatabaseType
|
||||
);
|
||||
|
||||
return {
|
||||
offset,
|
||||
limit,
|
||||
logs: await sdk.forProject(params.region, params.project).documentsDB.listCollectionLogs({
|
||||
logs: await collectionSdk.listCollectionLogs({
|
||||
databaseId: params.database,
|
||||
collectionId: params.collection,
|
||||
queries: [Query.limit(limit), Query.offset(offset)]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Wizard } from '$lib/layout';
|
||||
import { Fieldset, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { Button, InputCheckbox, Form } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import { toLocalDateTimeISO } from '$lib/helpers/date';
|
||||
import { writable } from 'svelte/store';
|
||||
import { queries, type TagValue } from '$lib/components/filters/store';
|
||||
import { TagList } from '$lib/components/filters';
|
||||
|
||||
let showExitModal = $state(false);
|
||||
let formComponent: Form;
|
||||
let isSubmitting = $state(writable(false));
|
||||
|
||||
let localQueries = $state<Map<TagValue, string>>(new Map());
|
||||
const localTags = $derived(Array.from(localQueries.keys()));
|
||||
|
||||
const timestamp = toLocalDateTimeISO(Date.now())
|
||||
.replace(/[:.]/g, '-')
|
||||
.split('T')
|
||||
.join('_')
|
||||
.slice(0, -4);
|
||||
|
||||
const collectionName = page.params.collection;
|
||||
const filename = `${collectionName}_${timestamp}.json`;
|
||||
|
||||
let exportWithFilters = $state(false);
|
||||
|
||||
const collectionUrl = $derived.by(() => {
|
||||
const queryParam = page.url.searchParams.get('query');
|
||||
const url = resolve(
|
||||
'/(console)/project-[region]-[project]/databases/database-[database]/collection-[collection]',
|
||||
{
|
||||
region: page.params.region,
|
||||
project: page.params.project,
|
||||
database: page.params.database,
|
||||
collection: page.params.collection
|
||||
}
|
||||
);
|
||||
return queryParam ? `${url}?query=${encodeURIComponent(queryParam)}` : url;
|
||||
});
|
||||
|
||||
function removeLocalFilter(tag: TagValue) {
|
||||
localQueries.delete(tag);
|
||||
localQueries = new Map(localQueries);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
await (
|
||||
sdk.forProject(page.params.region, page.params.project).migrations as unknown as {
|
||||
createJSONExport: (params: {
|
||||
resourceId: string;
|
||||
filename: string;
|
||||
columns: string[];
|
||||
queries: string[];
|
||||
notify: boolean;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
).createJSONExport({
|
||||
resourceId: `${page.params.database}:${page.params.collection}`,
|
||||
filename: filename,
|
||||
columns: [],
|
||||
queries: exportWithFilters ? Array.from(localQueries.values()) : [],
|
||||
notify: true
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'JSON export has started. You will receive an email when it is ready.'
|
||||
});
|
||||
|
||||
trackEvent(Submit.DatabaseExportCsv);
|
||||
|
||||
await goto(collectionUrl);
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
|
||||
trackError(error, Submit.DatabaseExportCsv);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
localQueries = new Map($queries);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Wizard
|
||||
title="Export JSON"
|
||||
columnSize="s"
|
||||
href={collectionUrl}
|
||||
bind:showExitModal
|
||||
confirmExit
|
||||
column>
|
||||
<Form bind:this={formComponent} bind:isSubmitting onSubmit={handleExport}>
|
||||
<Layout.Stack gap="xxl">
|
||||
<Fieldset legend="Export options">
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Stack gap="m">
|
||||
<div class:disabled-checkbox={localTags.length === 0}>
|
||||
<InputCheckbox
|
||||
id="exportWithFilters"
|
||||
label="Export with filters"
|
||||
description="Export documents that match the current collection filters"
|
||||
disabled={localTags.length === 0}
|
||||
bind:checked={exportWithFilters} />
|
||||
</div>
|
||||
|
||||
{#if localTags.length > 0}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
gap="xs"
|
||||
alignItems="center"
|
||||
style="padding-left: 1.75rem;"
|
||||
wrap="wrap">
|
||||
<TagList
|
||||
tags={localTags}
|
||||
on:remove={(e) => {
|
||||
removeLocalFilter(e.detail);
|
||||
}} />
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Fieldset>
|
||||
</Layout.Stack>
|
||||
</Form>
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack justifyContent="flex-end" direction="row">
|
||||
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
fullWidthMobile
|
||||
on:click={() => formComponent.triggerSubmit()}
|
||||
disabled={$isSubmitting}>
|
||||
Export
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Wizard>
|
||||
|
||||
<style>
|
||||
.disabled-checkbox :global(*) {
|
||||
cursor: unset;
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { PageProps } from './$types';
|
||||
import {
|
||||
type CreateIndexesCallbackType,
|
||||
type DatabaseType,
|
||||
Indexes,
|
||||
EmptySheet,
|
||||
EmptySheetCards
|
||||
EmptySheetCards,
|
||||
useDatabaseSdk
|
||||
} from '$database/(entity)';
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const params = $derived({
|
||||
databaseId: page.params.database,
|
||||
collectionId: page.params.collection
|
||||
});
|
||||
|
||||
const documentsDB = $derived(
|
||||
sdk.forProject(page.params.region, page.params.project).documentsDB
|
||||
const databaseSdk = useDatabaseSdk(
|
||||
page.params.region,
|
||||
page.params.project,
|
||||
data.database.type as DatabaseType
|
||||
);
|
||||
|
||||
async function onCreateIndex(index: CreateIndexesCallbackType) {
|
||||
await documentsDB.createIndex({
|
||||
...params,
|
||||
await databaseSdk.createIndex({
|
||||
databaseId: page.params.database,
|
||||
entityId: page.params.collection,
|
||||
key: index.key,
|
||||
type: index.type,
|
||||
attributes: index.fields,
|
||||
@@ -36,8 +34,9 @@
|
||||
async function onDeleteIndexes(selectedKeys: string[]) {
|
||||
await Promise.all(
|
||||
selectedKeys.map((key) =>
|
||||
documentsDB.deleteIndex({
|
||||
...params,
|
||||
databaseSdk.deleteIndex({
|
||||
databaseId: page.params.database,
|
||||
entityId: page.params.collection,
|
||||
key
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Container } from '$lib/layout';
|
||||
import {
|
||||
DangerZone,
|
||||
UpdateName,
|
||||
UpdatePermissions,
|
||||
UpdateSecurity,
|
||||
UpdateStatus
|
||||
UpdateStatus,
|
||||
useDatabaseSdk,
|
||||
type DatabaseType
|
||||
} from '$database/(entity)';
|
||||
import type { PageProps } from './$types';
|
||||
import DisplayName from './displayName.svelte';
|
||||
@@ -17,18 +18,19 @@
|
||||
|
||||
const collection = $derived(data.collection);
|
||||
|
||||
const params = $derived.by(() => {
|
||||
return {
|
||||
name: collection.name,
|
||||
collectionId: page.params.collection,
|
||||
databaseId: page.params.database
|
||||
};
|
||||
const databaseSdk = useDatabaseSdk(
|
||||
page.params.region,
|
||||
page.params.project,
|
||||
data.database.type as DatabaseType
|
||||
);
|
||||
|
||||
const entityParams = $derived({
|
||||
databaseId: page.params.database,
|
||||
entityId: page.params.collection
|
||||
});
|
||||
|
||||
async function deleteCollection() {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.deleteCollection({ ...params });
|
||||
await databaseSdk.deleteEntity(entityParams);
|
||||
}
|
||||
|
||||
async function updateCollection(
|
||||
@@ -39,9 +41,11 @@
|
||||
documentSecurity: boolean;
|
||||
}>
|
||||
) {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.updateCollection({ ...params, ...updates });
|
||||
await databaseSdk.updateEntity({
|
||||
...entityParams,
|
||||
name: collection.name,
|
||||
...updates
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { Column } from '$lib/helpers/types';
|
||||
import {
|
||||
@@ -39,6 +39,7 @@
|
||||
import { mapToQueryParams } from '$lib/components/filters/store';
|
||||
import { expandTabs, buildWildcardEntitiesQuery } from '$database/store';
|
||||
import { setupUnsavedChangesGuard } from '$lib/helpers/unsavedChanges';
|
||||
import { mockSuggestions } from '$database/(suggestions)';
|
||||
import {
|
||||
collectionColumns,
|
||||
documentActivitySheet,
|
||||
@@ -46,7 +47,8 @@
|
||||
noSqlDocument,
|
||||
paginatedDocuments,
|
||||
paginatedDocumentsLoading,
|
||||
sortState
|
||||
sortState,
|
||||
showCreateIndexSheet
|
||||
} from '$database/collection-[collection]/store';
|
||||
import {
|
||||
type SortState,
|
||||
@@ -58,6 +60,7 @@
|
||||
type JsonValue,
|
||||
NoSqlEditor
|
||||
} from '$database/collection-[collection]/(components)/editor';
|
||||
import EmbeddingModal from '$database/collection-[collection]/(components)/editor/embeddingModal.svelte';
|
||||
import { buildFieldUrl } from '$database/(entity)/helpers/navigation';
|
||||
import {
|
||||
SpreadsheetOptions,
|
||||
@@ -73,13 +76,15 @@
|
||||
$: if ($documents) {
|
||||
paginatedDocuments.clear();
|
||||
|
||||
const docs = $documents.documents;
|
||||
|
||||
// If we have a new document, add it at the start
|
||||
if ($noSqlDocument.isDirty && $noSqlDocument.isNew) {
|
||||
const tempDoc = $noSqlDocument.document as Models.DefaultDocument;
|
||||
const docsWithTemp = [tempDoc, ...$documents.documents];
|
||||
const docsWithTemp = [tempDoc, ...docs];
|
||||
paginatedDocuments.setPage(1, docsWithTemp);
|
||||
} else {
|
||||
paginatedDocuments.setPage(1, $documents.documents);
|
||||
paginatedDocuments.setPage(1, docs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +96,25 @@
|
||||
data.database.type as DatabaseType
|
||||
);
|
||||
|
||||
const isVectorsDb = data.database.type === 'vectorsdb';
|
||||
let showEmbeddingModal = false;
|
||||
let editorRef: { replaceData: (data: JsonValue) => void } | undefined;
|
||||
|
||||
const projectSdk = sdk.forProject(page.params.region, page.params.project);
|
||||
const listDocumentsFn = isVectorsDb
|
||||
? projectSdk.vectorsDB.listDocuments.bind(projectSdk.vectorsDB)
|
||||
: projectSdk.documentsDB.listDocuments.bind(projectSdk.documentsDB);
|
||||
const getDocumentFn = isVectorsDb
|
||||
? projectSdk.vectorsDB.getDocument.bind(projectSdk.vectorsDB)
|
||||
: projectSdk.documentsDB.getDocument.bind(projectSdk.documentsDB);
|
||||
|
||||
function handleEmbeddingGenerated(embeddings: number[]) {
|
||||
if ($noSqlDocument.document && typeof $noSqlDocument.document === 'object') {
|
||||
const updated = { ...$noSqlDocument.document, embeddings };
|
||||
editorRef?.replaceData(updated);
|
||||
}
|
||||
}
|
||||
|
||||
const emptyCellsLimit = $spreadsheetLoading
|
||||
? 30
|
||||
: $isSmallViewport
|
||||
@@ -108,19 +132,20 @@
|
||||
let showDelete = false;
|
||||
let selectedDocumentForDelete: Models.Document['$id'] | null = null;
|
||||
|
||||
let showUnsavedChangesModal = false;
|
||||
let confirmNavigation: (() => void | Promise<void>) | null = null;
|
||||
|
||||
async function loadRemoteDocument() {
|
||||
try {
|
||||
noSqlDocument.update({ show: true, loading: true });
|
||||
const documentId = $noSqlDocument.documentId;
|
||||
noSqlDocument.update({ documentId: null }); // reset for later!
|
||||
|
||||
const loadedDocument = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.getDocument({
|
||||
databaseId: page.params.database,
|
||||
collectionId: page.params.collection,
|
||||
documentId
|
||||
});
|
||||
const loadedDocument = await getDocumentFn({
|
||||
databaseId: page.params.database,
|
||||
collectionId: page.params.collection,
|
||||
documentId
|
||||
});
|
||||
|
||||
if (loadedDocument) {
|
||||
noSqlDocument.edit(loadedDocument);
|
||||
@@ -278,7 +303,6 @@
|
||||
|
||||
async function handleDelete() {
|
||||
showDelete = false;
|
||||
let hadErrors = false;
|
||||
|
||||
try {
|
||||
if (selectedDocumentForDelete) {
|
||||
@@ -302,13 +326,10 @@
|
||||
await invalidate(Dependencies.DOCUMENTS);
|
||||
trackEvent(Click.DatabaseRowDelete);
|
||||
|
||||
if (!hadErrors) {
|
||||
// error is already shown above!
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedDocuments.length ? selectedDocuments.length : 1} document${selectedDocuments.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
}
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedDocuments.length ? selectedDocuments.length : 1} document${selectedDocuments.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
|
||||
spreadsheetRenderKey.set(
|
||||
hash([
|
||||
@@ -329,8 +350,29 @@
|
||||
|
||||
async function onSelectSheetOption(
|
||||
action: HeaderCellAction | RowCellAction,
|
||||
document: Models.Document | null = null
|
||||
document: Models.Document | null = null,
|
||||
columnId: string | null = null
|
||||
) {
|
||||
// Header actions
|
||||
if (action === 'create-index') {
|
||||
$showCreateIndexSheet.show = true;
|
||||
$showCreateIndexSheet.column = columnId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'sort-asc') {
|
||||
sortState.set({ column: columnId, direction: 'asc' });
|
||||
await sort(Query.orderAsc(columnId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'sort-desc') {
|
||||
sortState.set({ column: columnId, direction: 'desc' });
|
||||
await sort(Query.orderDesc(columnId));
|
||||
return;
|
||||
}
|
||||
|
||||
// Row actions
|
||||
if (action === 'update') {
|
||||
noSqlDocument.set({
|
||||
document: document,
|
||||
@@ -444,7 +486,9 @@
|
||||
spreadsheetRenderKey.set(hash(Date.now().toString()));
|
||||
const firstDocument = $documents?.documents?.[0];
|
||||
if (firstDocument) {
|
||||
noSqlDocument.update({ document: firstDocument });
|
||||
noSqlDocument.update({
|
||||
document: firstDocument
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
@@ -474,19 +518,17 @@
|
||||
const filterQueries = parsedQueries.size ? data.parsedQueries.values() : [];
|
||||
|
||||
$paginatedDocumentsLoading = true;
|
||||
const loadedRows = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.listDocuments({
|
||||
databaseId,
|
||||
collectionId,
|
||||
queries: [
|
||||
getCorrectOrderQuery(),
|
||||
Query.limit(SPREADSHEET_PAGE_LIMIT),
|
||||
Query.offset(pageToOffset(pageNumber, SPREADSHEET_PAGE_LIMIT)),
|
||||
...filterQueries /* filter queries */,
|
||||
...buildWildcardEntitiesQuery(collection)
|
||||
]
|
||||
});
|
||||
const loadedRows = await listDocumentsFn({
|
||||
databaseId,
|
||||
collectionId,
|
||||
queries: [
|
||||
getCorrectOrderQuery(),
|
||||
Query.limit(SPREADSHEET_PAGE_LIMIT),
|
||||
Query.offset(pageToOffset(pageNumber, SPREADSHEET_PAGE_LIMIT)),
|
||||
...filterQueries /* filter queries */,
|
||||
...buildWildcardEntitiesQuery(collection)
|
||||
]
|
||||
});
|
||||
|
||||
paginatedDocuments.setPage(pageNumber, loadedRows.documents);
|
||||
$paginatedDocumentsLoading = false;
|
||||
@@ -502,18 +544,16 @@
|
||||
paginatedDocuments.setMaxPage(targetPageNum);
|
||||
$paginatedDocumentsLoading = true;
|
||||
|
||||
const loadedRows = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.documentsDB.listDocuments({
|
||||
databaseId,
|
||||
collectionId,
|
||||
queries: [
|
||||
getCorrectOrderQuery(),
|
||||
Query.limit(SPREADSHEET_PAGE_LIMIT),
|
||||
Query.offset(pageToOffset(targetPageNum, SPREADSHEET_PAGE_LIMIT)),
|
||||
...buildWildcardEntitiesQuery(collection)
|
||||
]
|
||||
});
|
||||
const loadedRows = await listDocumentsFn({
|
||||
databaseId,
|
||||
collectionId,
|
||||
queries: [
|
||||
getCorrectOrderQuery(),
|
||||
Query.limit(SPREADSHEET_PAGE_LIMIT),
|
||||
Query.offset(pageToOffset(targetPageNum, SPREADSHEET_PAGE_LIMIT)),
|
||||
...buildWildcardEntitiesQuery(collection)
|
||||
]
|
||||
});
|
||||
|
||||
paginatedDocuments.setPage(targetPageNum, loadedRows.documents);
|
||||
$paginatedDocumentsLoading = false;
|
||||
@@ -532,24 +572,81 @@
|
||||
$: rowSelection =
|
||||
!$spreadsheetLoading && !$paginatedDocumentsLoading ? true : ('disabled' as const);
|
||||
|
||||
const MIN_DOCS_FOR_FUZZY_SUGGESTIONS = 5;
|
||||
|
||||
$: useMockSuggestions =
|
||||
!isVectorsDb &&
|
||||
$noSqlDocument.isNew &&
|
||||
($documents?.documents?.length ?? 0) < MIN_DOCS_FOR_FUZZY_SUGGESTIONS;
|
||||
|
||||
$: metadataKeys =
|
||||
isVectorsDb && $documents?.documents
|
||||
? (fuzzySearchKeys(
|
||||
$documents.documents.map((d) => d.metadata ?? {}),
|
||||
{ minOccurrences: 2 }
|
||||
) ?? [])
|
||||
: [];
|
||||
|
||||
$: vectorsDbMetadataDefaults = isVectorsDb
|
||||
? Object.fromEntries(metadataKeys.map((key) => [key, '']))
|
||||
: {};
|
||||
|
||||
$: suggestedAttributes =
|
||||
$noSqlDocument.isNew && $documents?.documents
|
||||
? (fuzzySearchKeys($documents.documents, { minOccurrences: 2 }) ?? [])
|
||||
? isVectorsDb
|
||||
? ['metadata', 'embeddings']
|
||||
: useMockSuggestions
|
||||
? mockSuggestions.columns.map((column) => column.name)
|
||||
: (fuzzySearchKeys($documents.documents, { minOccurrences: 2 }) ?? [])
|
||||
: [];
|
||||
|
||||
$: showSuggestions = $noSqlDocument.isNew && suggestedAttributes.length > 0;
|
||||
|
||||
const unsubscribeRenderKey = spreadsheetRenderKey.subscribe(() => {
|
||||
const firstDocument = $documents?.documents[0];
|
||||
const currentOpenDocument = $noSqlDocument.document;
|
||||
const isNewOrDirty = $noSqlDocument.isNew || $noSqlDocument.isDirty;
|
||||
|
||||
// check if current document still exists in the list
|
||||
const currentDocStillExists = currentOpenDocument?.$id
|
||||
? $documents?.documents.some((doc) => doc.$id === currentOpenDocument.$id)
|
||||
: false;
|
||||
|
||||
/* Only reset to first document if current doc was deleted or no doc is open */
|
||||
if (!isNewOrDirty && !currentDocStillExists && firstDocument) {
|
||||
noSqlDocument.update({ document: firstDocument });
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeRenderKey();
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = () =>
|
||||
Boolean(
|
||||
$noSqlDocument?.hasDataChanged || ($noSqlDocument?.isNew && $noSqlDocument?.isDirty)
|
||||
);
|
||||
|
||||
const requestDiscardChanges = (onConfirm: () => void | Promise<void>) => {
|
||||
if (!hasUnsavedChanges()) {
|
||||
void onConfirm();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmNavigation = onConfirm;
|
||||
showUnsavedChangesModal = true;
|
||||
};
|
||||
|
||||
const { beforeUnload } = setupUnsavedChangesGuard({
|
||||
hasUnsavedChanges,
|
||||
onConfirmNavigate: () => noSqlDocument.reset({ show: false }),
|
||||
shouldBlockNavigation: (navigation) => {
|
||||
const nextPath = navigation.to?.url?.pathname;
|
||||
return Boolean(nextPath && nextPath !== page.url.pathname);
|
||||
},
|
||||
onShowConfirmModal: (_, onConfirm) => {
|
||||
confirmNavigation = onConfirm;
|
||||
showUnsavedChangesModal = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -562,7 +659,7 @@
|
||||
sideSheetOptions={{
|
||||
sideSheetTitle: $noSqlDocument.document?.$id,
|
||||
submit: {
|
||||
text: 'Update',
|
||||
text: $noSqlDocument.documentId ? 'Update' : 'Save',
|
||||
disabled: !$noSqlDocument.hasDataChanged,
|
||||
onClick: async () => {
|
||||
await createOrUpdateDocument($noSqlDocument.document);
|
||||
@@ -603,25 +700,42 @@
|
||||
}}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{#each $collectionColumns as column (column.id)}
|
||||
<Spreadsheet.Header.Cell
|
||||
{root}
|
||||
column={column.id}
|
||||
icon={column.icon ?? undefined}>
|
||||
{#if !column.isAction}
|
||||
<Layout.Stack
|
||||
gap="xs"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
style="min-width: 0;">
|
||||
<Typography.Text truncate>
|
||||
{column.title}
|
||||
</Typography.Text>
|
||||
{#if column.isAction}
|
||||
<Spreadsheet.Header.Cell
|
||||
{root}
|
||||
column={column.id}
|
||||
icon={column.icon ?? undefined} />
|
||||
{:else}
|
||||
<SpreadsheetOptions
|
||||
type="header"
|
||||
columnId={column.id}
|
||||
onSelect={(option, columnId) =>
|
||||
onSelectSheetOption(option, null, columnId)}>
|
||||
{#snippet children(toggle)}
|
||||
<Spreadsheet.Header.Cell
|
||||
{root}
|
||||
column={column.id}
|
||||
icon={column.icon ?? undefined}
|
||||
on:contextmenu={toggle}>
|
||||
<Layout.Stack
|
||||
gap="xs"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
style="min-width: 0;">
|
||||
<Typography.Text truncate>
|
||||
{column.title}
|
||||
</Typography.Text>
|
||||
|
||||
<SortButton onSort={sort} column={column.id} state={sortState} />
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Spreadsheet.Header.Cell>
|
||||
<SortButton
|
||||
onSort={sort}
|
||||
column={column.id}
|
||||
state={sortState} />
|
||||
</Layout.Stack>
|
||||
</Spreadsheet.Header.Cell>
|
||||
{/snippet}
|
||||
</SpreadsheetOptions>
|
||||
{/if}
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -654,7 +768,8 @@
|
||||
<button
|
||||
onclick={() => {
|
||||
if (isUnsavedRow) return;
|
||||
noSqlDocument.edit(document);
|
||||
if (document.$id === $noSqlDocument.document?.$id) return;
|
||||
requestDiscardChanges(() => noSqlDocument.edit(document));
|
||||
}}
|
||||
style:cursor={isUnsavedRow ? 'default' : 'pointer'}>
|
||||
<Spreadsheet.Row.Base
|
||||
@@ -773,6 +888,8 @@
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
$randomDataModalState.show = true;
|
||||
$randomDataModalState.columns = true;
|
||||
$randomDataModalState.managed = false;
|
||||
}}>Generate sample data</Button.Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -783,6 +900,7 @@
|
||||
|
||||
{#snippet noSqlEditor()}
|
||||
<NoSqlEditor
|
||||
bind:this={editorRef}
|
||||
ctrlSave
|
||||
isNew={$noSqlDocument.isNew}
|
||||
loading={$noSqlDocument.loading}
|
||||
@@ -791,7 +909,14 @@
|
||||
showHeaderActions={!$isSmallViewport}
|
||||
{showSuggestions}
|
||||
{suggestedAttributes}
|
||||
onCancel={() => {
|
||||
showMockSuggestions={useMockSuggestions}
|
||||
suggestedDefaults={isVectorsDb
|
||||
? {
|
||||
metadata: vectorsDbMetadataDefaults,
|
||||
embeddings: []
|
||||
}
|
||||
: undefined}
|
||||
onDiscard={() => {
|
||||
const firstDocument = $documents?.documents?.[0];
|
||||
if (firstDocument) {
|
||||
noSqlDocument.edit(firstDocument);
|
||||
@@ -800,7 +925,8 @@
|
||||
}
|
||||
}}
|
||||
onSave={async (document) => await createOrUpdateDocument(document)}
|
||||
onChange={(_, hasDataChanged) => noSqlDocument.update({ hasDataChanged })} />
|
||||
onChange={(_, hasDataChanged) => noSqlDocument.update({ hasDataChanged })}
|
||||
onGenerateEmbedding={isVectorsDb ? () => (showEmbeddingModal = true) : undefined} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet sideSheetHeaderAction()}
|
||||
@@ -845,24 +971,64 @@
|
||||
{/if}
|
||||
</SpreadsheetContainer>
|
||||
|
||||
<Confirm
|
||||
confirmDeletion
|
||||
bind:open={showDelete}
|
||||
onSubmit={handleDelete}
|
||||
title={selectedDocuments.length === 1 ? 'Delete document' : 'Delete documents'}>
|
||||
{@const isSingle = selectedDocumentForDelete !== null}
|
||||
{#if showDelete}
|
||||
<Confirm
|
||||
confirmDeletion
|
||||
bind:open={showDelete}
|
||||
onSubmit={handleDelete}
|
||||
title={selectedDocuments.length === 1 ? 'Delete document' : 'Delete documents'}>
|
||||
{@const isSingle = selectedDocumentForDelete !== null}
|
||||
|
||||
<p>
|
||||
{#if isSingle}
|
||||
Are you sure you want to delete this document from <b>{collection.name}</b>?
|
||||
{:else}
|
||||
Are you sure you want to delete <b>{selectedDocuments.length}</b>
|
||||
{selectedDocuments.length > 1 ? 'documents' : 'document'} from <b>{collection.name}</b>?
|
||||
{/if}
|
||||
</p>
|
||||
<p>
|
||||
{#if isSingle}
|
||||
Are you sure you want to delete this document from <b>{collection.name}</b>?
|
||||
{:else}
|
||||
Are you sure you want to delete <b>{selectedDocuments.length}</b>
|
||||
{selectedDocuments.length > 1 ? 'documents' : 'document'} from
|
||||
<b>{collection.name}</b>?
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<p class="u-bold">This action is irreversible.</p>
|
||||
</Confirm>
|
||||
<p class="u-bold">This action is irreversible.</p>
|
||||
</Confirm>
|
||||
{/if}
|
||||
|
||||
{#if showUnsavedChangesModal}
|
||||
<Confirm
|
||||
bind:open={showUnsavedChangesModal}
|
||||
title="Unsaved changes"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
confirmNavigation?.();
|
||||
showUnsavedChangesModal = false;
|
||||
}}>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button.Button
|
||||
size="s"
|
||||
variant="text"
|
||||
on:click={() => {
|
||||
confirmNavigation = null;
|
||||
showUnsavedChangesModal = false;
|
||||
}}>
|
||||
Keep editing
|
||||
</Button.Button>
|
||||
|
||||
<Button.Button
|
||||
size="s"
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
confirmNavigation?.();
|
||||
showUnsavedChangesModal = false;
|
||||
}}>Discard changes</Button.Button>
|
||||
</svelte:fragment>
|
||||
|
||||
<p>You have changes that haven't been saved.</p>
|
||||
</Confirm>
|
||||
{/if}
|
||||
|
||||
{#if isVectorsDb}
|
||||
<EmbeddingModal bind:show={showEmbeddingModal} onGenerate={handleEmbeddingGenerated} />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.floating-action-bar {
|
||||
@@ -879,6 +1045,10 @@
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
:global(.virtual-row.hover .select-checkbox) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
:global(.floating-editor) {
|
||||
z-index: 3 !important;
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ const createNoSqlDocumentStore = () => {
|
||||
loading: false,
|
||||
documentId: null,
|
||||
hasDataChanged: false,
|
||||
isDirty: false
|
||||
// isSaving: false
|
||||
isDirty: false,
|
||||
isSaving: false
|
||||
}),
|
||||
create: (document: Models.Document | (object & { $id?: string })) =>
|
||||
set({
|
||||
@@ -99,3 +99,11 @@ export const documentActivitySheet = writable({
|
||||
show: false,
|
||||
document: null as Models.Document
|
||||
});
|
||||
|
||||
export const showCreateIndexSheet = writable<{
|
||||
show: boolean;
|
||||
column?: string;
|
||||
}>({
|
||||
show: false,
|
||||
column: null
|
||||
});
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { isValueOfStringEnum } from '$lib/helpers/types';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { UsageRange } from '@appwrite.io/console';
|
||||
import type { PageLoad } from './$types';
|
||||
import { getCollectionService, type DatabaseType } from '$database/(entity)';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
export const load: PageLoad = async ({ params, parent }) => {
|
||||
const { database } = await parent();
|
||||
const period = isValueOfStringEnum(UsageRange, params.period)
|
||||
? params.period
|
||||
: UsageRange.ThirtyDays;
|
||||
|
||||
return sdk.forProject(params.region, params.project).documentsDB.getCollectionUsage({
|
||||
databaseId: params.database,
|
||||
collectionId: params.collection,
|
||||
range: period
|
||||
});
|
||||
const collectionSdk = getCollectionService(
|
||||
params.region,
|
||||
params.project,
|
||||
database.type as DatabaseType
|
||||
);
|
||||
|
||||
return {
|
||||
...(await collectionSdk.getCollectionUsage({
|
||||
databaseId: params.database,
|
||||
collectionId: params.collection,
|
||||
range: period
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
</div>
|
||||
<div class="reference-item">
|
||||
<Typography.Text variant="m-500">Port</Typography.Text>
|
||||
<Typography.Text variant="m-400">{database.connectionPort || '-'}</Typography.Text>
|
||||
<Typography.Text variant="m-400"
|
||||
>{database.connectionPort || '-'}</Typography.Text>
|
||||
</div>
|
||||
<div class="reference-item">
|
||||
<Typography.Text variant="m-500">Database</Typography.Text>
|
||||
@@ -123,7 +124,8 @@
|
||||
</div>
|
||||
<div class="reference-item">
|
||||
<Typography.Text variant="m-500">Username</Typography.Text>
|
||||
<Typography.Text variant="m-400">{database.connectionUser || '-'}</Typography.Text>
|
||||
<Typography.Text variant="m-400"
|
||||
>{database.connectionUser || '-'}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Status as DatabaseStatus, type Models } from '@appwrite.io/console';
|
||||
import {
|
||||
Badge,
|
||||
Layout,
|
||||
@@ -69,8 +69,8 @@
|
||||
let isSpinningDown = $state(false);
|
||||
let connectionTab = $state<'direct' | 'string'>('direct');
|
||||
|
||||
const isDedicated = $derived(database.type === 'dedicated');
|
||||
const isShared = $derived(database.type === 'shared');
|
||||
const isDedicated = $derived(database.type === 'dedicateddb');
|
||||
const isShared = $derived(false);
|
||||
const isActive = $derived(database.status === 'ready' || database.status === 'active');
|
||||
const isPaused = $derived(database.status === 'paused');
|
||||
const containerIsRunning = $derived(
|
||||
@@ -208,9 +208,10 @@
|
||||
async function pauseDatabase() {
|
||||
isPausing = true;
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.updateDatabase({ databaseId: database.$id, status: 'paused' as any });
|
||||
await sdk.forProject(page.params.region, page.params.project).compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
status: 'paused' as unknown as DatabaseStatus
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -231,9 +232,10 @@
|
||||
async function resumeDatabase() {
|
||||
isResuming = true;
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.updateDatabase({ databaseId: database.$id, status: 'active' as any });
|
||||
await sdk.forProject(page.params.region, page.params.project).compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
status: DatabaseStatus.Active
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -254,9 +256,10 @@
|
||||
async function spinDownDatabase() {
|
||||
isSpinningDown = true;
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.updateDatabase({ databaseId: database.$id, status: 'inactive' as any });
|
||||
await sdk.forProject(page.params.region, page.params.project).compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
status: 'inactive' as unknown as DatabaseStatus
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
@@ -275,13 +278,9 @@
|
||||
}
|
||||
|
||||
// Check if connection details are available
|
||||
const hasConnectionDetails = $derived(
|
||||
!!database.hostname || !!database.connectionString
|
||||
);
|
||||
const hasConnectionDetails = $derived(!!database.hostname || !!database.connectionString);
|
||||
|
||||
const hasCredentials = $derived(
|
||||
!!database.connectionUser && !!database.connectionPassword
|
||||
);
|
||||
const hasCredentials = $derived(!!database.connectionUser && !!database.connectionPassword);
|
||||
|
||||
// Build a connection string from parts when one is not provided by the API
|
||||
const resolvedConnectionString = $derived.by(() => {
|
||||
@@ -486,9 +485,7 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<Layout.Stack gap="m">
|
||||
<CopyInput
|
||||
label="Connection String"
|
||||
value={resolvedConnectionString} />
|
||||
<CopyInput label="Connection String" value={resolvedConnectionString} />
|
||||
<Layout.Stack gap="xs">
|
||||
<Typography.Caption
|
||||
variant="400"
|
||||
@@ -538,11 +535,11 @@
|
||||
{/if}
|
||||
|
||||
<!-- Free Tier Limits (shared databases only) -->
|
||||
{#if database.type === 'shared'}
|
||||
{#if isShared}
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Free Tier Limits</svelte:fragment>
|
||||
Your shared database runs within the free tier. Resources are constrained to the
|
||||
limits below. Upgrade to a dedicated database for higher limits.
|
||||
Your shared database runs within the free tier. Resources are constrained to the limits below.
|
||||
Upgrade to a dedicated database for higher limits.
|
||||
<svelte:fragment slot="aside">
|
||||
<Layout.Grid columns={2} columnsS={1} gap="l">
|
||||
<Layout.Stack gap="xxs">
|
||||
@@ -590,7 +587,8 @@
|
||||
Engine
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{getEngineDisplayName(database.engine)} {database.version}
|
||||
{getEngineDisplayName(database.engine)}
|
||||
{database.version}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
@@ -642,101 +640,99 @@
|
||||
</CardGrid>
|
||||
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">High Availability</svelte:fragment>
|
||||
Configure replicas and failover settings for your database.
|
||||
<svelte:fragment slot="aside">
|
||||
<svelte:fragment slot="title">High Availability</svelte:fragment>
|
||||
Configure replicas and failover settings for your database.
|
||||
<svelte:fragment slot="aside">
|
||||
<Layout.Grid columns={3} columnsL={2} columnsS={1} gap="l">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Status
|
||||
</Typography.Caption>
|
||||
<Badge
|
||||
type={database.highAvailability ? 'success' : undefined}
|
||||
variant="secondary"
|
||||
size="s"
|
||||
content={database.highAvailability ? 'Enabled' : 'Disabled'} />
|
||||
</Layout.Stack>
|
||||
{#if database.highAvailability}
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Replicas
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.haReplicaCount}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{#if database.haSyncMode}
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Sync Mode
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{capitalizeFirst(database.haSyncMode)}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/if}
|
||||
</Layout.Grid>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Network</svelte:fragment>
|
||||
Connection limits and network configuration.
|
||||
<svelte:fragment slot="aside">
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Grid columns={3} columnsL={2} columnsS={1} gap="l">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Status
|
||||
Max Connections
|
||||
</Typography.Caption>
|
||||
<Badge
|
||||
type={database.highAvailability ? 'success' : undefined}
|
||||
variant="secondary"
|
||||
size="s"
|
||||
content={database.highAvailability ? 'Enabled' : 'Disabled'} />
|
||||
</Layout.Stack>
|
||||
{#if database.highAvailability}
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Replicas
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.haReplicaCount}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{#if database.haSyncMode}
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Text variant="m-500">
|
||||
{database.networkMaxConnections}{#if tierMaxConnections}
|
||||
<Typography.Caption
|
||||
variant="400"
|
||||
color="--fgcolor-neutral-tertiary">
|
||||
Sync Mode
|
||||
/ {tierMaxConnections.toLocaleString()} (tier limit)
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{capitalizeFirst(database.haSyncMode)}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Connection Timeout
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.networkIdleTimeoutSeconds}s
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{#if database.idleTimeoutMinutes}
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Scale-to-Zero After
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.idleTimeoutMinutes} min
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Grid>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Network</svelte:fragment>
|
||||
Connection limits and network configuration.
|
||||
<svelte:fragment slot="aside">
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Grid columns={3} columnsL={2} columnsS={1} gap="l">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Max Connections
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.networkMaxConnections}{#if tierMaxConnections}
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
/ {tierMaxConnections.toLocaleString()} (tier limit)
|
||||
</Typography.Caption>
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
{#if database.networkIPAllowlist?.length > 0}
|
||||
<Layout.Stack gap="xs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
IP Allowlist
|
||||
</Typography.Caption>
|
||||
<Layout.Stack direction="row" gap="xs" wrap="wrap">
|
||||
{#each database.networkIPAllowlist as ip}
|
||||
<Badge variant="secondary" size="s" content={ip} />
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
Connection Timeout
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.networkIdleTimeoutSeconds}s
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{#if database.idleTimeoutMinutes}
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Caption
|
||||
variant="400"
|
||||
color="--fgcolor-neutral-tertiary">
|
||||
Scale-to-Zero After
|
||||
</Typography.Caption>
|
||||
<Typography.Text variant="m-500">
|
||||
{database.idleTimeoutMinutes} min
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Grid>
|
||||
|
||||
{#if database.networkIPAllowlist?.length > 0}
|
||||
<Layout.Stack gap="xs">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
IP Allowlist
|
||||
</Typography.Caption>
|
||||
<Layout.Stack direction="row" gap="xs" wrap="wrap">
|
||||
{#each database.networkIPAllowlist as ip}
|
||||
<Badge variant="secondary" size="s" content={ip} />
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
|
||||
<!-- Backups -->
|
||||
<CardGrid>
|
||||
@@ -766,7 +762,9 @@
|
||||
size="s"
|
||||
content={database.backupPitr ? 'Enabled' : 'Disabled'} />
|
||||
{#if database.backupPitr && database.pitrRetentionDays}
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
<Typography.Caption
|
||||
variant="400"
|
||||
color="--fgcolor-neutral-tertiary">
|
||||
({database.pitrRetentionDays} day window)
|
||||
</Typography.Caption>
|
||||
{/if}
|
||||
@@ -923,7 +921,10 @@
|
||||
</Typography.Caption>
|
||||
<Layout.Stack direction="row" gap="xs" wrap="wrap">
|
||||
{#each database.sqlApiAllowedStatements as statement}
|
||||
<Badge variant="secondary" size="s" content={statement.toUpperCase()} />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
size="s"
|
||||
content={statement.toUpperCase()} />
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -21,10 +21,7 @@
|
||||
);
|
||||
|
||||
// Check if this is a dedicated database type
|
||||
const isDedicatedType = $derived(
|
||||
(database?.type as DatabaseType) === 'dedicated' ||
|
||||
(database?.type as DatabaseType) === 'shared'
|
||||
);
|
||||
const isDedicatedType = $derived((database?.type as DatabaseType) === 'dedicateddb');
|
||||
|
||||
const tabs = $derived(
|
||||
[
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { calculateSize } from '$lib/helpers/sizeConvertion';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { Period, type Models } from '@appwrite.io/console';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -30,13 +30,24 @@
|
||||
|
||||
const database = $derived(data.dedicatedDatabase as ExtendedDedicatedDatabase);
|
||||
|
||||
const computeSdk = $derived(
|
||||
sdk.forProject(page.params.region, page.params.project).compute
|
||||
);
|
||||
const computeSdk = $derived(sdk.forProject(page.params.region, page.params.project).compute);
|
||||
|
||||
type Connection = {
|
||||
pid: number;
|
||||
user: string;
|
||||
database: string;
|
||||
state: string;
|
||||
query: string;
|
||||
connectedAt: string;
|
||||
waitEvent: string;
|
||||
};
|
||||
|
||||
let metricsPeriod = $state<'1h' | '24h' | '7d' | '30d'>('24h');
|
||||
let metrics = $state<Models.DedicatedDatabaseMetrics | null>(null);
|
||||
let activeConnections = $state<{ total: number; activeConnections: unknown[] }>({ total: 0, activeConnections: [] });
|
||||
let activeConnections = $state<{ total: number; activeConnections: Connection[] }>({
|
||||
total: 0,
|
||||
activeConnections: []
|
||||
});
|
||||
let slowQueries = $state<Models.DedicatedDatabaseSlowQueryList>({ total: 0, slowQueries: [] });
|
||||
let performanceInsights = $state<Models.DedicatedDatabasePerformanceInsights | null>(null);
|
||||
let auditLogs = $state<Models.DedicatedDatabaseAuditLogList>({ total: 0, auditLogs: [] });
|
||||
@@ -51,7 +62,6 @@
|
||||
'metrics' | 'connections' | 'slowQueries' | 'insights' | 'auditLogs'
|
||||
>('metrics');
|
||||
|
||||
// -- Column definitions --
|
||||
const connectionsColumns = [
|
||||
{ id: 'pid', width: { min: 80 } },
|
||||
{ id: 'user', width: { min: 100 } },
|
||||
@@ -94,12 +104,14 @@
|
||||
{ id: 'client', width: { min: 120 } }
|
||||
];
|
||||
|
||||
// -- Data loading --
|
||||
async function loadMetrics() {
|
||||
if (!database) return;
|
||||
isLoadingMetrics = true;
|
||||
try {
|
||||
metrics = await computeSdk.getDatabaseMetrics({ databaseId: database.$id, period: metricsPeriod as any });
|
||||
metrics = await computeSdk.getDatabaseMetrics({
|
||||
databaseId: database.$id,
|
||||
period: metricsPeriod as Period
|
||||
});
|
||||
} catch (error) {
|
||||
metrics = null;
|
||||
addNotification({
|
||||
@@ -140,7 +152,9 @@
|
||||
if (!database) return;
|
||||
isLoadingInsights = true;
|
||||
try {
|
||||
performanceInsights = await computeSdk.getDatabaseInsights({ databaseId: database.$id });
|
||||
performanceInsights = await computeSdk.getDatabaseInsights({
|
||||
databaseId: database.$id
|
||||
});
|
||||
} catch (error) {
|
||||
performanceInsights = null;
|
||||
} finally {
|
||||
@@ -441,11 +455,14 @@
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="pid" {root}>PID</Table.Header.Cell>
|
||||
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
|
||||
<Table.Header.Cell column="database" {root}>Database</Table.Header.Cell>
|
||||
<Table.Header.Cell column="database" {root}
|
||||
>Database</Table.Header.Cell>
|
||||
<Table.Header.Cell column="state" {root}>State</Table.Header.Cell>
|
||||
<Table.Header.Cell column="query" {root}>Query</Table.Header.Cell>
|
||||
<Table.Header.Cell column="connected" {root}>Connected</Table.Header.Cell>
|
||||
<Table.Header.Cell column="waitEvent" {root}>Wait Event</Table.Header.Cell>
|
||||
<Table.Header.Cell column="connected" {root}
|
||||
>Connected</Table.Header.Cell>
|
||||
<Table.Header.Cell column="waitEvent" {root}
|
||||
>Wait Event</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each activeConnections.activeConnections as conn}
|
||||
<Table.Row.Base id={String(conn.pid)} {root}>
|
||||
@@ -493,8 +510,7 @@
|
||||
{#if activeSection === 'slowQueries'}
|
||||
<Layout.Stack gap="l">
|
||||
<Typography.Text variant="m-500">
|
||||
Queries that exceeded the slow query threshold
|
||||
({database.metricsSlowQueryLogThresholdMs}ms).
|
||||
Queries that exceeded the slow query threshold ({database.metricsSlowQueryLogThresholdMs}ms).
|
||||
</Typography.Text>
|
||||
|
||||
{#if isLoadingSlowQueries}
|
||||
@@ -511,10 +527,12 @@
|
||||
<Table.Root columns={slowQueryColumns} let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="query" {root}>Query</Table.Header.Cell>
|
||||
<Table.Header.Cell column="duration" {root}>Duration</Table.Header.Cell>
|
||||
<Table.Header.Cell column="duration" {root}
|
||||
>Duration</Table.Header.Cell>
|
||||
<Table.Header.Cell column="calls" {root}>Calls</Table.Header.Cell>
|
||||
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
|
||||
<Table.Header.Cell column="database" {root}>Database</Table.Header.Cell>
|
||||
<Table.Header.Cell column="database" {root}
|
||||
>Database</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each slowQueries.slowQueries as sq, i}
|
||||
<Table.Row.Base id={`sq-${i}`} {root}>
|
||||
@@ -600,11 +618,16 @@
|
||||
</Typography.Text>
|
||||
<Table.Root columns={topQueryColumns} let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="query" {root}>Query</Table.Header.Cell>
|
||||
<Table.Header.Cell column="calls" {root}>Calls</Table.Header.Cell>
|
||||
<Table.Header.Cell column="totalTime" {root}>Total Time</Table.Header.Cell>
|
||||
<Table.Header.Cell column="meanTime" {root}>Mean Time</Table.Header.Cell>
|
||||
<Table.Header.Cell column="rows" {root}>Rows</Table.Header.Cell>
|
||||
<Table.Header.Cell column="query" {root}
|
||||
>Query</Table.Header.Cell>
|
||||
<Table.Header.Cell column="calls" {root}
|
||||
>Calls</Table.Header.Cell>
|
||||
<Table.Header.Cell column="totalTime" {root}
|
||||
>Total Time</Table.Header.Cell>
|
||||
<Table.Header.Cell column="meanTime" {root}
|
||||
>Mean Time</Table.Header.Cell>
|
||||
<Table.Header.Cell column="rows" {root}
|
||||
>Rows</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each performanceInsights.topQueries as tq, i}
|
||||
<Table.Row.Base id={`tq-${i}`} {root}>
|
||||
@@ -639,10 +662,14 @@
|
||||
</Typography.Text>
|
||||
<Table.Root columns={waitEventColumns} let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="event" {root}>Event</Table.Header.Cell>
|
||||
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
|
||||
<Table.Header.Cell column="count" {root}>Count</Table.Header.Cell>
|
||||
<Table.Header.Cell column="totalWait" {root}>Total Wait</Table.Header.Cell>
|
||||
<Table.Header.Cell column="event" {root}
|
||||
>Event</Table.Header.Cell>
|
||||
<Table.Header.Cell column="type" {root}
|
||||
>Type</Table.Header.Cell>
|
||||
<Table.Header.Cell column="count" {root}
|
||||
>Count</Table.Header.Cell>
|
||||
<Table.Header.Cell column="totalWait" {root}
|
||||
>Total Wait</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each performanceInsights.waitEvents as we, i}
|
||||
<Table.Row.Base id={`we-${i}`} {root}>
|
||||
@@ -665,8 +692,8 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<Alert.Inline status="info" title="No insights available">
|
||||
Performance insights data is not available. Ensure metrics collection
|
||||
is enabled and the database has been active.
|
||||
Performance insights data is not available. Ensure metrics collection is
|
||||
enabled and the database has been active.
|
||||
</Alert.Inline>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
@@ -675,9 +702,7 @@
|
||||
<!-- Audit Logs Section -->
|
||||
{#if activeSection === 'auditLogs'}
|
||||
<Layout.Stack gap="l">
|
||||
<Typography.Text variant="m-500">
|
||||
Database audit log entries.
|
||||
</Typography.Text>
|
||||
<Typography.Text variant="m-500">Database audit log entries.</Typography.Text>
|
||||
|
||||
{#if isLoadingAuditLogs}
|
||||
<Layout.Stack gap="s">
|
||||
@@ -692,28 +717,25 @@
|
||||
{:else}
|
||||
<Table.Root columns={auditLogColumns} let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="timestamp" {root}>Timestamp</Table.Header.Cell>
|
||||
<Table.Header.Cell column="timestamp" {root}
|
||||
>Timestamp</Table.Header.Cell>
|
||||
<Table.Header.Cell column="user" {root}>User</Table.Header.Cell>
|
||||
<Table.Header.Cell column="action" {root}>Action</Table.Header.Cell>
|
||||
<Table.Header.Cell column="object" {root}>Object</Table.Header.Cell>
|
||||
<Table.Header.Cell column="statement" {root}>Statement</Table.Header.Cell>
|
||||
<Table.Header.Cell column="statement" {root}
|
||||
>Statement</Table.Header.Cell>
|
||||
<Table.Header.Cell column="client" {root}>Client</Table.Header.Cell>
|
||||
</svelte:fragment>
|
||||
{#each auditLogs.auditLogs as log, i}
|
||||
<Table.Row.Base id={`al-${i}`} {root}>
|
||||
<Table.Cell column="timestamp" {root}>
|
||||
{log.timestamp
|
||||
? toLocaleDateTime(log.timestamp)
|
||||
: '-'}
|
||||
{log.timestamp ? toLocaleDateTime(log.timestamp) : '-'}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="user" {root}>
|
||||
{log.user}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="action" {root}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
size="s"
|
||||
content={log.action} />
|
||||
<Badge variant="secondary" size="s" content={log.action} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="object" {root}>
|
||||
{log.object || '-'}
|
||||
|
||||
@@ -37,14 +37,10 @@
|
||||
const database = $derived(data.database);
|
||||
const dedicatedDatabase = $derived(data.dedicatedDatabase as Models.DedicatedDatabase | null);
|
||||
|
||||
const isDedicatedType = $derived(
|
||||
dedicatedDatabase !== null &&
|
||||
(database.type === 'dedicated' ||
|
||||
database.type === 'shared')
|
||||
);
|
||||
const isDedicatedType = $derived(dedicatedDatabase !== null && database.type === 'dedicateddb');
|
||||
|
||||
const isDedicated = $derived(dedicatedDatabase?.type === 'dedicated');
|
||||
const isShared = $derived(dedicatedDatabase?.type === 'shared');
|
||||
const isDedicated = $derived(isDedicatedType);
|
||||
const isShared = $derived(false);
|
||||
const isPostgres = $derived(dedicatedDatabase?.engine === 'postgres');
|
||||
|
||||
// Legacy database fallback state
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Delete database</svelte:fragment>
|
||||
The database will be permanently deleted, including all data and backups. This action is
|
||||
irreversible.
|
||||
The database will be permanently deleted, including all data and backups. This action is irreversible.
|
||||
<svelte:fragment slot="aside">
|
||||
<BoxAvatar>
|
||||
<svelte:fragment slot="title">
|
||||
@@ -42,7 +41,8 @@
|
||||
<h6 class="u-bold u-trim-1">{database.name}</h6>
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<Typography.Caption variant="400" color="--fgcolor-neutral-tertiary">
|
||||
{getEngineDisplayName(database.engine)} {database.version}
|
||||
{getEngineDisplayName(database.engine)}
|
||||
{database.version}
|
||||
</Typography.Caption>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -51,8 +51,7 @@
|
||||
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Credential rotation</svelte:fragment>
|
||||
Generate new database credentials. Existing connections using the old credentials will be
|
||||
terminated.
|
||||
Generate new database credentials. Existing connections using the old credentials will be terminated.
|
||||
<svelte:fragment slot="aside">
|
||||
<Alert.Inline status="warning" title="Warning">
|
||||
Rotating credentials will invalidate the current username and password. All active
|
||||
@@ -73,10 +72,7 @@
|
||||
</svelte:fragment>
|
||||
</CardGrid>
|
||||
|
||||
<Modal
|
||||
title="Rotate credentials"
|
||||
bind:show={showConfirm}
|
||||
onSubmit={rotateCredentials}>
|
||||
<Modal title="Rotate credentials" bind:show={showConfirm} onSubmit={rotateCredentials}>
|
||||
<p class="text">
|
||||
Are you sure you want to rotate the credentials for <b>{database.name}</b>? This will
|
||||
generate a new username and password, and all existing connections will be terminated.
|
||||
|
||||
@@ -27,16 +27,12 @@
|
||||
|
||||
async function updateAutoscaling() {
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
storageAutoscaling: autoscaling,
|
||||
storageAutoscalingThresholdPercent: autoscaling
|
||||
? thresholdPercent
|
||||
: undefined,
|
||||
storageAutoscalingMaxGb: autoscaling ? maxGb : undefined
|
||||
});
|
||||
await sdk.forProject(page.params.region, page.params.project).compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
storageAutoscaling: autoscaling,
|
||||
storageAutoscalingThresholdPercent: autoscaling ? thresholdPercent : undefined,
|
||||
storageAutoscalingMaxGb: autoscaling ? maxGb : undefined
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.DATABASE);
|
||||
|
||||
@@ -59,8 +55,8 @@
|
||||
<Form onSubmit={updateAutoscaling}>
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Storage autoscaling</svelte:fragment>
|
||||
Automatically increase storage when disk usage reaches a threshold. Storage will never
|
||||
exceed the configured maximum.
|
||||
Automatically increase storage when disk usage reaches a threshold. Storage will never exceed
|
||||
the configured maximum.
|
||||
<svelte:fragment slot="aside">
|
||||
<ul>
|
||||
<InputSwitch
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { type Models, Provider } from '@appwrite.io/console';
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
@@ -63,7 +63,7 @@
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.updateDatabaseBackupStorage({
|
||||
databaseId: database.$id,
|
||||
provider,
|
||||
provider: provider as Provider,
|
||||
bucket,
|
||||
accessKey: accessKeyId,
|
||||
secretKey: secretAccessKey,
|
||||
@@ -141,8 +141,8 @@
|
||||
{#if isConfigured && config}
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Backup storage</svelte:fragment>
|
||||
Your database backups are stored on an external storage provider for added durability
|
||||
and disaster recovery.
|
||||
Your database backups are stored on an external storage provider for added durability and
|
||||
disaster recovery.
|
||||
<svelte:fragment slot="aside">
|
||||
<ul>
|
||||
<li class="u-margin-block-end-16">
|
||||
@@ -198,8 +198,8 @@
|
||||
<Form onSubmit={configureStorage}>
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Backup storage</svelte:fragment>
|
||||
Configure off-cluster backup storage to store backups on an external cloud provider
|
||||
for added durability and disaster recovery.
|
||||
Configure off-cluster backup storage to store backups on an external cloud provider for
|
||||
added durability and disaster recovery.
|
||||
<svelte:fragment slot="aside">
|
||||
<ul>
|
||||
<InputSelect
|
||||
@@ -245,7 +245,13 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="actions">
|
||||
<Button disabled={!bucket || !region || !accessKeyId || !secretAccessKey || isSubmitting} submit>
|
||||
<Button
|
||||
disabled={!bucket ||
|
||||
!region ||
|
||||
!accessKeyId ||
|
||||
!secretAccessKey ||
|
||||
isSubmitting}
|
||||
submit>
|
||||
{isSubmitting ? 'Configuring...' : 'Configure'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
@@ -253,10 +259,7 @@
|
||||
</Form>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
title="Remove backup storage"
|
||||
bind:show={showRemoveConfirm}
|
||||
onSubmit={removeStorage}>
|
||||
<Modal title="Remove backup storage" bind:show={showRemoveConfirm} onSubmit={removeStorage}>
|
||||
<p class="text">
|
||||
Are you sure you want to remove the off-cluster backup storage configuration for
|
||||
<b>{database.name}</b>? Existing backups in the external storage will not be deleted,
|
||||
|
||||
@@ -4,13 +4,7 @@
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { CardGrid } from '$lib/components';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
InputSwitch,
|
||||
InputCron,
|
||||
InputNumber
|
||||
} from '$lib/elements/forms';
|
||||
import { Button, Form, InputSwitch, InputCron, InputNumber } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
@@ -35,15 +29,13 @@
|
||||
|
||||
async function updateBackups() {
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
backupEnabled,
|
||||
backupPitr: backupEnabled ? backupPitr : false,
|
||||
backupCron: backupEnabled ? backupCron : undefined,
|
||||
backupRetentionDays: backupEnabled ? backupRetentionDays : undefined
|
||||
});
|
||||
await sdk.forProject(page.params.region, page.params.project).compute.updateDatabase({
|
||||
databaseId: database.$id,
|
||||
backupEnabled,
|
||||
backupPitr: backupEnabled ? backupPitr : false,
|
||||
backupCron: backupEnabled ? backupCron : undefined,
|
||||
backupRetentionDays: backupEnabled ? backupRetentionDays : undefined
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.DATABASE);
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.deleteDatabaseConnection({ databaseId: database.$id, connectionId: connectionToDelete.$id });
|
||||
.compute.deleteDatabaseConnection({
|
||||
databaseId: database.$id,
|
||||
connectionId: connectionToDelete.$id
|
||||
});
|
||||
|
||||
connections = connections.filter((c) => c.$id !== connectionToDelete.$id);
|
||||
showDeleteConfirm = false;
|
||||
@@ -201,10 +204,7 @@
|
||||
</CardGrid>
|
||||
</Form>
|
||||
|
||||
<Modal
|
||||
title="Delete database user"
|
||||
bind:show={showDeleteConfirm}
|
||||
onSubmit={deleteConnection}>
|
||||
<Modal title="Delete database user" bind:show={showDeleteConfirm} onSubmit={deleteConnection}>
|
||||
<p class="text">
|
||||
Are you sure you want to delete the database user
|
||||
<b>{connectionToDelete?.username}</b>? Any active connections using this user will be
|
||||
|
||||
@@ -56,9 +56,7 @@
|
||||
regionOptions.filter((r) => r.value !== database.region)
|
||||
);
|
||||
|
||||
function getStandbyStatusType(
|
||||
status: string
|
||||
): 'success' | 'warning' | 'error' | undefined {
|
||||
function getStandbyStatusType(status: string): 'success' | 'warning' | 'error' | undefined {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'success';
|
||||
@@ -191,7 +189,9 @@
|
||||
<span class="u-bold">Standby status</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
type={getStandbyStatusType(crossRegionStatus.standbyStatus)}
|
||||
type={getStandbyStatusType(
|
||||
crossRegionStatus.standbyStatus
|
||||
)}
|
||||
content={crossRegionStatus.standbyStatus} />
|
||||
</Layout.Stack>
|
||||
<span class="text u-x-small">
|
||||
@@ -199,8 +199,9 @@
|
||||
• Standby: {crossRegionStatus.standbyRegion}
|
||||
</span>
|
||||
<span class="text u-x-small">
|
||||
Lag: {crossRegionStatus.lagSeconds}s
|
||||
• Last synced: {toLocaleDateTime(crossRegionStatus.lastSyncedAt)}
|
||||
Lag: {crossRegionStatus.lagSeconds}s • Last synced: {toLocaleDateTime(
|
||||
crossRegionStatus.lastSyncedAt
|
||||
)}
|
||||
</span>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
@@ -232,8 +233,8 @@
|
||||
<Form onSubmit={enableCrossRegion}>
|
||||
<CardGrid>
|
||||
<svelte:fragment slot="title">Cross-region failover</svelte:fragment>
|
||||
Enable cross-region failover to maintain a standby replica in a different region for
|
||||
disaster recovery.
|
||||
Enable cross-region failover to maintain a standby replica in a different region for disaster
|
||||
recovery.
|
||||
<svelte:fragment slot="aside">
|
||||
<ul>
|
||||
<InputSelect
|
||||
@@ -259,9 +260,9 @@
|
||||
bind:show={showDisableConfirm}
|
||||
onSubmit={disableCrossRegion}>
|
||||
<p class="text">
|
||||
Are you sure you want to disable cross-region failover for <b>{database.name}</b>?
|
||||
The standby replica will be removed and your database will no longer have
|
||||
disaster recovery across regions.
|
||||
Are you sure you want to disable cross-region failover for <b>{database.name}</b>? The
|
||||
standby replica will be removed and your database will no longer have disaster recovery
|
||||
across regions.
|
||||
</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
@@ -282,8 +283,8 @@
|
||||
<p class="text">
|
||||
Are you sure you want to trigger a cross-region failover for <b>{database.name}</b>?
|
||||
This will promote the standby replica in <b>{crossRegionStatus?.standbyRegion}</b>
|
||||
to primary. The current primary in <b>{crossRegionStatus?.primaryRegion}</b> will
|
||||
become the new standby. This operation may cause brief downtime.
|
||||
to primary. The current primary in <b>{crossRegionStatus?.primaryRegion}</b> will become the
|
||||
new standby. This operation may cause brief downtime.
|
||||
</p>
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
try {
|
||||
extensions = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.createDatabaseExtension({ databaseId: database.$id, name: selectedExtension });
|
||||
.compute.createDatabaseExtension({
|
||||
databaseId: database.$id,
|
||||
name: selectedExtension
|
||||
});
|
||||
|
||||
selectedExtension = '';
|
||||
|
||||
@@ -81,7 +84,10 @@
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.compute.deleteDatabaseExtension({ databaseId: database.$id, extensionName: extensionToUninstall });
|
||||
.compute.deleteDatabaseExtension({
|
||||
databaseId: database.$id,
|
||||
extensionName: extensionToUninstall
|
||||
});
|
||||
|
||||
if (extensions) {
|
||||
extensions = {
|
||||
@@ -126,9 +132,7 @@
|
||||
<label class="label u-margin-block-end-8">Installed extensions</label>
|
||||
<Layout.Stack direction="row" gap="xs" wrap="wrap">
|
||||
{#each extensions.installed as ext}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content={ext} />
|
||||
<Badge variant="secondary" content={ext} />
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
</li>
|
||||
|
||||