Merge branch 'main' of github.com:appwrite/console
@@ -41,6 +41,7 @@ jobs:
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=true"
|
||||
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
|
||||
@@ -82,6 +83,7 @@ jobs:
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=true"
|
||||
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
|
||||
@@ -120,7 +122,8 @@ jobs:
|
||||
build-args: |
|
||||
"PUBLIC_CONSOLE_MODE=self-hosted"
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=false"
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
|
||||
|
||||
@@ -159,6 +162,7 @@ jobs:
|
||||
build-args: |
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=false"
|
||||
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
|
||||
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
|
||||
|
||||
@@ -11,7 +11,7 @@ RUN corepack prepare pnpm@10.0.0 --activate
|
||||
ADD ./package.json /app/package.json
|
||||
ADD ./pnpm-lock.yaml /app/pnpm-lock.yaml
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
ADD ./build.js /app/build.js
|
||||
ADD ./tsconfig.json /app/tsconfig.json
|
||||
@@ -23,6 +23,7 @@ ADD ./static /app/static
|
||||
ARG PUBLIC_CONSOLE_MODE
|
||||
ARG PUBLIC_CONSOLE_FEATURE_FLAGS
|
||||
ARG PUBLIC_APPWRITE_MULTI_REGION
|
||||
ARG PUBLIC_CONSOLE_EMAIL_VERIFICATION
|
||||
ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
|
||||
ARG PUBLIC_APPWRITE_ENDPOINT
|
||||
ARG PUBLIC_GROWTH_ENDPOINT
|
||||
@@ -35,6 +36,7 @@ ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
|
||||
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
|
||||
ENV PUBLIC_CONSOLE_FEATURE_FLAGS=$PUBLIC_CONSOLE_FEATURE_FLAGS
|
||||
ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION
|
||||
ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION
|
||||
ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
|
||||
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
|
||||
@@ -28,6 +28,8 @@ async function main() {
|
||||
logEnv('MULTI REGION', env?.PUBLIC_APPWRITE_MULTI_REGION);
|
||||
logEnv('APPWRITE ENDPOINT', env?.PUBLIC_APPWRITE_ENDPOINT, 'relative');
|
||||
logEnv('GROWTH ENDPOINT', env?.PUBLIC_GROWTH_ENDPOINT);
|
||||
logEnv('CONSOLE EMAIL VERIFICATION', env?.PUBLIC_CONSOLE_EMAIL_VERIFICATION);
|
||||
logEnv('CONSOLE MOCK AI SUGGESTIONS', env?.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS);
|
||||
log();
|
||||
logDelimiter();
|
||||
await build();
|
||||
|
||||
@@ -17,10 +17,22 @@ export async function createFreeProject(page: Page): Promise<Metadata> {
|
||||
await page.waitForURL(/\/organization-[^/]+/);
|
||||
await page.getByRole('button', { name: 'create project' }).first().click();
|
||||
const dialog = page.locator('dialog[open]');
|
||||
|
||||
await dialog.getByPlaceholder('Project name').fill('test project');
|
||||
|
||||
let region = 'fra'; // for fallback
|
||||
const regionPicker = dialog.locator('button[role="combobox"]');
|
||||
if (await regionPicker.isVisible()) {
|
||||
await regionPicker.click();
|
||||
await page.getByRole('option', { name: /New York/i }).click();
|
||||
|
||||
region = 'nyc';
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: 'create' }).click();
|
||||
await page.waitForURL(/\/project-fra-[^/]+/);
|
||||
expect(page.url()).toContain('/console/project-fra-');
|
||||
|
||||
await page.waitForURL(new RegExp(`/project-${region}-[^/]+`));
|
||||
expect(page.url()).toContain(`/console/project-${region}-`);
|
||||
|
||||
return getProjectIdFromUrl(page.url());
|
||||
});
|
||||
|
||||
@@ -50,10 +50,21 @@ export async function createProProject(page: Page): Promise<Metadata> {
|
||||
await page.waitForURL(/\/organization-[^/]+/);
|
||||
await page.getByRole('button', { name: 'create project' }).first().click();
|
||||
const dialog = page.locator('dialog[open]');
|
||||
|
||||
await dialog.getByPlaceholder('Project name').fill('test project');
|
||||
|
||||
let region = 'fra'; // for fallback
|
||||
const regionPicker = dialog.locator('button[role="combobox"]');
|
||||
if (await regionPicker.isVisible()) {
|
||||
await regionPicker.click();
|
||||
await page.getByRole('option', { name: /New York/i }).click();
|
||||
|
||||
region = 'nyc';
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: 'create' }).click();
|
||||
await page.waitForURL(/\/project-fra-[^/]+/);
|
||||
expect(page.url()).toContain('/console/project-fra-');
|
||||
await page.waitForURL(new RegExp(`/project-${region}-[^/]+`));
|
||||
expect(page.url()).toContain(`/console/project-${region}-`);
|
||||
|
||||
return getProjectIdFromUrl(page.url());
|
||||
});
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74",
|
||||
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.38.0",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.5",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.42.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
|
||||
@@ -12,20 +12,20 @@ importers:
|
||||
specifier: ^1.1.24
|
||||
version: 1.1.24(svelte@5.25.3)(zod@3.24.3)
|
||||
'@appwrite.io/console':
|
||||
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74
|
||||
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74
|
||||
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e
|
||||
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e
|
||||
'@appwrite.io/pink-icons':
|
||||
specifier: 0.25.0
|
||||
version: 0.25.0
|
||||
'@appwrite.io/pink-icons-svelte':
|
||||
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c
|
||||
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c(svelte@5.25.3)
|
||||
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc
|
||||
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc(svelte@5.25.3)
|
||||
'@appwrite.io/pink-legacy':
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
'@appwrite.io/pink-svelte':
|
||||
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c
|
||||
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c(svelte@5.25.3)
|
||||
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4
|
||||
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4(svelte@5.25.3)
|
||||
'@faker-js/faker':
|
||||
specifier: ^9.9.0
|
||||
version: 9.9.0
|
||||
@@ -97,8 +97,8 @@ importers:
|
||||
specifier: ^0.86.5
|
||||
version: 0.86.5(svelte@5.25.3)
|
||||
'@playwright/test':
|
||||
specifier: ^1.51.1
|
||||
version: 1.51.1
|
||||
specifier: ^1.55.1
|
||||
version: 1.56.1
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8(@sveltejs/kit@2.42.1(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.0.3(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))(svelte@5.25.3)(vite@7.0.6(@types/node@22.13.14)(sass@1.86.0)))
|
||||
@@ -260,8 +260,8 @@ packages:
|
||||
'@analytics/type-utils@0.6.2':
|
||||
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
|
||||
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74':
|
||||
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74}
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e':
|
||||
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e}
|
||||
version: 1.10.0
|
||||
|
||||
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1':
|
||||
@@ -269,8 +269,8 @@ packages:
|
||||
peerDependencies:
|
||||
svelte: ^4.0.0
|
||||
|
||||
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c':
|
||||
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c}
|
||||
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc':
|
||||
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc}
|
||||
version: 2.0.0-RC.1
|
||||
peerDependencies:
|
||||
svelte: ^4.0.0
|
||||
@@ -284,8 +284,8 @@ packages:
|
||||
'@appwrite.io/pink-legacy@1.0.3':
|
||||
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
|
||||
|
||||
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c':
|
||||
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c}
|
||||
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4':
|
||||
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4}
|
||||
version: 2.0.0-RC.2
|
||||
peerDependencies:
|
||||
svelte: ^4.0.0
|
||||
@@ -335,8 +335,8 @@ packages:
|
||||
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.27.1':
|
||||
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-option@7.25.9':
|
||||
@@ -352,8 +352,8 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
|
||||
'@babel/parser@7.28.5':
|
||||
resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -373,8 +373,8 @@ packages:
|
||||
resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.28.4':
|
||||
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
|
||||
'@babel/types@7.28.5':
|
||||
resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@csstools/color-helpers@5.0.2':
|
||||
@@ -993,8 +993,8 @@ packages:
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@playwright/test@1.51.1':
|
||||
resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==}
|
||||
'@playwright/test@1.56.1':
|
||||
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2607,8 +2607,8 @@ packages:
|
||||
magic-string@0.30.18:
|
||||
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
|
||||
|
||||
magic-string@0.30.19:
|
||||
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magic-string@0.30.7:
|
||||
resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==}
|
||||
@@ -2830,13 +2830,13 @@ packages:
|
||||
resolution: {integrity: sha512-hMhneYm3GCPyQon88SZrVJx+LlqhM1kZFQbuAgXPoh/Az2YvO1B6bitT9qlhpiTdJlsT5lsr3gPmzoVjb5CDXA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
playwright-core@1.51.1:
|
||||
resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==}
|
||||
playwright-core@1.56.1:
|
||||
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.51.1:
|
||||
resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==}
|
||||
playwright@1.56.1:
|
||||
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3703,13 +3703,13 @@ snapshots:
|
||||
|
||||
'@analytics/type-utils@0.6.2': {}
|
||||
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74': {}
|
||||
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e': {}
|
||||
|
||||
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)':
|
||||
dependencies:
|
||||
svelte: 5.25.3
|
||||
|
||||
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c(svelte@5.25.3)':
|
||||
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@50b60cc(svelte@5.25.3)':
|
||||
dependencies:
|
||||
svelte: 5.25.3
|
||||
|
||||
@@ -3722,7 +3722,7 @@ snapshots:
|
||||
'@appwrite.io/pink-icons': 1.0.0
|
||||
the-new-css-reset: 1.11.3
|
||||
|
||||
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c(svelte@5.25.3)':
|
||||
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@10305c4(svelte@5.25.3)':
|
||||
dependencies:
|
||||
'@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3)
|
||||
'@floating-ui/dom': 1.6.13
|
||||
@@ -3812,7 +3812,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-validator-identifier@7.25.9': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.27.1': {}
|
||||
'@babel/helper-validator-identifier@7.28.5': {}
|
||||
|
||||
'@babel/helper-validator-option@7.25.9': {}
|
||||
|
||||
@@ -3825,9 +3825,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.27.0
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
'@babel/parser@7.28.5':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.4
|
||||
'@babel/types': 7.28.5
|
||||
|
||||
'@babel/runtime@7.27.0':
|
||||
dependencies:
|
||||
@@ -3856,10 +3856,10 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
|
||||
'@babel/types@7.28.4':
|
||||
'@babel/types@7.28.5':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@csstools/color-helpers@5.0.2': {}
|
||||
|
||||
@@ -4453,9 +4453,9 @@ snapshots:
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.51.1':
|
||||
'@playwright/test@1.56.1':
|
||||
dependencies:
|
||||
playwright: 1.51.1
|
||||
playwright: 1.56.1
|
||||
|
||||
'@polka/url@1.0.0-next.28': {}
|
||||
|
||||
@@ -5124,7 +5124,7 @@ snapshots:
|
||||
|
||||
'@vue/compiler-core@3.5.13':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/parser': 7.28.5
|
||||
'@vue/shared': 3.5.13
|
||||
entities: 4.5.0
|
||||
estree-walker: 2.0.2
|
||||
@@ -5137,13 +5137,13 @@ snapshots:
|
||||
|
||||
'@vue/compiler-sfc@3.5.13':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/parser': 7.28.5
|
||||
'@vue/compiler-core': 3.5.13
|
||||
'@vue/compiler-dom': 3.5.13
|
||||
'@vue/compiler-ssr': 3.5.13
|
||||
'@vue/shared': 3.5.13
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.19
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -6285,7 +6285,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magic-string@0.30.19:
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
@@ -6486,11 +6486,11 @@ snapshots:
|
||||
|
||||
plausible-tracker@0.3.9: {}
|
||||
|
||||
playwright-core@1.51.1: {}
|
||||
playwright-core@1.56.1: {}
|
||||
|
||||
playwright@1.51.1:
|
||||
playwright@1.56.1:
|
||||
dependencies:
|
||||
playwright-core: 1.51.1
|
||||
playwright-core: 1.56.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
|
||||
@@ -7,6 +7,30 @@
|
||||
content="Appwrite is an open-source platform for building applications at any scale, using your preferred programming languages and tools." />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/logos/appwrite-icon.svg" />
|
||||
<link rel="mask-icon" type="image/png" href="%sveltekit.assets%/logos/appwrite-icon.png" />
|
||||
|
||||
<!-- apple touch icons for ios/ipados -->
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%sveltekit.assets%/logos/apple-touch-icon-180x180.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="167x167"
|
||||
href="%sveltekit.assets%/logos/apple-touch-icon-167x167.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="%sveltekit.assets%/logos/apple-touch-icon-152x152.png" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="%sveltekit.assets%/logos/apple-touch-icon-120x120.png" />
|
||||
|
||||
<!-- apple web app meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Appwrite Console" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
|
||||
<link rel="stylesheet" href="%sveltekit.assets%/css/loading.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
|
||||
@@ -1,79 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { invalidate, goto } from '$app/navigation';
|
||||
import { Modal } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { user } from '$lib/stores/user';
|
||||
import { get } from 'svelte/store';
|
||||
import { page } from '$app/state';
|
||||
import Link from '$lib/elements/link.svelte';
|
||||
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { browser } from '$app/environment';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let { show = $bindable(false) } = $props();
|
||||
let {
|
||||
show = $bindable(false),
|
||||
email
|
||||
}: {
|
||||
show?: boolean;
|
||||
email?: string;
|
||||
} = $props();
|
||||
|
||||
let error = $state(null);
|
||||
let creating = $state(false);
|
||||
let emailSent = $state(false);
|
||||
let resendTimer = $state(0);
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
let cleanUrl = $derived(page.url.origin + page.url.pathname);
|
||||
async function logout() {
|
||||
error = null;
|
||||
try {
|
||||
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
|
||||
await invalidate(Dependencies.ACCOUNT);
|
||||
await goto(resolve('/login'));
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanUrl = $derived(page.url.origin + page.url.pathname);
|
||||
|
||||
// manage resend timer in localStorage
|
||||
const EMAIL_SENT_KEY = 'email_verification_sent';
|
||||
const TIMER_END_KEY = 'email_verification_timer_end';
|
||||
|
||||
function startResendTimer() {
|
||||
resendTimer = 60;
|
||||
emailSent = true;
|
||||
const timerEndTime = Date.now() + 60 * 1000;
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem(EMAIL_SENT_KEY, 'true');
|
||||
localStorage.setItem(TIMER_END_KEY, timerEndTime.toString());
|
||||
}
|
||||
|
||||
startTimerCountdown(timerEndTime);
|
||||
}
|
||||
|
||||
function restoreTimerState() {
|
||||
if (!browser) return;
|
||||
const savedTimerEnd = localStorage.getItem(TIMER_END_KEY);
|
||||
const savedEmailSent = localStorage.getItem(EMAIL_SENT_KEY);
|
||||
|
||||
if (savedTimerEnd && savedEmailSent) {
|
||||
const timerEndTime = parseInt(savedTimerEnd);
|
||||
const now = Date.now();
|
||||
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000));
|
||||
|
||||
if (remainingTime > 0) {
|
||||
resendTimer = remainingTime;
|
||||
emailSent = true;
|
||||
startTimerCountdown(timerEndTime);
|
||||
} else {
|
||||
// timer has expired, clean up
|
||||
localStorage.removeItem(TIMER_END_KEY);
|
||||
localStorage.removeItem(EMAIL_SENT_KEY);
|
||||
|
||||
resendTimer = 0;
|
||||
emailSent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startTimerCountdown(timerEndTime: number) {
|
||||
timerInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000));
|
||||
resendTimer = remainingTime;
|
||||
if (remainingTime <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
if (browser) {
|
||||
localStorage.removeItem(TIMER_END_KEY);
|
||||
localStorage.removeItem(EMAIL_SENT_KEY);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (creating) return;
|
||||
if (creating || resendTimer > 0) return;
|
||||
error = null;
|
||||
creating = true;
|
||||
try {
|
||||
await sdk.forConsole.account.createVerification({ url: cleanUrl });
|
||||
addNotification({ message: 'Verification email has been sent', type: 'success' });
|
||||
emailSent = true;
|
||||
show = false;
|
||||
} catch (error) {
|
||||
addNotification({ message: error.message, type: 'error' });
|
||||
startResendTimer();
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateEmailVerification() {
|
||||
const searchParams = page.url.searchParams;
|
||||
const userId = searchParams.get('userId');
|
||||
const secret = searchParams.get('secret');
|
||||
onMount(restoreTimerState);
|
||||
|
||||
if (userId && secret) {
|
||||
try {
|
||||
await sdk.forConsole.account.updateVerification({ userId, secret });
|
||||
addNotification({
|
||||
message: 'Email verified successfully',
|
||||
type: 'success'
|
||||
});
|
||||
await Promise.all([
|
||||
invalidate(Dependencies.ACCOUNT),
|
||||
invalidate(Dependencies.FACTORS)
|
||||
]);
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
message: error.message,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
onDestroy(() => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateEmailVerification();
|
||||
if (browser) {
|
||||
localStorage.removeItem(TIMER_END_KEY);
|
||||
localStorage.removeItem(EMAIL_SENT_KEY);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:show title="Send verification email" {onSubmit}>
|
||||
<Card.Base variant="secondary" padding="s">
|
||||
<Layout.Stack gap="m">
|
||||
<Typography.Text gap="m">
|
||||
To continue using Appwrite Cloud, please verify your email address. An email will be
|
||||
sent to <Typography.Text variant="m-600" style="display: inline;"
|
||||
>{get(user)?.email}</Typography.Text>
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
<div class="email-verification-scrim">
|
||||
<Modal
|
||||
bind:show
|
||||
bind:error
|
||||
title="Verify your email address"
|
||||
{onSubmit}
|
||||
dismissible={false}
|
||||
autoClose={false}
|
||||
backdrop={false}>
|
||||
<Card.Base variant="secondary" padding="s">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Text gap="m">
|
||||
To continue using Appwrite Cloud, please verify your email address. An email
|
||||
will be sent to <Typography.Text
|
||||
variant="m-600"
|
||||
color="neutral-secondary"
|
||||
style="display: inline;">{email || get(user)?.email}</Typography.Text>
|
||||
</Typography.Text>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button submit disabled={creating}>{emailSent ? 'Resend email' : 'Send email'}</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
<Link variant="default" on:click={() => logout()}>Switch account</Link>
|
||||
|
||||
{#if emailSent && resendTimer > 0}
|
||||
<div transition:slide={{ duration: 150 }}>
|
||||
<Typography.Text
|
||||
color="neutral-secondary"
|
||||
style="margin-block-start: var(--gap-L, 16px);">
|
||||
Didn't get the email? Try again in {resendTimer}s
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Card.Base>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button
|
||||
submit
|
||||
submissionLoader
|
||||
forceShowLoader={creating}
|
||||
disabled={creating || resendTimer > 0}>
|
||||
{emailSent ? 'Resend email' : 'Send email'}
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.email-verification-scrim {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: hsl(240 5% 8% / 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* avoids the background scroll and bars */
|
||||
:global(html:has(.email-verification-scrim)) {
|
||||
height: 100%;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { HeaderAlert } from '$lib/layout';
|
||||
import { Typography } from '@appwrite.io/pink-svelte';
|
||||
import { user } from '$lib/stores/user';
|
||||
import SendVerificationEmailModal from '../account/sendVerificationEmailModal.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard';
|
||||
import { isCloud, VARS } from '$lib/system';
|
||||
|
||||
const hasUser = $derived(!!$user);
|
||||
const needsEmailVerification = $derived(hasUser && !$user.emailVerification);
|
||||
const notOnOnboarding = $derived(!$page.route.id.includes('/onboarding'));
|
||||
const notOnWizard = $derived(!$wizard.show && !$isNewWizardStatusOpen);
|
||||
const isEnabledViaEnvConfig = $derived(VARS.EMAIL_VERIFICATION);
|
||||
const shouldShowEmailBanner = $derived(
|
||||
isEnabledViaEnvConfig &&
|
||||
isCloud &&
|
||||
hasUser &&
|
||||
needsEmailVerification &&
|
||||
notOnOnboarding &&
|
||||
notOnWizard
|
||||
);
|
||||
|
||||
let showSendVerification = $state(false);
|
||||
</script>
|
||||
|
||||
{#if shouldShowEmailBanner}
|
||||
<HeaderAlert type="warning" title="Your email address needs to be verified">
|
||||
<svelte:fragment>
|
||||
To avoid losing access to your projects, make sure <Typography.Text
|
||||
variant="m-500"
|
||||
style="display:inline">{$user.email}</Typography.Text> is valid and up to date. Email
|
||||
verification will be required soon.
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="buttons">
|
||||
<Button secondary size="s" on:click={() => (showSendVerification = true)}
|
||||
>Verify email</Button>
|
||||
</svelte:fragment>
|
||||
</HeaderAlert>
|
||||
<SendVerificationEmailModal bind:show={showSendVerification} />
|
||||
{/if}
|
||||
@@ -169,7 +169,7 @@
|
||||
migrations.migrations.forEach(updateOrAddItem);
|
||||
});
|
||||
|
||||
return sdk.forConsoleIn(page.params.region).client.subscribe('console', (response) => {
|
||||
return sdk.forConsoleIn(page.params.region).realtime.subscribe('console', (response) => {
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
if (response.events.includes('migrations.*')) {
|
||||
updateOrAddItem(response.payload as Payload);
|
||||
|
||||
@@ -27,10 +27,12 @@
|
||||
id = null;
|
||||
}
|
||||
|
||||
if (!id?.length) {
|
||||
if (id !== null && !id.length) {
|
||||
id = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (show) {
|
||||
trackEvent(Click.ShowCustomIdClick);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import RepositoryBehaviour from '$lib/components/git/repositoryBehaviour.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { connectGitHub } from '$lib/stores/git';
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
@@ -121,7 +122,7 @@
|
||||
<svelte:fragment slot="footer">
|
||||
{#if repositoryBehaviour === 'existing'}
|
||||
<Layout.Stack>
|
||||
<Link variant="quiet" href="#/">
|
||||
<Link variant="quiet" href={connectGitHub(callbackState).toString()}>
|
||||
<Layout.Stack direction="row" gap="xs">
|
||||
Missing a repository? check your permissions <Icon
|
||||
icon={IconArrowSmRight} />
|
||||
|
||||
@@ -85,5 +85,5 @@ export { default as ViewToggle } from './viewToggle.svelte';
|
||||
export { default as RegionEndpoint } from './regionEndpoint.svelte';
|
||||
export { default as ExpirationInput } from './expirationInput.svelte';
|
||||
export { default as EstimatedCard } from './estimatedCard.svelte';
|
||||
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
|
||||
export { default as SortButton, type SortDirection } from './sortButton.svelte';
|
||||
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
};
|
||||
export let title = '';
|
||||
export let hideFooter = false;
|
||||
export let backdrop: boolean = true;
|
||||
|
||||
let alert: HTMLElement;
|
||||
|
||||
@@ -29,7 +30,7 @@
|
||||
</script>
|
||||
|
||||
<Form isModal {onSubmit}>
|
||||
<Modal {size} {title} bind:open={show} {hideFooter} {dismissible}>
|
||||
<Modal {backdrop} {size} {title} bind:open={show} {hideFooter} {dismissible}>
|
||||
<slot slot="description" name="description" />
|
||||
{#if error}
|
||||
<div bind:this={alert}>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { type ComponentProps, type Snippet } from 'svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { AvatarInitials } from '../';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import {
|
||||
Button,
|
||||
Badge,
|
||||
Divider,
|
||||
Icon,
|
||||
InteractiveText,
|
||||
Layout,
|
||||
Link,
|
||||
Popover,
|
||||
@@ -13,31 +16,118 @@
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import Avatar from '../avatar.svelte';
|
||||
import { IconAnonymous, IconExternalLink, IconMinusSm } from '@appwrite.io/pink-icons-svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { IconAnonymous, IconMinusSm } from '@appwrite.io/pink-icons-svelte';
|
||||
import { page } from '$app/state';
|
||||
import { menuOpen } from '$lib/components/menu/store';
|
||||
import { base } from '$app/paths';
|
||||
import { formatName } from '$lib/helpers/string';
|
||||
|
||||
export let role: string;
|
||||
type PermissionData = Partial<Models.User & Models.Team> & {
|
||||
notFound?: boolean;
|
||||
roleName?: string;
|
||||
customName?: string;
|
||||
};
|
||||
|
||||
async function getData(
|
||||
permission: string
|
||||
): Promise<
|
||||
Partial<Models.User<Record<string, unknown>> & Models.Team<Record<string, unknown>>>
|
||||
> {
|
||||
const role = permission.split(':')[0];
|
||||
const id = permission.split(':')[1].split('/')[0];
|
||||
if (role === 'user') {
|
||||
const user = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.users.get({ userId: id });
|
||||
return user;
|
||||
const permissionDataCache: Map<string, Promise<PermissionData>> = new Map();
|
||||
|
||||
interface Props {
|
||||
role: string;
|
||||
placement?: ComponentProps<Popover>['placement'];
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { role, placement = 'bottom-start', children }: Props = $props();
|
||||
|
||||
type ParsedPermission = {
|
||||
type: 'user' | 'team' | 'other';
|
||||
id: string;
|
||||
roleName?: string;
|
||||
isValid: boolean;
|
||||
};
|
||||
|
||||
function parsePermission(permission: string): ParsedPermission {
|
||||
try {
|
||||
const [type, rest] = permission.split(':');
|
||||
if (!rest) {
|
||||
return { type: 'other', id: permission, isValid: false };
|
||||
}
|
||||
|
||||
const [id, roleName] = rest.split('/');
|
||||
if (!id) {
|
||||
return { type: 'other', id: permission, isValid: false };
|
||||
}
|
||||
|
||||
if (type === 'user' || type === 'team') {
|
||||
return {
|
||||
type: type as 'user' | 'team',
|
||||
id,
|
||||
roleName,
|
||||
isValid: true
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'other', id: permission, isValid: false };
|
||||
} catch (error) {
|
||||
return { type: 'other', id: permission, isValid: false };
|
||||
}
|
||||
if (role === 'team') {
|
||||
const team = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.teams.get({ teamId: id });
|
||||
return team;
|
||||
}
|
||||
|
||||
async function fetchPermissionData(parsed: ParsedPermission): Promise<PermissionData> {
|
||||
if (!parsed.isValid || parsed.type === 'other') {
|
||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
|
||||
}
|
||||
|
||||
if (parsed.type === 'user') {
|
||||
try {
|
||||
return await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.users.get({ userId: parsed.id });
|
||||
} catch (error) {
|
||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.type === 'team') {
|
||||
try {
|
||||
return await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.teams.get({ teamId: parsed.id });
|
||||
} catch (error) {
|
||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { notFound: true, roleName: parsed.roleName, customName: parsed.id };
|
||||
}
|
||||
|
||||
async function getData(permission: string): Promise<PermissionData> {
|
||||
const cached = permissionDataCache.get(permission);
|
||||
if (cached) return cached;
|
||||
|
||||
const parsed = parsePermission(permission);
|
||||
const fetchPromise = fetchPermissionData(parsed);
|
||||
|
||||
permissionDataCache.set(permission, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
let isMouseOverTooltip = $state(false);
|
||||
function hidePopover(hideTooltip: () => void, timeout = true) {
|
||||
if (!timeout) {
|
||||
isMouseOverTooltip = false;
|
||||
return hideTooltip();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isMouseOverTooltip) {
|
||||
hideTooltip();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function isCustomPermission(role: string): boolean {
|
||||
const parsed = parsePermission(role);
|
||||
return !!parsed.roleName || !parsed.isValid;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,70 +138,178 @@
|
||||
{:else if role === 'any'}
|
||||
<div>Any</div>
|
||||
{:else}
|
||||
<Popover let:toggle placement="bottom-start">
|
||||
<Link.Button on:click={toggle}>{role}</Link.Button>
|
||||
<div let:showing slot="tooltip" style:width="200px">
|
||||
{#key showing}
|
||||
{#await getData(role)}
|
||||
<Layout.Stack alignItems="center">
|
||||
<Spinner />
|
||||
</Layout.Stack>
|
||||
{:then data}
|
||||
{@const isUser = role.startsWith('user')}
|
||||
{@const isTeam = role.startsWith('team')}
|
||||
{@const isAnonymous = !data.email && !data.phone && !data.name && isUser}
|
||||
<Layout.Stack>
|
||||
<Layout.Stack direction="row" gap="s" alignItems="center">
|
||||
{#if isAnonymous}
|
||||
<Avatar alt="avatar" size="xs">
|
||||
<Icon icon={IconAnonymous} size="s" />
|
||||
</Avatar>
|
||||
{:else if data.name}
|
||||
<AvatarInitials name={data.name} size="xs" />
|
||||
{:else}
|
||||
<Avatar alt="avatar" size="xs">
|
||||
<Icon icon={IconMinusSm} size="s" />
|
||||
</Avatar>
|
||||
{/if}
|
||||
<Typography.Text truncate color="--fgcolor-neutral-primary">
|
||||
{data.name ?? data?.email ?? data?.phone ?? '-'}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
<Popover let:show let:hide {placement} portal>
|
||||
<button
|
||||
onmouseenter={() => {
|
||||
if (!$menuOpen) {
|
||||
setTimeout(show, 150);
|
||||
}
|
||||
}}
|
||||
onmouseleave={() => hidePopover(hide)}>
|
||||
{@render children?.()}
|
||||
{#if isCustomPermission(role)}
|
||||
<Typography.Text style="text-decoration: underline;">
|
||||
{formatName(role, $isSmallViewport ? 8 : 15)}
|
||||
</Typography.Text>
|
||||
{:else}
|
||||
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
|
||||
<Typography.Text>
|
||||
{#await getData(role)}
|
||||
{role}
|
||||
{:then data}
|
||||
{formatName(
|
||||
data.name ?? data?.email ?? data?.phone ?? '-',
|
||||
$isSmallViewport ? 5 : 7
|
||||
)}
|
||||
{/await}
|
||||
</Typography.Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
content={role.startsWith('user') ? 'User' : 'Team'} />
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<Divider />
|
||||
{#if isUser}
|
||||
{#if data?.email}
|
||||
<Typography.Text truncate>Email: {data?.email}</Typography.Text>
|
||||
{/if}
|
||||
{#if data?.phone}
|
||||
<Typography.Text truncate>Phone: {data?.phone}</Typography.Text>
|
||||
{/if}
|
||||
<div>
|
||||
<Button.Anchor
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${data?.$id}`}
|
||||
size="xs"
|
||||
target="_blank"
|
||||
variant="secondary">
|
||||
View user
|
||||
<Icon slot="end" icon={IconExternalLink} size="s" />
|
||||
</Button.Anchor>
|
||||
</div>
|
||||
{:else if isTeam}
|
||||
<Typography.Text>Members: {data?.total}</Typography.Text>
|
||||
<div>
|
||||
<Button.Anchor
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${data?.$id}`}
|
||||
size="s"
|
||||
target="_blank"
|
||||
variant="secondary">
|
||||
View team
|
||||
<Icon slot="end" icon={IconExternalLink} size="s" />
|
||||
</Button.Anchor>
|
||||
</div>
|
||||
<div
|
||||
let:hide
|
||||
let:showing
|
||||
slot="tooltip"
|
||||
role="tooltip"
|
||||
class="popover"
|
||||
onmouseenter={() => (isMouseOverTooltip = true)}
|
||||
onmouseleave={() => hidePopover(hide, false)}>
|
||||
{#if showing}
|
||||
<Layout.Stack gap="s" alignContent="flex-start">
|
||||
{#await getData(role)}
|
||||
<Layout.Stack alignItems="center">
|
||||
<Spinner />
|
||||
</Layout.Stack>
|
||||
{:then data}
|
||||
{#if data.notFound}
|
||||
<Layout.Stack gap="s" alignItems="flex-start">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
gap="s"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start">
|
||||
<Avatar alt="avatar" size="m">
|
||||
<Icon icon={IconMinusSm} size="s" />
|
||||
</Avatar>
|
||||
|
||||
<Layout.Stack alignItems="flex-start" gap="xxs">
|
||||
<Layout.Stack style="padding-left: 0.25rem;">
|
||||
<Typography.Text
|
||||
size="s"
|
||||
color="--fgcolor-neutral-primary">
|
||||
{data.customName}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{#if data.roleName}
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={data.roleName}
|
||||
value={data.roleName} />
|
||||
{:else}
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={role}
|
||||
value={role} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
{@const isUser = role.startsWith('user')}
|
||||
{@const isAnonymous =
|
||||
!data.email && !data.phone && !data.name && isUser}
|
||||
{@const parsed = parsePermission(role)}
|
||||
{@const id = parsed.id}
|
||||
|
||||
<Layout.Stack gap="s" alignItems="flex-start">
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
gap="s"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start">
|
||||
{#if isAnonymous}
|
||||
<Avatar alt="avatar" size="m">
|
||||
<Icon icon={IconAnonymous} size="s" />
|
||||
</Avatar>
|
||||
{:else if data.name}
|
||||
<AvatarInitials name={data.name} size="m" />
|
||||
{:else}
|
||||
<Avatar alt="avatar" size="m">
|
||||
<Icon icon={IconMinusSm} size="s" />
|
||||
</Avatar>
|
||||
{/if}
|
||||
|
||||
<Layout.Stack alignItems="flex-start" gap="xxs">
|
||||
<Layout.Stack style="padding-left: 0.25rem;">
|
||||
<Link.Anchor
|
||||
variant="quiet"
|
||||
href={role.startsWith('user')
|
||||
? `${base}/project-${page.params.region}-${page.params.project}/auth/user-${id}`
|
||||
: `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${id}`}>
|
||||
<Typography.Text
|
||||
size="s"
|
||||
color="--fgcolor-neutral-primary">
|
||||
{formatName(
|
||||
data.name ??
|
||||
data?.email ??
|
||||
data?.phone ??
|
||||
'-',
|
||||
$isSmallViewport ? 12 : 20
|
||||
)}
|
||||
</Typography.Text>
|
||||
</Link.Anchor>
|
||||
</Layout.Stack>
|
||||
<InteractiveText
|
||||
isVisible
|
||||
variant="copy"
|
||||
text={id}
|
||||
value={id} />
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if isUser && (data.email || data.phone)}
|
||||
<Divider />
|
||||
<Layout.Stack gap="xs" alignItems="flex-start">
|
||||
{#if data.email}
|
||||
<Typography.Text
|
||||
size="xs"
|
||||
color="--fgcolor-neutral-secondary">
|
||||
Email: {data.email}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
{#if data.phone}
|
||||
<Typography.Text
|
||||
size="xs"
|
||||
color="--fgcolor-neutral-secondary">
|
||||
Phone: {data.phone}
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{/await}
|
||||
{/key}
|
||||
{/await}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.popover {
|
||||
display: flex;
|
||||
width: 280px;
|
||||
min-width: 260px;
|
||||
padding: var(--space-5, 10px) var(--space-6, 12px);
|
||||
align-items: flex-start;
|
||||
gap: var(--gap-XXS, 4px);
|
||||
margin: -1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export let id: string;
|
||||
export let label: string = '';
|
||||
export let value: string;
|
||||
export let value: string | null;
|
||||
export let required = false;
|
||||
export let nullable = false;
|
||||
export let disabled = false;
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
let error: string;
|
||||
let element: HTMLInputElement;
|
||||
let previousValue: string | null = null;
|
||||
|
||||
onMount(() => {
|
||||
if (element && autofocus) {
|
||||
@@ -24,14 +25,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
let prevValue = '';
|
||||
function handleNullChange(e: CustomEvent<boolean>) {
|
||||
const isNull = e.detail;
|
||||
|
||||
if (isNull) {
|
||||
prevValue = value;
|
||||
if (value !== null) {
|
||||
previousValue = value;
|
||||
}
|
||||
value = null;
|
||||
} else {
|
||||
value = prevValue;
|
||||
value = previousValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +42,7 @@
|
||||
error = null;
|
||||
}
|
||||
|
||||
function onChange(event: CustomEvent) {
|
||||
value = (event.target as HTMLInputElement).value;
|
||||
}
|
||||
$: isValueNull = value === null;
|
||||
</script>
|
||||
|
||||
<Layout.Stack gap="s" direction="row">
|
||||
@@ -51,20 +52,20 @@
|
||||
{disabled}
|
||||
{readonly}
|
||||
{required}
|
||||
{value}
|
||||
bind:value
|
||||
{step}
|
||||
{type}
|
||||
helper={error}
|
||||
{leadingIcon}
|
||||
on:change={onChange}
|
||||
autocomplete={autocomplete ? 'on' : 'off'}>
|
||||
{#if nullable}
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
slot="end"
|
||||
label="NULL"
|
||||
checked={value === null}
|
||||
on:change={handleNullChange} />
|
||||
{/if}
|
||||
<svelte:fragment slot="end">
|
||||
{#if nullable}
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
label="NULL"
|
||||
checked={isValueNull}
|
||||
on:change={handleNullChange} />
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Input.DateTime>
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
|
||||
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.currentTarget.validity.patternMismatch) {
|
||||
error = true;
|
||||
return;
|
||||
}
|
||||
error = true; // show on any error
|
||||
};
|
||||
|
||||
$: if (value) {
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
|
||||
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.currentTarget.validity.patternMismatch) {
|
||||
error = true;
|
||||
return;
|
||||
}
|
||||
error = true; // show on any error
|
||||
};
|
||||
|
||||
$: if (value) {
|
||||
|
||||
@@ -198,15 +198,21 @@ function generateSingleValue(
|
||||
|
||||
case 'integer': {
|
||||
const intAttr = column as Models.ColumnInteger;
|
||||
const min = !isWithinSafeRange(intAttr.min) ? 0 : intAttr.min;
|
||||
const max = !isWithinSafeRange(intAttr.max) ? 100 : intAttr.max;
|
||||
const min = isWithinSafeRange(intAttr.min) ? intAttr.min : 0;
|
||||
const fallbackMax = Math.max(min + 100, 100);
|
||||
const max = isWithinSafeRange(intAttr.max)
|
||||
? intAttr.max
|
||||
: Math.min(fallbackMax, Number.MAX_SAFE_INTEGER);
|
||||
return faker.number.int({ min, max });
|
||||
}
|
||||
|
||||
case 'double': {
|
||||
const floatAttr = column as Models.ColumnFloat;
|
||||
const min = !isWithinSafeRange(floatAttr.min) ? 0 : floatAttr.min;
|
||||
const max = !isWithinSafeRange(floatAttr.max) ? 100 : floatAttr.max;
|
||||
const min = isWithinSafeRange(floatAttr.min) ? floatAttr.min : 0;
|
||||
const fallbackMax = Math.max(min + 100, 100);
|
||||
const max = isWithinSafeRange(floatAttr.max)
|
||||
? floatAttr.max
|
||||
: Math.min(fallbackMax, Number.MAX_SAFE_INTEGER);
|
||||
const precision = 4;
|
||||
|
||||
return faker.number.float({ min, max, fractionDigits: precision });
|
||||
|
||||
@@ -18,21 +18,38 @@ export function toDecimals(num: number, decimals: number = 1): number {
|
||||
return parseFloat(num.toFixed(decimals));
|
||||
}
|
||||
|
||||
export function formatNumberWithCommas(number: number): string {
|
||||
export function formatNumberWithCommas(number: number, min: number = 0): string {
|
||||
if (isNaN(number)) return String(number);
|
||||
const formatter = new Intl.NumberFormat('en');
|
||||
return formatter.format(number);
|
||||
return formatter.format(clampMin(number, min));
|
||||
}
|
||||
|
||||
export function formatCurrency(number: number, locale = 'en-US', currency = 'USD'): string {
|
||||
export function formatCurrency(
|
||||
number: number,
|
||||
locale = 'en-US',
|
||||
currency = 'USD',
|
||||
min: number = 0
|
||||
): string {
|
||||
if (isNaN(number)) return String(number);
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency
|
||||
});
|
||||
return formatter.format(number);
|
||||
return formatter.format(clampMin(number, min));
|
||||
}
|
||||
|
||||
export function isWithinSafeRange(val: number) {
|
||||
return Math.abs(val) < Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a number to a minimum value
|
||||
*
|
||||
* @export
|
||||
* @param {number} value
|
||||
* @param {number} min
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampMin(value: number, min: number = 0): number {
|
||||
return Math.max(min, value || 0);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampMin } from './numbers';
|
||||
|
||||
/**
|
||||
* Capitalizes the first letter of a string
|
||||
*
|
||||
@@ -45,8 +47,8 @@ const formatter = Intl.NumberFormat('en', {
|
||||
notation: 'compact'
|
||||
});
|
||||
|
||||
export function formatNum(number: number): string {
|
||||
return formatter.format(number);
|
||||
export function formatNum(number: number, min: number = 0): string {
|
||||
return formatter.format(clampMin(number, min));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 451 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -181,6 +181,11 @@
|
||||
&.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& :global(i) {
|
||||
// because the `IconCloud` shows above floating action bars.
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
|
||||
:global(main:has(.sub-navigation)) footer {
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
{#each $notifications as notification (notification.id)}
|
||||
<span animate:flip={{ duration: 500 }} transition:fly|global={{ x: 50 }}>
|
||||
<Toast
|
||||
isHtml={notification.isHtml}
|
||||
title={notification.title}
|
||||
status={notification.type}
|
||||
icon={notification.icon}
|
||||
description={notification.message}
|
||||
actions={notification.buttons?.map((button) => {
|
||||
return {
|
||||
label: button.name,
|
||||
onClick: button.method
|
||||
onClick: button.method,
|
||||
isHtml: button.isHtml
|
||||
};
|
||||
})}
|
||||
on:dismiss={() => dismissNotification(notification.id)} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { navigationCancelled } from '$lib/stores/navigation';
|
||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||
|
||||
const minimum = 0.08;
|
||||
@@ -98,13 +97,6 @@
|
||||
complete();
|
||||
});
|
||||
|
||||
navigationCancelled.subscribe((cancelled) => {
|
||||
if (cancelled) {
|
||||
complete();
|
||||
navigationCancelled.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
$: barStyle = (width && width * 100 && `width: ${width * 100}%;`) || '';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -106,11 +106,11 @@
|
||||
{#if hasSearch}
|
||||
<SearchQuery placeholder={searchPlaceholder} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
{#if hasFilters && $columns?.length}
|
||||
<QuickFilters {columns} {analyticsSource} {filterCols} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
{#if hasDisplaySettings}
|
||||
<ViewSelector ui="new" {view} {columns} {hideView} {hideColumns} />
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Card, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
|
||||
import { page } from '$app/state';
|
||||
import { type Models } from '@appwrite.io/console';
|
||||
import { formatNumberWithCommas } from '$lib/helpers/numbers';
|
||||
import { formatNumberWithCommas, clampMin } from '$lib/helpers/numbers';
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export let title: string;
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<Card>
|
||||
{#if count}
|
||||
{@const totalCount = total.reduce((a, b) => a + b, 0)}
|
||||
{@const totalCount = clampMin(total.reduce((a, b) => a + b, 0))}
|
||||
|
||||
<Layout.Stack gap="xs">
|
||||
<Typography.Title>{formatNumberWithCommas(totalCount)}</Typography.Title>
|
||||
|
||||
@@ -21,14 +21,21 @@
|
||||
</script>
|
||||
|
||||
<Layout.Stack>
|
||||
<header class="form-header" class:hide-divider={!$$slots.subtitle}>
|
||||
<Typography.Title><slot name="title" /></Typography.Title>
|
||||
{#if $$slots.subtitle}
|
||||
<p>
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
{#if $$slots.title || $$slots.subtitle}
|
||||
<header
|
||||
class="form-header"
|
||||
class:hide-divider={!$$slots.subtitle}
|
||||
class:only-subtitle={!$$slots.title && $$slots.subtitle}>
|
||||
{#if $$slots.title}
|
||||
<Typography.Title><slot name="title" /></Typography.Title>
|
||||
{/if}
|
||||
{#if $$slots.subtitle}
|
||||
<p>
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</Layout.Stack>
|
||||
@@ -42,4 +49,8 @@
|
||||
padding-block-end: 0;
|
||||
border-block-end: none;
|
||||
}
|
||||
|
||||
.only-subtitle {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const navigationCancelled = writable(false);
|
||||
@@ -1,13 +1,14 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export type Notification = {
|
||||
id: number;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
dismissible?: boolean;
|
||||
timeout?: number;
|
||||
message: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
icon?: ComponentType;
|
||||
buttons?: Buttons[];
|
||||
isHtml?: boolean;
|
||||
};
|
||||
@@ -15,32 +16,52 @@ export type Notification = {
|
||||
export type Buttons = {
|
||||
method: () => void | Promise<void>;
|
||||
name: string;
|
||||
isHtml?: boolean;
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const activeTimeouts = new Map<number, NodeJS.Timeout>();
|
||||
|
||||
export const notifications = writable<Notification[]>([]);
|
||||
|
||||
export const dismissNotification = (id: number) => {
|
||||
const timeoutId = activeTimeouts.get(id);
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
activeTimeouts.delete(id);
|
||||
}
|
||||
|
||||
notifications.update((all) => all.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
export const dismissAllNotifications = () => {
|
||||
activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
|
||||
activeTimeouts.clear();
|
||||
|
||||
notifications.set([]);
|
||||
};
|
||||
|
||||
export const addNotification = (notification: Omit<Notification, 'id'>) => {
|
||||
const defaults = {
|
||||
export const addNotification = (notification: Omit<Notification, 'id'>): number => {
|
||||
const defaults: Notification = {
|
||||
id: counter++,
|
||||
type: 'info',
|
||||
dismissible: true,
|
||||
timeout: 6000
|
||||
timeout: 6000,
|
||||
...notification
|
||||
};
|
||||
|
||||
const n = { ...defaults, ...notification };
|
||||
notifications.update((all) => {
|
||||
return [n, ...all.slice(0, 4)];
|
||||
});
|
||||
notifications.update((all) => [defaults, ...all.slice(0, 4)]);
|
||||
|
||||
if (n.timeout) setTimeout(() => dismissNotification(n.id), n.timeout);
|
||||
if (defaults.timeout) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
activeTimeouts.delete(defaults.id);
|
||||
dismissNotification(defaults.id);
|
||||
}, defaults.timeout);
|
||||
|
||||
activeTimeouts.set(defaults.id, timeoutId);
|
||||
}
|
||||
|
||||
return defaults.id;
|
||||
};
|
||||
|
||||
@@ -224,6 +224,8 @@ function createPreferences() {
|
||||
delete teamPreferences?.displayNames?.[tableId];
|
||||
delete teamPreferences?.columnOrder?.[tableId];
|
||||
delete teamPreferences?.columnWidths?.[tableId];
|
||||
delete teamPreferences?.columnWidths?.[tableId + '#columns'];
|
||||
delete teamPreferences?.columnWidths?.[tableId + '#indexes'];
|
||||
|
||||
const removeTablePreferences = sdk.forConsole.teams.updatePrefs({
|
||||
teamId: orgId,
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
Sites,
|
||||
Tokens,
|
||||
TablesDB,
|
||||
Domains
|
||||
Domains,
|
||||
Realtime
|
||||
} from '@appwrite.io/console';
|
||||
import { Billing } from '../sdk/billing';
|
||||
import { Backups } from '../sdk/backups';
|
||||
@@ -90,7 +91,8 @@ function createConsoleSdk(client: Client) {
|
||||
sources: new Sources(client),
|
||||
sites: new Sites(client),
|
||||
domains: new Domains(client),
|
||||
storage: new Storage(client)
|
||||
storage: new Storage(client),
|
||||
realtime: new Realtime(client)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,8 +105,8 @@ const clientProject = new Client();
|
||||
const clientRealtime = new Client();
|
||||
|
||||
if (!building) {
|
||||
scopedConsoleClient.setProject('console');
|
||||
clientConsole.setEndpoint(endpoint).setProject('console');
|
||||
scopedConsoleClient.setMode(endpoint).setProject('console');
|
||||
|
||||
clientRealtime.setEndpoint(endpoint).setProject('console');
|
||||
clientProject.setEndpoint(endpoint).setMode('admin');
|
||||
@@ -185,6 +187,11 @@ export enum RuleTrigger {
|
||||
MANUAL = 'manual'
|
||||
}
|
||||
|
||||
/**
|
||||
* Some type imports are broken on the SDK, this works correctly for the time being!
|
||||
*/
|
||||
export type AppwriteRealtimeSubscription = Awaited<ReturnType<Realtime['subscribe']>>;
|
||||
|
||||
export const createAdminClient = () => {
|
||||
return new Client().setEndpoint(getApiEndpoint()).setMode('admin').setProject(getProjectId());
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export function getFrameworkIcon(framework: string) {
|
||||
return 'vite';
|
||||
case framework.toLocaleLowerCase().includes('lynx'):
|
||||
return 'lynx';
|
||||
case framework.toLocaleLowerCase().includes('tanstack'):
|
||||
return 'tanstack';
|
||||
case framework.toLocaleLowerCase().includes('other'):
|
||||
return 'empty';
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
import { headerAlert } from '$lib/stores/headerAlert';
|
||||
import { UsageRates } from '$lib/components/billing';
|
||||
import { canSeeProjects } from '$lib/stores/roles';
|
||||
import { BottomModalAlert, EmailVerificationBanner } from '$lib/components';
|
||||
import { BottomModalAlert } from '$lib/components';
|
||||
import {
|
||||
IconAnnotation,
|
||||
IconBookOpen,
|
||||
@@ -346,8 +346,6 @@
|
||||
<Footer slot="footer" />
|
||||
</Shell>
|
||||
|
||||
<EmailVerificationBanner />
|
||||
|
||||
{#if $wizard.show && $wizard.component}
|
||||
<svelte:component this={$wizard.component} {...$wizard.props} />
|
||||
{:else if $wizard.cover}
|
||||
|
||||
@@ -1,76 +1,32 @@
|
||||
import { isCloud } from '$lib/system';
|
||||
import { isSameDay } from '$lib/helpers/date';
|
||||
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
|
||||
import SpatialColumnsLight from '$lib/images/promos/spatial-columns-api-light.png';
|
||||
import SpatialColumnsDark from '$lib/images/promos/spatial-columns-api-dark.png';
|
||||
import InversionQueriesDark from '$lib/images/promos/inversion-queries-dark.png';
|
||||
import InversionQueriesLight from '$lib/images/promos/inversion-queries-light.png';
|
||||
import TimeHelperQueriesDark from '$lib/images/promos/time-helper-queries-dark.png';
|
||||
import TimeHelperQueriesLight from '$lib/images/promos/time-helper-queries-light.png';
|
||||
import TransactionsApiDark from '$lib/images/promos/transactions-api-dark.png';
|
||||
import TransactionsApiLight from '$lib/images/promos/transactions-api-light.png';
|
||||
|
||||
const listOfPromotions: BottomModalAlertItem[] = [];
|
||||
|
||||
if (isCloud) {
|
||||
const spatialColumnsPromo: BottomModalAlertItem = {
|
||||
id: 'modal:spatial_columns_announcement',
|
||||
const transactionsApiPromo: BottomModalAlertItem = {
|
||||
id: 'modal:transactions_api_announcement',
|
||||
src: {
|
||||
dark: SpatialColumnsDark,
|
||||
light: SpatialColumnsLight
|
||||
dark: TransactionsApiDark,
|
||||
light: TransactionsApiLight
|
||||
},
|
||||
title: 'Announcing API for spatial columns',
|
||||
message: 'Store and query geo data directly in your database.',
|
||||
title: 'Announcing Transactions API',
|
||||
message: 'Ensure data consistency across tables with atomic, all-or-nothing commits.',
|
||||
plan: 'free',
|
||||
importance: 8,
|
||||
scope: 'project',
|
||||
cta: {
|
||||
text: 'Read announcement',
|
||||
link: () => 'https://appwrite.io/blog/post/announcing-spatial-columns',
|
||||
link: () => 'https://appwrite.io/blog/post/announcing-transactions-api',
|
||||
external: true,
|
||||
hideOnClick: true
|
||||
},
|
||||
show: true
|
||||
};
|
||||
|
||||
const inversionQueriesPromo: BottomModalAlertItem = {
|
||||
id: 'modal:inversion_queries_announcement',
|
||||
src: {
|
||||
dark: InversionQueriesDark,
|
||||
light: InversionQueriesLight
|
||||
},
|
||||
title: 'Announcing inversion queries',
|
||||
message: 'New NOT operators to exclude data directly in queries.',
|
||||
plan: 'free',
|
||||
importance: 8,
|
||||
scope: 'project',
|
||||
cta: {
|
||||
text: 'Read announcement',
|
||||
link: () => 'https://appwrite.io/blog/post/announcing-inversion-queries',
|
||||
external: true,
|
||||
hideOnClick: true
|
||||
},
|
||||
show: true
|
||||
};
|
||||
|
||||
const timeHelperQueriesPromo: BottomModalAlertItem = {
|
||||
id: 'modal:time_helper_queries_announcement',
|
||||
src: {
|
||||
dark: TimeHelperQueriesDark,
|
||||
light: TimeHelperQueriesLight
|
||||
},
|
||||
title: 'Announcing Time helper queries',
|
||||
message: 'New before/after filters for simpler time-based queries.',
|
||||
plan: 'free',
|
||||
importance: 8,
|
||||
scope: 'project',
|
||||
cta: {
|
||||
text: 'Read announcement',
|
||||
link: () => 'https://appwrite.io/blog/post/announcing-time-helper-queries',
|
||||
external: true,
|
||||
hideOnClick: true
|
||||
},
|
||||
show: true
|
||||
};
|
||||
listOfPromotions.push(spatialColumnsPromo, inversionQueriesPromo, timeHelperQueriesPromo);
|
||||
listOfPromotions.push(transactionsApiPromo);
|
||||
}
|
||||
|
||||
export function addBottomModalAlerts() {
|
||||
|
||||
@@ -83,12 +83,13 @@
|
||||
}
|
||||
|
||||
$: projectCreationDisabled =
|
||||
(isCloud && getServiceLimit('projects') <= data.projects.total) ||
|
||||
(isCloud && getServiceLimit('projects', null, data.currentPlan) <= data.projects.total) ||
|
||||
(isCloud && $readOnly && !GRACE_PERIOD_OVERRIDE) ||
|
||||
!$canWriteProjects;
|
||||
|
||||
$: reachedProjectLimit = isCloud && getServiceLimit('projects') <= data.projects.total;
|
||||
$: projectsLimit = getServiceLimit('projects');
|
||||
$: reachedProjectLimit =
|
||||
isCloud && getServiceLimit('projects', null, data.currentPlan) <= data.projects.total;
|
||||
$: projectsLimit = getServiceLimit('projects', null, data.currentPlan);
|
||||
|
||||
$: $registerCommands([
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { CardGrid, PaginationInline } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import { formatCurrency } from '$lib/helpers/numbers';
|
||||
import type { Invoice, InvoiceList } from '$lib/sdk/billing';
|
||||
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
|
||||
@@ -112,8 +112,9 @@
|
||||
{#each invoiceList?.invoices as invoice (invoice.$id)}
|
||||
{@const status = invoice.status}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="dueDate" {root}
|
||||
>{toLocaleDate(invoice.dueAt)}</Table.Cell>
|
||||
<Table.Cell column="dueDate" {root}>
|
||||
<DualTimeView time={invoice.dueAt} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="status" {root}>
|
||||
{@const isDanger =
|
||||
status === 'overdue' ||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let id: string;
|
||||
let id: string = '';
|
||||
let error: string;
|
||||
let showCustomId = false;
|
||||
let disabled: boolean = false;
|
||||
@@ -28,7 +28,7 @@
|
||||
disabled = true;
|
||||
showSubmissionLoader = true;
|
||||
const project = await sdk.forConsole.projects.create({
|
||||
projectId: id ?? ID.unique(),
|
||||
projectId: id || ID.unique(),
|
||||
name,
|
||||
teamId
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { EmptySearch, PaginationWithLimit, ViewSelector } from '$lib/components/index.js';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import Link from '$lib/elements/link.svelte';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import Container from '$lib/layout/container.svelte';
|
||||
import { protocol } from '$routes/(console)/store.js';
|
||||
import {
|
||||
@@ -103,9 +103,13 @@
|
||||
{:else if column.id === 'nameservers'}
|
||||
{domain.nameservers || '-'}
|
||||
{:else if column.id === 'expiry_date'}
|
||||
{domain?.expire ? toLocaleDateTime(domain.expire) : '-'}
|
||||
{#if domain?.expire}
|
||||
<DualTimeView time={domain.expire} />
|
||||
{:else}-{/if}
|
||||
{:else if column.id === 'renewal'}
|
||||
{domain?.renewal ? toLocaleDateTime(domain.renewal) : '-'}
|
||||
{#if domain?.renewal}
|
||||
<DualTimeView time={domain.renewal} />
|
||||
{:else}-{/if}
|
||||
{:else if column.id === 'auto_renewal'}
|
||||
{domain?.autoRenewal ? 'On' : 'Off'}
|
||||
{/if}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
let areMembersLimited: boolean = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const limit = getServiceLimit('members') || Infinity;
|
||||
const limit = getServiceLimit('members', null, page.data.currentPlan) || Infinity;
|
||||
const isLimited = limit !== 0 && limit < Infinity;
|
||||
areMembersLimited =
|
||||
isCloud &&
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
SearchQuery
|
||||
} from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import { Container } from '$lib/layout';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { writable } from 'svelte/store';
|
||||
@@ -196,9 +196,13 @@
|
||||
{user.labels.join(', ')}
|
||||
</Typography.Text>
|
||||
{:else if id === 'joined'}
|
||||
{toLocaleDateTime(user.registration)}
|
||||
<DualTimeView time={user.registration} />
|
||||
{:else if id === 'lastActivity'}
|
||||
{user.accessedAt ? toLocaleDate(user.accessedAt) : 'never'}
|
||||
{#if user.accessedAt}
|
||||
<DualTimeView time={user.accessedAt} />
|
||||
{:else}
|
||||
never
|
||||
{/if}
|
||||
{:else}
|
||||
{user[id]}
|
||||
{/if}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import CreateMember from '../createMembership.svelte';
|
||||
import DeleteMembership from '../deleteMembership.svelte';
|
||||
@@ -117,7 +117,7 @@
|
||||
{membership.roles}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="joined" {root}>
|
||||
{toLocaleDateTime(membership.joined)}
|
||||
<DualTimeView time={membership.joined} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<button
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<Alert.Inline status="warning">{error}</Alert.Inline>
|
||||
<Alert.Inline status="warning" title={error} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Id } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import type { PageData } from './$types';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
@@ -87,7 +87,7 @@
|
||||
{#if !identity[column.id]}
|
||||
-
|
||||
{:else}
|
||||
{toLocaleDateTime(identity[column.id])}
|
||||
<DualTimeView time={identity[column.id]} />
|
||||
{/if}
|
||||
{:else}
|
||||
{identity[column.id]}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import DeleteMembership from '../deleteMembership.svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import {
|
||||
Table,
|
||||
Layout,
|
||||
@@ -108,7 +108,7 @@
|
||||
{membership.roles}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="joined" {root}>
|
||||
{toLocaleDateTime(membership.joined)}
|
||||
<DualTimeView time={membership.joined} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
|
||||
import { Modal, CustomId } from '$lib/components';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { Badge, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { Tag, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Button, InputText, InputSelect, InputPhone } from '$lib/elements/forms';
|
||||
import InputEmail from '$lib/elements/forms/inputEmail.svelte';
|
||||
@@ -18,7 +18,7 @@
|
||||
let identifier = '';
|
||||
let name = '';
|
||||
let providerId = '';
|
||||
let id: string = null;
|
||||
let id: string | null = null;
|
||||
let showCustomId = false;
|
||||
|
||||
const providerTypeOptions = [
|
||||
@@ -115,12 +115,10 @@
|
||||
|
||||
{#if !showCustomId}
|
||||
<div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content="Target ID"
|
||||
on:click={() => (showCustomId = !showCustomId)}>
|
||||
<Icon icon={IconPencil} size="s" slot="start" />
|
||||
</Badge>
|
||||
<Tag size="s" on:click={() => (showCustomId = !showCustomId)}>
|
||||
<Icon icon={IconPencil} size="s" />
|
||||
Target ID
|
||||
</Tag>
|
||||
</div>
|
||||
{:else}
|
||||
<CustomId bind:show={showCustomId} name="Target" bind:id autofocus={false} />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import type { PageData } from './$types';
|
||||
import { columns } from './store';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import ProviderType from '$routes/(console)/project-[region]-[project]/messaging/providerType.svelte';
|
||||
import Provider from '$routes/(console)/project-[region]-[project]/messaging/provider.svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
@@ -83,7 +83,7 @@
|
||||
<Provider provider={provider.provider} />
|
||||
{/if}
|
||||
{:else if column.id === '$createdAt'}
|
||||
{toLocaleDateTime(target[column.id])}
|
||||
<DualTimeView time={target[column.id]} />
|
||||
{:else}
|
||||
{target[column.id]}
|
||||
{/if}
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Databases - Appwrite</title>
|
||||
<!-- svelte bug, the table header just stays! -->
|
||||
{#key page.url.pathname}
|
||||
<title>Databases - Appwrite</title>
|
||||
{/key}
|
||||
</svelte:head>
|
||||
|
||||
<slot />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
FloatingActionBar,
|
||||
Popover
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { IconCalendar, IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
|
||||
import type { Column } from '$lib/helpers/types';
|
||||
import { expandTabs } from '../table-[table]/store';
|
||||
@@ -25,23 +25,21 @@
|
||||
tableColumnSuggestions,
|
||||
basicColumnOptions,
|
||||
mockSuggestions,
|
||||
createTableRequest
|
||||
showIndexesSuggestions
|
||||
} from './store';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { addNotification, dismissNotification } from '$lib/stores/notifications';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { sleep } from '$lib/helpers/promises';
|
||||
import { invalidate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { showCreateTable } from '../store';
|
||||
import { showSubNavigation } from '$lib/stores/layout';
|
||||
import { navigationCancelled } from '$lib/stores/navigation';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { isWithinSafeRange } from '$lib/helpers/numbers';
|
||||
import type { Columns } from '../table-[table]/store';
|
||||
import { columnOptions } from '../table-[table]/columns/store';
|
||||
import Options from './options.svelte';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
import { Confirm } from '$lib/components';
|
||||
import { VARS } from '$lib/system';
|
||||
import { isCloud, VARS } from '$lib/system';
|
||||
|
||||
import IconAINotification from './icon/aiNotification.svelte';
|
||||
|
||||
let resizeObserver: ResizeObserver;
|
||||
let spreadsheetContainer: HTMLElement;
|
||||
@@ -51,18 +49,19 @@
|
||||
let rangeOverlayEl: HTMLDivElement | null = null;
|
||||
let fadeBottomOverlayEl: HTMLDivElement | null = null;
|
||||
|
||||
let customColumns = $state([]);
|
||||
let customColumns = $state<
|
||||
(SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean })[]
|
||||
>(Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index)));
|
||||
|
||||
let showFloatingBar = $state(true);
|
||||
let hasTransitioned = $state(false);
|
||||
let scrollAnimationFrame: number | null = null;
|
||||
|
||||
let confirmDismiss = $state(false);
|
||||
let confirmNavigation = $state(false);
|
||||
let pendingNavigationUrl: string | null = null;
|
||||
|
||||
let creatingColumns = $state(false);
|
||||
const baseColProps = { draggable: false, resizable: false };
|
||||
|
||||
const NOTIFICATION_AND_MOCK_DELAY = 1250;
|
||||
|
||||
const getColumnWidth = (columnKey: string) => Math.max(180, columnKey.length * 8 + 60);
|
||||
const safeNumericValue = (value: number | undefined) =>
|
||||
value !== undefined && isWithinSafeRange(value) ? value : undefined;
|
||||
@@ -128,12 +127,6 @@
|
||||
rangeOverlayEl.style.display = 'block';
|
||||
}
|
||||
|
||||
if (customColumns.length === 0) {
|
||||
spreadsheetContainer.style.setProperty('--group-left', '40px');
|
||||
spreadsheetContainer.style.setProperty('--group-width', '100%');
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom columns mode: calculate precise overlay bounds
|
||||
const containerRect = spreadsheetContainer.getBoundingClientRect();
|
||||
const getById = (id: string) =>
|
||||
@@ -141,6 +134,31 @@
|
||||
`[role="cell"][data-header="true"][data-column-id="${id}"]`
|
||||
);
|
||||
|
||||
const hasRealColumns = customColumns.some((col) => !col.isPlaceholder);
|
||||
if (!hasRealColumns) {
|
||||
// For placeholders or no columns, position overlay to cover custom columns area
|
||||
const idCell = getById('$id');
|
||||
const actionsCell = headerElement!.querySelector<HTMLElement>(
|
||||
'[role="cell"][data-column-id="actions"]'
|
||||
);
|
||||
|
||||
if (idCell && actionsCell) {
|
||||
const idRect = idCell.getBoundingClientRect();
|
||||
const actionsRect = actionsCell.getBoundingClientRect();
|
||||
const left = Math.round(idRect.right - containerRect.left);
|
||||
const actionsLeft = actionsRect.left - containerRect.left;
|
||||
|
||||
const width = actionsLeft - left;
|
||||
|
||||
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
|
||||
spreadsheetContainer.style.setProperty('--group-width', `${width + 2}px`);
|
||||
} else {
|
||||
spreadsheetContainer.style.setProperty('--group-left', '40px');
|
||||
spreadsheetContainer.style.setProperty('--group-width', 'calc(100% - 80px)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate visible viewport bounds
|
||||
const scrollerRect = hScroller ? hScroller.getBoundingClientRect() : containerRect;
|
||||
const visibleRight = scrollerRect.right;
|
||||
@@ -190,27 +208,22 @@
|
||||
|
||||
const left = Math.round(startLeft - containerRect.left);
|
||||
|
||||
// maximum possible width of all custom columns
|
||||
const totalFullWidth = customColumns.reduce(
|
||||
(total, col) => total + getColumnWidth(col.key),
|
||||
0
|
||||
);
|
||||
|
||||
// get the actions column and use its left border as the boundary
|
||||
const actionsCell = headerElement!.querySelector<HTMLElement>(
|
||||
'[role="cell"][data-column-id="actions"]'
|
||||
);
|
||||
const rawVisibleWidth = Math.round(visibleRight - idRect.right);
|
||||
let maxAllowedWidth = rawVisibleWidth;
|
||||
|
||||
if (actionsCell) {
|
||||
const actionsRect = actionsCell.getBoundingClientRect();
|
||||
const actionsLeft = actionsRect.left - containerRect.left;
|
||||
maxAllowedWidth = Math.min(rawVisibleWidth, actionsLeft - left);
|
||||
if (!actionsCell) {
|
||||
if (rangeOverlayEl) {
|
||||
rangeOverlayEl.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Set overlay width to not exceed actions column boundary
|
||||
const width = Math.min(totalFullWidth, maxAllowedWidth);
|
||||
const actionsRect = actionsCell.getBoundingClientRect();
|
||||
const actionsLeft = actionsRect.left - containerRect.left;
|
||||
|
||||
const width = actionsLeft - left;
|
||||
|
||||
// Apply overlay positioning
|
||||
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
|
||||
@@ -219,7 +232,7 @@
|
||||
|
||||
// only for mobile, we can remove if not needed!
|
||||
const scrollToFirstCustomColumn = () => {
|
||||
if (!$isSmallViewport || customColumns.length === 0) return;
|
||||
if (!$isSmallViewport) return;
|
||||
|
||||
if (!headerElement || !headerElement.isConnected) {
|
||||
headerElement = spreadsheetContainer.querySelector('[role="rowheader"]');
|
||||
@@ -231,12 +244,19 @@
|
||||
`[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]`
|
||||
);
|
||||
|
||||
if (firstCustomColumnCell && hScroller) {
|
||||
const cellRect = firstCustomColumnCell.getBoundingClientRect();
|
||||
const scrollerRect = hScroller.getBoundingClientRect();
|
||||
const scrollLeft = hScroller.scrollLeft + cellRect.left - scrollerRect.left - 40;
|
||||
const directAccessScroller =
|
||||
hScroller ??
|
||||
findHorizontalScroller(headerElement) ??
|
||||
// internal spreadsheet root main container!
|
||||
spreadsheetContainer.querySelector('.spreadsheet-container');
|
||||
|
||||
hScroller.scrollTo({
|
||||
if (firstCustomColumnCell && directAccessScroller) {
|
||||
const cellRect = firstCustomColumnCell.getBoundingClientRect();
|
||||
const scrollerRect = directAccessScroller.getBoundingClientRect();
|
||||
const scrollLeft =
|
||||
directAccessScroller.scrollLeft + cellRect.left - scrollerRect.left - 40;
|
||||
|
||||
directAccessScroller.scrollTo({
|
||||
left: Math.max(0, scrollLeft),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
@@ -260,25 +280,6 @@
|
||||
});
|
||||
};
|
||||
|
||||
// Handle create table requests from subNavigation
|
||||
const unsubscribeCreateTable = createTableRequest.subscribe((requested) => {
|
||||
if (requested) {
|
||||
if (customColumns.length > 0 && !creatingColumns) {
|
||||
confirmNavigation = true;
|
||||
pendingNavigationUrl = 'create-table';
|
||||
} else {
|
||||
executeCreateTable();
|
||||
}
|
||||
|
||||
createTableRequest.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
function executeCreateTable() {
|
||||
$showCreateTable = true;
|
||||
$showSubNavigation = false;
|
||||
}
|
||||
|
||||
const customSuggestedColumns = $derived.by(() => {
|
||||
return customColumns.map((col: SuggestedColumnSchema) => {
|
||||
const columnOption = getColumnOption(col.type, col.format);
|
||||
@@ -301,54 +302,54 @@
|
||||
});
|
||||
});
|
||||
|
||||
const getRowColumns = (): Column[] => [
|
||||
{
|
||||
id: '$id',
|
||||
title: '$id',
|
||||
type: 'string',
|
||||
width: 180,
|
||||
icon: IconFingerPrint,
|
||||
...baseColProps
|
||||
},
|
||||
...customSuggestedColumns,
|
||||
...(customColumns.length === 0
|
||||
? [
|
||||
{
|
||||
id: '$createdAt',
|
||||
title: '$createdAt',
|
||||
type: 'datetime' as Column['type'],
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: '$updatedAt',
|
||||
title: '$updatedAt',
|
||||
type: 'datetime' as Column['type'],
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'actions',
|
||||
title: '',
|
||||
type: 'string' as Column['type'],
|
||||
width: 40,
|
||||
isAction: true,
|
||||
...baseColProps
|
||||
}
|
||||
];
|
||||
const getRowColumns = (): Column[] => {
|
||||
const minColumnWidth = 180;
|
||||
const fixedWidths = { id: minColumnWidth, actions: 40, selection: 40 };
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (customColumns.length > 0 && !creatingColumns) {
|
||||
event.preventDefault();
|
||||
event.returnValue =
|
||||
'You have unsaved column suggestions. Are you sure you want to leave?';
|
||||
return event.returnValue;
|
||||
}
|
||||
// calculate base widths and total
|
||||
const columnsWithBase = customSuggestedColumns.map((col) => ({
|
||||
...col,
|
||||
baseWidth: Math.max(minColumnWidth, getColumnWidth(col.id))
|
||||
}));
|
||||
|
||||
const totalUsed =
|
||||
fixedWidths.id +
|
||||
fixedWidths.actions +
|
||||
fixedWidths.selection +
|
||||
columnsWithBase.reduce((sum, col) => sum + col.baseWidth, 0);
|
||||
|
||||
// distribute excess space equally across custom columns
|
||||
const viewportWidth =
|
||||
spreadsheetContainer?.clientWidth ||
|
||||
(typeof window !== 'undefined' ? window.innerWidth : totalUsed);
|
||||
|
||||
const extraPerColumn =
|
||||
Math.max(0, viewportWidth - totalUsed) / (columnsWithBase.length || 1);
|
||||
|
||||
const finalCustomColumns = columnsWithBase.map((col) => ({
|
||||
...col,
|
||||
width: { min: col.baseWidth + extraPerColumn }
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: '$id',
|
||||
title: '$id',
|
||||
type: 'string',
|
||||
width: fixedWidths.id,
|
||||
icon: IconFingerPrint,
|
||||
...baseColProps
|
||||
},
|
||||
...finalCustomColumns,
|
||||
{
|
||||
id: 'actions',
|
||||
title: '',
|
||||
type: 'string' as Column['type'],
|
||||
width: fixedWidths.actions,
|
||||
isAction: true,
|
||||
...baseColProps
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const spreadsheetColumns = $derived(getRowColumns());
|
||||
@@ -364,15 +365,6 @@
|
||||
await suggestColumns();
|
||||
});
|
||||
|
||||
beforeNavigate(({ cancel, to }) => {
|
||||
if (customColumns.length > 0 && !creatingColumns) {
|
||||
cancel();
|
||||
confirmNavigation = true;
|
||||
$navigationCancelled = true;
|
||||
pendingNavigationUrl = to?.url?.pathname || null;
|
||||
}
|
||||
});
|
||||
|
||||
function resetSuggestionsStore(fullReset: boolean = true) {
|
||||
if ($tableColumnSuggestions.table?.id !== page.params.table) {
|
||||
return;
|
||||
@@ -391,6 +383,12 @@
|
||||
|
||||
async function suggestColumns() {
|
||||
$tableColumnSuggestions.thinking = true;
|
||||
|
||||
if ($isSmallViewport) {
|
||||
await tick();
|
||||
scrollToFirstCustomColumn();
|
||||
}
|
||||
|
||||
let suggestedColumns: {
|
||||
total: number;
|
||||
columns: ColumnInput[];
|
||||
@@ -402,7 +400,7 @@
|
||||
try {
|
||||
if (VARS.MOCK_AI_SUGGESTIONS) {
|
||||
/* animation */
|
||||
await sleep(1250);
|
||||
await sleep(NOTIFICATION_AND_MOCK_DELAY);
|
||||
suggestedColumns = mockSuggestions;
|
||||
} else {
|
||||
suggestedColumns = (await sdk
|
||||
@@ -410,7 +408,8 @@
|
||||
.console.suggestColumns({
|
||||
databaseId: page.params.database,
|
||||
tableId: page.params.table,
|
||||
context: $tableColumnSuggestions.context ?? undefined
|
||||
context: $tableColumnSuggestions.context ?? undefined,
|
||||
min: 6
|
||||
})) as unknown as {
|
||||
total: number;
|
||||
columns: ColumnInput[];
|
||||
@@ -423,21 +422,54 @@
|
||||
total: suggestedColumns.total
|
||||
});
|
||||
|
||||
customColumns = mapSuggestedColumns(suggestedColumns.columns);
|
||||
const mappedColumns = mapSuggestedColumns(suggestedColumns.columns);
|
||||
|
||||
if (customColumns.length > 0) {
|
||||
setTimeout(scrollToFirstCustomColumn, 100);
|
||||
setTimeout(() => (hasTransitioned = true), 300);
|
||||
// replace with actual columns and trim excess
|
||||
if (mappedColumns.length < customColumns.length) {
|
||||
customColumns = customColumns.slice(0, mappedColumns.length);
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
|
||||
// replace existing placeholders and
|
||||
// add any additional columns if needed
|
||||
mappedColumns.forEach((column, index) => {
|
||||
setTimeout(() => {
|
||||
if (index < customColumns.length) {
|
||||
// replace existing placeholder
|
||||
customColumns[index] = { ...column, isPlaceholder: false };
|
||||
} else {
|
||||
// new column directly if we have more than expected
|
||||
// just added in case the max ever changes on backend!
|
||||
customColumns.push({ ...column, isPlaceholder: false });
|
||||
}
|
||||
|
||||
// recalculate overlay bounds
|
||||
// after each column is populated!
|
||||
requestAnimationFrame(() => updateOverlayBounds());
|
||||
}, index * 150);
|
||||
});
|
||||
|
||||
trackError(error, Submit.ColumnSuggestions);
|
||||
} finally {
|
||||
if (mappedColumns.length > 0) {
|
||||
setTimeout(
|
||||
() => {
|
||||
hasTransitioned = true;
|
||||
// final recal after
|
||||
// all animations complete
|
||||
requestAnimationFrame(() => {
|
||||
recalcAll();
|
||||
});
|
||||
},
|
||||
mappedColumns.length * 150 + 300
|
||||
);
|
||||
}
|
||||
|
||||
resetSuggestionsStore(false);
|
||||
} catch (error) {
|
||||
// remove completely!
|
||||
resetSuggestionsStore();
|
||||
|
||||
// track & notify!
|
||||
trackError(error, Submit.ColumnSuggestions);
|
||||
addNotification({ type: 'error', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,6 +515,31 @@
|
||||
return !['$id', '$createdAt', '$updatedAt', 'actions'].includes(id);
|
||||
}
|
||||
|
||||
function showIndexSuggestionsNotification() {
|
||||
// safeguard anyways!
|
||||
if (!isCloud) return;
|
||||
|
||||
setTimeout(() => {
|
||||
const notifId = addNotification({
|
||||
isHtml: true,
|
||||
title: '<b>Next step: add indexes</b>',
|
||||
message: 'See suggested indexes based on your columns',
|
||||
dismissible: true,
|
||||
icon: IconAINotification,
|
||||
timeout: 10000, // ten seconds
|
||||
buttons: [
|
||||
{
|
||||
name: 'Create indexes',
|
||||
method: () => {
|
||||
dismissNotification(notifId);
|
||||
showIndexesSuggestions.update(() => true);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}, NOTIFICATION_AND_MOCK_DELAY);
|
||||
}
|
||||
|
||||
async function createColumns() {
|
||||
creatingColumns = true;
|
||||
const client = sdk.forProject(page.params.region, page.params.project);
|
||||
@@ -589,9 +646,13 @@
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Columns created successfully'
|
||||
message: 'Columns created successfully',
|
||||
timeout: NOTIFICATION_AND_MOCK_DELAY
|
||||
});
|
||||
|
||||
// show index notification!
|
||||
showIndexSuggestionsNotification();
|
||||
|
||||
trackEvent(Submit.ColumnCreate, { type: 'suggestions' });
|
||||
} catch (error) {
|
||||
trackError(error, Submit.ColumnCreate);
|
||||
@@ -603,6 +664,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
function createPlaceholderColumn(
|
||||
index: number
|
||||
): SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean } {
|
||||
return {
|
||||
key: `column${index + 1}`,
|
||||
type: 'string',
|
||||
required: false,
|
||||
default: null,
|
||||
format: null,
|
||||
size: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
elements: undefined,
|
||||
isPlaceholder: true
|
||||
};
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
resizeObserver?.disconnect();
|
||||
hScroller?.removeEventListener('scroll', recalcAllThrottled);
|
||||
@@ -612,15 +690,15 @@
|
||||
|
||||
customColumns = [];
|
||||
resetSuggestionsStore();
|
||||
unsubscribeCreateTable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} on:beforeunload={handleBeforeUnload} />
|
||||
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} />
|
||||
|
||||
<div
|
||||
bind:this={spreadsheetContainer}
|
||||
class:custom-columns={customColumns.length > 0}
|
||||
class:thinking={$tableColumnSuggestions.thinking}
|
||||
class="databases-spreadsheet spreadsheet-container-outer"
|
||||
style:--overlay-icon-color="#fd366e99"
|
||||
style:--non-overlay-icon-color="--fgcolor-neutral-weak">
|
||||
@@ -643,7 +721,6 @@
|
||||
bottomActionClick={() => {}}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{#each spreadsheetColumns as column, index (index)}
|
||||
{@const isColumnInteractable = isCustomColumn(column.id)}
|
||||
{#if column.isAction}
|
||||
<Spreadsheet.Header.Cell column="actions" {root}>
|
||||
<Button.Button icon variant="extra-compact">
|
||||
@@ -658,6 +735,8 @@
|
||||
{@const columnIconColor = !columnObj?.type
|
||||
? '--non-overlay-icon-color'
|
||||
: '--overlay-icon-color'}
|
||||
{@const isColumnInteractable =
|
||||
isCustomColumn(column.id) && !columnObj.isPlaceholder}
|
||||
|
||||
<Options
|
||||
enabled={isColumnInteractable}
|
||||
@@ -680,30 +759,42 @@
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
justifyContent="space-between">
|
||||
{column.title}
|
||||
<span
|
||||
class="column-title"
|
||||
class:animate-in={!columnObj?.isPlaceholder}
|
||||
style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`}>
|
||||
{column.title}
|
||||
</span>
|
||||
|
||||
<Popover
|
||||
let:toggle
|
||||
portal
|
||||
padding="none"
|
||||
placement="bottom-start">
|
||||
<Button.Button
|
||||
size="xs"
|
||||
variant="extra-compact"
|
||||
disabled={!isColumnInteractable}
|
||||
on:click={(event) => {
|
||||
if (
|
||||
isColumnInteractable &&
|
||||
!$isTabletViewport
|
||||
) {
|
||||
toggle(event);
|
||||
}
|
||||
}}>
|
||||
<Icon
|
||||
size="s"
|
||||
color={columnIconColor}
|
||||
icon={column.icon ?? undefined} />
|
||||
</Button.Button>
|
||||
<div
|
||||
class="column-icon-wrapper"
|
||||
class:animate-in={!columnObj?.isPlaceholder}
|
||||
style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`}>
|
||||
<Button.Button
|
||||
size="xs"
|
||||
variant="extra-compact"
|
||||
disabled={!isColumnInteractable}
|
||||
on:click={(event) => {
|
||||
if (
|
||||
isColumnInteractable &&
|
||||
!$isTabletViewport
|
||||
) {
|
||||
toggle(event);
|
||||
}
|
||||
}}>
|
||||
{#if !columnObj?.isPlaceholder}
|
||||
<Icon
|
||||
size="s"
|
||||
color={columnIconColor}
|
||||
icon={column.icon ?? undefined} />
|
||||
{/if}
|
||||
</Button.Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
let:toggle
|
||||
@@ -832,9 +923,12 @@
|
||||
<svelte:fragment slot="start">
|
||||
<Layout.Stack direction="row" gap="xxs" alignItems="center">
|
||||
<Spinner size="s" />
|
||||
<Typography.Text style="white-space: nowrap">
|
||||
<Typography.Caption
|
||||
variant="500"
|
||||
color="--fgcolor-neutral-secondary"
|
||||
style="white-space: nowrap">
|
||||
Thinking of column suggestions
|
||||
</Typography.Text>
|
||||
</Typography.Caption>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
@@ -847,7 +941,7 @@
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
{:else if customColumns.length > 0 && showFloatingBar}
|
||||
{:else if customColumns.some((col) => !col.isPlaceholder) && showFloatingBar}
|
||||
<div
|
||||
class="floating-action-wrapper"
|
||||
class:expanded={!creatingColumns}
|
||||
@@ -878,7 +972,10 @@
|
||||
size="xs"
|
||||
variant="text"
|
||||
disabled={creatingColumns}
|
||||
on:click={() => (confirmDismiss = true)}
|
||||
on:click={() => {
|
||||
customColumns = [];
|
||||
resetSuggestionsStore();
|
||||
}}
|
||||
style="opacity: {creatingColumns ? '0' : '1'}"
|
||||
>Dismiss
|
||||
</Button.Button>
|
||||
@@ -897,48 +994,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
confirmDeletion
|
||||
action="Dismiss"
|
||||
title="Dismiss columns"
|
||||
bind:open={confirmDismiss}
|
||||
onSubmit={() => {
|
||||
customColumns = [];
|
||||
resetSuggestionsStore();
|
||||
}}>
|
||||
Are you sure you want to dismiss these columns suggested by AI? This action is irreversible.
|
||||
</Confirm>
|
||||
|
||||
<Confirm
|
||||
confirmDeletion
|
||||
action="Leave"
|
||||
title="Leave page"
|
||||
bind:open={confirmNavigation}
|
||||
onSubmit={() => {
|
||||
customColumns = [];
|
||||
resetSuggestionsStore();
|
||||
confirmNavigation = false;
|
||||
|
||||
if (pendingNavigationUrl) {
|
||||
if (pendingNavigationUrl === 'create-table') {
|
||||
executeCreateTable();
|
||||
} else {
|
||||
goto(pendingNavigationUrl);
|
||||
}
|
||||
|
||||
pendingNavigationUrl = null;
|
||||
}
|
||||
}}>
|
||||
You have unsaved column suggestions. If you leave this page, you'll lose these suggestions. Are
|
||||
you sure you want to continue?
|
||||
</Confirm>
|
||||
|
||||
<style lang="scss">
|
||||
.spreadsheet-container-outer {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
scrollbar-width: none;
|
||||
--columns-range-pink-border-color: rgba(253, 54, 110, 0.24);
|
||||
|
||||
&.custom-columns {
|
||||
width: unset;
|
||||
@@ -948,19 +1010,19 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:not(:has(.columns-range-overlay.thinking)) {
|
||||
&:has(.columns-range-overlay) {
|
||||
&
|
||||
:global(
|
||||
[role='cell']:has(.column-resizer-disabled):not([data-column-id^='$']):not(
|
||||
[data-column-id='actions']
|
||||
)
|
||||
) {
|
||||
box-shadow: 0 -1px 0 0 rgba(253, 54, 110, 0.24) inset !important;
|
||||
box-shadow: 0 -1px 0 0 var(--columns-range-pink-border-color) inset !important;
|
||||
transition: box-shadow 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
& :global(.column-resizer-disabled) {
|
||||
border-left: var(--border-width-s, 1px) solid rgba(253, 54, 110, 0.24) !important;
|
||||
& :global([role='cell']:not([data-column-id='actions']) .column-resizer-disabled) {
|
||||
border-left: var(--border-width-s, 1px) solid var(--columns-range-pink-border-color) !important;
|
||||
transition: border-color 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
}
|
||||
@@ -1010,7 +1072,7 @@
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.08),
|
||||
rgba(255, 255, 255, 0.8),
|
||||
transparent
|
||||
);
|
||||
animation: inner-shimmer 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
|
||||
@@ -1021,7 +1083,17 @@
|
||||
& .floating-action-wrapper {
|
||||
& :global(:first-child) {
|
||||
z-index: 21;
|
||||
left: calc(65% - 525px / 2);
|
||||
transition: all 600ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
left: calc(50% - 525px / 2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
left: calc(50% - 400px / 2);
|
||||
max-width: 400px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded :global(:first-child) {
|
||||
@@ -1054,11 +1126,17 @@
|
||||
}
|
||||
|
||||
& :global(.spreadsheet-container) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
&.thinking {
|
||||
& :global(.spreadsheet-container) {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
& :global([data-select='true']) {
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
@@ -1079,7 +1157,7 @@
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.855) 21%,
|
||||
rgba(255, 255, 255, 0.86) 32.25%,
|
||||
#fff 100%
|
||||
);
|
||||
z-index: 20; /* under overlay */
|
||||
@@ -1093,10 +1171,9 @@
|
||||
:global(.theme-dark) .spreadsheet-fade-bottom {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(25, 25, 28, 0.38) 13%,
|
||||
rgba(25, 25, 28, 0.7) 21%,
|
||||
rgba(25, 25, 28, 0.95) 38%,
|
||||
var(--bgcolor-neutral-default, #19191c) 100%
|
||||
rgba(29, 29, 33, 0) 0%,
|
||||
rgba(29, 29, 33, 0.86) 21%,
|
||||
#1d1d21 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1117,6 +1194,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(.theme-dark) .spreadsheet-container-outer {
|
||||
--columns-range-pink-border-color: rgba(253, 54, 110, 0.12) !important;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .columns-range-overlay.thinking {
|
||||
&::before {
|
||||
background: linear-gradient(
|
||||
@@ -1146,7 +1227,8 @@
|
||||
border: var(--border-width-L, 2px) solid rgba(253, 54, 110, 0.24) !important;
|
||||
|
||||
& :global(i) {
|
||||
margin-inline-end: 8px !important;
|
||||
margin-inline-end: 6px !important;
|
||||
margin-block-start: -4px !important;
|
||||
}
|
||||
|
||||
& :global(::selection) {
|
||||
@@ -1157,4 +1239,21 @@
|
||||
:global(.filter-modal-actions-menu.variant) {
|
||||
max-height: 184px;
|
||||
}
|
||||
|
||||
/* Sequential animation for column titles and icons */
|
||||
.column-title,
|
||||
.column-icon-wrapper {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 0.4s ease-out,
|
||||
transform 0.4s ease-out;
|
||||
}
|
||||
|
||||
.column-title.animate-in,
|
||||
.column-icon-wrapper.animate-in {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition-delay: var(--animation-delay, 0ms);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
notification = false
|
||||
}: {
|
||||
notification?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Layout.Stack inline alignItems="center" justifyContent="center" class="ai-icon-holder">
|
||||
<Layout.Stack
|
||||
inline
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
class="ai-icon-holder {notification ? 'notification' : ''}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
|
||||
<path
|
||||
d="M11.0156 6.20801C11.3539 5.29392 12.6461 5.29392 12.9844 6.20801L14.5801 10.5186C14.7776 11.052 15.198 11.4724 15.7314 11.6699L20.042 13.2656C20.9561 13.6039 20.9561 14.8961 20.042 15.2344L15.7314 16.8301C15.198 17.0276 14.7776 17.448 14.5801 17.9814L12.9844 22.292C12.6461 23.2061 11.3539 23.2061 11.0156 22.292L9.41992 17.9814C9.2224 17.448 8.80197 17.0276 8.26855 16.8301L3.95801 15.2344C3.04392 14.8961 3.04392 13.6039 3.95801 13.2656L8.26855 11.6699C8.80196 11.4724 9.2224 11.052 9.41992 10.5186L11.0156 6.20801Z"
|
||||
@@ -64,4 +74,9 @@
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
:global(.ai-icon-holder.notification) {
|
||||
width: 36px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import IconAI from './ai.svelte';
|
||||
</script>
|
||||
|
||||
<IconAI notification />
|
||||
@@ -0,0 +1,567 @@
|
||||
<script lang="ts">
|
||||
import { Alert, Accordion, Icon, Layout, Skeleton, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Button, InputSelect /*InputNumber*/ } from '$lib/elements/forms';
|
||||
import {
|
||||
showIndexesSuggestions,
|
||||
IndexOrder,
|
||||
mockSuggestions,
|
||||
type SuggestedIndexSchema
|
||||
} from './store';
|
||||
import { Modal, Confirm } from '$lib/components';
|
||||
import SideSheet from '../table-[table]/layout/sidesheet.svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { IndexType, type Models } from '@appwrite.io/console';
|
||||
import { capitalize } from '$lib/helpers/string';
|
||||
import { type Columns, table } from '../table-[table]/store';
|
||||
import { isRelationship } from '../table-[table]/rows/store';
|
||||
import { VARS } from '$lib/system';
|
||||
import { sleep } from '$lib/helpers/promises';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { page } from '$app/state';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { type ComponentType, onDestroy, onMount } from 'svelte';
|
||||
import { columnOptions as baseColumnOptions } from '../table-[table]/columns/store';
|
||||
|
||||
const MAX_INDEXES = 5;
|
||||
|
||||
let modalError = $state(null);
|
||||
let creatingIndexes = $state(false);
|
||||
let loadingSuggestions = $state(false);
|
||||
let indexes = $state<SuggestedIndexSchema[]>([]);
|
||||
let confirmDismiss = $state(false);
|
||||
let columnOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
leadingIcon?: ComponentType;
|
||||
}> = $state();
|
||||
|
||||
const tableId = page.params.table;
|
||||
const databaseId = page.params.database;
|
||||
|
||||
function makeColumnOptions() {
|
||||
if (VARS.MOCK_AI_SUGGESTIONS) {
|
||||
columnOptions = mockSuggestions.columns.map((column) => ({
|
||||
value: column.name,
|
||||
label: column.name,
|
||||
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)?.icon
|
||||
}));
|
||||
} else {
|
||||
columnOptions = $table.columns
|
||||
.filter((column) => !isRelationship(column))
|
||||
.map((column) => ({
|
||||
value: column.key,
|
||||
label: column.key,
|
||||
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)
|
||||
?.icon
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIndexSuggestions(): Promise<SuggestedIndexSchema[]> {
|
||||
modalError = null;
|
||||
loadingSuggestions = true;
|
||||
|
||||
if (VARS.MOCK_AI_SUGGESTIONS) {
|
||||
await sleep(1250);
|
||||
|
||||
indexes = mockSuggestions.columns.slice(0, 3).map((column, index) => ({
|
||||
key: column.name,
|
||||
type: IndexType.Key,
|
||||
columns: [column.name],
|
||||
orders: index === 2 ? IndexOrder.DESC : IndexOrder.ASC,
|
||||
lengths: []
|
||||
}));
|
||||
} else {
|
||||
try {
|
||||
const suggestions = await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.console.suggestIndexes({
|
||||
databaseId,
|
||||
tableId: $table.$id
|
||||
});
|
||||
|
||||
indexes = suggestions.indexes.map((index) => {
|
||||
return {
|
||||
key: index.columns[0],
|
||||
type: index.type as IndexType,
|
||||
orders: (index.orders?.[0] as IndexOrder) || IndexOrder.ASC,
|
||||
columns: index.columns,
|
||||
lengths: index.lengths ?? []
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
|
||||
await closeAndInvalidate();
|
||||
return []; // formality, UI is closed by now!
|
||||
}
|
||||
}
|
||||
|
||||
makeColumnOptions();
|
||||
|
||||
loadingSuggestions = false;
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function addIndex() {
|
||||
if (indexes.length < MAX_INDEXES) {
|
||||
indexes.push({
|
||||
key: '',
|
||||
type: IndexType.Key,
|
||||
orders: IndexOrder.ASC,
|
||||
columns: [],
|
||||
lengths: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeIndex(index: number) {
|
||||
indexes.splice(index, 1);
|
||||
}
|
||||
|
||||
function syncIndexState(event: CustomEvent, index: SuggestedIndexSchema) {
|
||||
const selected = event.detail;
|
||||
index.key = selected;
|
||||
index.columns = selected ? [selected] : [];
|
||||
if (index.lengths) {
|
||||
index.lengths = index.lengths.slice(0, index.columns.length);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderOptions(selectedType: IndexType) {
|
||||
const base = [IndexOrder.ASC, IndexOrder.DESC];
|
||||
const values = selectedType === IndexType.Spatial ? [...base, IndexOrder.NONE] : base;
|
||||
|
||||
return values.map((order) => ({
|
||||
label: capitalize(String(order)),
|
||||
value: order
|
||||
}));
|
||||
}
|
||||
|
||||
function generateUniqueIndexKey(index: SuggestedIndexSchema, usedKeys: Set<string>): string {
|
||||
const existingKeys = $table.indexes.map((idx) => idx.key);
|
||||
let suggestedKey = `${index.key || index.columns[0]}_${index.type.toLowerCase()}`;
|
||||
let uniqueKey = suggestedKey;
|
||||
let counter = 1;
|
||||
|
||||
while (existingKeys.includes(uniqueKey) || usedKeys.has(uniqueKey)) {
|
||||
uniqueKey = `${suggestedKey}_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
usedKeys.add(uniqueKey);
|
||||
return uniqueKey;
|
||||
}
|
||||
|
||||
function prepareIndexForCreation(index: SuggestedIndexSchema, columnMap: Map<string, Columns>) {
|
||||
// prepare orders array
|
||||
const orders = index.orders !== null ? index.columns.map(() => String(index.orders)) : [];
|
||||
|
||||
// prepare lengths array
|
||||
let lengths: (number | null)[];
|
||||
if (index.type === IndexType.Key) {
|
||||
// only validate if it's a key index
|
||||
lengths = index.columns.map((columnKey, i) => {
|
||||
const column = columnMap.get(columnKey);
|
||||
if (column?.type === 'string') {
|
||||
const stringColumn = column as Models.ColumnString;
|
||||
const requestedLength = index.lengths?.[i];
|
||||
if (
|
||||
requestedLength &&
|
||||
stringColumn.size &&
|
||||
requestedLength > stringColumn.size
|
||||
) {
|
||||
return stringColumn.size;
|
||||
}
|
||||
return requestedLength || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
// non-key indexes, lengths are null!
|
||||
lengths = Array(index.columns.length).fill(null);
|
||||
}
|
||||
|
||||
return { orders, lengths };
|
||||
}
|
||||
|
||||
function dismissIndexes() {
|
||||
indexes = [];
|
||||
confirmDismiss = false;
|
||||
$showIndexesSuggestions = false;
|
||||
}
|
||||
|
||||
async function closeAndInvalidate() {
|
||||
// close modal/sheet.
|
||||
$showIndexesSuggestions = false;
|
||||
|
||||
/**
|
||||
* Invalidate dependencies after modal/sheet close,
|
||||
* because otherwise the `await` block will re-run in the modal.
|
||||
*/
|
||||
await invalidate(Dependencies.TABLE);
|
||||
}
|
||||
|
||||
async function applySuggestedIndexes(): Promise<boolean> {
|
||||
modalError = null;
|
||||
creatingIndexes = true;
|
||||
|
||||
for (const [i, index] of indexes.entries()) {
|
||||
if (!index.key || !index.type || !index.columns || index.columns.length === 0) {
|
||||
modalError = `Index ${i + 1}: Selected column or type invalid`;
|
||||
creatingIndexes = false;
|
||||
return true; // keep sheet open!
|
||||
}
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
const usedKeys = new Set<string>();
|
||||
const columnMap = new Map($table.columns.map((col) => [col.key, col]));
|
||||
const sdkClient = sdk.forProject(page.params.region, page.params.project);
|
||||
|
||||
for (const [_, index] of indexes.entries()) {
|
||||
try {
|
||||
// prepare and validate index data
|
||||
const { orders, lengths } = prepareIndexForCreation(index, columnMap);
|
||||
|
||||
// generate unique key name for the index
|
||||
const uniqueIndexKey = generateUniqueIndexKey(index, usedKeys);
|
||||
|
||||
await sdkClient.tablesDB.createIndex({
|
||||
databaseId,
|
||||
tableId,
|
||||
key: uniqueIndexKey,
|
||||
type: index.type,
|
||||
columns: index.columns,
|
||||
lengths,
|
||||
...(orders.length ? { orders } : {})
|
||||
});
|
||||
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
if (!modalError) {
|
||||
modalError = error.message;
|
||||
}
|
||||
trackError(error, Submit.IndexCreate);
|
||||
creatingIndexes = false;
|
||||
return true; // keep sheet open!
|
||||
}
|
||||
}
|
||||
|
||||
creatingIndexes = false;
|
||||
|
||||
if (successCount > 0) {
|
||||
trackEvent(Submit.IndexCreate, { type: 'suggestions', count: successCount });
|
||||
}
|
||||
|
||||
if (successCount === indexes.length) {
|
||||
// all succeeded
|
||||
addNotification({
|
||||
message: `${successCount} index${successCount > 1 ? 'es are' : ' is'} being created`,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
await closeAndInvalidate();
|
||||
} else if (successCount > 0) {
|
||||
// some succeeded, some failed
|
||||
addNotification({
|
||||
message: 'Some indexes could not be created',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
await closeAndInvalidate();
|
||||
} else {
|
||||
// all failed
|
||||
addNotification({
|
||||
message: 'Failed to create indexes',
|
||||
type: 'error'
|
||||
});
|
||||
|
||||
return true; // keep sheet open!
|
||||
}
|
||||
|
||||
return false; // close the sheet!
|
||||
}
|
||||
|
||||
const typeOptions = Object.values(IndexType).map((type) => ({
|
||||
label: capitalize(type),
|
||||
value: type
|
||||
}));
|
||||
|
||||
onMount(() => showIndexesSuggestions.set(false));
|
||||
|
||||
onDestroy(() => showIndexesSuggestions.set(false));
|
||||
</script>
|
||||
|
||||
{#if !$isSmallViewport}
|
||||
<Modal
|
||||
dismissible={false}
|
||||
title="Suggested indexes"
|
||||
bind:error={modalError}
|
||||
bind:show={$showIndexesSuggestions}
|
||||
onSubmit={async () => {
|
||||
await applySuggestedIndexes();
|
||||
}}>
|
||||
<Layout.Stack gap="m">
|
||||
{#await loadIndexSuggestions()}
|
||||
<Layout.Stack gap="xs">
|
||||
<Layout.Stack gap="m">
|
||||
{#each Array(3) as _, index}
|
||||
{@const firstItem = index === 0}
|
||||
<Layout.Stack gap="m" direction="row" alignItems="center">
|
||||
{@render fieldSkeleton({ label: 'Column', showLabel: firstItem })}
|
||||
{@render fieldSkeleton({ label: 'Type', showLabel: firstItem })}
|
||||
{@render fieldSkeleton({ label: 'Order', showLabel: firstItem })}
|
||||
<!--{@render fieldSkeleton({ label: 'Length', showLabel: firstItem })}-->
|
||||
|
||||
<div style:margin-top={firstItem ? '27.6px' : '0'}>
|
||||
<Skeleton
|
||||
variant="square"
|
||||
width="33px"
|
||||
height="33px"
|
||||
style="opacity: 0.25" />
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
{@render addIndexButton()}
|
||||
</Layout.Stack>
|
||||
{:then suggestedIndexes}
|
||||
<Layout.Stack gap="xs">
|
||||
<Layout.Stack gap="m">
|
||||
{#each suggestedIndexes as index, count}
|
||||
{@render indexEditForm({ index, count, isDesktop: true })}
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
|
||||
{@render addIndexButton()}
|
||||
</Layout.Stack>
|
||||
{/await}
|
||||
</Layout.Stack>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Layout.Stack direction="row" justifyContent="flex-end" alignItems="center" inline>
|
||||
<Layout.Stack direction="row" gap="m">
|
||||
<Button
|
||||
text
|
||||
size="s"
|
||||
disabled={loadingSuggestions || creatingIndexes}
|
||||
on:click={() => {
|
||||
if (indexes.length > 0 && !creatingIndexes) {
|
||||
confirmDismiss = true;
|
||||
} else {
|
||||
$showIndexesSuggestions = false;
|
||||
}
|
||||
}}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
size="s"
|
||||
submit
|
||||
submissionLoader
|
||||
disabled={indexes.length === 0 || loadingSuggestions}
|
||||
forceShowLoader={creatingIndexes}>
|
||||
Create
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if $isSmallViewport}
|
||||
<SideSheet
|
||||
closeOnBlur={false}
|
||||
title="Suggested indexes"
|
||||
data-index-edit-form-sheet
|
||||
bind:show={$showIndexesSuggestions}
|
||||
submit={{
|
||||
text: 'Create',
|
||||
disabled: indexes.length === 0 || creatingIndexes || loadingSuggestions,
|
||||
onClick: async () => await applySuggestedIndexes()
|
||||
}}
|
||||
cancel={{
|
||||
disabled: loadingSuggestions || creatingIndexes,
|
||||
onClick: () => {
|
||||
if (indexes.length > 0 && !creatingIndexes) {
|
||||
confirmDismiss = true;
|
||||
} else {
|
||||
$showIndexesSuggestions = false;
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{#if modalError}
|
||||
<Alert.Inline status="error" title={modalError} />
|
||||
{/if}
|
||||
|
||||
{#await loadIndexSuggestions()}
|
||||
<Layout.Stack gap="m" alignItems="center">
|
||||
{#each Array(3) as _}
|
||||
{@render fieldSkeleton({
|
||||
label: undefined,
|
||||
showLabel: false,
|
||||
isDesktop: false
|
||||
})}
|
||||
{/each}
|
||||
</Layout.Stack>
|
||||
{:then suggestedIndexes}
|
||||
<Layout.Stack gap="s">
|
||||
{#each suggestedIndexes as index, count}
|
||||
<Accordion title="{index.key}_index">
|
||||
{@render indexEditForm({ index, count, isDesktop: false })}
|
||||
</Accordion>
|
||||
{/each}
|
||||
|
||||
{@render addIndexButton()}
|
||||
</Layout.Stack>
|
||||
{/await}
|
||||
</SideSheet>
|
||||
{/if}
|
||||
|
||||
{#snippet fieldSkeleton({ label, showLabel, isDesktop = true })}
|
||||
<Layout.Stack gap="xxxs">
|
||||
{#if showLabel}
|
||||
<Typography.Text variant="m-500" style="margin-block-end: var(--gap-s);"
|
||||
>{label}</Typography.Text>
|
||||
{/if}
|
||||
<Skeleton
|
||||
variant="line"
|
||||
width="100%"
|
||||
height={isDesktop ? 33.602 : 39.398}
|
||||
style="opacity: 0.25" />
|
||||
</Layout.Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet indexEditForm({ index, count, isDesktop = true })}
|
||||
{@const firstItem = count === 0}
|
||||
{#if isDesktop}
|
||||
<Layout.Stack gap="m" direction="row" alignItems="center" class="desktop-selects-form">
|
||||
<InputSelect
|
||||
id="key-{count}"
|
||||
label={firstItem ? 'Column' : undefined}
|
||||
bind:value={index.key}
|
||||
on:change={(event) => syncIndexState(event, index)}
|
||||
options={columnOptions}
|
||||
placeholder="Select column"
|
||||
required
|
||||
disabled={creatingIndexes} />
|
||||
|
||||
<InputSelect
|
||||
id="type-{count}"
|
||||
label={firstItem ? 'Type' : ''}
|
||||
bind:value={index.type}
|
||||
options={typeOptions}
|
||||
required
|
||||
disabled={creatingIndexes} />
|
||||
|
||||
<InputSelect
|
||||
id="order-{count}"
|
||||
label={firstItem ? 'Order' : ''}
|
||||
bind:value={index.orders}
|
||||
options={getOrderOptions(index.type)}
|
||||
required
|
||||
disabled={creatingIndexes} />
|
||||
|
||||
{@render removeIndexButton({ count, isDesktop: true })}
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<!-- Mobile: Vertical layout -->
|
||||
<Layout.Stack gap="m">
|
||||
<Layout.Stack gap="s">
|
||||
<InputSelect
|
||||
id="key-{count}-mobile"
|
||||
bind:value={index.key}
|
||||
on:change={(event) => syncIndexState(event, index)}
|
||||
options={columnOptions}
|
||||
placeholder="Select column"
|
||||
required />
|
||||
|
||||
<InputSelect
|
||||
id="type-{count}-mobile"
|
||||
bind:value={index.type}
|
||||
options={typeOptions}
|
||||
required />
|
||||
|
||||
<InputSelect
|
||||
id="order-{count}-mobile"
|
||||
bind:value={index.orders}
|
||||
options={getOrderOptions(index.type)}
|
||||
required />
|
||||
</Layout.Stack>
|
||||
|
||||
{@render removeIndexButton({ count, isDesktop: false })}
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet addIndexButton()}
|
||||
{#if indexes.length < MAX_INDEXES}
|
||||
<Layout.Stack direction="row" justifyContent="flex-start">
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
compact
|
||||
on:click={addIndex}
|
||||
disabled={loadingSuggestions || creatingIndexes}>
|
||||
<Icon icon={IconPlus} size="s" />
|
||||
Add Index
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet removeIndexButton({ count, isDesktop = true })}
|
||||
{#if isDesktop}
|
||||
<div style:margin-top={count === 0 ? '27.6px' : '0'}>
|
||||
<Button
|
||||
icon
|
||||
size="xs"
|
||||
secondary
|
||||
on:click={() => removeIndex(count)}
|
||||
disabled={indexes.length <= 1 || creatingIndexes}>
|
||||
<Icon icon={IconX} color="--fgcolor-danger-primary" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if indexes.length > 1}
|
||||
<Layout.Stack direction="row" justifyContent="flex-start">
|
||||
<Button
|
||||
size="s"
|
||||
secondary
|
||||
on:click={() => removeIndex(count)}
|
||||
disabled={indexes.length <= 1 || creatingIndexes}>Delete</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<Confirm
|
||||
confirmDeletion
|
||||
action="Dismiss"
|
||||
title="Dismiss indexes"
|
||||
bind:open={confirmDismiss}
|
||||
onSubmit={dismissIndexes}>
|
||||
Are you sure you want to dismiss these suggested indexes? This action cannot be undone.
|
||||
</Confirm>
|
||||
|
||||
<style lang="scss">
|
||||
// Custom logic to hide the Sheet's
|
||||
// `X` close button (not configurable via props)
|
||||
:global([data-index-edit-form-sheet] header) {
|
||||
& :global(.divider),
|
||||
& :global(button) {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
}
|
||||
|
||||
// stack > div > input-select button!
|
||||
:global(.desktop-selects-form :first-child button:first-of-type) {
|
||||
width: 164px;
|
||||
max-width: 164px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { isCloud } from '$lib/system';
|
||||
import IconAI from './icon/ai.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { tableColumnSuggestions } from './store';
|
||||
import { InputTextarea } from '$lib/elements/forms';
|
||||
import { Button, InputTextarea } from '$lib/elements/forms';
|
||||
import { Card, Layout, Selector, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
// enable by default!
|
||||
$tableColumnSuggestions.enabled = true;
|
||||
if (featureActive) {
|
||||
$tableColumnSuggestions.enabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
const featureActive = $derived(isCloud);
|
||||
|
||||
const title = $derived.by(() => {
|
||||
return featureActive
|
||||
? 'Smart column suggestions'
|
||||
: 'Smart column suggestions available on Cloud';
|
||||
});
|
||||
|
||||
const subtitle = $derived.by(() => {
|
||||
return featureActive
|
||||
? 'Enable AI to suggest useful columns based on your table name'
|
||||
: 'Sign up for Cloud to generate columns based on your table name';
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Base variant="secondary" radius="s" padding="xs">
|
||||
<Layout.Stack gap="m">
|
||||
<Layout.Stack direction="row" gap="s" alignItems="center">
|
||||
<Layout.Stack gap={featureActive ? 'm' : 'l'}>
|
||||
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
|
||||
<IconAI />
|
||||
|
||||
<Layout.Stack direction="column" gap="none">
|
||||
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
|
||||
>Smart column suggestions</Typography.Text>
|
||||
|
||||
<div class="suggestions-switch">
|
||||
<Selector.Switch
|
||||
id="suggestions"
|
||||
label={undefined}
|
||||
bind:checked={$tableColumnSuggestions.enabled} />
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
|
||||
>{title}</Typography.Text>
|
||||
|
||||
<Typography.Text color="--fgcolor-neutral-secondary">
|
||||
Enable AI to suggest useful columns based on your table name
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if featureActive}
|
||||
<div class="suggestions-switch">
|
||||
<Selector.Switch
|
||||
id="suggestions"
|
||||
label={undefined}
|
||||
bind:checked={$tableColumnSuggestions.enabled} />
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if $tableColumnSuggestions.enabled}
|
||||
{#if !featureActive}
|
||||
<Layout.Stack>
|
||||
<Button external secondary href="https://cloud.appwrite.io/register">
|
||||
Sign up
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<!-- just being safe with extra guard! -->
|
||||
{#if $tableColumnSuggestions.enabled && featureActive}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<InputTextarea
|
||||
id="context"
|
||||
@@ -50,7 +75,7 @@
|
||||
</Card.Base>
|
||||
|
||||
<style lang="scss">
|
||||
.suggestions-switch :global(button) {
|
||||
.suggestions-switch :global(button):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { IndexType } from '@appwrite.io/console';
|
||||
import { columnOptions } from '../table-[table]/columns/store';
|
||||
|
||||
export type TableColumnSuggestions = {
|
||||
@@ -24,6 +25,20 @@ export type SuggestedColumnSchema = {
|
||||
format?: string | null;
|
||||
};
|
||||
|
||||
export enum IndexOrder {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
NONE = null
|
||||
}
|
||||
|
||||
export type SuggestedIndexSchema = {
|
||||
key: string;
|
||||
type: IndexType;
|
||||
orders: IndexOrder;
|
||||
columns: string[];
|
||||
lengths?: number[] | undefined;
|
||||
};
|
||||
|
||||
export const tableColumnSuggestions = writable<TableColumnSuggestions>({
|
||||
enabled: false,
|
||||
context: null,
|
||||
@@ -31,7 +46,7 @@ export const tableColumnSuggestions = writable<TableColumnSuggestions>({
|
||||
table: null
|
||||
});
|
||||
|
||||
export const createTableRequest = writable<boolean>(false);
|
||||
export const showIndexesSuggestions = writable<boolean>(false);
|
||||
|
||||
export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
total: 7,
|
||||
|
||||
@@ -138,7 +138,10 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Database - Appwrite</title>
|
||||
<!-- svelte bug, the table header just stays! -->
|
||||
{#key page.url.pathname}
|
||||
<title>Database - Appwrite</title>
|
||||
{/key}
|
||||
</svelte:head>
|
||||
|
||||
<slot />
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
import { trackEvent } from '$lib/actions/analytics';
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { page } from '$app/state';
|
||||
import IconQuestionMarkCircle from './components/questionIcon.svelte';
|
||||
|
||||
let policyCreateError: string;
|
||||
let totalPolicies: UserBackupPolicy[] = [];
|
||||
@@ -40,7 +41,7 @@
|
||||
if (parsedCounter === showOnCount || !counter) {
|
||||
addNotification({
|
||||
type: 'info',
|
||||
icon: 'question-mark-circle',
|
||||
icon: IconQuestionMarkCircle,
|
||||
message:
|
||||
'How was your experience with our new Backups feature? Give us your feedback and help us improve!',
|
||||
timeout: 15000,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconQuestionMarkCircle } from '@appwrite.io/pink-icons-svelte';
|
||||
</script>
|
||||
|
||||
<Icon icon={IconQuestionMarkCircle} />
|
||||
@@ -19,6 +19,7 @@
|
||||
} = $props();
|
||||
|
||||
const databaseId = page.params.database;
|
||||
const isOnTablesPage = $derived(page.route?.id.endsWith('table-[table]'));
|
||||
|
||||
let name = $state('');
|
||||
let id: string = $state(null);
|
||||
@@ -27,6 +28,20 @@
|
||||
|
||||
let creatingTable = $state(false);
|
||||
|
||||
function enableThinkingModeForSuggestions(table: Models.Table) {
|
||||
if ($tableColumnSuggestions.enabled) {
|
||||
// if enabled, trigger thinking mode!
|
||||
tableColumnSuggestions.update((store) => ({
|
||||
...store,
|
||||
thinking: true,
|
||||
table: {
|
||||
id: table.$id,
|
||||
name: table.name
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function createTable() {
|
||||
error = null;
|
||||
try {
|
||||
@@ -38,14 +53,6 @@
|
||||
name
|
||||
});
|
||||
|
||||
tableColumnSuggestions.update((store) => ({
|
||||
...store,
|
||||
table: {
|
||||
id: table.$id,
|
||||
name: table.name
|
||||
}
|
||||
}));
|
||||
|
||||
updateAndCleanup();
|
||||
|
||||
await onTableCreated(table);
|
||||
@@ -53,6 +60,9 @@
|
||||
name = id = null;
|
||||
showCreate = false;
|
||||
creatingTable = false;
|
||||
|
||||
// don't wait for UI to mount!
|
||||
enableThinkingModeForSuggestions(table);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
trackError(e, Submit.TableCreate);
|
||||
@@ -93,6 +103,15 @@
|
||||
touchedId = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (showCreate && isOnTablesPage && $tableColumnSuggestions.table) {
|
||||
tableColumnSuggestions.update((store) => ({
|
||||
...store,
|
||||
table: null
|
||||
}));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title="Create table" size="m" bind:show={showCreate} onSubmit={createTable} bind:error>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { database } from './store';
|
||||
import { toLocaleDate } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import { type Models, Query } from '@appwrite.io/console';
|
||||
import { Spinner, Table } from '@appwrite.io/pink-svelte';
|
||||
|
||||
@@ -126,7 +126,9 @@
|
||||
{#each tableItems as table}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell {root}>{table.name}</Table.Cell>
|
||||
<Table.Cell {root}>{toLocaleDate(table.updatedAt)}</Table.Cell>
|
||||
<Table.Cell {root}>
|
||||
<DualTimeView time={table.updatedAt} />
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
|
||||
@@ -28,14 +28,13 @@
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { subNavigation } from '$lib/stores/database';
|
||||
import { createTableRequest, tableColumnSuggestions } from './(suggestions)/store';
|
||||
|
||||
let data = $derived(page.data) as PageData;
|
||||
const data = $derived(page.data) as PageData;
|
||||
|
||||
let region = $derived(page.params.region);
|
||||
let project = $derived(page.params.project);
|
||||
let tableId = $derived(page.params.table);
|
||||
let databaseId = $derived(page.params.database);
|
||||
const region = $derived(page.params.region);
|
||||
const project = $derived(page.params.project);
|
||||
const tableId = $derived(page.params.table);
|
||||
const databaseId = $derived(page.params.database);
|
||||
|
||||
let openBottomSheet = $state(false);
|
||||
|
||||
@@ -56,10 +55,11 @@
|
||||
|
||||
const isMainDatabaseScreen = $derived(page.route.id.endsWith('database-[database]'));
|
||||
|
||||
// If banner open, `-1rem` to adjust banner size, else `-70.5px`.
|
||||
// If banner open, adjust bottom position to account for banner container.
|
||||
// 70.5px is the size of the container of the banner holder and not just the banner!
|
||||
// Needed because things vary a bit much on how different browsers treat bottom layouts.
|
||||
const bottomNavHeight = $derived(`calc(20% ${$bannerSpacing ? '- 1rem' : '- 70.5px'})`);
|
||||
const bottomNavOffset = $derived($bannerSpacing ? '70.5px' : '0px');
|
||||
const tableContentPadding = $derived($bannerSpacing ? '210px' : '140px');
|
||||
|
||||
async function loadTables() {
|
||||
tables = await sdk.forProject(region, project).tablesDB.listTables({
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
{data.database?.name}
|
||||
</a>
|
||||
<div class="table-content">
|
||||
<div class="table-content" style:padding-bottom={tableContentPadding}>
|
||||
{#if tables?.total}
|
||||
<ul class="drop-list u-margin-inline-start-8 u-margin-block-start-4">
|
||||
{#each sortedTables as table, index}
|
||||
@@ -147,25 +147,15 @@
|
||||
<Button
|
||||
compact
|
||||
on:click={() => {
|
||||
if (
|
||||
$tableColumnSuggestions.enabled &&
|
||||
$tableColumnSuggestions.table?.id
|
||||
) {
|
||||
$createTableRequest = true;
|
||||
} else {
|
||||
$showCreateTable = true;
|
||||
$showSubNavigation = false;
|
||||
}
|
||||
$showCreateTable = true;
|
||||
$showSubNavigation = false;
|
||||
}}>
|
||||
Create table
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
|
||||
<Layout.Stack
|
||||
gap="xxs"
|
||||
direction="column"
|
||||
style="bottom: 1rem; position: relative; height: {bottomNavHeight}">
|
||||
<div class="bottom-nav-container" style:bottom={bottomNavOffset}>
|
||||
<div class="action-menu-divider">
|
||||
<Divider />
|
||||
</div>
|
||||
@@ -177,7 +167,7 @@
|
||||
{@const href = `${base}/project-${region}-${project}/databases/database-${databaseId}/${action.href}`}
|
||||
|
||||
<Layout.Stack gap="s" direction="row" alignItems="center">
|
||||
<li>
|
||||
<li class="bottom-nav-item">
|
||||
<a
|
||||
{href}
|
||||
class="u-padding-block-8 u-padding-inline-end-4 u-padding-inline-start-8 u-flex u-cross-center u-gap-8">
|
||||
@@ -191,7 +181,7 @@
|
||||
</Layout.Stack>
|
||||
{/each}
|
||||
</ul>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
</section>
|
||||
</Sidebar.Base>
|
||||
{:else if data?.database?.name && !isMainDatabaseScreen}
|
||||
@@ -271,11 +261,15 @@
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
margin-bottom: auto;
|
||||
padding-bottom: 16px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-neutral, #ededf0) transparent;
|
||||
color: var(--fgcolor-neutral-secondary, #56565c);
|
||||
|
||||
/* hide scrollbars */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
// scrollbar-width: thin;
|
||||
// scrollbar-color: var(--border-neutral, #ededf0) transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
@@ -304,6 +298,10 @@
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--fgcolor-neutral-secondary);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:not(.bottom-nav)::before {
|
||||
content: '';
|
||||
right: 99%;
|
||||
@@ -335,12 +333,16 @@
|
||||
position: relative;
|
||||
padding-inline-end: 0.5rem;
|
||||
margin-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
color: var(--fgcolor-neutral-primary);
|
||||
border-radius: var(--border-radius-s, 6px);
|
||||
background: var(--bgcolor-neutral-secondary);
|
||||
&:hover {
|
||||
color: var(--fgcolor-neutral-primary);
|
||||
border-radius: var(--border-radius-s, 6px);
|
||||
background: var(--bgcolor-neutral-secondary);
|
||||
}
|
||||
|
||||
&.bottom-nav-item:hover {
|
||||
margin-inline-end: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table-name {
|
||||
@@ -382,8 +384,16 @@
|
||||
line-height: 150%; /* 21px */
|
||||
}
|
||||
|
||||
.bottom-nav-container {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 1.25rem;
|
||||
position: absolute;
|
||||
padding-block-end: 1rem;
|
||||
}
|
||||
|
||||
.action-menu-divider {
|
||||
margin-inline: -1.2rem;
|
||||
padding-block-end: 0.25rem;
|
||||
margin-inline-start: -1.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
spreadsheetRenderKey,
|
||||
expandTabs,
|
||||
databaseRelatedRowSheetOptions,
|
||||
rowPermissionSheet
|
||||
rowPermissionSheet,
|
||||
type Columns
|
||||
} from './store';
|
||||
import { addSubPanel, registerCommands, updateCommandGroupRanks } from '$lib/commandCenter';
|
||||
import CreateColumn from './createColumn.svelte';
|
||||
@@ -56,7 +57,6 @@
|
||||
import { Button, Seekbar } from '$lib/elements/forms';
|
||||
import { generateFakeRecords, generateColumns } from '$lib/helpers/faker';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { sleep } from '$lib/helpers/promises';
|
||||
import CreateIndex from './indexes/createIndex.svelte';
|
||||
import { hash } from '$lib/helpers/string';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
@@ -64,6 +64,10 @@
|
||||
import { chunks } from '$lib/helpers/array';
|
||||
import { Submit, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
|
||||
import { showIndexesSuggestions, tableColumnSuggestions } from '../(suggestions)';
|
||||
import type { RealtimeResponseEvent } from '@appwrite.io/console';
|
||||
|
||||
let editRow: EditRow;
|
||||
let editRelatedRow: EditRelatedRow;
|
||||
let editRowPermissions: EditRowPermissions;
|
||||
@@ -79,6 +83,8 @@
|
||||
*/
|
||||
let isWaterfallFromFaker = false;
|
||||
|
||||
let columnCreationHandler: ((response: RealtimeResponseEvent<unknown>) => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
expandTabs.set(preferences.getKey('tableHeaderExpanded', true));
|
||||
|
||||
@@ -89,7 +95,19 @@
|
||||
response.events.includes('databases.*.tables.*.columns.*') ||
|
||||
response.events.includes('databases.*.tables.*.indexes.*')
|
||||
) {
|
||||
if (!isWaterfallFromFaker) {
|
||||
if (isWaterfallFromFaker) {
|
||||
columnCreationHandler?.(response);
|
||||
}
|
||||
|
||||
// don't invalidate when -
|
||||
// 1. from faker
|
||||
// 2. ai columns creation
|
||||
// 3. ai indexes creation
|
||||
if (
|
||||
!isWaterfallFromFaker &&
|
||||
!$showIndexesSuggestions &&
|
||||
!$tableColumnSuggestions.table
|
||||
) {
|
||||
invalidate(Dependencies.TABLE);
|
||||
}
|
||||
}
|
||||
@@ -242,21 +260,76 @@
|
||||
indexes: 700
|
||||
});
|
||||
|
||||
function setupColumnObserver() {
|
||||
let expectedCount = 0;
|
||||
let resolvePromise: () => void;
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const availableColumns = new Set<string>();
|
||||
const waitPromise = new Promise<void>((resolve) => (resolvePromise = resolve));
|
||||
|
||||
columnCreationHandler = (response) => {
|
||||
const { events, payload } = response;
|
||||
|
||||
if (
|
||||
events.includes('databases.*.tables.*.columns.*.create') ||
|
||||
events.includes('databases.*.tables.*.columns.*.update')
|
||||
) {
|
||||
const asColumn = payload as Columns;
|
||||
const columnId = asColumn.key;
|
||||
const status = asColumn.status;
|
||||
|
||||
if (status === 'available') {
|
||||
availableColumns.add(columnId);
|
||||
|
||||
if (expectedCount > 0 && availableColumns.size >= expectedCount) {
|
||||
clearTimeout(timeout);
|
||||
columnCreationHandler = null;
|
||||
resolvePromise();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// return function to start waiting!
|
||||
const startWaiting = (count: number) => {
|
||||
expectedCount = count;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
columnCreationHandler = null;
|
||||
resolvePromise();
|
||||
}, 10000);
|
||||
|
||||
if (availableColumns.size >= expectedCount) {
|
||||
clearTimeout(timeout);
|
||||
columnCreationHandler = null;
|
||||
resolvePromise();
|
||||
}
|
||||
};
|
||||
|
||||
return { startWaiting, waitPromise };
|
||||
}
|
||||
|
||||
async function createFakeData() {
|
||||
isWaterfallFromFaker = true;
|
||||
|
||||
$spreadsheetLoading = true;
|
||||
$randomDataModalState.show = false;
|
||||
|
||||
let columns = $table.columns;
|
||||
let columns = page.data.table.columns as Columns[];
|
||||
const hasAnyRelationships = columns.some((column) => isRelationship(column));
|
||||
const filteredColumns = columns.filter((col) => col.type !== 'relationship');
|
||||
|
||||
if (!filteredColumns.length) {
|
||||
try {
|
||||
const { startWaiting, waitPromise } = setupColumnObserver();
|
||||
columns = await generateColumns($project, page.params.database, page.params.table);
|
||||
startWaiting(columns.length);
|
||||
await waitPromise;
|
||||
|
||||
await invalidate(Dependencies.TABLE);
|
||||
columns = page.data.table.columns as Columns[];
|
||||
|
||||
trackEvent(Submit.ColumnCreate, { type: 'faker' });
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
@@ -268,9 +341,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* let the columns be processed! */
|
||||
await sleep(1250);
|
||||
|
||||
let rowIds = [];
|
||||
try {
|
||||
const { rows, ids } = generateFakeRecords(columns, $randomDataModalState.value);
|
||||
@@ -442,7 +512,7 @@
|
||||
title="Row permissions"
|
||||
bind:show={$rowPermissionSheet.show}
|
||||
submit={{
|
||||
text: 'Create',
|
||||
text: 'Update',
|
||||
disabled: editRowPermissions?.disableSubmit(),
|
||||
onClick: async () => editRowPermissions?.updatePermissions()
|
||||
}}>
|
||||
@@ -470,3 +540,5 @@
|
||||
</Layout.Stack>
|
||||
</svelte:fragment>
|
||||
</Dialog>
|
||||
|
||||
<IndexesSuggestions />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Container } from '$lib/layout';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { canWriteTables, canWriteRows } from '$lib/stores/roles';
|
||||
import { Icon, Layout, Divider } from '@appwrite.io/pink-svelte';
|
||||
import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import type { PageData } from './$types';
|
||||
import {
|
||||
table,
|
||||
@@ -31,6 +31,7 @@
|
||||
import EmptySheet from './layout/emptySheet.svelte';
|
||||
import CreateRow from './rows/create.svelte';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { Empty as SuggestionsEmptySheet, tableColumnSuggestions } from '../(suggestions)';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -75,6 +76,12 @@
|
||||
|
||||
$: hasColumns = !!$table.columns.length;
|
||||
$: hasValidColumns = $table?.columns?.some((col) => col.status === 'available');
|
||||
$: canShowSuggestionsSheet =
|
||||
// enabled, has table details
|
||||
// and it matches current table
|
||||
$tableColumnSuggestions.enabled &&
|
||||
$tableColumnSuggestions.table &&
|
||||
$tableColumnSuggestions.table.id === page.params.table;
|
||||
|
||||
async function onSelect(file: Models.File, localFile = false) {
|
||||
$isCsvImportInProgress = true;
|
||||
@@ -114,21 +121,31 @@
|
||||
<Layout.Stack direction="column" gap="xl">
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Layout.Stack direction="row" gap="s">
|
||||
<ViewSelector
|
||||
onlyIcon
|
||||
ui="new"
|
||||
view={data.view}
|
||||
columns={tableColumns}
|
||||
hideView
|
||||
showAnyway
|
||||
isCustomTable />
|
||||
<Tooltip>
|
||||
<div>
|
||||
<ViewSelector
|
||||
onlyIcon
|
||||
ui="new"
|
||||
view={data.view}
|
||||
columns={tableColumns}
|
||||
hideView
|
||||
showAnyway
|
||||
isCustomTable />
|
||||
</div>
|
||||
|
||||
<Filters
|
||||
onlyIcon
|
||||
query={data.query}
|
||||
columns={filterColumns}
|
||||
disabled={!(hasColumns && hasValidColumns)}
|
||||
analyticsSource="database_rows" />
|
||||
<svelte:fragment slot="tooltip">Columns</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<Filters
|
||||
onlyIcon
|
||||
query={data.query}
|
||||
columns={filterColumns}
|
||||
disabled={!(hasColumns && hasValidColumns)}
|
||||
analyticsSource="database_tables" />
|
||||
|
||||
<svelte:fragment slot="tooltip">Filters</svelte:fragment>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
<Button
|
||||
@@ -192,7 +209,7 @@
|
||||
queries.clearAll();
|
||||
queries.apply();
|
||||
trackEvent(Submit.FilterClear, {
|
||||
source: 'database_rows'
|
||||
source: 'database_tables'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -216,7 +233,7 @@
|
||||
}
|
||||
}} />
|
||||
{/if}
|
||||
{:else if $tableColumnSuggestions.enabled && $tableColumnSuggestions.table && $tableColumnSuggestions.table.id === page.params.table}
|
||||
{:else if isCloud && canShowSuggestionsSheet}
|
||||
<SuggestionsEmptySheet />
|
||||
{:else}
|
||||
<EmptySheet
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import {
|
||||
columns,
|
||||
type Columns,
|
||||
type ColumnsWidth,
|
||||
indexes,
|
||||
isCsvImportInProgress,
|
||||
reorderItems,
|
||||
@@ -39,7 +40,8 @@
|
||||
IconTrash,
|
||||
IconViewList,
|
||||
IconLockClosed,
|
||||
IconFingerPrint
|
||||
IconFingerPrint,
|
||||
IconMail
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { type ComponentProps, onDestroy, onMount } from 'svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
@@ -51,6 +53,14 @@
|
||||
import { type Models } from '@appwrite.io/console';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { page } from '$app/state';
|
||||
import { debounce } from '$lib/helpers/debounce';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
const {
|
||||
data
|
||||
}: {
|
||||
data: PageData;
|
||||
} = $props();
|
||||
|
||||
const updatedColumnsForSheet = $derived.by(() => {
|
||||
const baseAttrs = [
|
||||
@@ -104,8 +114,11 @@
|
||||
let selectedColumn: Columns = $state(null);
|
||||
let columnIndexMap: Record<string, boolean> = $state({});
|
||||
|
||||
let columnsOrder = $state([]);
|
||||
let columnsOrder = $state<string[]>([]);
|
||||
let columnsWidth = $state<ColumnsWidth | null>(null);
|
||||
|
||||
const tableId = page.params.table;
|
||||
const organizationId = data.organization.$id ?? data.project.teamId;
|
||||
|
||||
let showEdit = $state(false);
|
||||
let editColumn: EditColumn;
|
||||
@@ -113,7 +126,7 @@
|
||||
const columnFormatIcon = {
|
||||
ip: IconLocationMarker,
|
||||
url: IconLink,
|
||||
email: IconLink,
|
||||
email: IconMail,
|
||||
enum: IconViewList
|
||||
};
|
||||
|
||||
@@ -126,6 +139,7 @@
|
||||
|
||||
onMount(() => {
|
||||
columnsOrder = preferences.getColumnOrder(tableId);
|
||||
columnsWidth = preferences.getColumnWidths(tableId + '#columns');
|
||||
});
|
||||
|
||||
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
|
||||
@@ -182,8 +196,80 @@
|
||||
return `Type: ${formattedType}`;
|
||||
}
|
||||
|
||||
function isSystemColumnKey(column: Columns) {
|
||||
return column.key.startsWith('$');
|
||||
}
|
||||
|
||||
function getColumnWidth(columnId: string, defaultWidth: number): number {
|
||||
const savedWidth = columnsWidth?.[columnId];
|
||||
if (!savedWidth) return defaultWidth;
|
||||
|
||||
return savedWidth.resized;
|
||||
}
|
||||
|
||||
function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) {
|
||||
const existing = columnsWidth?.[columnId];
|
||||
const fixed = existing
|
||||
? typeof existing?.fixed === 'number'
|
||||
? existing.fixed
|
||||
: existing?.fixed?.min
|
||||
: newWidth;
|
||||
|
||||
columnsWidth = {
|
||||
...(columnsWidth ?? {}),
|
||||
[columnId]: {
|
||||
fixed,
|
||||
resized: Math.ceil(newWidth)
|
||||
}
|
||||
};
|
||||
|
||||
saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed });
|
||||
}
|
||||
|
||||
const saveColumnWidthsToPreferences = debounce(
|
||||
(column: { columnId: string; newWidth: number; fixedWidth: number }) => {
|
||||
if (!organizationId) return;
|
||||
|
||||
preferences.saveColumnWidths(organizationId, tableId + '#columns', {
|
||||
[column.columnId]: {
|
||||
fixed: column.fixedWidth,
|
||||
resized: Math.ceil(column.newWidth)
|
||||
}
|
||||
});
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
onDestroy(() => ($showCreateColumnSheet.show = false));
|
||||
|
||||
const spreadsheetColumns = $derived([
|
||||
{
|
||||
id: 'key',
|
||||
width: getColumnWidth('key', 300),
|
||||
minimumWidth: 300,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
width: 150,
|
||||
minimumWidth: 150,
|
||||
resizable: false
|
||||
},
|
||||
{
|
||||
id: 'indexed',
|
||||
width: getColumnWidth('indexed', 150),
|
||||
minimumWidth: 150,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'default',
|
||||
width: getColumnWidth('default', 200),
|
||||
minimumWidth: 200,
|
||||
resizable: true
|
||||
},
|
||||
{ id: 'actions', width: 40, isAction: true, resizable: false }
|
||||
]);
|
||||
|
||||
$effect(() => {
|
||||
if (!$showCreateIndexSheet.show && $showCreateIndexSheet.column) {
|
||||
const columnKey = $showCreateIndexSheet.column;
|
||||
@@ -220,34 +306,40 @@
|
||||
<SpreadsheetContainer>
|
||||
<Spreadsheet.Root
|
||||
let:root
|
||||
allowSelection
|
||||
height="100%"
|
||||
allowSelection
|
||||
emptyCells={emptyCellsCount}
|
||||
bind:selectedRows={selectedColumns}
|
||||
columns={[
|
||||
// more size until we decide if we want a new column!
|
||||
{ id: 'key', width: { min: $isSmallViewport ? 250 : 200 } },
|
||||
{ id: 'indexed', width: { min: 150 } },
|
||||
{ id: 'default', width: { min: 200 } },
|
||||
{ id: 'actions', width: 40, isAction: true }
|
||||
]}
|
||||
bottomActionClick={() => ($showCreateColumnSheet.show = true)}>
|
||||
columns={spreadsheetColumns}
|
||||
bottomActionClick={() => ($showCreateColumnSheet.show = true)}
|
||||
on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Spreadsheet.Header.Cell column="key" {root}>Column name</Spreadsheet.Header.Cell>
|
||||
<Spreadsheet.Header.Cell column="type" {root}>Type</Spreadsheet.Header.Cell>
|
||||
<Spreadsheet.Header.Cell column="indexed" {root}>Indexed</Spreadsheet.Header.Cell>
|
||||
<Spreadsheet.Header.Cell column="default" {root}
|
||||
>Default value</Spreadsheet.Header.Cell>
|
||||
<Spreadsheet.Header.Cell column="actions" {root} />
|
||||
</svelte:fragment>
|
||||
|
||||
{#each updatedColumnsForSheet as column, index}
|
||||
{#each updatedColumnsForSheet as column, index (column.key)}
|
||||
{@const isId = column.key === '$id'}
|
||||
{@const option = columnOptions.find((option) => option.type === column.type)}
|
||||
{@const isSelectable =
|
||||
column['system'] || column.type === 'relationship' ? 'disabled' : true}
|
||||
<Spreadsheet.Row.Base {root} select={isSelectable} id={column.key}>
|
||||
<Spreadsheet.Cell column="key" {root} isEditable={false}>
|
||||
<Layout.Stack direction="row" justifyContent="space-between">
|
||||
<Layout.Stack direction="row" alignItems="center" inline>
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
style="min-width:0">
|
||||
<Layout.Stack
|
||||
gap="s"
|
||||
inline
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
style="min-width:0; flex:1 1 auto;">
|
||||
{#if isRelationship(column)}
|
||||
<Icon
|
||||
size="s"
|
||||
@@ -263,29 +355,35 @@
|
||||
<Icon icon={option.icon} size="s" />
|
||||
{/if}
|
||||
|
||||
<Layout.Stack direction="row" alignItems="center" gap="s">
|
||||
<Layout.Stack
|
||||
inline
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap="xxs">
|
||||
<span class="text u-trim-1" data-private>
|
||||
{#if column.key === '$id' || column.key === '$sequence' || column.key === '$createdAt' || column.key === '$updatedAt'}
|
||||
{column['name']}
|
||||
{:else}
|
||||
{column.key} {column.array ? '[]' : undefined}
|
||||
{/if}
|
||||
</span>
|
||||
{#if isString(column) && column.encrypt}
|
||||
<Tooltip>
|
||||
<Icon
|
||||
size="s"
|
||||
icon={IconLockClosed}
|
||||
color="--fgcolor-neutral-tertiary" />
|
||||
<div slot="tooltip">Encrypted</div>
|
||||
</Tooltip>
|
||||
<Layout.Stack
|
||||
gap="s"
|
||||
inline
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
style="min-width:0; flex:1 1 auto; overflow:hidden;">
|
||||
<Typography.Text truncate>
|
||||
{#if isSystemColumnKey(column)}
|
||||
{column.key}
|
||||
{:else}
|
||||
{column.key}{column.array ? '[]' : undefined}
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Typography.Text>
|
||||
{#if isString(column) && column.encrypt}
|
||||
<Tooltip>
|
||||
<Icon
|
||||
size="s"
|
||||
icon={IconLockClosed}
|
||||
color="--fgcolor-neutral-tertiary" />
|
||||
<div slot="tooltip">Encrypted</div>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
<Layout.Stack
|
||||
gap="s"
|
||||
inline
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
style="flex:0 0 auto; white-space:nowrap;">
|
||||
{#if column.status !== 'available'}
|
||||
<Badge
|
||||
size="s"
|
||||
@@ -323,12 +421,17 @@
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Spreadsheet.Cell>
|
||||
<Spreadsheet.Cell column="type" {root} isEditable={false}>
|
||||
{@const columnType = column['format'] ? column['format'] : column.type}
|
||||
{columnType.toLowerCase()}
|
||||
</Spreadsheet.Cell>
|
||||
<Spreadsheet.Cell column="indexed" {root} isEditable={false}>
|
||||
{@const isActuallyIndexed = $indexes.some((index) =>
|
||||
index.columns.includes(column.key)
|
||||
)}
|
||||
<!-- $id is always indexed internally -->
|
||||
{@const isActuallyIndexed =
|
||||
isId || $indexes.some((index) => index.columns.includes(column.key))}
|
||||
|
||||
{@const checked = isActuallyIndexed || !!columnIndexMap[column.key]}
|
||||
<!-- $id is always indexed internally -->
|
||||
{@const checked = isId || isActuallyIndexed || !!columnIndexMap[column.key]}
|
||||
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
@@ -345,15 +448,18 @@
|
||||
}} />
|
||||
</Spreadsheet.Cell>
|
||||
<Spreadsheet.Cell column="default" {root} isEditable={false}>
|
||||
{@const _default =
|
||||
column?.default !== null && column?.default !== undefined
|
||||
? column?.default
|
||||
: null}
|
||||
{@const _default = column.required
|
||||
? '-'
|
||||
: column?.default !== null && column?.default !== undefined
|
||||
? column?.default
|
||||
: null}
|
||||
|
||||
{#if _default === null}
|
||||
<Badge variant="secondary" content="NULL" size="xs" />
|
||||
{:else if isSpatialType(column)}
|
||||
{JSON.stringify(_default)}
|
||||
{:else}
|
||||
{isSpatialType(column) ? JSON.stringify(_default) : _default}
|
||||
{_default}
|
||||
{/if}
|
||||
</Spreadsheet.Cell>
|
||||
<Spreadsheet.Cell column="actions" {root} isEditable={false}>
|
||||
@@ -363,8 +469,7 @@
|
||||
<Icon icon={IconDotsHorizontal} size="s" />
|
||||
</Button>
|
||||
</CsvDisabled>
|
||||
{:else if column.key !== '$sequence'}
|
||||
<!-- TODO: no portal, rather see if we can fix the cell -->
|
||||
{:else if !isId}
|
||||
<Popover let:toggle padding="none" placement="bottom-end" portal>
|
||||
<Button text icon ariaLabel="more options" on:click={toggle}>
|
||||
<Icon icon={IconDotsHorizontal} size="s" />
|
||||
@@ -458,7 +563,7 @@
|
||||
</div>
|
||||
|
||||
{#if selectedColumn}
|
||||
<DeleteColumn bind:showDelete {selectedColumn} />
|
||||
<DeleteColumn bind:showDelete bind:selectedColumn />
|
||||
{:else if selectedColumns && selectedColumns.length}
|
||||
<DeleteColumn bind:showDelete bind:selectedColumn={selectedColumns} />
|
||||
{/if}
|
||||
@@ -476,3 +581,13 @@
|
||||
{#if showFailed}
|
||||
<FailedModal bind:show={showFailed} title="Create attribute" header="Creation failed" {error} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.floating-action-bar {
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
z-index: 14;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
<script lang="ts">
|
||||
import { InputSelect } from '$lib/elements/forms';
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let data: Partial<Models.ColumnBoolean> = {
|
||||
required: false,
|
||||
@@ -45,9 +48,6 @@
|
||||
default: null
|
||||
};
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -67,6 +67,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -83,17 +84,5 @@
|
||||
{ label: 'False', value: false }
|
||||
]}
|
||||
bind:value={data.default} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -42,13 +42,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { InputDateTime } from '$lib/elements/forms';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let data: Partial<Models.ColumnDatetime>;
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -68,6 +67,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -79,17 +79,5 @@
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
await invalidate(Dependencies.TABLE);
|
||||
showDelete = false;
|
||||
selectedColumn = [];
|
||||
selectedColumn = Array.isArray(selectedColumn) ? [] : null;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
trackError(e, Submit.ColumnDelete);
|
||||
|
||||
@@ -38,13 +38,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { InputEmail } from '$lib/elements/forms';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let data: Partial<Models.ColumnEmail>;
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -64,6 +63,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -76,17 +76,5 @@
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -39,15 +39,15 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
import { InputSelect, InputTags } from '$lib/elements/forms';
|
||||
import { Icon, Tooltip, Typography } from '@appwrite.io/pink-svelte';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let data: Partial<Models.ColumnEnum>;
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Icon, Selector, Tooltip, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -107,17 +107,5 @@
|
||||
placeholder="Select a value"
|
||||
{options}
|
||||
bind:value={data.default} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
import { InputNumber } from '$lib/elements/forms';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let data: Partial<Models.ColumnFloat> = {
|
||||
@@ -52,9 +55,6 @@
|
||||
array: false
|
||||
};
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Layout, Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -85,14 +85,14 @@
|
||||
label="Min"
|
||||
placeholder="Enter size"
|
||||
bind:value={data.min}
|
||||
step="any"
|
||||
step={0.1}
|
||||
required={editing} />
|
||||
<InputNumber
|
||||
id="max"
|
||||
label="Max"
|
||||
placeholder="Enter size"
|
||||
bind:value={data.max}
|
||||
step="any"
|
||||
step={0.1}
|
||||
required={editing} />
|
||||
</Layout.Stack>
|
||||
<InputNumber
|
||||
@@ -104,18 +104,6 @@
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array}
|
||||
step="any" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
step={0.1} />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
import { InputNumber } from '$lib/elements/forms';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
|
||||
@@ -53,9 +56,6 @@
|
||||
array: false
|
||||
};
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Layout, Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -103,17 +103,5 @@
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -37,13 +37,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { InputText } from '$lib/elements/forms';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let data: Partial<Models.ColumnIp>;
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -63,6 +62,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -75,17 +75,5 @@
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Selector, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
required = $bindable(false),
|
||||
array = $bindable(false),
|
||||
editing = false
|
||||
}: {
|
||||
required: boolean;
|
||||
array: boolean;
|
||||
editing: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip disabled={!array} maxWidth="275px" placement="bottom-start">
|
||||
<div style:width="fit-content">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={required}
|
||||
disabled={array}
|
||||
description="Indicate whether this column is required." />
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">
|
||||
Required cannot be selected because array columns may contain more than one value.
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip disabled={!(required || editing)} maxWidth="275px" placement="bottom-start">
|
||||
<div style:width="fit-content">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={array}
|
||||
disabled={required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">
|
||||
{#if editing}
|
||||
Array cannot be selected to avoid data incompatibility.
|
||||
{:else}
|
||||
Array cannot be selected because required columns must be populated in all rows with a
|
||||
single value.
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
@@ -44,6 +44,7 @@
|
||||
import { currentPlan } from '$lib/stores/organization';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { ActionMenu, Selector } from '@appwrite.io/pink-svelte';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
import { InputNumber, InputText, InputTextarea } from '$lib/elements/forms';
|
||||
import { Popover, Layout, Tag, Typography, Link } from '@appwrite.io/pink-svelte';
|
||||
|
||||
@@ -105,21 +106,7 @@
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array} />
|
||||
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required." />
|
||||
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
<Layout.Stack gap="xs" direction="column">
|
||||
<div
|
||||
|
||||
@@ -38,13 +38,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { InputURL } from '$lib/elements/forms';
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let data: Partial<Models.ColumnUrl>;
|
||||
export let editing = false;
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Selector } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
function handleDefaultState(hideDefault: boolean) {
|
||||
@@ -64,6 +63,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -76,17 +76,5 @@
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
nullable={!data.required && !data.array} />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={data.required}
|
||||
disabled={data.array}
|
||||
description="Indicate whether this column is required" />
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={data.array}
|
||||
disabled={data.required || editing}
|
||||
description="Indicate whether this column is an array. Defaults to an empty array." />
|
||||
|
||||
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Container } from '$lib/layout';
|
||||
import { table } from '../store';
|
||||
import { table, type ColumnsWidth } from '../store';
|
||||
import Delete from './deleteIndex.svelte';
|
||||
import CreateIndex from './createIndex.svelte';
|
||||
import Overview from './overviewIndex.svelte';
|
||||
@@ -8,6 +8,8 @@
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import FailedModal from '../failedModal.svelte';
|
||||
import { canWriteTables } from '$lib/stores/roles';
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { debounce } from '$lib/helpers/debounce';
|
||||
import {
|
||||
ActionMenu,
|
||||
Badge,
|
||||
@@ -26,7 +28,7 @@
|
||||
IconPlus,
|
||||
IconTrash
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { type ComponentProps, onDestroy } from 'svelte';
|
||||
import { type ComponentProps, onDestroy, onMount } from 'svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import EmptySheet from '../layout/emptySheet.svelte';
|
||||
import SpreadsheetContainer from '../layout/spreadsheet.svelte';
|
||||
@@ -34,6 +36,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { showCreateColumnSheet } from '../store';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let {
|
||||
data
|
||||
@@ -52,15 +55,49 @@
|
||||
let showDelete = $state(false);
|
||||
let showOverview = $state(false);
|
||||
|
||||
let columns = $state([
|
||||
{ id: 'key' },
|
||||
{ id: 'type' },
|
||||
{ id: 'columns' },
|
||||
let columnsWidth = $state<ColumnsWidth | null>(null);
|
||||
|
||||
const tableId = page.params.table;
|
||||
const organizationId = data.organization.$id ?? data.project.teamId;
|
||||
|
||||
const spreadsheetColumns = $derived([
|
||||
{
|
||||
id: 'key',
|
||||
width: getColumnWidth('key', $isSmallViewport ? 250 : 200),
|
||||
minimumWidth: $isSmallViewport ? 250 : 200,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
width: getColumnWidth('type', 120),
|
||||
minimumWidth: 120,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'columns',
|
||||
width: getColumnWidth('columns', 200),
|
||||
minimumWidth: 200,
|
||||
resizable: true
|
||||
},
|
||||
// { id: 'orders' }, // design doesn't have orders atm
|
||||
{ id: 'lengths' },
|
||||
{ id: 'actions', width: 40, isAction: true }
|
||||
{
|
||||
id: 'lengths',
|
||||
width: getColumnWidth('lengths', 180),
|
||||
minimumWidth: 180,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
width: 40,
|
||||
isAction: true,
|
||||
resizable: false
|
||||
}
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
columnsWidth = preferences.getColumnWidths(tableId + '#indexes');
|
||||
});
|
||||
|
||||
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
@@ -74,6 +111,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getColumnWidth(columnId: string, defaultWidth: number): number {
|
||||
const savedWidth = columnsWidth?.[columnId];
|
||||
if (!savedWidth) return defaultWidth;
|
||||
|
||||
return savedWidth.resized;
|
||||
}
|
||||
|
||||
function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) {
|
||||
const existing = columnsWidth?.[columnId];
|
||||
const fixed = existing
|
||||
? typeof existing?.fixed === 'number'
|
||||
? existing.fixed
|
||||
: existing?.fixed?.min
|
||||
: newWidth;
|
||||
|
||||
columnsWidth = {
|
||||
...(columnsWidth ?? {}),
|
||||
[columnId]: {
|
||||
fixed,
|
||||
resized: Math.ceil(newWidth)
|
||||
}
|
||||
};
|
||||
|
||||
saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed });
|
||||
}
|
||||
|
||||
const saveColumnWidthsToPreferences = debounce(
|
||||
(column: { columnId: string; newWidth: number; fixedWidth: number }) => {
|
||||
if (!organizationId) return;
|
||||
|
||||
preferences.saveColumnWidths(organizationId, tableId + '#indexes', {
|
||||
[column.columnId]: {
|
||||
fixed: column.fixedWidth,
|
||||
resized: Math.ceil(column.newWidth)
|
||||
}
|
||||
});
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
onDestroy(() => ($showCreateColumnSheet.show = false));
|
||||
|
||||
const emptyCellsLimit = $derived($isSmallViewport ? 14 : 17);
|
||||
@@ -108,12 +185,13 @@
|
||||
<SpreadsheetContainer>
|
||||
<Spreadsheet.Root
|
||||
let:root
|
||||
{columns}
|
||||
columns={spreadsheetColumns}
|
||||
height="100%"
|
||||
allowSelection
|
||||
emptyCells={emptyCellsCount}
|
||||
bind:selectedRows={selectedIndexes}
|
||||
bottomActionClick={() => (showCreateIndex = true)}>
|
||||
bottomActionClick={() => (showCreateIndex = true)}
|
||||
on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Spreadsheet.Header.Cell column="key" {root}>Key</Spreadsheet.Header.Cell>
|
||||
<Spreadsheet.Header.Cell column="type" {root}>Type</Spreadsheet.Header.Cell>
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
import { isRelationship, isSpatialType } from '../rows/store';
|
||||
import { table, indexes } from '../store';
|
||||
import { Icon, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { IconCalendar, IconFingerPrint, IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { columnOptions as baseColumnOptions } from '../columns/store';
|
||||
|
||||
let {
|
||||
showCreateIndex = $bindable(false),
|
||||
@@ -37,7 +38,11 @@
|
||||
}
|
||||
return !isRelationship(column) && !isSpatialType(column); // keep non-relationship and non-spatial
|
||||
})
|
||||
.map((column) => ({ value: column.key, label: column.key }))
|
||||
.map((column) => ({
|
||||
value: column.key,
|
||||
label: column.key,
|
||||
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)?.icon
|
||||
}))
|
||||
);
|
||||
|
||||
let columnList = $state([{ value: '', order: '', length: null }]);
|
||||
@@ -154,7 +159,7 @@
|
||||
]
|
||||
: undefined
|
||||
});
|
||||
trackEvent(Submit.IndexCreate);
|
||||
trackEvent(Submit.IndexCreate, { type: 'manual' });
|
||||
showCreateIndex = false;
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
@@ -196,9 +201,17 @@
|
||||
...(selectedType === IndexType.Spatial
|
||||
? []
|
||||
: [
|
||||
{ value: '$id', label: '$id' },
|
||||
{ value: '$createdAt', label: '$createdAt' },
|
||||
{ value: '$updatedAt', label: '$updatedAt' }
|
||||
{ value: '$id', label: '$id', leadingIcon: IconFingerPrint },
|
||||
{
|
||||
value: '$createdAt',
|
||||
label: '$createdAt',
|
||||
leadingIcon: IconCalendar
|
||||
},
|
||||
{
|
||||
value: '$updatedAt',
|
||||
label: '$updatedAt',
|
||||
leadingIcon: IconCalendar
|
||||
}
|
||||
]),
|
||||
...columnOptions
|
||||
]}
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
})
|
||||
)
|
||||
);
|
||||
await invalidate(Dependencies.TABLE);
|
||||
showDelete = false;
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
@@ -43,7 +41,13 @@
|
||||
? 'Index has been deleted'
|
||||
: `${selectedKeys.length} indexes have been deleted`
|
||||
});
|
||||
|
||||
trackEvent(Submit.IndexDelete);
|
||||
|
||||
await invalidate(Dependencies.TABLE);
|
||||
|
||||
showDelete = false;
|
||||
selectedIndex = Array.isArray(selectedIndex) ? [] : null;
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
trackError(e, Submit.IndexDelete);
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.855) 21%,
|
||||
rgba(255, 255, 255, 0.86) 32.25%,
|
||||
#ffffff 100%
|
||||
);
|
||||
z-index: 20;
|
||||
@@ -347,10 +347,9 @@
|
||||
:global(.theme-dark) .spreadsheet-fade-bottom {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(25, 25, 28, 0.38) 13%,
|
||||
rgba(25, 25, 28, 0.7) 21%,
|
||||
rgba(25, 25, 28, 0.95) 38%,
|
||||
var(--bgcolor-neutral-default, #19191c) 100%
|
||||
rgba(29, 29, 33, 0) 0%,
|
||||
rgba(29, 29, 33, 0.86) 21%,
|
||||
#1d1d21 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
import { Button, Form } from '$lib/elements/forms';
|
||||
import { isTabletViewport } from '$lib/stores/viewport';
|
||||
import { Badge, Divider, Layout, Sheet, Tag, Typography } from '@appwrite.io/pink-svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
|
||||
let {
|
||||
show = $bindable(false),
|
||||
title,
|
||||
closeOnBlur = false,
|
||||
submit,
|
||||
cancel,
|
||||
children = null,
|
||||
footer = null,
|
||||
titleBadge = null,
|
||||
topAction = null
|
||||
topAction = null,
|
||||
...restProps
|
||||
}: {
|
||||
show: boolean;
|
||||
title: string;
|
||||
@@ -36,17 +40,27 @@
|
||||
onClick?: () => boolean | void | Promise<boolean | void>;
|
||||
}
|
||||
| undefined;
|
||||
cancel?:
|
||||
| {
|
||||
text?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
| undefined;
|
||||
children?: Snippet;
|
||||
footer?: Snippet | null;
|
||||
} = $props();
|
||||
} & HTMLAttributes<HTMLDivElement> = $props();
|
||||
|
||||
let form: Form;
|
||||
let submitting = $state(writable(false));
|
||||
|
||||
let copyText = $state(undefined);
|
||||
beforeNavigate(() => {
|
||||
show = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="sheet-container" data-side-sheet-visible={show}>
|
||||
<div class="sheet-container" data-side-sheet-visible={show} {...restProps}>
|
||||
<Sheet bind:open={show} {closeOnBlur}>
|
||||
<div slot="header" style:width="100%">
|
||||
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
@@ -110,8 +124,19 @@
|
||||
{#if footer}
|
||||
{@render footer?.()}
|
||||
{/if}
|
||||
<Button size="s" secondary on:click={() => (show = false)}
|
||||
>Cancel</Button>
|
||||
|
||||
<Button
|
||||
size="s"
|
||||
secondary
|
||||
disabled={cancel?.disabled}
|
||||
on:click={() => {
|
||||
if (cancel?.onClick) {
|
||||
cancel.onClick();
|
||||
} else {
|
||||
show = false;
|
||||
}
|
||||
}}>{cancel?.text ?? 'Cancel'}</Button>
|
||||
|
||||
<Button
|
||||
size="s"
|
||||
submit
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
min={column.min}
|
||||
max={column.max}
|
||||
required={column.required}
|
||||
step={column.type === 'double' ? 'any' : 1}
|
||||
step={column.type === 'double' ? 0.1 : 1}
|
||||
leadingIcon={!limited ? IconHashtag : undefined} />
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
),
|
||||
permissions: [],
|
||||
permissions: existingData?.$permissions ?? [],
|
||||
columns: availableColumns
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
IconTrash
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { type Columns, databaseColumnSheetOptions } from './store';
|
||||
import { isRelationship } from './rows/store';
|
||||
|
||||
interface MenuItem {
|
||||
label?: string;
|
||||
@@ -122,6 +123,11 @@
|
||||
if (['delete', 'update', 'duplicate-header'].includes(item.action) && isSystemColumn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// hide sort options for relationship columns
|
||||
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item.action ?? '')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
export let data: PageData;
|
||||
export let showRowCreateSheet: {
|
||||
show: boolean;
|
||||
row: Models.Row | null;
|
||||
row: Partial<Models.Row> | null;
|
||||
};
|
||||
|
||||
$: rows = writable(data.rows);
|
||||
@@ -297,7 +297,7 @@
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return IconText;
|
||||
case 'float':
|
||||
case 'double':
|
||||
case 'integer':
|
||||
return IconHashtag;
|
||||
case 'boolean':
|
||||
@@ -543,7 +543,13 @@
|
||||
}
|
||||
|
||||
if (action === 'duplicate-row') {
|
||||
showRowCreateSheet.row = row;
|
||||
/**
|
||||
* remove dates because
|
||||
* console can override timestamps!
|
||||
*/
|
||||
const { $createdAt, $updatedAt, ...rowWithoutDates } = row;
|
||||
|
||||
showRowCreateSheet.row = rowWithoutDates;
|
||||
showRowCreateSheet.show = true;
|
||||
}
|
||||
|
||||
@@ -793,10 +799,11 @@
|
||||
</Tooltip>
|
||||
</Spreadsheet.Header.Cell>
|
||||
{:else}
|
||||
{@const structureColumn = $columns.find((col) => col.key === column.id)}
|
||||
<SheetOptions
|
||||
type="header"
|
||||
columnId={column.id}
|
||||
column={$columns.find((col) => col.key === column.id)}
|
||||
column={structureColumn}
|
||||
onSelect={(option, columnId) =>
|
||||
onSelectSheetOption(option, columnId, 'header')}>
|
||||
{#snippet children(toggle)}
|
||||
@@ -818,10 +825,12 @@
|
||||
<!-- array indicator -->
|
||||
{#if column.array}[]{/if}
|
||||
|
||||
<SortButton
|
||||
onSort={sort}
|
||||
column={column.id}
|
||||
state={sortState} />
|
||||
{#if !isRelationship(structureColumn)}
|
||||
<SortButton
|
||||
onSort={sort}
|
||||
column={column.id}
|
||||
state={sortState} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Spreadsheet.Header.Cell>
|
||||
{/snippet}
|
||||
@@ -1147,7 +1156,7 @@
|
||||
</Table.Root>
|
||||
|
||||
<Layout.Stack direction="column" gap="m">
|
||||
<Alert.Inline>To change the selection edit the relationship settings.</Alert.Inline>
|
||||
<Alert.Inline title="To change the selection edit the relationship settings." />
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<p class="u-bold">This action is irreversible.</p>
|
||||
@@ -1211,6 +1220,10 @@
|
||||
padding-inline: 8px !important;
|
||||
}
|
||||
|
||||
& :global(.input:has([type^='date'])) {
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
& :global(.input:focus-within) {
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -33,9 +33,12 @@ export const tableColumns = writable<Column[]>([]);
|
||||
export const isCsvImportInProgress = writable(false);
|
||||
|
||||
export const columnsOrder = writable<string[]>([]);
|
||||
export const columnsWidth = writable<{
|
||||
|
||||
export type ColumnsWidth = {
|
||||
[columnId: string]: { fixed: number | { min: number }; resized: number };
|
||||
}>();
|
||||
};
|
||||
|
||||
export const columnsWidth = writable<ColumnsWidth>();
|
||||
|
||||
type DatabaseSheetOptions = {
|
||||
show: boolean;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Id } from '$lib/components';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { toLocaleDateTime } from '$lib/helpers/date';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { canWriteTables } from '$lib/stores/roles';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
@@ -14,6 +14,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { tableViewColumns } from './store';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import { subNavigation } from '$lib/stores/database';
|
||||
|
||||
export let data: PageData;
|
||||
const databaseId = page.params.database;
|
||||
@@ -38,7 +39,8 @@
|
||||
type: 'success',
|
||||
message: `${selectedTables.length} table${selectedTables.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.TABLES);
|
||||
await invalidate(Dependencies.TABLES);
|
||||
subNavigation.update();
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -76,7 +78,7 @@
|
||||
{:else if column.id === 'name'}
|
||||
{table.name}
|
||||
{:else}
|
||||
{toLocaleDateTime(table[column.id])}
|
||||
<DualTimeView time={table[column.id]} />
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export let name: string;
|
||||
export let id: string;
|
||||
export let id: string | null;
|
||||
export let entrypoint: string;
|
||||
export let showEntrypoint = false;
|
||||
export let runtime: string;
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
let isSubmitting = writable(false);
|
||||
|
||||
let name = '';
|
||||
let id: string;
|
||||
let id: string | null = null;
|
||||
let runtime: Runtime;
|
||||
let entrypoint = '';
|
||||
let buildCommand = '';
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
let isSubmitting = writable(false);
|
||||
|
||||
let name = '';
|
||||
let id: string;
|
||||
let id: string | null = null;
|
||||
let runtime: Runtime;
|
||||
let entrypoint = '';
|
||||
let buildCommand = '';
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
let isSubmitting = writable(false);
|
||||
|
||||
let name = data.template.name;
|
||||
let id: string;
|
||||
let id: string | null = null;
|
||||
let runtime: Runtime;
|
||||
let branch = 'main';
|
||||
let rootDir = './';
|
||||
|
||||
@@ -86,10 +86,11 @@
|
||||
</svelte:fragment>
|
||||
</Alert.Inline>
|
||||
{:else}
|
||||
<Alert.Inline status="info" dismissible on:dismiss={() => (showAlert = false)}>
|
||||
Some configuration changes are not live yet. Your function is redeploying —
|
||||
changes will be applied once the build is complete.
|
||||
</Alert.Inline>
|
||||
<Alert.Inline
|
||||
status="info"
|
||||
dismissible
|
||||
on:dismiss={() => (showAlert = false)}
|
||||
title="Some configuration changes are not live yet. Your function is redeploying — changes will be applied once the build is complete." />
|
||||
{/if}
|
||||
{/if}
|
||||
<Layout.Stack gap="xxxl">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
export let data;
|
||||
|
||||
onMount(() => {
|
||||
return sdk.forConsole.client.subscribe('console', (response) => {
|
||||
return sdk.forConsole.realtime.subscribe('console', (response) => {
|
||||
if (response.events.includes('functions.*.executions.*')) {
|
||||
invalidate(Dependencies.EXECUTIONS);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/elements/forms/button.svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { ExecutionStatus } from '@appwrite.io/console';
|
||||
import { IconChevronDown, IconChevronUp } from '@appwrite.io/pink-icons-svelte';
|
||||
import { calculateTime } from '$lib/helpers/timeConversion';
|
||||
import {
|
||||
@@ -112,7 +113,7 @@
|
||||
|
||||
<Tooltip
|
||||
disabled={!selectedLog?.scheduledAt ||
|
||||
selectedLog.status !== 'scheduled'}
|
||||
selectedLog.status !== ExecutionStatus.Scheduled}
|
||||
maxWidth="400px">
|
||||
<div>
|
||||
<Status
|
||||
|
||||