Merge branch 'feat-profiles' of https://github.com/appwrite/console into feat-profiles
@@ -0,0 +1,28 @@
|
||||
name: Copilot Setup Steps
|
||||
|
||||
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||
# allow manual testing through the repository's "Actions" tab
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
"PUBLIC_CONSOLE_MODE=cloud"
|
||||
"PUBLIC_CONSOLE_FEATURE_FLAGS="
|
||||
"PUBLIC_APPWRITE_MULTI_REGION=true"
|
||||
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
|
||||
"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 }}"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Appwrite Console - Copilot Instructions
|
||||
|
||||
## Repository Overview
|
||||
|
||||
Appwrite Console is the web-based GUI for the Appwrite backend-as-a-service platform. Single-page application built with **Svelte 5 + SvelteKit 2**, **TypeScript** (not strict mode), **Vite 7**, tested with **Vitest + Playwright**. Package manager: **pnpm 10.15.1**, Node 20+. ~1500 files with extensive component-based architecture.
|
||||
|
||||
## Critical Build & Test Commands
|
||||
|
||||
### Setup (REQUIRED before any commands)
|
||||
|
||||
1. **Install pnpm**: `npm install -g corepack && corepack enable && corepack prepare pnpm@10.15.1 --activate`
|
||||
2. **Create .env**: `cp .env.example .env` (configure `PUBLIC_APPWRITE_ENDPOINT` and `PUBLIC_CONSOLE_MODE`)
|
||||
3. **Configure network access** (if using GitHub Actions or restricted environments):
|
||||
- Ensure firewall/proxy allows access to: `pkg.pr.new`, `pkg.vc`, `registry.npmjs.org`
|
||||
- These domains are required for dependencies: `@appwrite.io/console`, `@appwrite.io/pink-icons-svelte`, `@appwrite.io/pink-svelte`
|
||||
- In GitHub Actions: Use `pnpm/action-setup@v4` which handles registry configuration
|
||||
- If network errors persist, check proxy settings: `npm config get proxy` and `npm config get https-proxy`
|
||||
4. **Install dependencies**: `pnpm install --frozen-lockfile` (if pkg.pr.new/pkg.vc fail due to network restrictions, installation may still succeed with cached versions)
|
||||
|
||||
### Development Commands
|
||||
|
||||
**Standard workflow**: `check` → `lint` → `test` → `build` (before committing)
|
||||
|
||||
- `pnpm run check` - TypeScript/Svelte validation (~30-60s)
|
||||
- `pnpm run lint` - ESLint check (~10-20s)
|
||||
- `pnpm run format` - Auto-fix Prettier formatting
|
||||
- `pnpm run test` - Vitest unit tests with TZ=EST (~10-30s)
|
||||
- `pnpm run build` - Production build via build.js (~60-120s)
|
||||
- `pnpm dev` - Dev server on port 3000
|
||||
- `pnpm run preview` - Preview build on port 4173
|
||||
- `pnpm run e2e` - Playwright tests (needs `pnpm exec playwright install --with-deps chromium` first, ~120s+)
|
||||
|
||||
**CI Pipeline** (`.github/workflows/tests.yml`): audit → install → check → lint → test → build
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/ # Reusable logic ($lib alias)
|
||||
│ ├── components/ # Feature components (billing, domains, permissions, etc.)
|
||||
│ ├── elements/ # Basic UI elements
|
||||
│ ├── helpers/ # Utility functions (array, date, string, etc.)
|
||||
│ ├── stores/ # Svelte stores for state
|
||||
│ ├── sdk/ # Appwrite SDK wrappers
|
||||
│ └── constants.ts, flags.ts, system.ts
|
||||
├── routes/
|
||||
│ ├── (console)/ # Auth-required routes
|
||||
│ │ ├── organization-[organization]/
|
||||
│ │ └── project-[region]-[project]/ # databases, functions, messaging, storage
|
||||
│ └── (public)/ # Public routes (login, register, auth callbacks)
|
||||
├── themes/ # Theme definitions ($themes alias)
|
||||
└── app.html, hooks.{client,server}.ts, service-worker.ts
|
||||
```
|
||||
|
||||
**SvelteKit conventions**: `+page.svelte` (component), `+page.ts` (data loader), `+layout.svelte` (wrapper), `+error.svelte` (errors). Groups like `(console)` organize routes without affecting URLs. Dynamic params: `[param]`.
|
||||
|
||||
## Key Configuration
|
||||
|
||||
**svelte.config.js**: Adapter = static SPA (fallback: index.html), base path `/console`, aliases: `$lib`, `$routes`, `$themes`
|
||||
**vite.config.ts**: Dev port 3000, Vitest (client=jsdom, server=node), test files: `src/**/*.{test,spec}.{js,ts}`
|
||||
**tsconfig.json**: Extends `.svelte-kit/tsconfig.json`, **NOT strict mode** (`strict: false`)
|
||||
**eslint.config.js**: Flat config (ESLint 9+), many rules disabled (see TODOs)
|
||||
**.prettierrc**: 4 spaces, single quotes, 100 char width, no trailing commas
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit (Vitest)**: Tests in `src/lib/helpers/*.test.ts`, run with `TZ=EST` (timezone matters). Setup mocks SvelteKit (`$app/*`) in `vitest-setup-client.ts`.
|
||||
**E2E (Playwright)**: Tests in `e2e/journeys/*.spec.ts`, needs build+preview on port 4173, retries 3x, timeout 120s, Chromium only.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Blank page in dev**: Disable ad blockers if seeing "Failed to fetch dynamically imported module" (known SvelteKit issue)
|
||||
2. **Network errors on install**:
|
||||
- pkg.pr.new/pkg.vc deps may fail due to firewall/proxy restrictions
|
||||
- Check access: `curl -I https://pkg.pr.new` and `curl -I https://pkg.vc`
|
||||
- Configure proxy if needed: `npm config set proxy http://proxy:port` and `npm config set https-proxy http://proxy:port`
|
||||
- GitHub Actions: Ensure runner has internet access; use `pnpm/action-setup@v4` action
|
||||
- Local dev: Often safe to continue with cached versions if network fails
|
||||
3. **OOM on build**: Set `NODE_OPTIONS=--max_old_space_size=8192` (like Dockerfile does)
|
||||
4. **Test failures**: Always use `pnpm run test` (sets TZ=EST), not `vitest` directly
|
||||
5. **TS errors not showing**: Run `pnpm run check` explicitly (dev server doesn't always surface them)
|
||||
6. **Format vs lint conflicts**: Run `pnpm run format` before `pnpm run lint`
|
||||
7. **E2E timeouts**: Wait 120s for preview server startup, tests auto-retry 3x
|
||||
8. **Stale build**: Clear `.svelte-kit` if changes not reflected: `rm -rf .svelte-kit && pnpm run build`
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Imports: Use `$lib`, `$routes`, `$themes` aliases
|
||||
- Components: PascalCase, in `src/lib/components/[feature]/`
|
||||
- Helpers: Pure functions in `src/lib/helpers/`
|
||||
- Types: Inline or `.d.ts`, not `.types.ts` files
|
||||
- Comments: Minimal, use for TODOs or complex logic
|
||||
- TypeScript: Not strict mode, `any` tolerated
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Run Appwrite backend locally (see [docs](https://appwrite.io/docs/advanced/self-hosting))
|
||||
2. Configure `.env` with backend endpoint
|
||||
3. `pnpm install --frozen-lockfile`
|
||||
4. `pnpm dev` (hot reload on port 3000)
|
||||
5. Before commit: `pnpm run check && pnpm run format && pnpm run lint && pnpm run test && pnpm run build`
|
||||
6. **Take screenshots**: For any UI changes, capture screenshots and include them in the PR description or comments before finalizing
|
||||
|
||||
**Trust these instructions** - only search if incomplete/incorrect. See CONTRIBUTING.md for PR conventions. Use `--frozen-lockfile` always. Docker builds: multi-stage, final image is nginx serving static files from `/console` path.
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"clean": "rm -rf node_modules && rm -rf .svelte_kit && pnpm i --force",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --cache --write .",
|
||||
"format": "prettier --cache --write --cache .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test": "TZ=EST vitest run",
|
||||
"test:ui": "TZ=EST vitest --ui",
|
||||
@@ -24,9 +24,9 @@
|
||||
"@ai-sdk/svelte": "^1.1.24",
|
||||
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@2752",
|
||||
"@appwrite.io/pink-icons": "0.25.0",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877",
|
||||
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@4472521",
|
||||
"@appwrite.io/pink-legacy": "^1.0.3",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877",
|
||||
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@4472521",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@sentry/sveltekit": "^8.55.0",
|
||||
@@ -99,5 +99,5 @@
|
||||
"svelte-preprocess"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.20.0"
|
||||
"packageManager": "pnpm@10.21.0"
|
||||
}
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -4,22 +4,64 @@ import { sdk } from '$lib/stores/sdk';
|
||||
import { Query } from '@appwrite.io/console';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Searcher } from '../commands';
|
||||
import { project } from '$routes/(console)/project-[region]-[project]/store';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
export const projectsSearcher = (async (query: string) => {
|
||||
const q = query.toLowerCase().trim();
|
||||
const keywords = [
|
||||
'endpoint',
|
||||
'api key',
|
||||
'api-key',
|
||||
'apikey',
|
||||
'project id',
|
||||
'project-id',
|
||||
'api end'
|
||||
];
|
||||
|
||||
const wantsCredentials = keywords.some((k) => q.includes(k));
|
||||
|
||||
if (wantsCredentials) {
|
||||
const curr = get(project);
|
||||
if (curr?.$id) {
|
||||
return [
|
||||
{
|
||||
label: 'Go to Settings',
|
||||
callback: () => {
|
||||
goto(`${base}/project-${curr.region}-${curr.$id}/settings`);
|
||||
},
|
||||
group: 'navigation'
|
||||
}
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const { projects } = await sdk.forConsole.projects.list({
|
||||
queries: [Query.equal('teamId', get(organization).$id), Query.orderDesc('')]
|
||||
});
|
||||
|
||||
return projects
|
||||
.filter((project) => project.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.filter((project) => {
|
||||
const searchable = [project.name, project.$id, project.region]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const words = q.split(/\s+/).filter(Boolean);
|
||||
return words.every((w) => searchable.includes(w));
|
||||
})
|
||||
.map((project) => {
|
||||
const href = `${base}/project-${project.region}-${project.$id}`;
|
||||
|
||||
const label = project.name;
|
||||
|
||||
return {
|
||||
label: project.name,
|
||||
label,
|
||||
callback: () => {
|
||||
goto(`${base}/project-${project.region}-${project.$id}`);
|
||||
goto(href);
|
||||
},
|
||||
group: 'projects'
|
||||
} as const;
|
||||
};
|
||||
});
|
||||
}) satisfies Searcher;
|
||||
|
||||
@@ -136,7 +136,8 @@
|
||||
title="Verify your email address"
|
||||
{onSubmit}
|
||||
dismissible={false}
|
||||
autoClose={false}>
|
||||
autoClose={false}
|
||||
backdrop={false}>
|
||||
<Card.Base variant="secondary" padding="s">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Text gap="m">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Copy } from '.';
|
||||
import { Icon, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
|
||||
import { getProjectEndpoint } from '$lib/helpers/project';
|
||||
</script>
|
||||
|
||||
<Copy value={getProjectEndpoint()} copyText="Copy endpoint">
|
||||
<Tag size="xs" variant="code">
|
||||
<Icon icon={IconDuplicate} size="s" slot="start" />
|
||||
<span style:white-space="nowrap" style:overflow="hidden" style:word-break="break-all">
|
||||
API endpoint
|
||||
</span>
|
||||
</Tag>
|
||||
</Copy>
|
||||
@@ -125,20 +125,18 @@
|
||||
|
||||
onMount(() => {
|
||||
// fast path: don't subscribe if org is on a free plan or is self-hosted.
|
||||
if (isSelfHosted || (isCloud && $organization.billingPlan === BillingPlan.FREE)) return;
|
||||
if (isSelfHosted || (isCloud && $organization?.billingPlan === BillingPlan.FREE)) return;
|
||||
|
||||
return realtime
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.subscribe('console', (response) => {
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
return realtime.forProject(page.params.region, 'console', (response) => {
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
|
||||
if (
|
||||
response.events.includes('archives.*') ||
|
||||
response.events.includes('restorations.*')
|
||||
) {
|
||||
updateOrAddItem(response.payload);
|
||||
}
|
||||
});
|
||||
if (
|
||||
response.events.includes('archives.*') ||
|
||||
response.events.includes('restorations.*')
|
||||
) {
|
||||
updateOrAddItem(response.payload);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,21 +20,23 @@
|
||||
};
|
||||
|
||||
type ButtonProps = {
|
||||
isButton: true;
|
||||
isButton: boolean;
|
||||
href?: never;
|
||||
};
|
||||
|
||||
type AnchorProps = {
|
||||
href: string;
|
||||
isButton?: never;
|
||||
isButton?: boolean;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
let classes = '';
|
||||
type $$Props = BaseProps & (ButtonProps | AnchorProps | BaseProps) & BaseCardProps;
|
||||
|
||||
export let isDashed = false;
|
||||
export let isButton = false;
|
||||
export let isDashed: boolean = false;
|
||||
export let isButton: boolean = false;
|
||||
export let href: string = null;
|
||||
let classes = '';
|
||||
export let external: boolean = false;
|
||||
export { classes as class };
|
||||
export let style = '';
|
||||
export let padding: $$Props['padding'] = 'm';
|
||||
@@ -45,7 +47,15 @@
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<Card.Link class={resolvedClasses} {href} {style} {padding} {radius} {variant} on:click>
|
||||
<Card.Link
|
||||
{href}
|
||||
{style}
|
||||
{padding}
|
||||
{radius}
|
||||
{variant}
|
||||
on:click
|
||||
class={resolvedClasses}
|
||||
{...external ? { target: '_blank' } : {}}>
|
||||
<Layout.Stack gap="xl">
|
||||
<slot />
|
||||
</Layout.Stack>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
disabled={tooltipDisabled}
|
||||
portal={tooltipPortal}
|
||||
delay={tooltipDelay}
|
||||
maxWidth="500px"
|
||||
placement={tooltipPlacement}>
|
||||
<span
|
||||
data-private
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { realtime, sdk } from '$lib/stores/sdk';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { getProjectId } from '$lib/helpers/project';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { Layout, Typography, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Modal, Code } from '$lib/components';
|
||||
import { type Models, type Payload, Query } from '@appwrite.io/console';
|
||||
|
||||
// re-render the key for sheet UI.
|
||||
import { hash } from '$lib/helpers/string';
|
||||
import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
|
||||
import { Link } from '$lib/elements';
|
||||
|
||||
type CsvImportError = {
|
||||
[key: string]: number | string | null;
|
||||
};
|
||||
|
||||
type ImportItem = {
|
||||
status: string;
|
||||
table?: string;
|
||||
errors?: string[];
|
||||
};
|
||||
|
||||
type ImportItemsMap = Map<string, ImportItem>;
|
||||
@@ -28,7 +35,7 @@
|
||||
* The structure is as follows -
|
||||
* `{ migrationId: { status: status, table: table } }`
|
||||
*/
|
||||
const importItems: Writable<ImportItemsMap> = writable(new Map());
|
||||
let importItems = $state<ImportItemsMap>(new Map());
|
||||
|
||||
async function showCompletionNotification(database: string, table: string, payload: Payload) {
|
||||
const isSuccess = payload.status === 'completed';
|
||||
@@ -37,13 +44,9 @@
|
||||
if (!isSuccess && !isError) return;
|
||||
|
||||
let errorMessage = 'Import failed. Check your CSV for correct fields and required values.';
|
||||
if (isError && Array.isArray(payload.errors)) {
|
||||
try {
|
||||
// the `errors` is a list of json encoded string.
|
||||
errorMessage = JSON.parse(payload.errors[0]).message;
|
||||
} catch {
|
||||
// do nothing, fallback to default message.
|
||||
}
|
||||
const errors = getErrors(payload);
|
||||
if (errors) {
|
||||
errorMessage = extractErrorMessage(errors);
|
||||
}
|
||||
|
||||
const type = isSuccess ? 'success' : 'error';
|
||||
@@ -73,7 +76,7 @@
|
||||
const resourceId = importData.resourceId ?? '';
|
||||
const [databaseId, tableId] = resourceId.split(':') ?? [];
|
||||
|
||||
const current = $importItems.get(importData.$id);
|
||||
const current = importItems.get(importData.$id);
|
||||
let tableName = current?.table ?? null;
|
||||
|
||||
if (!tableName && tableId) {
|
||||
@@ -91,30 +94,27 @@
|
||||
}
|
||||
|
||||
if (tableId && tableName === null) {
|
||||
importItems.update((items) => {
|
||||
const next = new Map(items);
|
||||
next.delete(importData.$id);
|
||||
return next;
|
||||
});
|
||||
const next = new Map(importItems);
|
||||
next.delete(importData.$id);
|
||||
importItems = next;
|
||||
return;
|
||||
}
|
||||
|
||||
importItems.update((items) => {
|
||||
const existing = items.get(importData.$id);
|
||||
const existing = importItems.get(importData.$id);
|
||||
|
||||
const isDone = (s: string) => s === 'completed' || s === 'failed';
|
||||
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
|
||||
const isDone = (s: string) => s === 'completed' || s === 'failed';
|
||||
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
|
||||
|
||||
const shouldSkip =
|
||||
(existing && isDone(existing.status) && isInProgress(status)) ||
|
||||
existing?.status === status;
|
||||
const shouldSkip =
|
||||
(existing && isDone(existing.status) && isInProgress(status)) ||
|
||||
existing?.status === status;
|
||||
|
||||
if (shouldSkip) return items;
|
||||
|
||||
const next = new Map(items);
|
||||
next.set(importData.$id, { status, table: tableName ?? undefined });
|
||||
return next;
|
||||
});
|
||||
if (!shouldSkip) {
|
||||
const next = new Map(importItems);
|
||||
const errors = getErrors(importData);
|
||||
next.set(importData.$id, { status, table: tableName ?? undefined, errors });
|
||||
importItems = next;
|
||||
}
|
||||
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
await showCompletionNotification(databaseId, tableId, importData);
|
||||
@@ -122,10 +122,27 @@
|
||||
}
|
||||
|
||||
function clear() {
|
||||
importItems.update((items) => {
|
||||
items.clear();
|
||||
return items;
|
||||
});
|
||||
importItems = new Map();
|
||||
}
|
||||
|
||||
function getErrors(importData: Payload | Models.Migration): string[] | undefined {
|
||||
return Array.isArray(importData.errors) ? importData.errors : undefined;
|
||||
}
|
||||
|
||||
function parseError(error: string): string | CsvImportError {
|
||||
try {
|
||||
return JSON.parse(error) as CsvImportError;
|
||||
} catch {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorMessage(errors: string[]): string {
|
||||
try {
|
||||
return JSON.parse(errors[0]).message;
|
||||
} catch {
|
||||
return 'Import failed. Check your CSV for correct fields and required values.';
|
||||
}
|
||||
}
|
||||
|
||||
function graphSize(status: string): number {
|
||||
@@ -148,8 +165,9 @@
|
||||
const name = collectionName ? `<b>${collectionName}</b>` : '';
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return `CSV import completed${name ? ` to ${name}` : ''}`;
|
||||
case 'failed':
|
||||
return `Import to ${name} ${status}`;
|
||||
return `CSV import failed${name ? ` to ${name}` : ''}`;
|
||||
case 'processing':
|
||||
return `Importing CSV file${name ? ` to ${name}` : ''}`;
|
||||
default:
|
||||
@@ -169,7 +187,7 @@
|
||||
migrations.migrations.forEach(updateOrAddItem);
|
||||
});
|
||||
|
||||
return sdk.forConsoleIn(page.params.region).client.subscribe('console', (response) => {
|
||||
return realtime.forConsole(page.params.region, 'console', (response) => {
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
if (response.events.includes('migrations.*')) {
|
||||
updateOrAddItem(response.payload as Payload);
|
||||
@@ -177,8 +195,18 @@
|
||||
});
|
||||
});
|
||||
|
||||
$: isOpen = true;
|
||||
$: showCsvImportBox = $importItems.size > 0;
|
||||
let isOpen = $state(true);
|
||||
let showCsvImportBox = $derived(importItems.size > 0);
|
||||
|
||||
let showDetails = $state(false);
|
||||
let selectedErrors = $state<string[]>([]);
|
||||
let parsedErrors = $state<Array<string | CsvImportError>>([]);
|
||||
|
||||
function openDetails(errors: string[] | undefined) {
|
||||
selectedErrors = errors ?? [];
|
||||
parsedErrors = selectedErrors.map(parseError);
|
||||
showDetails = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showCsvImportBox}
|
||||
@@ -187,26 +215,23 @@
|
||||
<header class="upload-box-header">
|
||||
<h4 class="upload-box-title">
|
||||
<Typography.Text variant="m-500">
|
||||
Importing rows ({$importItems.size})
|
||||
Importing rows ({importItems.size})
|
||||
</Typography.Text>
|
||||
</h4>
|
||||
<button
|
||||
class="upload-box-button"
|
||||
class:is-open={isOpen}
|
||||
aria-label="toggle upload box"
|
||||
on:click={() => (isOpen = !isOpen)}>
|
||||
onclick={() => (isOpen = !isOpen)}>
|
||||
<span class="icon-cheveron-up" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
class="upload-box-button"
|
||||
aria-label="close backup restore box"
|
||||
on:click={clear}>
|
||||
<button class="upload-box-button" aria-label="close CSV import box" onclick={clear}>
|
||||
<span class="icon-x" aria-hidden="true"></span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="upload-box-content-list">
|
||||
{#each [...$importItems.entries()] as [key, value] (key)}
|
||||
{#each [...importItems.entries()] as [key, value] (key)}
|
||||
<div class="upload-box-content" class:is-open={isOpen}>
|
||||
<ul class="upload-box-list">
|
||||
<li class="upload-box-item">
|
||||
@@ -222,6 +247,25 @@
|
||||
class:is-danger={value.status === 'failed'}
|
||||
style="--graph-size:{graphSize(value.status)}%">
|
||||
</div>
|
||||
{#if value.status === 'failed'}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
gap="xs"
|
||||
alignItems="center"
|
||||
inline>
|
||||
<Icon
|
||||
icon={IconExclamationCircle}
|
||||
color="--fgcolor-error"
|
||||
size="s" />
|
||||
<Typography.Text color="--fgcolor-error">
|
||||
There was an import issue.
|
||||
<Link
|
||||
style="color: inherit"
|
||||
onclick={() => openDetails(value.errors)}
|
||||
>View details</Link>
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</section>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -232,6 +276,18 @@
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<Modal title="Import error" bind:show={showDetails} hideFooter>
|
||||
<Layout.Stack gap="m">
|
||||
<Layout.Stack>
|
||||
<Code
|
||||
language="json"
|
||||
code={JSON.stringify(parsedErrors, null, 2)}
|
||||
withCopy
|
||||
allowScroll />
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.upload-box {
|
||||
display: flex;
|
||||
@@ -252,7 +308,7 @@
|
||||
}
|
||||
|
||||
.upload-box-content {
|
||||
width: 304px;
|
||||
width: 324px;
|
||||
}
|
||||
|
||||
.upload-box-button {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -95,10 +95,11 @@
|
||||
export let tooltipPortal = false;
|
||||
export let tooltipDelay: number = 0;
|
||||
export let tooltipPlacement: TooltipPlacement = undefined;
|
||||
export let copyText: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
{#key value}
|
||||
<Copy {value} {event} {tooltipPortal} {tooltipDelay} {tooltipPlacement}>
|
||||
<Copy {value} {event} {tooltipPortal} {tooltipDelay} {tooltipPlacement} {copyText}>
|
||||
<Tag size="xs" variant="code">
|
||||
<Icon icon={IconDuplicate} size="s" slot="start" />
|
||||
<span
|
||||
|
||||
@@ -82,8 +82,11 @@ export { default as BottomSheet } from './bottom-sheet/index';
|
||||
export { default as Confirm } from './confirm.svelte';
|
||||
export { default as UsageCard } from './usageCard.svelte';
|
||||
export { default as ViewToggle } from './viewToggle.svelte';
|
||||
export { default as ApiEndpoint } from './apiEndpoint.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 SortButton, type SortDirection } from './sortButton.svelte';
|
||||
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
|
||||
export { default as MultiSelectionTable } from './multiSelectTable.svelte';
|
||||
export * from './multiSelectTable.svelte';
|
||||
|
||||
@@ -50,15 +50,14 @@
|
||||
})();
|
||||
|
||||
onMount(() => {
|
||||
return realtime
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.subscribe<Models.Migration>(['console'], async (response) => {
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
if (response.events.includes('migrations.*')) {
|
||||
if (response.payload.source === 'Backup') return;
|
||||
migration = response.payload;
|
||||
}
|
||||
});
|
||||
return realtime.forProject(page.params.region, ['console'], async (response) => {
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
if (response.events.includes('migrations.*')) {
|
||||
const payload = response.payload as Models.Migration;
|
||||
if (payload.source === 'Backup') return;
|
||||
migration = payload;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts" module>
|
||||
export type DeleteOperationState = Error | void;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
Table,
|
||||
Badge,
|
||||
Typography,
|
||||
FloatingActionBar,
|
||||
type TableColumn,
|
||||
type TableRootProps
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import Confirm from './confirm.svelte';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
|
||||
let {
|
||||
columns,
|
||||
resource,
|
||||
allowSelection = true,
|
||||
confirmDeletion = true,
|
||||
showSuccessNotification = true,
|
||||
computeKey = 'multiSelectionTable',
|
||||
header,
|
||||
children,
|
||||
onDelete,
|
||||
onCancel,
|
||||
deleteContent,
|
||||
deleteContentNotice
|
||||
}: {
|
||||
resource: string;
|
||||
allowSelection?: boolean;
|
||||
confirmDeletion?: boolean;
|
||||
computeKey?: string | number;
|
||||
showSuccessNotification?: boolean;
|
||||
columns: Array<TableColumn> | number;
|
||||
header: Snippet<[root: TableRootProps]>;
|
||||
children: Snippet<[root: TableRootProps]>;
|
||||
deleteContent?: Snippet<[count: number]>;
|
||||
deleteContentNotice?: Snippet;
|
||||
onDelete?: (selectedRows: string[]) => Promise<DeleteOperationState> | DeleteOperationState;
|
||||
onCancel?: () => Promise<void> | void;
|
||||
} = $props();
|
||||
|
||||
let selectedRows: string[] = $state([]);
|
||||
let disableModal: boolean = $state(false);
|
||||
let onDeleteError: string | null = $state(null);
|
||||
let showConfirmDeletion: boolean = $state(false);
|
||||
|
||||
function notifySuccess() {
|
||||
if (!showSuccessNotification) return;
|
||||
|
||||
const count = selectedRows.length;
|
||||
if (count === 0) return;
|
||||
|
||||
const label = `${resource}${count > 1 ? 's' : ''}`;
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${count} ${label} deleted`
|
||||
});
|
||||
}
|
||||
|
||||
// this is kept very basic!
|
||||
function getPluralResource() {
|
||||
if (resource.endsWith('ty')) {
|
||||
return `${resource}ies`;
|
||||
}
|
||||
|
||||
return `${resource}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key computeKey}
|
||||
<Table.Root let:root {columns} {allowSelection} bind:selectedRows>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{@render header?.(root)}
|
||||
</svelte:fragment>
|
||||
|
||||
{@render children(root)}
|
||||
</Table.Root>
|
||||
|
||||
{#if allowSelection && selectedRows.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedRows.length.toString()} />
|
||||
<span>
|
||||
{selectedRows.length > 1 ? getPluralResource() : resource}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button
|
||||
text
|
||||
on:click={() => {
|
||||
onCancel?.();
|
||||
selectedRows = [];
|
||||
}}>Cancel</Button>
|
||||
<Button
|
||||
secondary
|
||||
on:click={async () => {
|
||||
if (confirmDeletion) {
|
||||
showConfirmDeletion = true;
|
||||
} else {
|
||||
const state = await onDelete?.(selectedRows);
|
||||
if (state instanceof Error) {
|
||||
// user should handle error on their own!
|
||||
} else {
|
||||
notifySuccess();
|
||||
selectedRows = [];
|
||||
}
|
||||
}
|
||||
}}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
|
||||
{#if allowSelection && confirmDeletion}
|
||||
<Confirm
|
||||
submissionLoader
|
||||
confirmDeletion
|
||||
error={onDeleteError}
|
||||
disabled={disableModal}
|
||||
title="Delete {getPluralResource()}"
|
||||
bind:open={showConfirmDeletion}
|
||||
onSubmit={async () => {
|
||||
disableModal = true;
|
||||
onDeleteError = null;
|
||||
|
||||
const state = await onDelete?.(selectedRows);
|
||||
if (state instanceof Error) {
|
||||
disableModal = false;
|
||||
onDeleteError = state.message || `Failed to delete ${resource}s`;
|
||||
} else {
|
||||
notifySuccess();
|
||||
selectedRows = [];
|
||||
disableModal = false;
|
||||
showConfirmDeletion = false;
|
||||
}
|
||||
}}>
|
||||
<Typography.Text>
|
||||
{@const selectionCount = selectedRows.length}
|
||||
{#if deleteContent}
|
||||
<!-- some show extra info -->
|
||||
{@render deleteContent(selectionCount)}
|
||||
{:else}
|
||||
Are you sure you want to delete <strong>{selectionCount}</strong>
|
||||
{selectionCount > 1 ? getPluralResource() : resource}?
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text variant="m-500">
|
||||
{#if deleteContentNotice}
|
||||
<!-- some show extra info -->
|
||||
{@render deleteContentNotice()}
|
||||
{:else}
|
||||
This action is irreversible.
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
{/if}
|
||||
{/key}
|
||||
@@ -20,6 +20,7 @@
|
||||
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
|
||||
import type { PinkColumn } from '$lib/helpers/types';
|
||||
import { Card } from '$lib/components';
|
||||
import { TableScroll } from '$lib/elements/table';
|
||||
|
||||
export let withCreate = false;
|
||||
export let permissions: string[] = [];
|
||||
@@ -113,32 +114,18 @@
|
||||
}, []);
|
||||
}
|
||||
|
||||
function sortRoles([a]: [string, Permission], [b]: [string, Permission]) {
|
||||
if ((a === 'any') !== (b === 'any')) {
|
||||
return a === 'any' ? -1 : 1;
|
||||
}
|
||||
if ((a === 'users') !== (b === 'users')) {
|
||||
return a === 'users' ? -1 : 1;
|
||||
}
|
||||
if ((a === 'guests') !== (b === 'guests')) {
|
||||
return a === 'guests' ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
const columns: PinkColumn[] = [
|
||||
{ id: 'role', width: { min: 80 } },
|
||||
{ id: 'create', width: { min: 80 }, hide: !withCreate },
|
||||
{ id: 'read', width: { min: 80 } },
|
||||
{ id: 'update', width: { min: 80 } },
|
||||
{ id: 'delete', width: { min: 80 } },
|
||||
{ id: 'role', width: { min: 220 } },
|
||||
{ id: 'create', width: { min: 64 }, hide: !withCreate },
|
||||
{ id: 'read', width: { min: 64 } },
|
||||
{ id: 'update', width: { min: 64 } },
|
||||
{ id: 'delete', width: { min: 64 } },
|
||||
{ id: 'action', width: 40 }
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if [...$groups]?.length}
|
||||
<div class="table-wrapper">
|
||||
<TableScroll>
|
||||
<Table.Root {columns} let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="role" {root}>Role</Table.Header.Cell>
|
||||
@@ -150,13 +137,14 @@
|
||||
<Table.Header.Cell column="delete" {root}>Delete</Table.Header.Cell>
|
||||
<Table.Header.Cell column="action" {root} />
|
||||
</svelte:fragment>
|
||||
{#each [...$groups].sort(sortRoles) as [role, permission] (role)}
|
||||
{#each [...$groups] as [role, permission] (role)}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="role" {root}>
|
||||
<Row {role} />
|
||||
<Row {role} onNotFound={() => deleteRole(role)} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="create" {root}>
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
checked={permission.create}
|
||||
on:change={() => togglePermission(role, 'create')} />
|
||||
</Table.Cell>
|
||||
@@ -187,7 +175,7 @@
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</div>
|
||||
</TableScroll>
|
||||
|
||||
<div>
|
||||
<Actions
|
||||
@@ -223,14 +211,3 @@
|
||||
</Layout.Stack>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.table-wrapper {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { type ComponentProps, type Snippet, onMount } 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,134 @@
|
||||
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;
|
||||
onNotFound?: (role: string) => void;
|
||||
}
|
||||
|
||||
let { role, placement = 'bottom-start', children, onNotFound }: 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;
|
||||
}
|
||||
|
||||
async function verifyExistence() {
|
||||
try {
|
||||
const data = await getData(role);
|
||||
if (data?.notFound) {
|
||||
onNotFound?.(role);
|
||||
}
|
||||
} catch {
|
||||
// Intentionally ignore fetch/parse errors; UI handles missing data state
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
verifyExistence();
|
||||
});
|
||||
|
||||
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 +154,184 @@
|
||||
{: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
|
||||
type="button"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
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 ? 16 : 20
|
||||
)}
|
||||
{/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="m"
|
||||
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="m"
|
||||
color="--fgcolor-neutral-primary">
|
||||
{formatName(
|
||||
data.name ??
|
||||
data?.email ??
|
||||
data?.phone ??
|
||||
'-',
|
||||
$isSmallViewport ? 18 : 24
|
||||
)}
|
||||
</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="xxs" alignItems="flex-start">
|
||||
{#if data.email}
|
||||
<Typography.Caption
|
||||
variant="400"
|
||||
color="--fgcolor-neutral-secondary">
|
||||
Email: {formatName(
|
||||
data.email,
|
||||
$isSmallViewport ? 24 : 32
|
||||
)}
|
||||
</Typography.Caption>
|
||||
{/if}
|
||||
{#if data.phone}
|
||||
<Typography.Caption
|
||||
variant="400"
|
||||
color="--fgcolor-neutral-secondary">
|
||||
Phone: {data.phone}
|
||||
</Typography.Caption>
|
||||
{/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>
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Base padding="none">
|
||||
<Empty title="You have no teams. Create a team to see them here." type="secondary">
|
||||
<Empty title="No teams yet. Create a team to see it here." type="secondary">
|
||||
<Typography.Text slot="description">
|
||||
Need a hand? Learn more in our <Link.Anchor
|
||||
href="https://appwrite.io/docs/products/auth/teams"
|
||||
|
||||
@@ -176,10 +176,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Base padding="none">
|
||||
<Empty title="You have no users. Create a user to see them here." type="secondary">
|
||||
<Empty title="No users yet. Create a user to see it here." type="secondary">
|
||||
<Typography.Text slot="description">
|
||||
Need a hand? Learn more in our <Link.Anchor
|
||||
href="https://appwrite.io/docs/products/auth/quick-start"
|
||||
href="https://appwrite.io/docs/products/auth/users"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
documentation</Link.Anchor
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Copy } from '.';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Layout, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { Icon, Tag } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Flag, type Models } from '@appwrite.io/console';
|
||||
import { truncateText } from '$lib/components/id.svelte';
|
||||
import { isValueOfStringEnum } from '$lib/helpers/types';
|
||||
import { getProjectEndpoint } from '$lib/helpers/project';
|
||||
|
||||
@@ -21,28 +21,28 @@
|
||||
</script>
|
||||
|
||||
{#if region}
|
||||
<Copy value={getProjectEndpoint()} copyText="Copy endpoint">
|
||||
<Tag size="xs" variant="default">
|
||||
<Layout.Stack direction="row" gap="s" alignItems="center" inline>
|
||||
{#if flagSrc}
|
||||
<img
|
||||
width={16}
|
||||
height={12}
|
||||
src={flagSrc}
|
||||
alt={region?.name}
|
||||
style:border-radius="2.5px" />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
style:white-space="nowrap"
|
||||
class="text u-line-height-1-5"
|
||||
style:overflow="hidden"
|
||||
style:word-break="break-all"
|
||||
use:truncateText
|
||||
style:font-family="unset">
|
||||
{region?.name}
|
||||
</span>
|
||||
</Layout.Stack>
|
||||
<Copy value={getProjectEndpoint()} copyText={`Copy endpoint (${region.name})`}>
|
||||
<Tag size="xs" variant="code">
|
||||
<Icon icon={IconDuplicate} size="s" slot="start" />
|
||||
<span class="endpoint-label"> API endpoint </span>
|
||||
{#if flagSrc}
|
||||
<img class="region-flag" src={flagSrc} alt={region?.name} />
|
||||
{/if}
|
||||
</Tag>
|
||||
</Copy>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.endpoint-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.region-flag {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border-radius: 2.5px;
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
Button,
|
||||
Layout,
|
||||
Avatar,
|
||||
Typography,
|
||||
Badge
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
|
||||
import {
|
||||
@@ -281,12 +280,6 @@
|
||||
class:has-text={state === 'open'}
|
||||
class="link-text">
|
||||
{projectOption.name}
|
||||
{#if projectOption?.badge}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
content={projectOption.badge}
|
||||
size="xs" />
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
<span slot="tooltip">{projectOption.name}</span>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
hideColumns?: boolean;
|
||||
allowNoColumns?: boolean;
|
||||
showAnyway?: boolean;
|
||||
disableButton?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -32,7 +33,8 @@
|
||||
hideView = false,
|
||||
hideColumns = false,
|
||||
allowNoColumns = false,
|
||||
showAnyway = false
|
||||
showAnyway = false,
|
||||
disableButton = false
|
||||
}: Props = $props();
|
||||
|
||||
let showCountBadge = $state(false);
|
||||
@@ -70,7 +72,7 @@
|
||||
icon={onlyIcon}
|
||||
onclick={toggle}
|
||||
variant="secondary"
|
||||
disabled={!$columns.length && showAnyway}
|
||||
disabled={(!$columns.length && showAnyway) || disableButton}
|
||||
class={onlyIcon && !$isSmallViewport ? 'width-fix' : undefined}
|
||||
badge={showCountBadge ? selectedColumnsNumber.toString() : undefined}>
|
||||
<Icon slot="start" icon={IconViewBoards} />
|
||||
|
||||
@@ -9,12 +9,14 @@ export const REGION_SYD = 'syd';
|
||||
export const REGION_NYC = 'nyc';
|
||||
export const REGION_SFO = 'sfo';
|
||||
export const REGION_SGP = 'sgp';
|
||||
export const REGION_TOR = 'tor';
|
||||
|
||||
export const SUBDOMAIN_FRA = 'fra.';
|
||||
export const SUBDOMAIN_SYD = 'syd.';
|
||||
export const SUBDOMAIN_NYC = 'nyc.';
|
||||
export const SUBDOMAIN_SFO = 'sfo.';
|
||||
export const SUBDOMAIN_SGP = 'sgp.';
|
||||
export const SUBDOMAIN_TOR = 'tor.';
|
||||
|
||||
export enum Dependencies {
|
||||
FACTORS = 'dependency:factors',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
onDeletePoint: (index: number) => void;
|
||||
onChangePoint: (pointIndex: number, coordIndex: number, newValue: number) => void;
|
||||
addLineButton?: Snippet;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -24,7 +25,8 @@
|
||||
onAddPoint,
|
||||
onDeletePoint,
|
||||
onChangePoint,
|
||||
addLineButton
|
||||
addLineButton,
|
||||
disabled
|
||||
}: Props = $props();
|
||||
|
||||
function isDeleteDisabled(index: number) {
|
||||
@@ -40,6 +42,7 @@
|
||||
<Layout.Stack>
|
||||
{#each values as value, index}
|
||||
<InputPoint
|
||||
{disabled}
|
||||
{nullable}
|
||||
values={value}
|
||||
deletePoints
|
||||
@@ -52,7 +55,11 @@
|
||||
|
||||
{#if values}
|
||||
<Layout.Stack direction="row" gap="s" alignItems="center">
|
||||
<Button size="xs" compact on:click={() => onAddPoint(-1)} disabled={nullable}>
|
||||
<Button
|
||||
size="xs"
|
||||
compact
|
||||
on:click={() => onAddPoint(-1)}
|
||||
disabled={nullable || disabled}>
|
||||
<Icon icon={IconPlus} size="s" /> Add coordinate
|
||||
</Button>
|
||||
{@render addLineButton?.()}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
deletePoints?: boolean;
|
||||
onDeletePoint?: () => void;
|
||||
disableDelete?: boolean;
|
||||
disabled?: boolean;
|
||||
onChangePoint: (index: number, newValue: number) => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +22,8 @@
|
||||
deletePoints = false,
|
||||
disableDelete = false,
|
||||
onDeletePoint,
|
||||
onChangePoint
|
||||
onChangePoint,
|
||||
disabled
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -38,6 +40,7 @@
|
||||
placeholder="Enter value"
|
||||
step={0.0001}
|
||||
value={values[index]}
|
||||
{disabled}
|
||||
on:change={(e) => onChangePoint(index, Number.parseFloat(`${e.detail}`))} />
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -45,7 +48,7 @@
|
||||
<Button
|
||||
size="s"
|
||||
secondary
|
||||
disabled={nullable || disableDelete}
|
||||
disabled={nullable || disableDelete || disabled}
|
||||
on:click={() => onDeletePoint?.()}>
|
||||
<Icon icon={IconX} size="s" />
|
||||
</Button>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
coordIndex: number,
|
||||
newValue: number
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -26,7 +27,8 @@
|
||||
onAddPoint,
|
||||
onAddLine,
|
||||
onDeletePoint,
|
||||
onChangePoint
|
||||
onChangePoint,
|
||||
disabled
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -34,6 +36,7 @@
|
||||
{#each values as value, index}
|
||||
<Layout.Stack gap="xs">
|
||||
<InputLine
|
||||
{disabled}
|
||||
values={value}
|
||||
onAddPoint={() => onAddPoint(index)}
|
||||
{nullable}
|
||||
|
||||
@@ -21,16 +21,17 @@
|
||||
}[];
|
||||
export let leadingIcon: ComponentType | undefined = undefined;
|
||||
|
||||
let element: HTMLSelectElement;
|
||||
let error: string;
|
||||
|
||||
const handleInvalid = (event: Event) => {
|
||||
event.preventDefault();
|
||||
const element = event.target as HTMLInputElement;
|
||||
|
||||
if (element.validity.valueMissing) {
|
||||
error = 'This field is required';
|
||||
return;
|
||||
}
|
||||
|
||||
error = element.validationMessage;
|
||||
};
|
||||
|
||||
@@ -38,12 +39,6 @@
|
||||
return typeof value === 'boolean' ? true : !!value;
|
||||
};
|
||||
|
||||
$: if (required && !isNotEmpty(value)) {
|
||||
element?.setCustomValidity('This field is required');
|
||||
} else {
|
||||
element?.setCustomValidity('');
|
||||
}
|
||||
|
||||
$: if (isNotEmpty(value)) {
|
||||
error = null;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
{#if href}
|
||||
<Link.Anchor
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:mousedown
|
||||
on:click={track}
|
||||
@@ -42,7 +43,16 @@
|
||||
<slot />
|
||||
</Link.Anchor>
|
||||
{:else}
|
||||
<Link.Button on:click on:mousedown on:click={track} {type} {disabled} {variant} {size} {icon}>
|
||||
<Link.Button
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:mousedown
|
||||
on:click={track}
|
||||
{type}
|
||||
{disabled}
|
||||
{variant}
|
||||
{size}
|
||||
{icon}>
|
||||
<slot />
|
||||
</Link.Button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
|
||||
/**
|
||||
* Checks if a build has exceeded the maximum build timeout duration
|
||||
*/
|
||||
function isBuildTimedOut(createdAt: string, status: string, timeoutSeconds: number): boolean {
|
||||
if (!['waiting', 'processing', 'building'].includes(status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!timeoutSeconds || timeoutSeconds <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const created = new Date(createdAt);
|
||||
const elapsedSeconds = Math.floor((Date.now() - created.getTime()) / 1000);
|
||||
|
||||
return elapsedSeconds > timeoutSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective status for a build, considering timeout
|
||||
*/
|
||||
export function getEffectiveBuildStatus(
|
||||
originalStatus: string,
|
||||
createdAt: string,
|
||||
consoleVariables: Models.ConsoleVariables | undefined
|
||||
): string {
|
||||
const timeoutSeconds = getBuildTimeoutSeconds(consoleVariables);
|
||||
if (isBuildTimedOut(createdAt, originalStatus, timeoutSeconds)) {
|
||||
return 'failed';
|
||||
}
|
||||
return originalStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to get timeout value from console variables
|
||||
*/
|
||||
function getBuildTimeoutSeconds(consoleVariables: Models.ConsoleVariables | undefined): number {
|
||||
if (!consoleVariables?._APP_COMPUTE_BUILD_TIMEOUT) {
|
||||
return 0;
|
||||
}
|
||||
const timeout = parseInt(String(consoleVariables._APP_COMPUTE_BUILD_TIMEOUT), 10);
|
||||
return isNaN(timeout) ? 0 : timeout;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 167 KiB |
@@ -140,7 +140,7 @@
|
||||
</Link.Anchor>
|
||||
{/if}
|
||||
{#if $isSmallViewport}
|
||||
{#if $version && !isCloud}
|
||||
{#if $version && isSelfHosted}
|
||||
<span class="divider-wrapper">
|
||||
<Divider vertical />
|
||||
</span>
|
||||
@@ -158,6 +158,9 @@
|
||||
{/if}
|
||||
|
||||
{#if isCloud && resolvedProfile.showGeneralAvailability}
|
||||
<span class="divider-wrapper">
|
||||
<Divider vertical />
|
||||
</span>
|
||||
<Icon size="s" icon={IconCloud} />
|
||||
|
||||
<Badge
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -488,6 +488,9 @@ export async function paymentExpired(org: Organization) {
|
||||
const nots = get(notifications);
|
||||
const expiredNotification = nots.some((n) => n.message === expiredMessage);
|
||||
const expiringNotification = nots.some((n) => n.message === expiringMessage);
|
||||
const cardExpiry = new Date(payment.expiryYear, payment.expiryMonth, 1);
|
||||
const nextMonth = new Date(year, month + 1, 1);
|
||||
const isExpiringNextMonth = cardExpiry.getTime() === nextMonth.getTime();
|
||||
if (payment.expired && !expiredNotification) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -503,7 +506,7 @@ export async function paymentExpired(org: Organization) {
|
||||
}
|
||||
]
|
||||
});
|
||||
} else if (!expiringNotification && payment.expiryYear <= year && payment.expiryMonth < month) {
|
||||
} else if (!expiringNotification && !payment.expired && isExpiringNextMonth) {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
isHtml: true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { StatusCode } from '@appwrite.io/console';
|
||||
|
||||
export const hideTypes = writable<boolean>(false);
|
||||
|
||||
export const statusCodeOptions = [
|
||||
{
|
||||
label: '301 Moved permanently',
|
||||
|
||||
@@ -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';
|
||||
@@ -32,14 +33,15 @@ import {
|
||||
REGION_SYD,
|
||||
REGION_SFO,
|
||||
REGION_SGP,
|
||||
REGION_TOR,
|
||||
SUBDOMAIN_FRA,
|
||||
SUBDOMAIN_NYC,
|
||||
SUBDOMAIN_SFO,
|
||||
SUBDOMAIN_SYD,
|
||||
SUBDOMAIN_SGP
|
||||
SUBDOMAIN_SGP,
|
||||
SUBDOMAIN_TOR
|
||||
} from '$lib/constants';
|
||||
import { building } from '$app/environment';
|
||||
import { getProjectId } from '$lib/helpers/project';
|
||||
|
||||
export function getApiEndpoint(region?: string): string {
|
||||
if (building) return '';
|
||||
@@ -67,6 +69,8 @@ const getSubdomain = (region?: string) => {
|
||||
return SUBDOMAIN_SFO;
|
||||
case REGION_SGP:
|
||||
return SUBDOMAIN_SGP;
|
||||
case REGION_TOR:
|
||||
return SUBDOMAIN_TOR;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -90,7 +94,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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,12 +139,32 @@ const sdkForProject = {
|
||||
};
|
||||
|
||||
export const realtime = {
|
||||
forProject(region: string, _projectId: string) {
|
||||
forProject(
|
||||
region: string,
|
||||
channels: string | string[],
|
||||
callback: AppwriteRealtimeResponseEvent
|
||||
) {
|
||||
const endpoint = getApiEndpoint(region);
|
||||
if (endpoint !== clientRealtime.config.endpoint) {
|
||||
clientRealtime.setEndpoint(endpoint);
|
||||
}
|
||||
return clientRealtime;
|
||||
|
||||
// because uses a different client!
|
||||
const realtime = new Realtime(clientRealtime);
|
||||
|
||||
return createRealtimeSubscription(realtime, channels, callback);
|
||||
},
|
||||
|
||||
forConsole(
|
||||
region: string,
|
||||
channels: string | string[],
|
||||
callback: AppwriteRealtimeResponseEvent
|
||||
): () => void {
|
||||
const realtimeInstance = region
|
||||
? sdk.forConsoleIn(region).realtime
|
||||
: sdk.forConsole.realtime;
|
||||
|
||||
return createRealtimeSubscription(realtimeInstance, channels, callback);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -169,8 +194,8 @@ export const sdk = {
|
||||
};
|
||||
|
||||
export enum RuleType {
|
||||
DEPLOYMENT = 'deployment',
|
||||
API = 'api',
|
||||
DEPLOYMENT = 'deployment',
|
||||
REDIRECT = 'redirect'
|
||||
}
|
||||
|
||||
@@ -184,6 +209,24 @@ export enum RuleTrigger {
|
||||
MANUAL = 'manual'
|
||||
}
|
||||
|
||||
export const createAdminClient = () => {
|
||||
return new Client().setEndpoint(getApiEndpoint()).setMode('admin').setProject(getProjectId());
|
||||
export type RealtimeResponse = {
|
||||
events: string[];
|
||||
channels: string[];
|
||||
timestamp: string;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
export type AppwriteRealtimeResponseEvent = (response: RealtimeResponse) => void;
|
||||
|
||||
function createRealtimeSubscription(
|
||||
realtimeInstance: Realtime,
|
||||
channels: string | string[],
|
||||
callback: AppwriteRealtimeResponseEvent
|
||||
): () => void {
|
||||
const channelsArray = Array.isArray(channels) ? channels : [channels];
|
||||
const subscriptionPromise = realtimeInstance.subscribe(channelsArray, callback);
|
||||
|
||||
return () => {
|
||||
subscriptionPromise.then((sub) => sub.close());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Link } from '$lib/elements';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { sdk, RuleType, DeploymentResourceType, RuleTrigger } from '$lib/stores/sdk';
|
||||
import { Query, type Models } from '@appwrite.io/console';
|
||||
import {
|
||||
IconDotsHorizontal,
|
||||
IconExternalLink,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconTrash
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import {
|
||||
ActionMenu,
|
||||
Badge,
|
||||
Icon,
|
||||
Layout,
|
||||
Popover,
|
||||
Table,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Divider
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { regionalProtocol } from '$routes/(console)/project-[region]-[project]/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import DeleteDomainModal from '$routes/(console)/project-[region]-[project]/sites/site-[site]/domains/deleteDomainModal.svelte';
|
||||
import RetryDomainModal from '$routes/(console)/project-[region]-[project]/sites/site-[site]/domains/retryDomainModal.svelte';
|
||||
|
||||
let {
|
||||
siteId,
|
||||
region,
|
||||
projectId
|
||||
}: {
|
||||
siteId: string;
|
||||
region: string;
|
||||
projectId: string;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let showRetry = $state(false);
|
||||
let showDelete = $state(false);
|
||||
|
||||
let selectedProxyRule: Models.ProxyRule = $state(null);
|
||||
let proxyRules = $state<Models.ProxyRuleList | null>(null);
|
||||
|
||||
async function loadDomains() {
|
||||
loading = true;
|
||||
try {
|
||||
proxyRules = await sdk.forProject(region, projectId).proxy.listRules({
|
||||
queries: [
|
||||
Query.equal('type', [RuleType.DEPLOYMENT, RuleType.REDIRECT]),
|
||||
Query.equal('deploymentResourceType', DeploymentResourceType.SITE),
|
||||
Query.equal('deploymentResourceId', siteId),
|
||||
Query.equal('trigger', RuleTrigger.MANUAL),
|
||||
Query.limit(100)
|
||||
]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load domains:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let previousDeleteState = $state(false);
|
||||
let previousRetryState = $state(false);
|
||||
|
||||
onMount(loadDomains);
|
||||
|
||||
$effect(() => {
|
||||
const wasDeleteOpen = previousDeleteState && !showDelete;
|
||||
const wasRetryOpen = previousRetryState && !showRetry;
|
||||
|
||||
if (wasDeleteOpen || wasRetryOpen) {
|
||||
loadDomains();
|
||||
}
|
||||
|
||||
previousDeleteState = showDelete;
|
||||
previousDeleteState = showDelete;
|
||||
previousRetryState = showRetry;
|
||||
});
|
||||
|
||||
const addDomainUrl = $derived.by(() => {
|
||||
const baseUrl = resolve(
|
||||
'/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain',
|
||||
{
|
||||
region,
|
||||
project: projectId,
|
||||
site: siteId
|
||||
}
|
||||
);
|
||||
return `${baseUrl}?types=false`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Table.Root columns={[{ id: 'domain' }, { id: 'actions', width: 40 }]} let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<Table.Header.Cell column="domain" {root}>Domain</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root} />
|
||||
</svelte:fragment>
|
||||
{#if loading}
|
||||
{#each Array(2) as _}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="domain" {root}>
|
||||
<Layout.Stack direction="row" gap="xs" alignItems="center">
|
||||
<Skeleton variant="line" width={200} height={20} />
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Skeleton variant="line" width={24} height={12} />
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
{:else if proxyRules && proxyRules.total > 0}
|
||||
{#each proxyRules.rules as rule}
|
||||
<Table.Row.Base {root}>
|
||||
<Table.Cell column="domain" {root}>
|
||||
<Layout.Stack direction="row" gap="xs" alignItems="center">
|
||||
<Link external variant="quiet" href={`${$regionalProtocol}${rule.domain}`}>
|
||||
<Layout.Stack direction="row" gap="xxs" alignItems="center">
|
||||
<Typography.Text truncate>
|
||||
{rule.domain}
|
||||
</Typography.Text>
|
||||
<Icon size="xs" icon={IconExternalLink} />
|
||||
</Layout.Stack>
|
||||
</Link>
|
||||
|
||||
{#if rule.status === 'verifying'}
|
||||
<Badge variant="secondary" content="Verifying" size="s" />
|
||||
{:else if rule.status !== 'verified'}
|
||||
<Badge
|
||||
size="s"
|
||||
type="warning"
|
||||
variant="secondary"
|
||||
content="Verification failed" />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<Popover let:toggle padding="none">
|
||||
<Button
|
||||
text
|
||||
icon
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
toggle(e);
|
||||
}}>
|
||||
<Icon icon={IconDotsHorizontal} size="s" />
|
||||
</Button>
|
||||
|
||||
<svelte:fragment slot="tooltip" let:toggle>
|
||||
{@render domainActions(rule, toggle)}
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Root>
|
||||
|
||||
<Layout.Stack style="width: min-content;">
|
||||
<Button compact on:onclick={async () => await goto(addDomainUrl)}>
|
||||
<Icon icon={IconPlus} size="s" />
|
||||
Add domain
|
||||
</Button>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if showDelete}
|
||||
<DeleteDomainModal bind:show={showDelete} {selectedProxyRule} />
|
||||
{/if}
|
||||
|
||||
{#if showRetry}
|
||||
<RetryDomainModal bind:show={showRetry} {selectedProxyRule} />
|
||||
{/if}
|
||||
|
||||
{#snippet domainActions(rule: Models.ProxyRule, toggle: () => void)}
|
||||
<ActionMenu.Root>
|
||||
<ActionMenu.Item.Anchor href={`${$regionalProtocol}${rule.domain}`} external>
|
||||
Open domain
|
||||
</ActionMenu.Item.Anchor>
|
||||
{#if rule.status !== 'verified' && rule.status !== 'verifying'}
|
||||
<ActionMenu.Item.Button
|
||||
leadingIcon={IconRefresh}
|
||||
on:click={() => {
|
||||
selectedProxyRule = rule;
|
||||
showRetry = true;
|
||||
toggle();
|
||||
}}>
|
||||
Retry
|
||||
</ActionMenu.Item.Button>
|
||||
|
||||
<div class="action-menu-divider">
|
||||
<Divider />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
leadingIcon={IconTrash}
|
||||
on:click={() => {
|
||||
selectedProxyRule = rule;
|
||||
showDelete = true;
|
||||
toggle();
|
||||
trackEvent(Click.DomainDeleteClick, {
|
||||
source: 'studio_manage_domains'
|
||||
});
|
||||
}}>
|
||||
Delete
|
||||
</ActionMenu.Item.Button>
|
||||
</ActionMenu.Root>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.action-menu-divider {
|
||||
margin-inline: -1rem;
|
||||
padding-block-end: 0.25rem;
|
||||
padding-block-start: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -271,7 +271,11 @@ export function hideStudio() {
|
||||
export async function initImagine(
|
||||
region: string,
|
||||
projectId: string,
|
||||
callbacks?: { onProjectNameChange?: (name: string) => void }
|
||||
callbacks?: {
|
||||
onProjectNameChange: () => void;
|
||||
onAddDomain: () => void | Promise<void>;
|
||||
onManageDomains: (primaryDomain?: string) => void | Promise<void>;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const { initImagineConfig, initImagineRouting } = await getWebComponents();
|
||||
|
||||
@@ -5,12 +5,28 @@
|
||||
<script lang="ts">
|
||||
import './shim.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { ensureStudioComponent, initImagine, getWebComponents } from './studio-widget';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Link } from '$lib/elements';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { IconExternalLink } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Layout, Typography, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { ensureStudioComponent, initImagine, getWebComponents } from './studio-widget';
|
||||
import DomainsTable from './domainsTable.svelte';
|
||||
import SideSheet from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/layout/sidesheet.svelte';
|
||||
|
||||
const { region, projectId }: { region: string; projectId: string } = $props();
|
||||
const {
|
||||
region,
|
||||
projectId
|
||||
}: {
|
||||
region: string;
|
||||
projectId: string;
|
||||
} = $props();
|
||||
|
||||
const siteId = `project-${projectId}`;
|
||||
let showManageDomainsSheet = $state(false);
|
||||
let primaryDomainForSite = $state(`imagine-${projectId}.stage.appwrite.network`);
|
||||
|
||||
onMount(() => {
|
||||
ensureStudioComponent();
|
||||
@@ -18,6 +34,23 @@
|
||||
initImagine(region, projectId, {
|
||||
onProjectNameChange: () => {
|
||||
invalidate(Dependencies.PROJECT);
|
||||
},
|
||||
onAddDomain: async () => {
|
||||
const baseUrl = resolve(
|
||||
'/(console)/project-[region]-[project]/sites/site-[site]/domains/add-domain',
|
||||
{
|
||||
region,
|
||||
project: projectId,
|
||||
site: siteId
|
||||
}
|
||||
);
|
||||
await goto(`${baseUrl}?types=false`);
|
||||
},
|
||||
onManageDomains: (primaryDomain) => {
|
||||
if (primaryDomain) {
|
||||
primaryDomainForSite = primaryDomain;
|
||||
}
|
||||
showManageDomainsSheet = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,3 +68,27 @@
|
||||
</script>
|
||||
|
||||
<div aria-hidden="true" style:display="none"></div>
|
||||
|
||||
<SideSheet title="Domains" bind:show={showManageDomainsSheet}>
|
||||
<Layout.Stack gap="xl">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Text color="--fgcolor-neutral-tertiary">Active domain</Typography.Text>
|
||||
|
||||
<Typography.Text>
|
||||
<Link size="m" external variant="quiet" href={primaryDomainForSite}>
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
gap="xxs"
|
||||
alignItems="center"
|
||||
alignContent="center">
|
||||
{primaryDomainForSite}
|
||||
|
||||
<Icon size="s" icon={IconExternalLink} />
|
||||
</Layout.Stack>
|
||||
</Link>
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
<DomainsTable {siteId} {region} {projectId} />
|
||||
</Layout.Stack>
|
||||
</SideSheet>
|
||||
|
||||
@@ -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 DbOperatorsDark from '$lib/images/promos/db-operators-dark.png';
|
||||
import DbOperatorsLight from '$lib/images/promos/db-operators-light.png';
|
||||
|
||||
const listOfPromotions: BottomModalAlertItem[] = [];
|
||||
|
||||
if (isCloud) {
|
||||
const spatialColumnsPromo: BottomModalAlertItem = {
|
||||
id: 'modal:spatial_columns_announcement',
|
||||
const dbOperatorsPromo: BottomModalAlertItem = {
|
||||
id: 'modal:db_operators_announcement',
|
||||
src: {
|
||||
dark: SpatialColumnsDark,
|
||||
light: SpatialColumnsLight
|
||||
dark: DbOperatorsDark,
|
||||
light: DbOperatorsLight
|
||||
},
|
||||
title: 'Announcing API for spatial columns',
|
||||
message: 'Store and query geo data directly in your database.',
|
||||
title: 'Announcing DB operators',
|
||||
message: 'Update multiple fields without fetching the entire row.',
|
||||
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-db-operators',
|
||||
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(dbOperatorsPromo);
|
||||
}
|
||||
|
||||
export function addBottomModalAlerts() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Card, Layout, Button } from '@appwrite.io/pink-svelte';
|
||||
import { Form } from '$lib/elements/forms';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { ID, Region } from '@appwrite.io/console';
|
||||
@@ -98,24 +99,22 @@
|
||||
class="u-only-dark"
|
||||
alt="{resolvedProfile.platform} Logo" />
|
||||
<Card.Base variant="primary" padding="l">
|
||||
<CreateProject
|
||||
showTitle
|
||||
bind:projectName
|
||||
bind:id={projectId}
|
||||
bind:region={projectRegion}
|
||||
regions={$regionsStore?.regions}>
|
||||
{#snippet submit()}
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Button.Button
|
||||
on:click={createProject}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="s">
|
||||
Create
|
||||
</Button.Button>
|
||||
</Layout.Stack>
|
||||
{/snippet}
|
||||
</CreateProject>
|
||||
<Form noStyle onSubmit={createProject}>
|
||||
<CreateProject
|
||||
showTitle
|
||||
bind:projectName
|
||||
bind:id={projectId}
|
||||
bind:region={projectRegion}
|
||||
regions={$regionsStore?.regions}>
|
||||
{#snippet submit()}
|
||||
<Layout.Stack direction="row" justifyContent="flex-end">
|
||||
<Button.Button type="submit" variant="primary" size="s">
|
||||
Create
|
||||
</Button.Button>
|
||||
</Layout.Stack>
|
||||
{/snippet}
|
||||
</CreateProject>
|
||||
</Form>
|
||||
</Card.Base>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
let organization = data.organization;
|
||||
|
||||
// Reactive statement to update organization when data changes
|
||||
$: organization = data.organization;
|
||||
|
||||
// why are these reactive?
|
||||
$: defaultPaymentMethod = data?.paymentMethods?.paymentMethods?.find(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -30,7 +30,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 &&
|
||||
|
||||
@@ -84,12 +84,14 @@
|
||||
}
|
||||
|
||||
$: 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([
|
||||
{
|
||||
|
||||
@@ -27,15 +27,13 @@
|
||||
import CsvImportBox from '$lib/components/csvImportBox.svelte';
|
||||
|
||||
onMount(() => {
|
||||
return realtime
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.subscribe(['project', 'console'], (response) => {
|
||||
if (response.events.includes('stats.connections')) {
|
||||
for (const [projectId, value] of Object.entries(response.payload)) {
|
||||
stats.add(projectId, [new Date(response.timestamp).toISOString(), value]);
|
||||
}
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
if (response.events.includes('stats.connections')) {
|
||||
for (const [projectId, value] of Object.entries(response.payload)) {
|
||||
stats.add(projectId, [new Date(response.timestamp).toISOString(), value]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$: $registerCommands([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" context="module">
|
||||
<script lang="ts" module>
|
||||
export let showCreateUser = writable(false);
|
||||
</script>
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
import {
|
||||
AvatarInitials,
|
||||
Copy,
|
||||
type DeleteOperationState,
|
||||
Empty,
|
||||
EmptySearch,
|
||||
MultiSelectionTable,
|
||||
PaginationWithLimit,
|
||||
SearchQuery
|
||||
} from '$lib/components';
|
||||
@@ -20,14 +22,7 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { writable } from 'svelte/store';
|
||||
import Create from './createUser.svelte';
|
||||
import {
|
||||
Badge,
|
||||
Icon,
|
||||
Table,
|
||||
Layout,
|
||||
Typography,
|
||||
FloatingActionBar
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Badge, Icon, Table, Layout, Typography } from '@appwrite.io/pink-svelte';
|
||||
import { Tag } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate, IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { canWriteUsers } from '$lib/stores/roles';
|
||||
@@ -37,11 +32,11 @@
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
|
||||
export let data;
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const columns = writable<Column[]>([
|
||||
{ id: '$id', title: 'User ID', type: 'string', width: 200 },
|
||||
@@ -59,44 +54,25 @@
|
||||
}
|
||||
]);
|
||||
|
||||
let selectedUsers: string[] = [];
|
||||
let showDelete = false;
|
||||
let deleting = false;
|
||||
|
||||
async function userCreated(event: CustomEvent<Models.User<Record<string, unknown>>>) {
|
||||
await goto(
|
||||
`${base}/project-${page.params.region}-${page.params.project}/auth/user-${event.detail.$id}`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
showDelete = false;
|
||||
deleting = true;
|
||||
|
||||
const promises = selectedUsers.map((userId) =>
|
||||
sdk.forProject(page.params.region, page.params.project).users.delete(userId)
|
||||
);
|
||||
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
const promises = selectedRows.map((userId) => {
|
||||
return sdk.forProject(page.params.region, page.params.project).users.delete({ userId });
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
trackEvent(Submit.UserDelete, {
|
||||
total: selectedUsers.length
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.USERS);
|
||||
trackEvent(Submit.UserDelete, { total: selectedRows.length });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.UserDelete);
|
||||
return error;
|
||||
} finally {
|
||||
selectedUsers = [];
|
||||
showDelete = false;
|
||||
deleting = false;
|
||||
await invalidate(Dependencies.USERS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -116,101 +92,111 @@
|
||||
</Layout.Stack>
|
||||
|
||||
{#if data.users.total}
|
||||
<Table.Root
|
||||
<MultiSelectionTable
|
||||
resource="user"
|
||||
columns={$columns}
|
||||
allowSelection={$canWriteUsers}
|
||||
bind:selectedRows={selectedUsers}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
onDelete={handleDelete}
|
||||
allowSelection={$canWriteUsers}>
|
||||
{#snippet header(root)}
|
||||
{#each $columns as { id, title } (id)}
|
||||
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
{#each data.users.users as user}
|
||||
<Table.Row.Link
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${user.$id}`}
|
||||
{root}
|
||||
id={user.$id}>
|
||||
{#each $columns as { id } (id)}
|
||||
<Table.Cell column={id} {root}>
|
||||
{#if id === '$id'}
|
||||
<Copy value={user.$id} event="user">
|
||||
<Tag size="xs" variant="code">
|
||||
<Icon size="s" icon={IconDuplicate} slot="start" />
|
||||
{user.$id}
|
||||
</Tag>
|
||||
</Copy>
|
||||
{:else if id === 'name'}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="s">
|
||||
{#if user.email || user.phone}
|
||||
{#if user.name}
|
||||
<AvatarInitials size="xs" name={user.name} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(root)}
|
||||
{#each data.users.users as user}
|
||||
<Table.Row.Link
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${user.$id}`}
|
||||
{root}
|
||||
id={user.$id}>
|
||||
{#each $columns as { id } (id)}
|
||||
<Table.Cell column={id} {root}>
|
||||
{#if id === '$id'}
|
||||
<Copy value={user.$id} event="user">
|
||||
<Tag size="xs" variant="code">
|
||||
<Icon size="s" icon={IconDuplicate} slot="start" />
|
||||
{user.$id}
|
||||
</Tag>
|
||||
</Copy>
|
||||
{:else if id === 'name'}
|
||||
<Layout.Stack direction="row" alignItems="center" gap="s">
|
||||
{#if user.email || user.phone}
|
||||
{#if user.name}
|
||||
<AvatarInitials size="xs" name={user.name} />
|
||||
<Typography.Text truncate>
|
||||
{user.name}
|
||||
</Typography.Text>
|
||||
{:else}
|
||||
<div class="avatar is-size-small">
|
||||
<span class="icon-minus-sm" aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="avatar is-size-small">
|
||||
<span class="icon-anonymous" aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
<Typography.Text truncate>
|
||||
{user.name}
|
||||
</Typography.Text>
|
||||
{:else}
|
||||
<div class="avatar is-size-small">
|
||||
<span class="icon-minus-sm" aria-hidden="true"
|
||||
></span>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{:else if id === 'identifiers'}
|
||||
<Typography.Text truncate>
|
||||
{user.email && user.phone
|
||||
? [user.email, user.phone].join(',')
|
||||
: user.email || user.phone}
|
||||
</Typography.Text>
|
||||
{:else if id === 'status'}
|
||||
{#if user.status}
|
||||
{@const success =
|
||||
user.emailVerification || user.phoneVerification}
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
type={success ? 'success' : undefined}
|
||||
content={user.emailVerification &&
|
||||
user.phoneVerification
|
||||
? 'Verified'
|
||||
: user.emailVerification
|
||||
? 'Verified email'
|
||||
: user.phoneVerification
|
||||
? 'Verified phone'
|
||||
: 'Unverified'} />
|
||||
{:else}
|
||||
<div class="avatar is-size-small">
|
||||
<span class="icon-anonymous" aria-hidden="true"></span>
|
||||
</div>
|
||||
<Typography.Text truncate>
|
||||
{user.name}
|
||||
</Typography.Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
type="error"
|
||||
content="blocked" />
|
||||
{/if}
|
||||
{:else if id === 'labels'}
|
||||
<Typography.Text truncate>
|
||||
{user.labels.join(', ')}
|
||||
</Typography.Text>
|
||||
{:else if id === 'joined'}
|
||||
<DualTimeView time={user.registration} />
|
||||
{:else if id === 'lastActivity'}
|
||||
{#if user.accessedAt}
|
||||
<DualTimeView time={user.accessedAt} />
|
||||
{:else}
|
||||
never
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{:else if id === 'identifiers'}
|
||||
<Typography.Text truncate>
|
||||
{user.email && user.phone
|
||||
? [user.email, user.phone].join(',')
|
||||
: user.email || user.phone}
|
||||
</Typography.Text>
|
||||
{:else if id === 'status'}
|
||||
{#if user.status}
|
||||
{@const success =
|
||||
user.emailVerification || user.phoneVerification}
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
type={success ? 'success' : undefined}
|
||||
content={user.emailVerification && user.phoneVerification
|
||||
? 'Verified'
|
||||
: user.emailVerification
|
||||
? 'Verified email'
|
||||
: user.phoneVerification
|
||||
? 'Verified phone'
|
||||
: 'Unverified'} />
|
||||
{:else}
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
type="error"
|
||||
content="blocked" />
|
||||
{user[id]}
|
||||
{/if}
|
||||
{:else if id === 'labels'}
|
||||
<Typography.Text truncate>
|
||||
{user.labels.join(', ')}
|
||||
</Typography.Text>
|
||||
{:else if id === 'joined'}
|
||||
<DualTimeView time={user.registration} />
|
||||
{:else if id === 'lastActivity'}
|
||||
{#if user.accessedAt}
|
||||
<DualTimeView time={user.accessedAt} />
|
||||
{:else}
|
||||
never
|
||||
{/if}
|
||||
{:else}
|
||||
{user[id]}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet deleteContentNotice()}
|
||||
This action is irreversible and will permanently remove the selected users and all
|
||||
their data.
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
|
||||
<PaginationWithLimit
|
||||
name="Users"
|
||||
@@ -220,9 +206,10 @@
|
||||
{:else if data.search}
|
||||
<EmptySearch target="users" hidePagination>
|
||||
<Button
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth`}
|
||||
size="s"
|
||||
secondary>Clear Search</Button>
|
||||
secondary
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth`}
|
||||
>Clear Search</Button>
|
||||
</EmptySearch>
|
||||
{:else}
|
||||
<Empty
|
||||
@@ -232,33 +219,6 @@
|
||||
allowCreate={$canWriteUsers}
|
||||
on:click={() => showCreateUser.set(true)} />
|
||||
{/if}
|
||||
|
||||
{#if selectedUsers.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedUsers.length.toString()} />
|
||||
<span>
|
||||
{selectedUsers.length > 1 ? 'users' : 'user'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedUsers = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<Create bind:showCreate={$showCreateUser} on:created={userCreated} />
|
||||
|
||||
<Confirm title="Delete users" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete <b>{selectedUsers.length}</b>
|
||||
{selectedUsers.length > 1 ? 'users' : 'user'}?
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
This action is irreversible and will permanently remove the selected users and all their
|
||||
data.
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" context="module">
|
||||
<script lang="ts" module>
|
||||
export let showCreateTeam = writable(false);
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
EmptySearch,
|
||||
AvatarInitials,
|
||||
SearchQuery,
|
||||
PaginationWithLimit
|
||||
PaginationWithLimit,
|
||||
type DeleteOperationState,
|
||||
MultiSelectionTable
|
||||
} from '$lib/components';
|
||||
import Create from '../createTeam.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -19,31 +21,16 @@
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { writable } from 'svelte/store';
|
||||
import { canWriteTeams } from '$lib/stores/roles';
|
||||
import {
|
||||
Icon,
|
||||
Layout,
|
||||
Table,
|
||||
FloatingActionBar,
|
||||
Badge,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Icon, Layout, Table } from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
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';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
export let data;
|
||||
|
||||
const region = page.params.region;
|
||||
const project = page.params.project;
|
||||
|
||||
let selectedTeams: string[] = [];
|
||||
let showDelete = false;
|
||||
let deleting = false;
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const columns = writable([
|
||||
{ id: 'name', title: 'Name', type: 'string', width: { min: 200, max: 300 } },
|
||||
@@ -52,37 +39,24 @@
|
||||
]);
|
||||
|
||||
const teamCreated = async (event: CustomEvent<Models.Team<Record<string, unknown>>>) => {
|
||||
await goto(`${base}/project-${region}-${project}/auth/teams/team-${event.detail.$id}`);
|
||||
await goto(
|
||||
`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${event.detail.$id}`
|
||||
);
|
||||
};
|
||||
|
||||
async function handleDelete() {
|
||||
showDelete = false;
|
||||
deleting = true;
|
||||
|
||||
const promises = selectedTeams.map((teamId) =>
|
||||
sdk.forProject(page.params.region, page.params.project).teams.delete(teamId)
|
||||
);
|
||||
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
const promises = selectedRows.map((teamId) => {
|
||||
return sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId });
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
trackEvent(Submit.TeamDelete, {
|
||||
total: selectedTeams.length
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedTeams.length} team${selectedTeams.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.TEAMS);
|
||||
trackEvent(Submit.TeamDelete, { total: selectedRows.length });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.TeamDelete);
|
||||
return error;
|
||||
} finally {
|
||||
selectedTeams = [];
|
||||
showDelete = false;
|
||||
deleting = false;
|
||||
await invalidate(Dependencies.TEAMS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -101,54 +75,47 @@
|
||||
</Layout.Stack>
|
||||
|
||||
{#if data.teams.total}
|
||||
<Table.Root
|
||||
<MultiSelectionTable
|
||||
resource="team"
|
||||
columns={$columns}
|
||||
allowSelection={$canWriteTeams}
|
||||
bind:selectedRows={selectedTeams}
|
||||
let:root>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
onDelete={handleDelete}
|
||||
allowSelection={$canWriteTeams}>
|
||||
{#snippet header(root)}
|
||||
{#each $columns as { id, title }}
|
||||
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
{#each data.teams.teams as team (team.$id)}
|
||||
<Table.Row.Link
|
||||
{root}
|
||||
href={`${base}/project-${region}-${project}/auth/teams/team-${team.$id}`}
|
||||
id={team.$id}>
|
||||
{#each $columns as column}
|
||||
<Table.Cell column={column.id} {root}>
|
||||
{#if column.id === 'name'}
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<AvatarInitials size="xs" name={team.name} />
|
||||
<span class="u-trim">{team.name}</span>
|
||||
</Layout.Stack>
|
||||
{:else if column.id === 'members'}
|
||||
{team.total} members
|
||||
{:else if column.id === 'created'}
|
||||
<DualTimeView time={team.$createdAt} />
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/snippet}
|
||||
|
||||
{#if selectedTeams.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedTeams.length.toString()} />
|
||||
<span>
|
||||
{selectedTeams.length > 1 ? 'teams' : 'team'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedTeams = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
{#snippet children(root)}
|
||||
{@const TableRowComponent = $canWriteTeams ? Table.Row.Link : Table.Row.Base}
|
||||
{#each data.teams.teams as team (team.$id)}
|
||||
{@const href = $canWriteTeams
|
||||
? `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${team.$id}`
|
||||
: undefined}
|
||||
<TableRowComponent {root} {href} id={team.$id}>
|
||||
{#each $columns as column}
|
||||
<Table.Cell column={column.id} {root}>
|
||||
{#if column.id === 'name'}
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<AvatarInitials size="xs" name={team.name} />
|
||||
<span class="u-trim">{team.name}</span>
|
||||
</Layout.Stack>
|
||||
{:else if column.id === 'members'}
|
||||
{team.total} members
|
||||
{:else if column.id === 'created'}
|
||||
<DualTimeView time={team.$createdAt} />
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</TableRowComponent>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet deleteContentNotice()}
|
||||
This action is irreversible and will permanently remove the selected teams and all
|
||||
their memberships.
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
|
||||
<PaginationWithLimit
|
||||
name="Teams"
|
||||
@@ -157,7 +124,10 @@
|
||||
total={data.teams.total} />
|
||||
{:else if data.search}
|
||||
<EmptySearch target="teams" search={data.search} hidePagination={data.teams.total === 0}>
|
||||
<Button secondary size="s" href={`${base}/project-${region}-${project}/auth/teams`}>
|
||||
<Button
|
||||
size="s"
|
||||
secondary
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams`}>
|
||||
Clear Search
|
||||
</Button>
|
||||
</EmptySearch>
|
||||
@@ -172,14 +142,3 @@
|
||||
</Container>
|
||||
|
||||
<Create bind:showCreate={$showCreateTeam} on:created={teamCreated} />
|
||||
|
||||
<Confirm title="Delete teams" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete <b>{selectedTeams.length}</b>
|
||||
{selectedTeams.length > 1 ? 'teams' : 'team'}?
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
This action is irreversible and will permanently remove the selected teams and all their
|
||||
memberships.
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
|
||||
@@ -1,77 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Empty, EmptySearch, AvatarInitials, PaginationWithLimit } from '$lib/components';
|
||||
import {
|
||||
Empty,
|
||||
EmptySearch,
|
||||
AvatarInitials,
|
||||
PaginationWithLimit,
|
||||
MultiSelectionTable,
|
||||
type DeleteOperationState
|
||||
} from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { Container } from '$lib/layout';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import type { PageProps } from './$types';
|
||||
import CreateMember from '../createMembership.svelte';
|
||||
import DeleteMembership from '../deleteMembership.svelte';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { Click, trackEvent, Submit, trackError } from '$lib/actions/analytics';
|
||||
import {
|
||||
Table,
|
||||
Layout,
|
||||
Icon,
|
||||
FloatingActionBar,
|
||||
Badge,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Table, Layout, Icon } from '@appwrite.io/pink-svelte';
|
||||
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
let showCreate = false;
|
||||
let showDelete = false;
|
||||
let selectedMembership: Models.Membership;
|
||||
let selectedMemberships: string[] = [];
|
||||
let showBulkDelete = false;
|
||||
let deleting = false;
|
||||
let showCreate = $state(false);
|
||||
let showDelete = $state(false);
|
||||
let selectedMembership: Models.Membership | null = $state(null);
|
||||
|
||||
const region = page.params.region;
|
||||
const project = page.params.project;
|
||||
|
||||
async function memberCreated() {
|
||||
invalidate(Dependencies.MEMBERSHIPS);
|
||||
}
|
||||
|
||||
async function handleBulkDelete() {
|
||||
showBulkDelete = false;
|
||||
deleting = true;
|
||||
|
||||
const promises = selectedMemberships.map((membershipId) =>
|
||||
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
|
||||
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
const promises = selectedRows.map((membershipId) => {
|
||||
return sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
|
||||
teamId: page.params.team,
|
||||
membershipId
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
trackEvent(Submit.MembershipUpdate, {
|
||||
total: selectedMemberships.length
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedMemberships.length} membership${selectedMemberships.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.MEMBERSHIPS);
|
||||
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.MembershipUpdate);
|
||||
return error;
|
||||
} finally {
|
||||
selectedMemberships = [];
|
||||
showBulkDelete = false;
|
||||
deleting = false;
|
||||
await invalidate(Dependencies.MEMBERSHIPS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -85,71 +58,62 @@
|
||||
</Layout.Stack>
|
||||
|
||||
{#if data.memberships.total}
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedMemberships}
|
||||
<MultiSelectionTable
|
||||
resource="membership"
|
||||
onDelete={handleBulkDelete}
|
||||
columns={[
|
||||
{ id: 'name' },
|
||||
{ id: 'roles' },
|
||||
{ id: 'joined' },
|
||||
{ id: 'actions', width: 40 }
|
||||
]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{#snippet header(root)}
|
||||
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="roles" {root}>Roles</Table.Header.Cell>
|
||||
<Table.Header.Cell column="joined" {root}>Joined</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root} />
|
||||
</svelte:fragment>
|
||||
{#each data.memberships.memberships as membership (membership.$id)}
|
||||
{@const username = membership.userName ? membership.userName : '-'}
|
||||
<Table.Row.Link
|
||||
{root}
|
||||
href={`${base}/project-${region}-${project}/auth/user-${membership.userId}`}
|
||||
id={membership.$id}>
|
||||
<Table.Cell column="name" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<AvatarInitials size="xs" name={username} />
|
||||
<span>{username}</span>
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="roles" {root}>
|
||||
{membership.roles}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="joined" {root}>
|
||||
<DualTimeView time={membership.joined} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<button
|
||||
class="button is-only-icon is-text"
|
||||
aria-label="Delete item"
|
||||
on:click|preventDefault={() => {
|
||||
selectedMembership = membership;
|
||||
showDelete = true;
|
||||
trackEvent(Click.MembershipDeleteClick);
|
||||
}}>
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/snippet}
|
||||
|
||||
{#if selectedMemberships.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedMemberships.length.toString()} />
|
||||
<span>
|
||||
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedMemberships = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
{#snippet children(root)}
|
||||
{#each data.memberships.memberships as membership (membership.$id)}
|
||||
{@const username = membership.userName ? membership.userName : '-'}
|
||||
<Table.Row.Link
|
||||
{root}
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${membership.userId}`}
|
||||
id={membership.$id}>
|
||||
<Table.Cell column="name" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<AvatarInitials size="xs" name={username} />
|
||||
<span>{username}</span>
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="roles" {root}>
|
||||
{membership.roles}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="joined" {root}>
|
||||
<DualTimeView time={membership.joined} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<button
|
||||
class="button is-only-icon is-text"
|
||||
aria-label="Delete item"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
showDelete = true;
|
||||
selectedMembership = membership;
|
||||
trackEvent(Click.MembershipDeleteClick);
|
||||
}}>
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet deleteContentNotice()}
|
||||
This action is irreversible and will remove the selected members from this team.
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
|
||||
<PaginationWithLimit
|
||||
name="Memberships"
|
||||
@@ -179,22 +143,12 @@
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<CreateMember teamId={page.params.team} bind:showCreate on:created={memberCreated} />
|
||||
<DeleteMembership
|
||||
{selectedMembership}
|
||||
bind:showDelete
|
||||
on:deleted={() => invalidate(Dependencies.MEMBERSHIPS)} />
|
||||
<CreateMember
|
||||
bind:showCreate
|
||||
teamId={page.params.team}
|
||||
on:created={() => invalidate(Dependencies.MEMBERSHIPS)} />
|
||||
|
||||
<Confirm
|
||||
title="Delete memberships"
|
||||
bind:open={showBulkDelete}
|
||||
onSubmit={handleBulkDelete}
|
||||
disabled={deleting}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete <b>{selectedMemberships.length}</b>
|
||||
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}?
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
This action is irreversible and will remove the selected members from this team.
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
<DeleteMembership
|
||||
bind:showDelete
|
||||
{selectedMembership}
|
||||
on:deleted={() => invalidate(Dependencies.MEMBERSHIPS)} />
|
||||
|
||||
@@ -1,122 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { Id } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
|
||||
import type { PageData } from './$types';
|
||||
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';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import type { Column } from '$lib/helpers/types';
|
||||
import { oAuthProviders } from '$lib/stores/oauth-providers';
|
||||
import { app } from '$lib/stores/app';
|
||||
import { base } from '$app/paths';
|
||||
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import { Table } from '@appwrite.io/pink-svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
export let columns: Column[];
|
||||
export let data: PageData;
|
||||
let {
|
||||
data,
|
||||
columns
|
||||
}: {
|
||||
data: PageData;
|
||||
columns: Column[];
|
||||
} = $props();
|
||||
|
||||
let selectedIds: string[] = [];
|
||||
let showDelete = false;
|
||||
|
||||
async function handleDelete() {
|
||||
showDelete = false;
|
||||
|
||||
const promises = selectedIds.map((id) =>
|
||||
sdk
|
||||
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
const promises = selectedRows.map((id) => {
|
||||
return sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.users.deleteIdentity({ identityId: id })
|
||||
);
|
||||
.users.deleteIdentity({ identityId: id });
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
trackEvent(Submit.UserIdentityDelete, {
|
||||
total: selectedIds.length
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedIds.length} target${selectedIds.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.USER_IDENTITIES);
|
||||
trackEvent(Submit.UserIdentityDelete, { total: selectedRows.length });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.UserIdentityDelete);
|
||||
return error;
|
||||
} finally {
|
||||
selectedIds = [];
|
||||
showDelete = false;
|
||||
await invalidate(Dependencies.USER_IDENTITIES);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Root {columns} allowSelection let:root bind:selectedRows={selectedIds}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<MultiSelectionTable {columns} resource="identity" onDelete={handleDelete}>
|
||||
{#snippet header(root)}
|
||||
{#each columns as { id, title }}
|
||||
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
{#each data.identities.identities as identity (identity.$id)}
|
||||
<Table.Row.Base {root} id={identity.$id}>
|
||||
{#each columns as column}
|
||||
<Table.Cell column={column.id} {root}>
|
||||
{#if column.id === '$id'}
|
||||
{#key columns}
|
||||
<Id value={identity[column.id]}>
|
||||
{identity[column.id]}
|
||||
</Id>
|
||||
{/key}
|
||||
{:else if column.id === 'provider'}
|
||||
{@const provider = oAuthProviders[identity[column.id]]}
|
||||
<div class="u-inline-flex u-cross-center u-gap-8">
|
||||
<div class="avatar is-size-small">
|
||||
<img
|
||||
style="--p-text-size: 1rem"
|
||||
height="20"
|
||||
width="20"
|
||||
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
|
||||
alt={provider.name} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(root)}
|
||||
{#each data.identities.identities as identity (identity.$id)}
|
||||
<Table.Row.Base {root} id={identity.$id}>
|
||||
{#each columns as column}
|
||||
<Table.Cell column={column.id} {root}>
|
||||
{#if column.id === '$id'}
|
||||
{#key columns}
|
||||
<Id value={identity[column.id]}>
|
||||
{identity[column.id]}
|
||||
</Id>
|
||||
{/key}
|
||||
{:else if column.id === 'provider'}
|
||||
{@const provider = oAuthProviders[identity[column.id]]}
|
||||
<div class="u-inline-flex u-cross-center u-gap-8">
|
||||
<div class="avatar is-size-small">
|
||||
<img
|
||||
style="--p-text-size: 1rem"
|
||||
height="20"
|
||||
width="20"
|
||||
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
|
||||
alt={provider.name} />
|
||||
</div>
|
||||
{provider.name}
|
||||
</div>
|
||||
{provider.name}
|
||||
</div>
|
||||
{:else if column.type === 'datetime'}
|
||||
{#if !identity[column.id]}
|
||||
-
|
||||
{:else if column.type === 'datetime'}
|
||||
{#if !identity[column.id]}
|
||||
-
|
||||
{:else}
|
||||
<DualTimeView time={identity[column.id]} />
|
||||
{/if}
|
||||
{:else}
|
||||
<DualTimeView time={identity[column.id]} />
|
||||
{identity[column.id]}
|
||||
{/if}
|
||||
{:else}
|
||||
{identity[column.id]}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
|
||||
{#if selectedIds.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedIds.length.toString()} />
|
||||
<span>
|
||||
{selectedIds.length > 1 ? 'identities' : 'identity'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
|
||||
<Confirm title="Delete Identity" bind:open={showDelete} onSubmit={handleDelete}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete <b>{selectedIds.length}</b>
|
||||
{selectedIds.length > 1 ? 'identities' : 'identity'}?
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
|
||||
@@ -1,50 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { base } from '$app/paths';
|
||||
import { AvatarInitials } from '$lib/components';
|
||||
import {
|
||||
AvatarInitials,
|
||||
type DeleteOperationState,
|
||||
MultiSelectionTable
|
||||
} from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { Container } from '$lib/layout';
|
||||
import DeleteMembership from '../deleteMembership.svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import {
|
||||
Table,
|
||||
Layout,
|
||||
Empty,
|
||||
Card,
|
||||
FloatingActionBar,
|
||||
Badge,
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import { Table, Layout, Empty, Card } from '@appwrite.io/pink-svelte';
|
||||
import { sdk } from '$lib/stores/sdk';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
export let data;
|
||||
const { data }: PageProps = $props();
|
||||
|
||||
let selectedMembership: Models.Membership;
|
||||
let showDelete = false;
|
||||
let selectedMemberships: string[] = [];
|
||||
let showBulkDelete = false;
|
||||
let deleting = false;
|
||||
|
||||
const region = page.params.region;
|
||||
const project = page.params.project;
|
||||
|
||||
async function handleBulkDelete() {
|
||||
showBulkDelete = false;
|
||||
deleting = true;
|
||||
let showDelete = $state(false);
|
||||
let selectedMembership: Models.Membership | null = $state(null);
|
||||
|
||||
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
// Precompute a lookup map from membershipId to teamId for efficient access
|
||||
const membershipIdToTeamId: Record<string, string> = {};
|
||||
for (const m of data.memberships.memberships) {
|
||||
membershipIdToTeamId[m.$id] = m.teamId;
|
||||
for (const membership of data.memberships.memberships) {
|
||||
membershipIdToTeamId[membership.$id] = membership.teamId;
|
||||
}
|
||||
|
||||
const promises = selectedMemberships.map((membershipId) =>
|
||||
const promises = selectedRows.map((membershipId) =>
|
||||
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
|
||||
teamId: membershipIdToTeamId[membershipId] || '',
|
||||
membershipId
|
||||
@@ -53,78 +39,73 @@
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
trackEvent(Submit.MembershipUpdate, {
|
||||
total: selectedMemberships.length
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedMemberships.length} membership${selectedMemberships.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.MEMBERSHIPS);
|
||||
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.MembershipUpdate);
|
||||
return error;
|
||||
} finally {
|
||||
selectedMemberships = [];
|
||||
showBulkDelete = false;
|
||||
deleting = false;
|
||||
await invalidate(Dependencies.MEMBERSHIPS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
{#if data.memberships.total}
|
||||
<Table.Root
|
||||
let:root
|
||||
allowSelection
|
||||
bind:selectedRows={selectedMemberships}
|
||||
<MultiSelectionTable
|
||||
resource="membership"
|
||||
onDelete={handleBulkDelete}
|
||||
columns={[
|
||||
{ id: 'name' },
|
||||
{ id: 'roles' },
|
||||
{ id: 'joined' },
|
||||
{ id: 'actions', width: 40 }
|
||||
]}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{#snippet header(root)}
|
||||
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
|
||||
<Table.Header.Cell column="roles" {root}>Roles</Table.Header.Cell>
|
||||
<Table.Header.Cell column="joined" {root}>Joined</Table.Header.Cell>
|
||||
<Table.Header.Cell column="actions" {root} />
|
||||
</svelte:fragment>
|
||||
{#each data.memberships.memberships as membership}
|
||||
<Table.Row.Link
|
||||
{root}
|
||||
href={`${base}/project-${region}-${project}/auth/teams/team-${membership.teamId}`}
|
||||
id={membership.$id}>
|
||||
<Table.Cell column="name" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<AvatarInitials size="xs" name={membership.teamName} />
|
||||
<span>{membership.teamName ? membership.teamName : 'n/a'}</span>
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="roles" {root}>
|
||||
{membership.roles}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="joined" {root}>
|
||||
<DualTimeView time={membership.joined} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<button
|
||||
class="button is-only-icon is-text"
|
||||
aria-label="Delete item"
|
||||
on:click|preventDefault={() => {
|
||||
selectedMembership = membership;
|
||||
showDelete = true;
|
||||
trackEvent('click_delete_membership');
|
||||
}}>
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(root)}
|
||||
{#each data.memberships.memberships as membership}
|
||||
<Table.Row.Link
|
||||
{root}
|
||||
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${membership.teamId}`}
|
||||
id={membership.$id}>
|
||||
<Table.Cell column="name" {root}>
|
||||
<Layout.Stack direction="row" alignItems="center">
|
||||
<AvatarInitials size="xs" name={membership.teamName} />
|
||||
<span>{membership.teamName ? membership.teamName : 'n/a'}</span>
|
||||
</Layout.Stack>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="roles" {root}>
|
||||
{membership.roles}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="joined" {root}>
|
||||
<DualTimeView time={membership.joined} />
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<button
|
||||
class="button is-only-icon is-text"
|
||||
aria-label="Delete item"
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
selectedMembership = membership;
|
||||
showDelete = true;
|
||||
trackEvent('click_delete_membership');
|
||||
}}>
|
||||
<span class="icon-trash" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row.Link>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
{#snippet deleteContentNotice()}
|
||||
This action is irreversible and will remove the user from the selected teams.
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
{:else}
|
||||
<Card.Base padding="none">
|
||||
<Empty
|
||||
@@ -142,36 +123,6 @@
|
||||
</Empty>
|
||||
</Card.Base>
|
||||
{/if}
|
||||
|
||||
{#if selectedMemberships.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedMemberships.length.toString()} />
|
||||
<span>
|
||||
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedMemberships = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<DeleteMembership {selectedMembership} bind:showDelete />
|
||||
|
||||
<Confirm
|
||||
title="Delete memberships"
|
||||
bind:open={showBulkDelete}
|
||||
onSubmit={handleBulkDelete}
|
||||
disabled={deleting}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete <b>{selectedMemberships.length}</b>
|
||||
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}?
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
This action is irreversible and will remove the user from the selected teams.
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Id } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
|
||||
import type { PageData } from './$types';
|
||||
import { columns } from './store';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
@@ -10,108 +9,74 @@
|
||||
import { page } from '$app/state';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { MessagingProviderType } from '@appwrite.io/console';
|
||||
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import { Table } from '@appwrite.io/pink-svelte';
|
||||
|
||||
export let data: PageData;
|
||||
const {
|
||||
data
|
||||
}: {
|
||||
data: PageData;
|
||||
} = $props();
|
||||
|
||||
let selectedIds: string[] = [];
|
||||
let showDelete = false;
|
||||
|
||||
async function handleDelete() {
|
||||
showDelete = false;
|
||||
|
||||
const promises = selectedIds.map((id) =>
|
||||
sdk
|
||||
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
const promises = selectedRows.map((id) => {
|
||||
return sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.users.deleteTarget({ userId: page.params.user, targetId: id })
|
||||
);
|
||||
.users.deleteTarget({ userId: page.params.user, targetId: id });
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
trackEvent(Submit.UserTargetDelete, {
|
||||
total: selectedIds.length
|
||||
});
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: `${selectedIds.length} target${selectedIds.length > 1 ? 's' : ''} deleted`
|
||||
});
|
||||
invalidate(Dependencies.USER_TARGETS);
|
||||
trackEvent(Submit.UserTargetDelete, { total: selectedRows.length });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
trackError(error, Submit.UserTargetDelete);
|
||||
return error;
|
||||
} finally {
|
||||
selectedIds = [];
|
||||
showDelete = false;
|
||||
await invalidate(Dependencies.USER_TARGETS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Table.Root columns={$columns} allowSelection let:root bind:selectedRows={selectedIds}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<MultiSelectionTable resource="target" columns={$columns} onDelete={handleDelete}>
|
||||
{#snippet header(root)}
|
||||
{#each $columns as { id, title }}
|
||||
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
{#each data.targets.targets as target (target.$id)}
|
||||
{@const provider = data.providersById[target.providerId]}
|
||||
<Table.Row.Base {root} id={target.$id}>
|
||||
{#each $columns as column}
|
||||
<Table.Cell column={column.id} {root}>
|
||||
{#if column.id === '$id'}
|
||||
{#key $columns}
|
||||
<Id value={target[column.id]}>
|
||||
{target[column.id]}
|
||||
</Id>
|
||||
{/key}
|
||||
{:else if column.id === 'target'}
|
||||
{#if target.providerType === MessagingProviderType.Push}
|
||||
{target.name}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(root)}
|
||||
{#each data.targets.targets as target (target.$id)}
|
||||
{@const provider = data.providersById[target.providerId]}
|
||||
<Table.Row.Base {root} id={target.$id}>
|
||||
{#each $columns as column}
|
||||
<Table.Cell column={column.id} {root}>
|
||||
{#if column.id === '$id'}
|
||||
{#key $columns}
|
||||
<Id value={target[column.id]}>
|
||||
{target[column.id]}
|
||||
</Id>
|
||||
{/key}
|
||||
{:else if column.id === 'target'}
|
||||
{#if target.providerType === MessagingProviderType.Push}
|
||||
{target.name}
|
||||
{:else}
|
||||
{target.identifier}
|
||||
{/if}
|
||||
{:else if column.id === 'providerType'}
|
||||
<ProviderType type={target.providerType} size="s" />
|
||||
{:else if column.id === 'provider'}
|
||||
{#if provider}
|
||||
<Provider provider={provider.provider} />
|
||||
{/if}
|
||||
{:else if column.id === '$createdAt'}
|
||||
<DualTimeView time={target[column.id]} />
|
||||
{:else}
|
||||
{target.identifier}
|
||||
{target[column.id]}
|
||||
{/if}
|
||||
{:else if column.id === 'providerType'}
|
||||
<ProviderType type={target.providerType} size="s" />
|
||||
{:else if column.id === 'provider'}
|
||||
{#if provider}
|
||||
<Provider provider={provider.provider} />
|
||||
{/if}
|
||||
{:else if column.id === '$createdAt'}
|
||||
<DualTimeView time={target[column.id]} />
|
||||
{:else}
|
||||
{target[column.id]}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
|
||||
{#if selectedIds.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedIds.length.toString()} />
|
||||
<span>
|
||||
{selectedIds.length > 1 ? 'targets' : 'target'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
|
||||
<Confirm title="Delete Target" bind:open={showDelete} onSubmit={handleDelete}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete <b>{selectedIds.length}</b>
|
||||
{selectedIds.length > 1 ? 'targets' : 'target'}?
|
||||
</Typography.Text>
|
||||
</Confirm>
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
import Input from './input.svelte';
|
||||
import { Modal } from '$lib/components';
|
||||
import { Button } from '$lib/elements/forms';
|
||||
import { tableColumnSuggestions } from './store';
|
||||
|
||||
let {
|
||||
show = $bindable(false)
|
||||
}: {
|
||||
show?: boolean;
|
||||
} = $props();
|
||||
|
||||
const isOnRowsPage = $derived(page.route?.id?.endsWith('table-[table]'));
|
||||
|
||||
function resetSuggestionsStore() {
|
||||
show = false;
|
||||
|
||||
$tableColumnSuggestions.table = null;
|
||||
$tableColumnSuggestions.context = null;
|
||||
|
||||
$tableColumnSuggestions.force = false;
|
||||
$tableColumnSuggestions.enabled = false;
|
||||
$tableColumnSuggestions.thinking = false;
|
||||
}
|
||||
|
||||
async function triggerColumnSuggestions() {
|
||||
// set table info. first!
|
||||
$tableColumnSuggestions.table = {
|
||||
id: page.params.table,
|
||||
name: page.data.table?.name ?? 'Table'
|
||||
};
|
||||
|
||||
if (!isOnRowsPage) {
|
||||
await goto(
|
||||
resolve(
|
||||
'/(console)/project-[region]-[project]/databases/database-[database]/table-[table]',
|
||||
{
|
||||
region: page.params.region,
|
||||
project: page.params.project,
|
||||
database: page.params.database,
|
||||
table: page.params.table
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$tableColumnSuggestions.force = true;
|
||||
$tableColumnSuggestions.enabled = true;
|
||||
|
||||
show = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:show title="Suggest columns" onSubmit={triggerColumnSuggestions}>
|
||||
<Input isModal />
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button text on:click={resetSuggestionsStore}>Cancel</Button>
|
||||
<Button submit>Generate columns</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
@@ -71,8 +71,16 @@
|
||||
border: 1.25px solid rgba(253, 54, 110, 0.12);
|
||||
|
||||
padding: 5px 0;
|
||||
min-width: 40px;
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
|
||||
& svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ai-icon-holder.notification) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
</script>
|
||||
|
||||
<Layout.Stack
|
||||
inline
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style="width: 18px !important; height: 20px !important;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.00049 2C5.55277 2 6.00049 2.44772 6.00049 3V4H7.00049C7.55277 4 8.00049 4.44772 8.00049 5C8.00049 5.55228 7.55277 6 7.00049 6H6.00049V7C6.00049 7.55228 5.55277 8 5.00049 8C4.4482 8 4.00049 7.55228 4.00049 7V6H3.00049C2.4482 6 2.00049 5.55228 2.00049 5C2.00049 4.44772 2.4482 4 3.00049 4H4.00049V3C4.00049 2.44772 4.4482 2 5.00049 2ZM5.00049 12C5.55277 12 6.00049 12.4477 6.00049 13V14H7.00049C7.55277 14 8.00049 14.4477 8.00049 15C8.00049 15.5523 7.55277 16 7.00049 16H6.00049V17C6.00049 17.5523 5.55277 18 5.00049 18C4.4482 18 4.00049 17.5523 4.00049 17V16H3.00049C2.4482 16 2.00049 15.5523 2.00049 15C2.00049 14.4477 2.4482 14 3.00049 14H4.00049V13C4.00049 12.4477 4.4482 12 5.00049 12Z"
|
||||
fill="#97979B" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12.0004 2C12.4542 2 12.851 2.30548 12.9671 2.74411L14.1464 7.19893L17.5002 9.13381C17.8097 9.3124 18.0004 9.64262 18.0004 10C18.0004 10.3574 17.8097 10.6876 17.5002 10.8662L14.1464 12.8011L12.9671 17.2559C12.851 17.6945 12.4542 18 12.0004 18C11.5467 18 11.1498 17.6945 11.0337 17.2559L9.85451 12.8011L6.50076 10.8662C6.19121 10.6876 6.00049 10.3574 6.00049 10C6.00049 9.64262 6.19121 9.31241 6.50076 9.13382L9.85451 7.19893L11.0337 2.74411C11.1498 2.30548 11.5467 2 12.0004 2Z"
|
||||
fill="#97979B" />
|
||||
</svg>
|
||||
</Layout.Stack>
|
||||
@@ -8,7 +8,7 @@
|
||||
mockSuggestions,
|
||||
type SuggestedIndexSchema
|
||||
} from './store';
|
||||
import { Modal, Confirm } from '$lib/components';
|
||||
import { Modal } 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';
|
||||
@@ -32,7 +32,6 @@
|
||||
let creatingIndexes = $state(false);
|
||||
let loadingSuggestions = $state(false);
|
||||
let indexes = $state<SuggestedIndexSchema[]>([]);
|
||||
let confirmDismiss = $state(false);
|
||||
let columnOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -195,7 +194,6 @@
|
||||
|
||||
function dismissIndexes() {
|
||||
indexes = [];
|
||||
confirmDismiss = false;
|
||||
$showIndexesSuggestions = false;
|
||||
}
|
||||
|
||||
@@ -354,13 +352,7 @@
|
||||
text
|
||||
size="s"
|
||||
disabled={loadingSuggestions || creatingIndexes}
|
||||
on:click={() => {
|
||||
if (indexes.length > 0 && !creatingIndexes) {
|
||||
confirmDismiss = true;
|
||||
} else {
|
||||
$showIndexesSuggestions = false;
|
||||
}
|
||||
}}>Cancel</Button>
|
||||
on:click={() => dismissIndexes()}>Cancel</Button>
|
||||
|
||||
<Button
|
||||
size="s"
|
||||
@@ -389,13 +381,7 @@
|
||||
}}
|
||||
cancel={{
|
||||
disabled: loadingSuggestions || creatingIndexes,
|
||||
onClick: () => {
|
||||
if (indexes.length > 0 && !creatingIndexes) {
|
||||
confirmDismiss = true;
|
||||
} else {
|
||||
$showIndexesSuggestions = false;
|
||||
}
|
||||
}
|
||||
onClick: () => dismissIndexes()
|
||||
}}>
|
||||
{#if modalError}
|
||||
<Alert.Inline status="error" title={modalError} />
|
||||
@@ -540,15 +526,6 @@
|
||||
{/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)
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
import { Button, InputTextarea } from '$lib/elements/forms';
|
||||
import { Card, Layout, Selector, Typography } from '@appwrite.io/pink-svelte';
|
||||
|
||||
const {
|
||||
isModal = false
|
||||
}: {
|
||||
isModal?: boolean;
|
||||
} = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (featureActive) {
|
||||
$tableColumnSuggestions.enabled = true;
|
||||
@@ -23,7 +29,9 @@
|
||||
|
||||
const subtitle = $derived.by(() => {
|
||||
return featureActive
|
||||
? 'Enable AI to suggest useful columns based on your table name'
|
||||
? isModal
|
||||
? 'Use AI to suggest useful columns'
|
||||
: 'Enable AI to suggest useful columns based on your table name'
|
||||
: 'Sign up for Cloud to generate columns based on your table name';
|
||||
});
|
||||
</script>
|
||||
@@ -42,7 +50,7 @@
|
||||
</Typography.Text>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if featureActive}
|
||||
{#if featureActive && !isModal}
|
||||
<div class="suggestions-switch">
|
||||
<Selector.Switch
|
||||
id="suggestions"
|
||||
@@ -62,7 +70,7 @@
|
||||
|
||||
<!-- just being safe with extra guard! -->
|
||||
{#if $tableColumnSuggestions.enabled && featureActive}
|
||||
<div transition:slide={{ duration: 200 }}>
|
||||
<div class="context-input" transition:slide={{ duration: 200 }}>
|
||||
<InputTextarea
|
||||
id="context"
|
||||
rows={3}
|
||||
@@ -78,4 +86,8 @@
|
||||
.suggestions-switch :global(button):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-input :global(.input) {
|
||||
background: var(--bgcolor-neutral-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Popover } from '@appwrite.io/pink-svelte';
|
||||
import { Popover, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import SideSheet from '../table-[table]/layout/sidesheet.svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
tooltipChildren,
|
||||
mobileFooterChildren,
|
||||
toggleOnTapClick = true,
|
||||
onShowStateChanged = null,
|
||||
enabled = true
|
||||
enabled = true,
|
||||
onChildrenClick,
|
||||
triggerOpen,
|
||||
headerTooltipText
|
||||
}: {
|
||||
children: Snippet<[toggle: (event: Event) => void]>;
|
||||
tooltipChildren: Snippet<[toggle: (event: Event) => void]>;
|
||||
mobileFooterChildren?: Snippet<[toggle: (event: Event) => void]>;
|
||||
toggleOnTapClick?: boolean;
|
||||
onShowStateChanged?: (showing: boolean) => void;
|
||||
enabled?: boolean;
|
||||
onChildrenClick?: () => void;
|
||||
triggerOpen?: () => boolean;
|
||||
headerTooltipText?: string;
|
||||
} = $props();
|
||||
|
||||
let showSheet = $state(false);
|
||||
@@ -25,6 +33,12 @@
|
||||
showSheet = false;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($isSmallViewport && triggerOpen && triggerOpen()) {
|
||||
showSheet = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Popover let:toggle let:showing portal padding="none" placement="bottom-start">
|
||||
@@ -36,9 +50,19 @@
|
||||
{@render children(() => (showSheet = false))}
|
||||
</button>
|
||||
{:else}
|
||||
<button style:cursor={enabled ? 'pointer' : undefined}>
|
||||
{@render children(toggle)}
|
||||
</button>
|
||||
<div style:display="grid">
|
||||
<Tooltip maxWidth="225px" portal disabled={!headerTooltipText || showing} delay={100}>
|
||||
<button
|
||||
onclick={() => enabled && onChildrenClick?.()}
|
||||
style:cursor={enabled ? 'pointer' : undefined}>
|
||||
{@render children(toggle)}
|
||||
</button>
|
||||
|
||||
<svelte:fragment slot="tooltip">
|
||||
{headerTooltipText}
|
||||
</svelte:fragment>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div let:toggle slot="tooltip" style:width="480px" style:padding="16px">
|
||||
@@ -56,6 +80,10 @@
|
||||
showSheet = false;
|
||||
}
|
||||
}}>
|
||||
{#snippet footer()}
|
||||
{@render mobileFooterChildren?.(() => (showSheet = false))}
|
||||
{/snippet}
|
||||
|
||||
{@render tooltipChildren(() => (showSheet = false))}
|
||||
</SideSheet>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IndexType } from '@appwrite.io/console';
|
||||
import { columnOptions } from '../table-[table]/columns/store';
|
||||
|
||||
export type TableColumnSuggestions = {
|
||||
force: boolean;
|
||||
enabled: boolean;
|
||||
thinking: boolean;
|
||||
context?: string | undefined;
|
||||
@@ -18,11 +19,15 @@ export type SuggestedColumnSchema = {
|
||||
key: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
array?: boolean;
|
||||
default?: string | number | boolean | number[] | number[][] | number[][][] | null;
|
||||
size?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
format?: string | null;
|
||||
encrypt?: boolean | null;
|
||||
elements?: string[];
|
||||
isPlaceholder?: boolean;
|
||||
};
|
||||
|
||||
export enum IndexOrder {
|
||||
@@ -43,11 +48,14 @@ export const tableColumnSuggestions = writable<TableColumnSuggestions>({
|
||||
enabled: false,
|
||||
context: null,
|
||||
thinking: false,
|
||||
table: null
|
||||
table: null,
|
||||
force: false
|
||||
});
|
||||
|
||||
export const showIndexesSuggestions = writable<boolean>(false);
|
||||
|
||||
export const showColumnsSuggestionsModal = writable<boolean>(false);
|
||||
|
||||
export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
total: 7,
|
||||
columns: [
|
||||
@@ -68,7 +76,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
formatOptions: null
|
||||
},
|
||||
{
|
||||
name: 'publishedYear',
|
||||
name: 'year',
|
||||
type: 'integer',
|
||||
size: null,
|
||||
format: null,
|
||||
@@ -79,7 +87,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'genre',
|
||||
name: 'category',
|
||||
type: 'string',
|
||||
size: 64,
|
||||
format: null,
|
||||
@@ -88,7 +96,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
default: null
|
||||
},
|
||||
{
|
||||
name: 'isbn',
|
||||
name: 'code',
|
||||
type: 'string',
|
||||
size: 13,
|
||||
required: false,
|
||||
@@ -96,7 +104,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
default: null
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
name: 'spokenLanguage',
|
||||
type: 'string',
|
||||
size: 32,
|
||||
format: null,
|
||||
@@ -105,7 +113,7 @@ export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
|
||||
default: null
|
||||
},
|
||||
{
|
||||
name: 'pageCount',
|
||||
name: 'count',
|
||||
type: 'integer',
|
||||
required: false,
|
||||
min: 1,
|
||||
@@ -123,6 +131,7 @@ export type ColumnInput = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
format?: string;
|
||||
elements?: string[];
|
||||
formatOptions?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
@@ -134,6 +143,7 @@ export function mapSuggestedColumns<T extends ColumnInput>(columns: T[]): Sugges
|
||||
key: col.name,
|
||||
type: col.type,
|
||||
required: col.required ?? false,
|
||||
array: false,
|
||||
default: col.default ?? null,
|
||||
size: col.type === 'string' ? (col.size ?? undefined) : undefined,
|
||||
min:
|
||||
@@ -144,7 +154,8 @@ export function mapSuggestedColumns<T extends ColumnInput>(columns: T[]): Sugges
|
||||
col.type === 'integer' || col.type === 'double'
|
||||
? (col.max ?? col.formatOptions?.max ?? undefined)
|
||||
: undefined,
|
||||
format: col.format ?? null
|
||||
format: col.format ?? null,
|
||||
elements: col.elements ?? undefined
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -69,11 +69,11 @@
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.backups.createArchive(['databases'], data.database.$id);
|
||||
await invalidate(Dependencies.BACKUPS);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Database backup has started'
|
||||
});
|
||||
invalidate(Dependencies.BACKUPS);
|
||||
trackEvent('click_manual_submit');
|
||||
showFeedbackNotification();
|
||||
} catch (error) {
|
||||
@@ -86,7 +86,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const trackEvents = (policies) => {
|
||||
const trackEvents = (policies: UserBackupPolicy[]) => {
|
||||
policies.forEach((policy) => {
|
||||
let actualDay = null;
|
||||
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
|
||||
@@ -139,7 +139,6 @@
|
||||
? `Backup policies have been created`
|
||||
: `<b>${totalPolicies[0].label}</b> policy has been created`;
|
||||
|
||||
// TODO: html isn't yet supported on Toast.
|
||||
addNotification({
|
||||
isHtml: true,
|
||||
type: 'success',
|
||||
@@ -148,7 +147,7 @@
|
||||
|
||||
trackEvents(totalPolicies);
|
||||
|
||||
invalidate(Dependencies.BACKUPS);
|
||||
await invalidate(Dependencies.BACKUPS);
|
||||
showFeedbackNotification();
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
@@ -162,19 +161,14 @@
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
return realtime
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.subscribe(['project', 'console'], (response) => {
|
||||
// fast path return.
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
// fast path return.
|
||||
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
|
||||
|
||||
if (
|
||||
response.events.includes('archives.*') ||
|
||||
response.events.includes('policies.*')
|
||||
) {
|
||||
invalidate(Dependencies.BACKUPS);
|
||||
}
|
||||
});
|
||||
if (response.events.includes('archives.*') || response.events.includes('policies.*')) {
|
||||
invalidate(Dependencies.BACKUPS);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Card, Modal } from '$lib/components';
|
||||
import {
|
||||
Card,
|
||||
Confirm,
|
||||
type DeleteOperationState,
|
||||
Modal,
|
||||
MultiSelectionTable
|
||||
} from '$lib/components';
|
||||
import { Button, InputCheckbox, InputText } from '$lib/elements/forms';
|
||||
import RestoreModal from './restoreModal.svelte';
|
||||
import type { PageData } from './$types';
|
||||
@@ -11,17 +17,14 @@
|
||||
import { ID } from '@appwrite.io/console';
|
||||
import { columns } from './store';
|
||||
import { database } from '../store';
|
||||
import type { BackupArchive } from '$lib/sdk/backups';
|
||||
import type { BackupArchive, BackupPolicy } from '$lib/sdk/backups';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import { copy } from '$lib/helpers/copy';
|
||||
import { LabelCard } from '$lib/components/index.js';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import {
|
||||
ActionMenu,
|
||||
Badge,
|
||||
FloatingActionBar,
|
||||
Icon,
|
||||
Layout,
|
||||
Popover,
|
||||
@@ -40,24 +43,29 @@
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { capitalize } from '$lib/helpers/string';
|
||||
import Ellipse from './components/Ellipse.svelte';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
export let data: PageData;
|
||||
const {
|
||||
data
|
||||
}: {
|
||||
data: PageData;
|
||||
} = $props();
|
||||
|
||||
let showDelete = false;
|
||||
let selectedBackup: BackupArchive = null;
|
||||
let showDelete = $state(false);
|
||||
let selectedBackup: BackupArchive | null = $state(null);
|
||||
|
||||
let showDropdown = [];
|
||||
let selectedBackups: string[] = [];
|
||||
|
||||
let showRestore = false;
|
||||
let showCustomId = false;
|
||||
let newDatabaseInfo: { name: string; id: string } = { name: null, id: null };
|
||||
let showRestore = $state(false);
|
||||
let showCustomId = $state(false);
|
||||
let newDatabaseInfo: { name: string | null; id: string | null } = $state({
|
||||
name: null,
|
||||
id: null
|
||||
});
|
||||
|
||||
let confirmSameDbRestore = false;
|
||||
let selectedRestoreOption = 'new';
|
||||
let restoreOptions = [
|
||||
let confirmSameDbRestore = $state(false);
|
||||
let selectedRestoreOption = $state('new');
|
||||
const restoreOptions = [
|
||||
{
|
||||
id: 'new',
|
||||
title: 'Restore in new database',
|
||||
@@ -70,89 +78,21 @@
|
||||
}
|
||||
];
|
||||
|
||||
const deleteBackups = async () => {
|
||||
if (!selectedBackups.length && selectedBackup) {
|
||||
selectedBackups.push(selectedBackup.$id);
|
||||
}
|
||||
|
||||
const message = `${selectedBackups.length} backup${selectedBackups.length > 1 ? 's have been' : ''} deleted`;
|
||||
|
||||
const promises = selectedBackups.map((archiveId) =>
|
||||
sdk.forProject(page.params.region, page.params.project).backups.deleteArchive(archiveId)
|
||||
const disableRestoreButton = $derived.by(() => {
|
||||
return (
|
||||
(selectedRestoreOption === 'new' &&
|
||||
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
|
||||
(selectedRestoreOption === 'same' && !confirmSameDbRestore)
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
addNotification({
|
||||
message,
|
||||
type: 'success'
|
||||
});
|
||||
invalidate(Dependencies.BACKUPS);
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
showDelete = false;
|
||||
selectedBackup = null;
|
||||
selectedBackups = [];
|
||||
}
|
||||
};
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (selectedRestoreOption === 'same') {
|
||||
newDatabaseInfo.id = $database.$id;
|
||||
newDatabaseInfo.name = $database.name;
|
||||
}
|
||||
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.backups.createRestoration(
|
||||
selectedBackup.$id,
|
||||
['databases'],
|
||||
newDatabaseInfo.id ?? ID.unique(),
|
||||
newDatabaseInfo.name
|
||||
);
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Database restore initiated'
|
||||
});
|
||||
|
||||
invalidate(Dependencies.BACKUPS);
|
||||
trackEvent('backup_restore_submit', {
|
||||
newDatabaseName: newDatabaseInfo.name
|
||||
});
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
showRestore = false;
|
||||
}
|
||||
};
|
||||
|
||||
const policyDetails = (policyId: string | null) =>
|
||||
data.policies.policies.find((policy) => policy.$id === policyId);
|
||||
|
||||
const cleanBackupName = (backup: BackupArchive) =>
|
||||
toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
|
||||
|
||||
$: if (!showRestore && !showDelete) {
|
||||
showCustomId = false;
|
||||
selectedBackup = null;
|
||||
|
||||
confirmSameDbRestore = false;
|
||||
selectedRestoreOption = 'new';
|
||||
newDatabaseInfo = { name: null, id: null };
|
||||
function getPolicyDetails(policyId: string | null): BackupPolicy | null {
|
||||
return data.policies.policies.find((policy) => policy.$id === policyId);
|
||||
}
|
||||
|
||||
$: disableButton =
|
||||
(selectedRestoreOption === 'new' &&
|
||||
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
|
||||
(selectedRestoreOption === 'same' && !confirmSameDbRestore);
|
||||
function getCleanBackupName(backup: BackupArchive): string {
|
||||
return toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
|
||||
}
|
||||
|
||||
function getBackupStatus(backup: BackupArchive) {
|
||||
switch (backup.status) {
|
||||
@@ -169,142 +109,210 @@
|
||||
return 'waiting';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBackups(selectedRows: string[]): Promise<DeleteOperationState> {
|
||||
const promises = selectedRows.map((archiveId) => {
|
||||
return sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.backups.deleteArchive(archiveId);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
|
||||
if (selectedBackup) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: '1 backup deleted'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (selectedBackup) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} else {
|
||||
return error;
|
||||
}
|
||||
} finally {
|
||||
if (selectedBackup) {
|
||||
showDelete = false;
|
||||
selectedBackup = null;
|
||||
}
|
||||
|
||||
await invalidate(Dependencies.BACKUPS);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (selectedRestoreOption === 'same') {
|
||||
newDatabaseInfo.id = $database.$id;
|
||||
newDatabaseInfo.name = $database.name;
|
||||
}
|
||||
|
||||
try {
|
||||
await sdk
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.backups.createRestoration(
|
||||
selectedBackup.$id,
|
||||
['databases'],
|
||||
newDatabaseInfo.id ?? ID.unique(),
|
||||
newDatabaseInfo.name
|
||||
);
|
||||
await invalidate(Dependencies.BACKUPS);
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Database restore initiated'
|
||||
});
|
||||
trackEvent('backup_restore_submit', { newDatabaseName: newDatabaseInfo.name });
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
});
|
||||
} finally {
|
||||
showRestore = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!showRestore && !showDelete) {
|
||||
showCustomId = false;
|
||||
selectedBackup = null;
|
||||
|
||||
confirmSameDbRestore = false;
|
||||
selectedRestoreOption = 'new';
|
||||
newDatabaseInfo = { name: null, id: null };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Table.Root let:root allowSelection columns={$columns} bind:selectedRows={selectedBackups}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
<MultiSelectionTable
|
||||
resource="backup"
|
||||
columns={$columns}
|
||||
onDelete={deleteBackups}
|
||||
computeKey={data.backups.archives.length}>
|
||||
{#snippet header(root)}
|
||||
{#each $columns as column}
|
||||
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
{/snippet}
|
||||
|
||||
{#each data.backups.archives as backup, index}
|
||||
{@const policy = policyDetails(backup.policyId)}
|
||||
{@const retainedUntil = new Date(
|
||||
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
|
||||
)}
|
||||
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
|
||||
<Table.Row.Base id={backup.$id} {root}>
|
||||
<Table.Cell column="backups" {root}>
|
||||
<DualTimeView time={backup.$createdAt}>
|
||||
{cleanBackupName(backup)}
|
||||
</DualTimeView>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="size" {root}>
|
||||
{#if backup.status === 'completed'}
|
||||
{calculateSize(backup.size)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="status" {root}>
|
||||
{@const backupStatus = getBackupStatus(backup)}
|
||||
<Status status={backupStatus} label={capitalize(backupStatus)} />
|
||||
<!--{#if backup.status === 'Failed'}-->
|
||||
<!-- <span class="u-underline">Get support</span>-->
|
||||
<!--{/if}-->
|
||||
</Table.Cell>
|
||||
<Table.Cell column="policy" {root}>
|
||||
<div class="u-flex u-cross-baseline">
|
||||
<Tooltip maxWidth="fit-content">
|
||||
<span>
|
||||
{policy?.name || 'Manual'}
|
||||
</span>
|
||||
<span slot="tooltip"
|
||||
>{policy
|
||||
? `Retained until: ${formattedRetainedUntil}`
|
||||
: `Retained forever`}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<div class="action-cell u-flex u-main-end u-width-full-line">
|
||||
<Popover let:toggle padding="m" placement="bottom-end">
|
||||
<Button extraCompact on:click={toggle}>
|
||||
<Icon icon={IconDotsHorizontal} />
|
||||
</Button>
|
||||
<svelte:fragment slot="tooltip" let:toggle>
|
||||
<ActionMenu.Root width="180px" noPadding>
|
||||
{#if backup.status === 'completed'}
|
||||
{#snippet children(root)}
|
||||
{#each data.backups.archives as backup, index}
|
||||
{@const policy = getPolicyDetails(backup.policyId)}
|
||||
{@const retainedUntil = new Date(
|
||||
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
|
||||
)}
|
||||
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
|
||||
<Table.Row.Base id={backup.$id} {root}>
|
||||
<Table.Cell column="backups" {root}>
|
||||
<DualTimeView time={backup.$createdAt}>
|
||||
{getCleanBackupName(backup)}
|
||||
</DualTimeView>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="size" {root}>
|
||||
{#if backup.status === 'completed'}
|
||||
{calculateSize(backup.size)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell column="status" {root}>
|
||||
{@const backupStatus = getBackupStatus(backup)}
|
||||
<Status status={backupStatus} label={capitalize(backupStatus)} />
|
||||
<!--{#if backup.status === 'Failed'}-->
|
||||
<!-- <span class="u-underline">Get support</span>-->
|
||||
<!--{/if}-->
|
||||
</Table.Cell>
|
||||
<Table.Cell column="policy" {root}>
|
||||
<div class="u-flex u-cross-baseline">
|
||||
<Tooltip maxWidth="fit-content">
|
||||
<span>
|
||||
{policy?.name || 'Manual'}
|
||||
</span>
|
||||
<span slot="tooltip"
|
||||
>{policy
|
||||
? `Retained until: ${formattedRetainedUntil}`
|
||||
: `Retained forever`}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell column="actions" {root}>
|
||||
<div class="action-cell u-flex u-main-end u-width-full-line">
|
||||
<Popover let:toggle padding="m" placement="bottom-end">
|
||||
<Button extraCompact on:click={toggle}>
|
||||
<Icon icon={IconDotsHorizontal} />
|
||||
</Button>
|
||||
<svelte:fragment slot="tooltip" let:toggle>
|
||||
<ActionMenu.Root width="180px" noPadding>
|
||||
{#if backup.status === 'completed'}
|
||||
<ActionMenu.Item.Button
|
||||
trailingIcon={IconRefresh}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
showRestore = true;
|
||||
selectedBackup = backup;
|
||||
showDropdown[index] = false;
|
||||
trackEvent(Click.BackupRestoreClick);
|
||||
}}>
|
||||
Restore
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
<ActionMenu.Item.Button
|
||||
trailingIcon={IconRefresh}
|
||||
trailingIcon={IconDuplicate}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
showRestore = true;
|
||||
copy(backup.$id);
|
||||
showDropdown[index] = false;
|
||||
trackEvent(Click.BackupCopyIdClick);
|
||||
}}>
|
||||
Copy ID
|
||||
</ActionMenu.Item.Button>
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
trailingIcon={IconTrash}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
showDelete = true;
|
||||
selectedBackup = backup;
|
||||
showDropdown[index] = false;
|
||||
trackEvent(Click.BackupRestoreClick);
|
||||
trackEvent(Click.BackupDeleteClick);
|
||||
}}>
|
||||
Restore
|
||||
Delete
|
||||
</ActionMenu.Item.Button>
|
||||
{/if}
|
||||
<ActionMenu.Item.Button
|
||||
trailingIcon={IconDuplicate}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
copy(backup.$id);
|
||||
showDropdown[index] = false;
|
||||
trackEvent(Click.BackupCopyIdClick);
|
||||
}}>
|
||||
Copy ID
|
||||
</ActionMenu.Item.Button>
|
||||
<ActionMenu.Item.Button
|
||||
status="danger"
|
||||
trailingIcon={IconTrash}
|
||||
on:click={(e) => {
|
||||
toggle(e);
|
||||
showDelete = true;
|
||||
selectedBackup = backup;
|
||||
showDropdown[index] = false;
|
||||
trackEvent(Click.BackupDeleteClick);
|
||||
}}>
|
||||
Delete
|
||||
</ActionMenu.Item.Button>
|
||||
</ActionMenu.Root>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
</Table.Root>
|
||||
|
||||
{#if selectedBackups.length > 0}
|
||||
<FloatingActionBar>
|
||||
<svelte:fragment slot="start">
|
||||
<Badge content={selectedBackups.length.toString()} />
|
||||
<span>
|
||||
{selectedBackups.length > 1 ? 'backups' : 'backup'}
|
||||
selected
|
||||
</span>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="end">
|
||||
<Button text on:click={() => (selectedBackups = [])}>Cancel</Button>
|
||||
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
|
||||
</svelte:fragment>
|
||||
</FloatingActionBar>
|
||||
{/if}
|
||||
</ActionMenu.Root>
|
||||
</svelte:fragment>
|
||||
</Popover>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row.Base>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</MultiSelectionTable>
|
||||
|
||||
<!-- this is for single backup delete -->
|
||||
<Confirm
|
||||
title="Delete {selectedBackups.length ? 'backups' : 'backup'}"
|
||||
title="Delete backup"
|
||||
bind:open={showDelete}
|
||||
onSubmit={deleteBackups}>
|
||||
onSubmit={async () => {
|
||||
if (!selectedBackup) return;
|
||||
await deleteBackups([selectedBackup.$id]);
|
||||
}}>
|
||||
<Typography.Text>
|
||||
Are you sure you want to delete
|
||||
{#if selectedBackups.length}
|
||||
<b>{selectedBackups.length}</b> {selectedBackups.length > 1 ? 'backups' : 'backup'}?
|
||||
{:else}
|
||||
the <b>{cleanBackupName(selectedBackup)}</b> backup?
|
||||
{/if}
|
||||
<br />This action is irreversible.
|
||||
Are you sure you want to delete the <b>{getCleanBackupName(selectedBackup)}</b> backup?
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text variant="m-500">This action is irreversible.</Typography.Text>
|
||||
</Confirm>
|
||||
|
||||
<Modal title="Restore backup" bind:show={showRestore} onSubmit={restoreBackup}>
|
||||
<Card radius="m" padding="s">
|
||||
<Layout.Stack gap="xxs">
|
||||
<Typography.Text variant="m-500">
|
||||
{cleanBackupName(selectedBackup)}
|
||||
{getCleanBackupName(selectedBackup)}
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Caption variant="500">
|
||||
@@ -374,6 +382,6 @@
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<Button text on:click={() => (showRestore = false)}>Cancel</Button>
|
||||
<Button submit disabled={disableButton}>Restore</Button>
|
||||
<Button submit disabled={disableRestoreButton}>Restore</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<script lang="ts">
|
||||
import { goto, invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { realtime, sdk } from '$lib/stores/sdk';
|
||||
import { type RealtimeResponse, realtime, sdk } from '$lib/stores/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
table,
|
||||
@@ -35,7 +35,9 @@
|
||||
spreadsheetRenderKey,
|
||||
expandTabs,
|
||||
databaseRelatedRowSheetOptions,
|
||||
rowPermissionSheet
|
||||
rowPermissionSheet,
|
||||
isWaterfallFromFaker,
|
||||
type Columns
|
||||
} from './store';
|
||||
import { addSubPanel, registerCommands, updateCommandGroupRanks } from '$lib/commandCenter';
|
||||
import CreateColumn from './createColumn.svelte';
|
||||
@@ -45,18 +47,24 @@
|
||||
import { page } from '$app/state';
|
||||
import { base } from '$app/paths';
|
||||
import { canWriteTables } from '$lib/stores/roles';
|
||||
import { IconEye, IconLockClosed, IconPlus, IconPuzzle } from '@appwrite.io/pink-icons-svelte';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconEye,
|
||||
IconLockClosed,
|
||||
IconPlus,
|
||||
IconPuzzle
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import SideSheet from './layout/sidesheet.svelte';
|
||||
import EditRow from './rows/edit.svelte';
|
||||
import EditRelatedRow from './rows/editRelated.svelte';
|
||||
import EditColumn from './columns/edit.svelte';
|
||||
import RowActivity from './rows/activity.svelte';
|
||||
import EditRowPermissions from './rows/editPermissions.svelte';
|
||||
import { Dialog, Layout, Typography, Selector } from '@appwrite.io/pink-svelte';
|
||||
import { Dialog, Layout, Typography, Selector, Icon } from '@appwrite.io/pink-svelte';
|
||||
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,8 +72,10 @@
|
||||
import { chunks } from '$lib/helpers/array';
|
||||
import { Submit, trackEvent } from '$lib/actions/analytics';
|
||||
|
||||
import { isTabletViewport } from '$lib/stores/viewport';
|
||||
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
|
||||
import { showIndexesSuggestions, tableColumnSuggestions } from '../(suggestions)';
|
||||
import ColumnsSuggestions from '../(suggestions)/columns.svelte';
|
||||
import { showColumnsSuggestionsModal } from '../(suggestions)';
|
||||
import { resolvedProfile } from '$lib/profiles/index.svelte';
|
||||
|
||||
let editRow: EditRow;
|
||||
@@ -77,35 +87,48 @@
|
||||
let selectedOption: Option['name'] = 'String';
|
||||
let createMoreColumns = false;
|
||||
|
||||
/**
|
||||
* adding a lot of fake data will trigger the realtime below
|
||||
* and will keep invalidating the `Dependencies.TABLE` making a lot of API noise!
|
||||
*/
|
||||
let isWaterfallFromFaker = false;
|
||||
let columnCreationHandler: ((response: RealtimeResponse) => void) | null = null;
|
||||
|
||||
// manual management of focus is needed!
|
||||
const autoFocusAction = (node: HTMLElement, shouldFocus: boolean) => {
|
||||
const button = node.querySelector('button');
|
||||
if (!button) return;
|
||||
|
||||
const handleBlur = () => button.classList.remove('focus-visible');
|
||||
const applyFocus = (focus: boolean) => {
|
||||
if (focus) {
|
||||
button.classList.add('focus-visible');
|
||||
button.focus();
|
||||
} else {
|
||||
button.classList.remove('focus-visible');
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener('blur', handleBlur);
|
||||
applyFocus(shouldFocus);
|
||||
|
||||
return {
|
||||
update: applyFocus,
|
||||
destroy() {
|
||||
button.removeEventListener('blur', handleBlur);
|
||||
button.classList.remove('focus-visible');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
expandTabs.set(preferences.getKey('tableHeaderExpanded', true));
|
||||
|
||||
return realtime
|
||||
.forProject(page.params.region, page.params.project)
|
||||
.subscribe(['project', 'console'], (response) => {
|
||||
if (
|
||||
response.events.includes('databases.*.tables.*.columns.*') ||
|
||||
response.events.includes('databases.*.tables.*.indexes.*')
|
||||
) {
|
||||
// don't invalidate when -
|
||||
// 1. from faker
|
||||
// 2. ai columns creation
|
||||
// 3. ai indexes creation
|
||||
if (
|
||||
!isWaterfallFromFaker &&
|
||||
!$showIndexesSuggestions &&
|
||||
!$tableColumnSuggestions.table
|
||||
) {
|
||||
invalidate(Dependencies.TABLE);
|
||||
}
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
if (
|
||||
response.events.includes('databases.*.tables.*.columns.*') ||
|
||||
response.events.includes('databases.*.tables.*.indexes.*')
|
||||
) {
|
||||
if ($isWaterfallFromFaker) {
|
||||
columnCreationHandler?.(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: use route ids instead of pathname
|
||||
@@ -254,21 +277,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: RealtimeResponse) => {
|
||||
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;
|
||||
isWaterfallFromFaker.set(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({
|
||||
@@ -280,9 +358,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* let the columns be processed! */
|
||||
await sleep(1250);
|
||||
|
||||
let rowIds = [];
|
||||
try {
|
||||
const { rows, ids } = generateFakeRecords(columns, $randomDataModalState.value);
|
||||
@@ -331,10 +406,8 @@
|
||||
$randomDataModalState.value = 25;
|
||||
}
|
||||
|
||||
/* api is too fast! */
|
||||
// await sleep(1250);
|
||||
$spreadsheetLoading = false;
|
||||
isWaterfallFromFaker = false;
|
||||
isWaterfallFromFaker.set(false);
|
||||
|
||||
spreadsheetRenderKey.set(hash(rowIds));
|
||||
}
|
||||
@@ -342,6 +415,8 @@
|
||||
$: if (!$showCreateColumnSheet.show) {
|
||||
createMoreColumns = false;
|
||||
}
|
||||
|
||||
$: currentRowId = $databaseRowSheetOptions.row?.$id ?? $databaseRowSheetOptions.rowId;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -398,7 +473,6 @@
|
||||
</SideSheet>
|
||||
|
||||
<SideSheet
|
||||
closeOnBlur
|
||||
title={$databaseRowSheetOptions.title}
|
||||
bind:show={$databaseRowSheetOptions.show}
|
||||
submit={{
|
||||
@@ -409,13 +483,67 @@
|
||||
topAction={{
|
||||
mode: 'copy-tag',
|
||||
text: 'Row URL',
|
||||
show: !!($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id),
|
||||
value: buildRowUrl($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id)
|
||||
show: !!currentRowId,
|
||||
value: buildRowUrl(currentRowId)
|
||||
}}>
|
||||
<EditRow
|
||||
bind:this={editRow}
|
||||
bind:row={$databaseRowSheetOptions.row}
|
||||
bind:rowId={$databaseRowSheetOptions.rowId} />
|
||||
{#snippet topEndActions()}
|
||||
{@const rows = $databaseRowSheetOptions.rows ?? []}
|
||||
{@const currentIndex = $databaseRowSheetOptions.rowIndex ?? -1}
|
||||
{@const isFirstRow = currentIndex <= 0}
|
||||
{@const isLastRow = currentIndex >= rows.length - 1}
|
||||
|
||||
{#if !$isTabletViewport}
|
||||
{@const shouldFocusPrev = !$databaseRowSheetOptions.autoFocus && !isFirstRow}
|
||||
{@const shouldFocusNext =
|
||||
!$databaseRowSheetOptions.autoFocus && isFirstRow && !isLastRow}
|
||||
|
||||
<div use:autoFocusAction={shouldFocusPrev} class:nav-button-wrapper={shouldFocusPrev}>
|
||||
<Button
|
||||
icon
|
||||
text
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (currentIndex > 0) {
|
||||
databaseRowSheetOptions.update((opts) => ({
|
||||
...opts,
|
||||
row: rows[currentIndex - 1],
|
||||
rowIndex: currentIndex - 1
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={isFirstRow}>
|
||||
<Icon icon={IconChevronUp} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div use:autoFocusAction={shouldFocusNext} class:nav-button-wrapper={shouldFocusNext}>
|
||||
<Button
|
||||
icon
|
||||
text
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
if (currentIndex < rows.length - 1) {
|
||||
databaseRowSheetOptions.update((opts) => ({
|
||||
...opts,
|
||||
row: rows[currentIndex + 1],
|
||||
rowIndex: currentIndex + 1
|
||||
}));
|
||||
}
|
||||
}}
|
||||
disabled={isLastRow}>
|
||||
<Icon icon={IconChevronDown} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#key currentRowId}
|
||||
<EditRow
|
||||
bind:this={editRow}
|
||||
bind:row={$databaseRowSheetOptions.row}
|
||||
bind:rowId={$databaseRowSheetOptions.rowId}
|
||||
autoFocus={$databaseRowSheetOptions.autoFocus} />
|
||||
{/key}
|
||||
</SideSheet>
|
||||
|
||||
<SideSheet
|
||||
@@ -483,4 +611,13 @@
|
||||
</svelte:fragment>
|
||||
</Dialog>
|
||||
|
||||
<ColumnsSuggestions bind:show={$showColumnsSuggestionsModal} />
|
||||
|
||||
<IndexesSuggestions />
|
||||
|
||||
<style lang="scss">
|
||||
// not the best solution but needed!
|
||||
.nav-button-wrapper :global(button.focus-visible) {
|
||||
outline: var(--border-width-l) solid var(--border-focus);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
import { Icon, Layout, Divider, Tooltip, Typography, Link } from '@appwrite.io/pink-svelte';
|
||||
import type { PageData } from './$types';
|
||||
import {
|
||||
table,
|
||||
@@ -26,16 +26,32 @@
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { IconChevronDown, IconChevronUp, IconPlus } from '@appwrite.io/pink-icons-svelte';
|
||||
import {
|
||||
IconBookOpen,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlus,
|
||||
IconViewBoards,
|
||||
IconRefresh
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
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)';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import {
|
||||
Empty as SuggestionsEmptySheet,
|
||||
tableColumnSuggestions,
|
||||
showColumnsSuggestionsModal
|
||||
} from '../(suggestions)';
|
||||
import EmptySheetCards from './layout/emptySheetCards.svelte';
|
||||
import IconAI from '../(suggestions)/icon/aiForButton.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let isRefreshing = false;
|
||||
let showImportCSV = false;
|
||||
|
||||
// todo: might need a type fix here.
|
||||
@@ -83,6 +99,8 @@
|
||||
$tableColumnSuggestions.table &&
|
||||
$tableColumnSuggestions.table.id === page.params.table;
|
||||
|
||||
$: disableButton = canShowSuggestionsSheet;
|
||||
|
||||
async function onSelect(file: Models.File, localFile = false) {
|
||||
$isCsvImportInProgress = true;
|
||||
|
||||
@@ -130,7 +148,8 @@
|
||||
columns={tableColumns}
|
||||
hideView
|
||||
showAnyway
|
||||
isCustomTable />
|
||||
isCustomTable
|
||||
{disableButton} />
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="tooltip">Columns</svelte:fragment>
|
||||
@@ -141,49 +160,84 @@
|
||||
onlyIcon
|
||||
query={data.query}
|
||||
columns={filterColumns}
|
||||
disabled={!(hasColumns && hasValidColumns)}
|
||||
disabled={!(hasColumns && hasValidColumns) || disableButton}
|
||||
analyticsSource="database_tables" />
|
||||
|
||||
<svelte:fragment slot="tooltip">Filters</svelte:fragment>
|
||||
</Tooltip>
|
||||
</Layout.Stack>
|
||||
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
|
||||
<Button
|
||||
secondary
|
||||
event={Click.DatabaseImportCsv}
|
||||
disabled={!(hasColumns && hasValidColumns)}
|
||||
on:click={() => (showImportCSV = true)}>
|
||||
Import CSV
|
||||
</Button>
|
||||
{#if !$isSmallViewport}
|
||||
<Layout.Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
style="padding-right: 40px;">
|
||||
<Layout.Stack
|
||||
gap="s"
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="flex-end">
|
||||
<Button
|
||||
secondary
|
||||
event="create_row"
|
||||
disabled={!(hasColumns && hasValidColumns)}
|
||||
on:click={() => ($showRowCreateSheet.show = true)}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Create row
|
||||
event={Click.DatabaseImportCsv}
|
||||
disabled={!(hasColumns && hasValidColumns) || disableButton}
|
||||
on:click={() => (showImportCSV = true)}>
|
||||
Import CSV
|
||||
</Button>
|
||||
{#if !$isSmallViewport}
|
||||
<Button
|
||||
secondary
|
||||
event="create_row"
|
||||
disabled={!(hasColumns && hasValidColumns) || disableButton}
|
||||
on:click={() => ($showRowCreateSheet.show = true)}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Create row
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
class="small-button-dimensions"
|
||||
on:click={() => {
|
||||
$expandTabs = !$expandTabs;
|
||||
preferences.setKey('tableHeaderExpanded', $expandTabs);
|
||||
}}>
|
||||
<Icon icon={!$expandTabs ? IconChevronDown : IconChevronUp} size="s" />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
class="small-button-dimensions"
|
||||
on:click={() => {
|
||||
$expandTabs = !$expandTabs;
|
||||
preferences.setKey('tableHeaderExpanded', $expandTabs);
|
||||
}}>
|
||||
<Icon
|
||||
size="s"
|
||||
icon={!$expandTabs ? IconChevronDown : IconChevronUp} />
|
||||
</Button>
|
||||
|
||||
<Tooltip disabled={isRefreshing || !data.rows.total} placement="top">
|
||||
<Button
|
||||
icon
|
||||
size="s"
|
||||
secondary
|
||||
disabled={isRefreshing ||
|
||||
!data.rows.total ||
|
||||
!(hasColumns && hasValidColumns) ||
|
||||
disableButton}
|
||||
class="small-button-dimensions"
|
||||
on:click={async () => {
|
||||
isRefreshing = true;
|
||||
await invalidate(Dependencies.TABLE);
|
||||
isRefreshing = false;
|
||||
}}>
|
||||
<div style:line-height="0px" class:rotating={isRefreshing}>
|
||||
<Icon icon={IconRefresh} size="s" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<svelte:fragment slot="tooltip">Refresh</svelte:fragment>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{#if $isSmallViewport}
|
||||
<Button
|
||||
secondary
|
||||
event="create_row"
|
||||
disabled={!(hasColumns && hasValidColumns)}
|
||||
disabled={!(hasColumns && hasValidColumns) || disableButton}
|
||||
on:click={() => ($showRowCreateSheet.show = true)}>
|
||||
<Icon icon={IconPlus} slot="start" size="s" />
|
||||
Create row
|
||||
@@ -193,7 +247,7 @@
|
||||
</Container>
|
||||
|
||||
<div class="databases-spreadsheet">
|
||||
{#if hasColumns && hasValidColumns}
|
||||
{#if hasColumns && hasValidColumns && $tableColumnSuggestions.force !== true}
|
||||
{#if data.rows.total}
|
||||
<Divider />
|
||||
<SpreadSheet {data} bind:showRowCreateSheet={$showRowCreateSheet} />
|
||||
@@ -201,58 +255,102 @@
|
||||
<EmptySheet
|
||||
mode="rows-filtered"
|
||||
title="There are no rows that match your filters"
|
||||
customColumns={createTableColumns($table.columns, selected)}
|
||||
actions={{
|
||||
primary: {
|
||||
text: 'Clear filters',
|
||||
onClick: () => {
|
||||
customColumns={createTableColumns($table.columns, selected)}>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
size="s"
|
||||
secondary
|
||||
on:click={() => {
|
||||
queries.clearAll();
|
||||
queries.apply();
|
||||
trackEvent(Submit.FilterClear, {
|
||||
source: 'database_tables'
|
||||
});
|
||||
}
|
||||
}
|
||||
}} />
|
||||
}}>
|
||||
Clear filters
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
{:else}
|
||||
<EmptySheet
|
||||
mode="rows"
|
||||
customColumns={createTableColumns($table.columns, selected)}
|
||||
showActions={$canWriteRows}
|
||||
actions={{
|
||||
primary: {
|
||||
text: 'Create rows',
|
||||
onClick: () => {
|
||||
customColumns={createTableColumns($table.columns, selected)}>
|
||||
{#snippet actions()}
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create rows"
|
||||
subtitle="Create rows manually"
|
||||
onClick={() => {
|
||||
$showRowCreateSheet.show = true;
|
||||
}
|
||||
},
|
||||
random: {
|
||||
onClick: () => {
|
||||
}} />
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconViewBoards}
|
||||
title="Generate sample data"
|
||||
subtitle="Generate data for testing"
|
||||
onClick={() => {
|
||||
$randomDataModalState.show = true;
|
||||
}
|
||||
}
|
||||
}} />
|
||||
}} />
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
{/if}
|
||||
{:else if isCloud && canShowSuggestionsSheet}
|
||||
<SuggestionsEmptySheet />
|
||||
<SuggestionsEmptySheet userColumns={$tableColumns} userDataRows={data.rows.rows} />
|
||||
{:else}
|
||||
<EmptySheet
|
||||
mode="rows"
|
||||
title="You have no columns yet"
|
||||
showActions={$canWriteTables}
|
||||
actions={{
|
||||
primary: {
|
||||
text: 'Create column',
|
||||
onClick: async () => {
|
||||
<EmptySheet mode="rows" showActions={$canWriteTables} title="You have no columns yet">
|
||||
{#snippet subtitle()}
|
||||
{#if !isCloud}
|
||||
<!-- shown on self-hosted -->
|
||||
<Typography.Text align="center">
|
||||
Need a hand? Learn more in the
|
||||
<Link.Anchor
|
||||
target="_blank"
|
||||
href="https://appwrite.io/docs/products/databases">
|
||||
docs.
|
||||
</Link.Anchor>
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if isCloud}
|
||||
<!-- shown on cloud -->
|
||||
<EmptySheetCards
|
||||
icon={IconAI}
|
||||
title="Suggest columns"
|
||||
subtitle="Use AI to generate columns"
|
||||
onClick={() => {
|
||||
$showColumnsSuggestionsModal = true;
|
||||
}} />
|
||||
{/if}
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create column"
|
||||
subtitle="Create columns manually"
|
||||
onClick={() => {
|
||||
$showCreateColumnSheet.show = true;
|
||||
}
|
||||
},
|
||||
random: {
|
||||
onClick: () => {
|
||||
}} />
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconViewBoards}
|
||||
title="Generate sample data"
|
||||
subtitle="Generate data for testing"
|
||||
onClick={() => {
|
||||
$randomDataModalState.show = true;
|
||||
}
|
||||
}
|
||||
}} />
|
||||
}} />
|
||||
|
||||
{#if isCloud}
|
||||
<!-- shown on cloud because self-hosted shows a link above -->
|
||||
<EmptySheetCards
|
||||
icon={IconBookOpen}
|
||||
title="Documentation"
|
||||
subtitle="Read the Appwrite docs"
|
||||
href="https://appwrite.io/docs/products/databases" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
@@ -282,4 +380,17 @@
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
:global(.rotating) {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
type ColumnsWidth,
|
||||
indexes,
|
||||
isCsvImportInProgress,
|
||||
isWaterfallFromFaker,
|
||||
reorderItems,
|
||||
showCreateIndexSheet
|
||||
} from '../store';
|
||||
@@ -40,7 +41,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';
|
||||
@@ -54,6 +56,9 @@
|
||||
import { page } from '$app/state';
|
||||
import { debounce } from '$lib/helpers/debounce';
|
||||
import type { PageData } from './$types';
|
||||
import { realtime } from '$lib/stores/sdk';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
|
||||
const {
|
||||
data
|
||||
@@ -125,7 +130,7 @@
|
||||
const columnFormatIcon = {
|
||||
ip: IconLocationMarker,
|
||||
url: IconLink,
|
||||
email: IconLink,
|
||||
email: IconMail,
|
||||
enum: IconViewList
|
||||
};
|
||||
|
||||
@@ -139,6 +144,16 @@
|
||||
onMount(() => {
|
||||
columnsOrder = preferences.getColumnOrder(tableId);
|
||||
columnsWidth = preferences.getColumnWidths(tableId + '#columns');
|
||||
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], async (response) => {
|
||||
if (
|
||||
response.events.includes('databases.*.tables.*.columns.*.delete') ||
|
||||
(response.events.includes('databases.*.tables.*.columns.*.update') &&
|
||||
!$isWaterfallFromFaker)
|
||||
) {
|
||||
await invalidate(Dependencies.TABLE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
|
||||
@@ -248,6 +263,12 @@
|
||||
minimumWidth: 300,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
width: 150,
|
||||
minimumWidth: 150,
|
||||
resizable: false
|
||||
},
|
||||
{
|
||||
id: 'indexed',
|
||||
width: getColumnWidth('indexed', 150),
|
||||
@@ -299,8 +320,8 @@
|
||||
<SpreadsheetContainer>
|
||||
<Spreadsheet.Root
|
||||
let:root
|
||||
allowSelection
|
||||
height="100%"
|
||||
allowSelection
|
||||
emptyCells={emptyCellsCount}
|
||||
bind:selectedRows={selectedColumns}
|
||||
columns={spreadsheetColumns}
|
||||
@@ -308,6 +329,7 @@
|
||||
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>
|
||||
@@ -315,6 +337,7 @@
|
||||
</svelte:fragment>
|
||||
|
||||
{#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}
|
||||
@@ -359,8 +382,9 @@
|
||||
{column.key}{column.array ? '[]' : undefined}
|
||||
{/if}
|
||||
</Typography.Text>
|
||||
|
||||
{#if isString(column) && column.encrypt}
|
||||
<Tooltip>
|
||||
<Tooltip portal>
|
||||
<Icon
|
||||
size="s"
|
||||
icon={IconLockClosed}
|
||||
@@ -368,13 +392,7 @@
|
||||
<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"
|
||||
@@ -412,12 +430,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"
|
||||
@@ -434,15 +457,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}>
|
||||
@@ -452,8 +478,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" />
|
||||
|
||||
@@ -38,16 +38,17 @@
|
||||
<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 disabled = false;
|
||||
export let data: Partial<Models.ColumnBoolean> = {
|
||||
required: false,
|
||||
array: false,
|
||||
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 +68,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -76,24 +78,16 @@
|
||||
id="default"
|
||||
label="Default value"
|
||||
placeholder="Select a value"
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
options={[
|
||||
{ label: 'NULL', value: null },
|
||||
{ label: 'True', value: true },
|
||||
{ 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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -42,13 +42,13 @@
|
||||
|
||||
<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 disabled = 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) {
|
||||
@@ -78,19 +78,11 @@
|
||||
id="default"
|
||||
label="Default value"
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { table } from '../store';
|
||||
@@ -9,7 +8,6 @@
|
||||
import { isRelationship } from '../rows/store';
|
||||
import Confirm from '$lib/components/confirm.svelte';
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import type { Models } from '@appwrite.io/console';
|
||||
|
||||
let {
|
||||
@@ -59,7 +57,6 @@
|
||||
: `${selectedColumns.length} columns have been deleted`
|
||||
});
|
||||
|
||||
await invalidate(Dependencies.TABLE);
|
||||
showDelete = false;
|
||||
selectedColumn = Array.isArray(selectedColumn) ? [] : null;
|
||||
} catch (e) {
|
||||
|
||||
@@ -38,13 +38,13 @@
|
||||
|
||||
<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 disabled = 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 +64,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -74,19 +75,11 @@
|
||||
label="Default value"
|
||||
placeholder="Enter value"
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -39,15 +39,16 @@
|
||||
</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 disabled = 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) {
|
||||
@@ -67,6 +68,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -103,21 +105,13 @@
|
||||
<InputSelect
|
||||
id="default"
|
||||
label="Default value"
|
||||
disabled={data.array || data.required}
|
||||
disabled={data.array || data.required || disabled}
|
||||
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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -41,9 +41,13 @@
|
||||
</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 disabled = false;
|
||||
export let data: Partial<Models.ColumnFloat> = {
|
||||
required: false,
|
||||
min: 0,
|
||||
@@ -52,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) {
|
||||
@@ -86,15 +87,19 @@
|
||||
placeholder="Enter size"
|
||||
bind:value={data.min}
|
||||
step={0.1}
|
||||
{disabled}
|
||||
required={editing} />
|
||||
|
||||
<InputNumber
|
||||
id="max"
|
||||
label="Max"
|
||||
placeholder="Enter size"
|
||||
bind:value={data.max}
|
||||
step={0.1}
|
||||
{disabled}
|
||||
required={editing} />
|
||||
</Layout.Stack>
|
||||
|
||||
<InputNumber
|
||||
id="default"
|
||||
label="Default value"
|
||||
@@ -102,20 +107,12 @@
|
||||
min={data.min}
|
||||
max={data.max}
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
nullable={!data.required && !data.array}
|
||||
step={0.1} />
|
||||
<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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -41,10 +41,13 @@
|
||||
</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 disabled = false;
|
||||
export let data: Partial<Models.ColumnInteger> = {
|
||||
required: false,
|
||||
min: 0,
|
||||
@@ -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) {
|
||||
@@ -84,16 +84,20 @@
|
||||
<InputNumber
|
||||
id="min"
|
||||
label="Min"
|
||||
{disabled}
|
||||
placeholder="Enter size"
|
||||
bind:value={data.min}
|
||||
required={editing} />
|
||||
|
||||
<InputNumber
|
||||
id="max"
|
||||
label="Max"
|
||||
{disabled}
|
||||
placeholder="Enter size"
|
||||
bind:value={data.max}
|
||||
required={editing} />
|
||||
</Layout.Stack>
|
||||
|
||||
<InputNumber
|
||||
id="default"
|
||||
label="Default value"
|
||||
@@ -101,19 +105,11 @@
|
||||
min={data.min}
|
||||
max={data.max}
|
||||
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." />
|
||||
disabled={data.required || data.array || disabled}
|
||||
nullable={(!data.required && !data.array) || disabled} />
|
||||
|
||||
<RequiredArrayCheckboxes
|
||||
{editing}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -37,13 +37,13 @@
|
||||
|
||||
<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 disabled = 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 +63,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -73,19 +74,11 @@
|
||||
label="Default value"
|
||||
placeholder="Enter value"
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -42,11 +42,16 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data?: Partial<Models.ColumnLine>;
|
||||
editing?: boolean;
|
||||
disabled?: boolean;
|
||||
data?: Partial<Models.ColumnLine>;
|
||||
}
|
||||
|
||||
let { data = { required: false, default: null }, editing = false }: Props = $props();
|
||||
let {
|
||||
data = { required: false, default: null },
|
||||
editing = false,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let savedDefault = $state(data.default);
|
||||
let defaultChecked = $state(!!data.default);
|
||||
@@ -106,6 +111,7 @@
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
{disabled}
|
||||
bind:checked={$required}
|
||||
on:change={(e) => {
|
||||
if (e.detail) defaultChecked = false;
|
||||
@@ -116,6 +122,7 @@
|
||||
size="s"
|
||||
id="default"
|
||||
label="Default value"
|
||||
{disabled}
|
||||
bind:checked={defaultChecked}
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
@@ -134,11 +141,13 @@
|
||||
<Typography.Caption variant="400">Optional</Typography.Caption>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<InputLine
|
||||
{disabled}
|
||||
values={defaultChecked ? data.default : null}
|
||||
onAddPoint={() => pushCoordinate()}
|
||||
onDeletePoint={deleteCoordinate}
|
||||
onChangePoint={(pointIndex: number, coordIndex: number, newValue: number) => {
|
||||
onChangePoint={(pointIndex, coordIndex, newValue) => {
|
||||
if (data.default) {
|
||||
data.default[pointIndex][coordIndex] = newValue;
|
||||
data.default = [...data.default];
|
||||
|
||||
@@ -42,11 +42,19 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data?: Partial<Models.ColumnPoint>;
|
||||
editing?: boolean;
|
||||
disabled?: boolean;
|
||||
data?: Partial<Models.ColumnPoint>;
|
||||
}
|
||||
|
||||
let { data = { required: false, default: null }, editing }: Props = $props();
|
||||
let {
|
||||
data = {
|
||||
default: null,
|
||||
required: false
|
||||
},
|
||||
editing = false,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let savedDefault = $state(data.default);
|
||||
let defaultChecked = $state(!!data.default);
|
||||
@@ -96,6 +104,7 @@
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
{disabled}
|
||||
bind:checked={$required}
|
||||
on:change={(e) => {
|
||||
if (e.detail) defaultChecked = false;
|
||||
@@ -106,6 +115,7 @@
|
||||
size="s"
|
||||
id="default"
|
||||
label="Default value"
|
||||
{disabled}
|
||||
bind:checked={defaultChecked}
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
@@ -126,6 +136,7 @@
|
||||
{/if}
|
||||
|
||||
<InputPoint
|
||||
{disabled}
|
||||
values={defaultChecked ? data.default : null}
|
||||
onChangePoint={(index, newValue) => {
|
||||
if (data.default) {
|
||||
|
||||
@@ -42,11 +42,19 @@
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
data?: Partial<Models.ColumnPolygon>;
|
||||
editing?: boolean;
|
||||
disabled?: boolean;
|
||||
data?: Partial<Models.ColumnPolygon>;
|
||||
}
|
||||
|
||||
let { data = { required: false, default: null }, editing = false }: Props = $props();
|
||||
let {
|
||||
data = {
|
||||
default: null,
|
||||
required: false
|
||||
},
|
||||
editing = false,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let savedDefault = $state(data.default);
|
||||
let defaultChecked = $state(!!data.default);
|
||||
@@ -117,6 +125,7 @@
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
{disabled}
|
||||
bind:checked={$required}
|
||||
on:change={(e) => {
|
||||
if (e.detail) defaultChecked = false;
|
||||
@@ -128,6 +137,7 @@
|
||||
size="s"
|
||||
id="default"
|
||||
label="Default value"
|
||||
{disabled}
|
||||
bind:checked={defaultChecked}
|
||||
on:change={(e) => {
|
||||
if (e.detail) {
|
||||
@@ -148,6 +158,7 @@
|
||||
{/if}
|
||||
|
||||
<InputPolygon
|
||||
{disabled}
|
||||
values={defaultChecked ? data.default : null}
|
||||
onAddLine={pushLine}
|
||||
onAddPoint={pushCoordinate}
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
|
||||
// Props
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
export let data: Models.ColumnRelationship;
|
||||
|
||||
// Constants
|
||||
@@ -158,7 +159,7 @@
|
||||
bind:group={way}
|
||||
name="one"
|
||||
value="one"
|
||||
disabled={editing}
|
||||
disabled={editing || disabled}
|
||||
icon={IconArrowSmRight}>
|
||||
One Relation column within this table
|
||||
</Card.Selector>
|
||||
@@ -167,7 +168,7 @@
|
||||
bind:group={way}
|
||||
name="two"
|
||||
value="two"
|
||||
disabled={editing}
|
||||
disabled={editing || disabled}
|
||||
icon={IconSwitchHorizontal}>
|
||||
One Relation column within this table and another within the related table
|
||||
</Card.Selector>
|
||||
@@ -180,7 +181,7 @@
|
||||
placeholder="Select a table"
|
||||
bind:value={data.relatedTable}
|
||||
on:change={updateKeyName}
|
||||
disabled={editing}
|
||||
disabled={editing || disabled}
|
||||
options={tables?.map((n) => ({ value: n.$id, label: `${n.name} (${n.$id})` })) ?? []} />
|
||||
|
||||
{#if data?.relatedTable}
|
||||
@@ -190,7 +191,8 @@
|
||||
placeholder="Enter key"
|
||||
bind:value={data.key}
|
||||
helper="Allowed characters: a-z, A-Z, 0-9, -, ."
|
||||
required />
|
||||
required
|
||||
{disabled} />
|
||||
|
||||
{#if way === 'two'}
|
||||
<InputText
|
||||
@@ -199,6 +201,7 @@
|
||||
placeholder="Enter key"
|
||||
bind:value={data.twoWayKey}
|
||||
required
|
||||
{disabled}
|
||||
helper="Allowed characters: a-z, A-Z, 0-9, -, . Once created, column key cannot be
|
||||
adjusted to maintain data integrity."
|
||||
readonly={editing} />
|
||||
@@ -211,7 +214,7 @@
|
||||
required
|
||||
placeholder="Select a relation"
|
||||
options={relationshipType}
|
||||
disabled={editing} />
|
||||
disabled={editing || disabled} />
|
||||
|
||||
<div class="u-flex u-flex-vertical u-gap-16">
|
||||
<Box>
|
||||
@@ -251,6 +254,7 @@
|
||||
label="On deleting a row"
|
||||
bind:value={data.onDelete}
|
||||
required
|
||||
{disabled}
|
||||
placeholder="Select a deletion method"
|
||||
options={deleteOptions} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { Selector, Tooltip } from '@appwrite.io/pink-svelte';
|
||||
|
||||
let {
|
||||
required = $bindable(false),
|
||||
array = $bindable(false),
|
||||
editing = false,
|
||||
disabled = false
|
||||
}: {
|
||||
required: boolean;
|
||||
array: boolean;
|
||||
editing?: boolean;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Tooltip disabled={!array || disabled} maxWidth="275px" placement="bottom-start">
|
||||
<div style:width="fit-content">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="required"
|
||||
label="Required"
|
||||
bind:checked={required}
|
||||
disabled={array || disabled}
|
||||
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) || disabled} maxWidth="275px" placement="bottom-start">
|
||||
<div style:width="fit-content">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="array"
|
||||
label="Array"
|
||||
bind:checked={array}
|
||||
disabled={required || editing || disabled}
|
||||
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';
|
||||
|
||||
@@ -55,6 +56,8 @@
|
||||
};
|
||||
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
export let autoIncreaseSize = false;
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
@@ -82,12 +85,17 @@
|
||||
|
||||
// Check plan on cloud, always allow on self-hosted
|
||||
$: supportsStringEncryption = isCloud ? $currentPlan?.databasesAllowEncrypt : true;
|
||||
|
||||
$: if (autoIncreaseSize && data.encrypt && data.size < 150) {
|
||||
data.size = 150;
|
||||
}
|
||||
</script>
|
||||
|
||||
<InputNumber
|
||||
id="size"
|
||||
label="Size"
|
||||
required
|
||||
{disabled}
|
||||
placeholder="Enter size"
|
||||
bind:value={data.size}
|
||||
min={supportsStringEncryption && data.encrypt ? 150 : undefined}
|
||||
@@ -102,44 +110,34 @@
|
||||
placeholder="Enter string"
|
||||
maxlength={data.size}
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
<Layout.Stack gap="xs" direction="column">
|
||||
<div
|
||||
class="popover-holder"
|
||||
class:cursor-not-allowed={editing}
|
||||
class:disabled-checkbox={!supportsStringEncryption || editing}>
|
||||
class:cursor-not-allowed={editing || disabled}
|
||||
class:disabled-checkbox={!supportsStringEncryption || editing || disabled}>
|
||||
<Layout.Stack inline gap="s" alignItems="flex-start" direction="row">
|
||||
<Popover let:toggle placement="bottom-start">
|
||||
<Selector.Checkbox
|
||||
size="s"
|
||||
id="encrypt"
|
||||
bind:checked={data.encrypt}
|
||||
disabled={!supportsStringEncryption || editing} />
|
||||
disabled={!supportsStringEncryption || editing || disabled} />
|
||||
|
||||
<Layout.Stack gap="xxs" direction="column">
|
||||
<button
|
||||
type="button"
|
||||
disabled={editing}
|
||||
disabled={editing || disabled}
|
||||
class:cursor-pointer={!editing}
|
||||
class:cursor-not-allowed={editing}
|
||||
class:cursor-not-allowed={editing || disabled}
|
||||
on:click={(e) => {
|
||||
if (!supportsStringEncryption) {
|
||||
toggle(e);
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { InputURL } from '$lib/elements/forms';
|
||||
|
||||
export let data: Partial<Models.ColumnUrl>;
|
||||
export let editing = false;
|
||||
|
||||
import { createConservative } from '$lib/helpers/stores';
|
||||
import { Selector } from '@appwrite.io/pink-svelte';
|
||||
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
|
||||
|
||||
export let editing = false;
|
||||
export let disabled = false;
|
||||
export let data: Partial<Models.ColumnUrl>;
|
||||
|
||||
let savedDefault = data.default;
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
array: false,
|
||||
...data
|
||||
});
|
||||
|
||||
$: listen(data);
|
||||
|
||||
$: handleDefaultState($required || $array);
|
||||
@@ -74,19 +75,11 @@
|
||||
label="Default value"
|
||||
placeholder="Enter value"
|
||||
bind:value={data.default}
|
||||
disabled={data.required || data.array}
|
||||
disabled={data.required || data.array || disabled}
|
||||
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}
|
||||
{disabled}
|
||||
bind:array={data.array}
|
||||
bind:required={data.required} />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { type Columns, type ColumnDirection } from './store';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
import { Layout } from '@appwrite.io/pink-svelte';
|
||||
import { Alert, Layout, Link } from '@appwrite.io/pink-svelte';
|
||||
import { InputSelect, InputText } from '$lib/elements/forms';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
|
||||
@@ -12,6 +11,12 @@
|
||||
import { preferences } from '$lib/stores/preferences';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { showColumnsSuggestionsModal } from '../(suggestions)/store';
|
||||
import IconAINotification from '../(suggestions)/icon/aiNotification.svelte';
|
||||
import { type Columns, type ColumnDirection, showCreateColumnSheet } from './store';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let {
|
||||
direction = null,
|
||||
column = null,
|
||||
@@ -35,6 +40,8 @@
|
||||
const tableId = page.params.table;
|
||||
const databaseId = page.params.database;
|
||||
|
||||
let showSuggestionsAlert = $state(true);
|
||||
|
||||
let key: string = $state(column?.key ?? null);
|
||||
let data: Partial<Columns> = $state({
|
||||
required: column?.required ?? false,
|
||||
@@ -180,6 +187,22 @@
|
||||
</script>
|
||||
|
||||
<Layout.Stack gap="xl">
|
||||
{#if isCloud && showSuggestionsAlert}
|
||||
<div class="custom-inline-alert" transition:slide>
|
||||
<Alert.Inline dismissible on:dismiss={() => (showSuggestionsAlert = false)}>
|
||||
<svelte:fragment slot="icon">
|
||||
<IconAINotification />
|
||||
</svelte:fragment>
|
||||
|
||||
Need help? Let AI <Link.Button
|
||||
on:click={() => {
|
||||
$showCreateColumnSheet.show = false;
|
||||
$showColumnsSuggestionsModal = true;
|
||||
}}>suggest columns</Link.Button> based on your data
|
||||
</Alert.Inline>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Layout.Stack direction="row">
|
||||
<InputText
|
||||
id="key"
|
||||
@@ -209,3 +232,22 @@
|
||||
<ColumnComponent bind:data onclose={() => ($option = null)} />
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
|
||||
<style lang="scss">
|
||||
.custom-inline-alert {
|
||||
& :global(article) {
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: var(--space-4, 8px);
|
||||
background: var(--bgcolor-neutral-primary);
|
||||
border: var(--border-width-s) solid var(--border-neutral);
|
||||
}
|
||||
|
||||
& :global(div:first-child > :nth-child(2)) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
& :global(.ai-icon-holder.notification) {
|
||||
height: 36px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
Typography
|
||||
} from '@appwrite.io/pink-svelte';
|
||||
import {
|
||||
IconBookOpen,
|
||||
IconDotsHorizontal,
|
||||
IconEye,
|
||||
IconPlus,
|
||||
@@ -37,6 +38,13 @@
|
||||
import { showCreateColumnSheet } from '../store';
|
||||
import { isSmallViewport } from '$lib/stores/viewport';
|
||||
import { page } from '$app/state';
|
||||
import { showIndexesSuggestions, showColumnsSuggestionsModal } from '../../(suggestions)';
|
||||
import IconAI from '../../(suggestions)/icon/aiForButton.svelte';
|
||||
import EmptySheetCards from '../layout/emptySheetCards.svelte';
|
||||
import { isCloud } from '$lib/system';
|
||||
import { realtime } from '$lib/stores/sdk';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { Dependencies } from '$lib/constants';
|
||||
|
||||
let {
|
||||
data
|
||||
@@ -63,14 +71,14 @@
|
||||
const spreadsheetColumns = $derived([
|
||||
{
|
||||
id: 'key',
|
||||
width: getColumnWidth('key', $isSmallViewport ? 250 : 200),
|
||||
minimumWidth: $isSmallViewport ? 250 : 200,
|
||||
width: getColumnWidth('key', 250),
|
||||
minimumWidth: 250,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
width: getColumnWidth('type', 120),
|
||||
minimumWidth: 120,
|
||||
width: getColumnWidth('type', 200),
|
||||
minimumWidth: 200,
|
||||
resizable: true
|
||||
},
|
||||
{
|
||||
@@ -96,6 +104,12 @@
|
||||
|
||||
onMount(() => {
|
||||
columnsWidth = preferences.getColumnWidths(tableId + '#indexes');
|
||||
|
||||
return realtime.forProject(page.params.region, ['project', 'console'], (response) => {
|
||||
if (response.events.includes('databases.*.tables.*.indexes.*')) {
|
||||
invalidate(Dependencies.TABLE);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
|
||||
@@ -287,27 +301,100 @@
|
||||
</Spreadsheet.Root>
|
||||
</SpreadsheetContainer>
|
||||
{:else}
|
||||
<EmptySheet
|
||||
mode="indexes"
|
||||
actions={{
|
||||
primary: {
|
||||
onClick: () => (showCreateIndex = true),
|
||||
disabled: !$table?.columns?.length
|
||||
}
|
||||
}} />
|
||||
<EmptySheet mode="indexes" showActions={$canWriteTables}>
|
||||
{#snippet subtitle()}
|
||||
{#if isCloud}
|
||||
<Typography.Text align="center">
|
||||
Need a hand? Learn more in the
|
||||
<Link.Anchor
|
||||
target="_blank"
|
||||
href="https://appwrite.io/docs/products/databases/tables#indexes">
|
||||
docs.
|
||||
</Link.Anchor>
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if isCloud}
|
||||
<EmptySheetCards
|
||||
icon={IconAI}
|
||||
title="Suggest indexes"
|
||||
disabled={!$table?.columns?.length}
|
||||
subtitle="Use AI to generate indexes"
|
||||
onClick={() => {
|
||||
showIndexesSuggestions.update(() => true);
|
||||
}} />
|
||||
{/if}
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create index"
|
||||
disabled={!$table?.columns?.length}
|
||||
subtitle="Create indexes manually"
|
||||
onClick={() => {
|
||||
showCreateIndex = true;
|
||||
}} />
|
||||
|
||||
{#if !isCloud}
|
||||
<EmptySheetCards
|
||||
icon={IconBookOpen}
|
||||
title="Documentation"
|
||||
subtitle="Read the Appwrite docs"
|
||||
href="https://appwrite.io/docs/products/databases/tables#indexes" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
{/if}
|
||||
{:else}
|
||||
<EmptySheet
|
||||
mode="indexes"
|
||||
title="You have no columns yet"
|
||||
actions={{
|
||||
primary: {
|
||||
text: 'Create columns',
|
||||
onClick: async () => {
|
||||
$showCreateColumnSheet.show = true;
|
||||
}
|
||||
}
|
||||
}} />
|
||||
<EmptySheet mode="indexes" title="You have no columns yet" showActions={$canWriteTables}>
|
||||
{#snippet subtitle()}
|
||||
{#if isCloud}
|
||||
<Typography.Text align="center">
|
||||
Need a hand? Learn more in the
|
||||
<Link.Anchor
|
||||
target="_blank"
|
||||
href="https://appwrite.io/docs/products/databases/tables#columns">
|
||||
docs.
|
||||
</Link.Anchor>
|
||||
</Typography.Text>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
{#if isCloud}
|
||||
<EmptySheetCards
|
||||
icon={IconAI}
|
||||
title="Suggest columns"
|
||||
subtitle="Use AI to generate columns"
|
||||
onClick={() => {
|
||||
$showColumnsSuggestionsModal = true;
|
||||
}} />
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create column"
|
||||
subtitle="Create columns manually"
|
||||
onClick={() => {
|
||||
$showCreateColumnSheet.show = true;
|
||||
}} />
|
||||
{:else}
|
||||
<EmptySheetCards
|
||||
icon={IconPlus}
|
||||
title="Create column"
|
||||
subtitle="Create columns manually"
|
||||
onClick={() => {
|
||||
$showCreateColumnSheet.show = true;
|
||||
}} />
|
||||
|
||||
<EmptySheetCards
|
||||
icon={IconBookOpen}
|
||||
title="Documentation"
|
||||
subtitle="Read the Appwrite docs"
|
||||
href="https://appwrite.io/docs/products/databases/tables#columns" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</EmptySheet>
|
||||
{/if}
|
||||
|
||||
{#if selectedIndexes.length > 0}
|
||||
|
||||
@@ -19,43 +19,57 @@
|
||||
expandTabs
|
||||
} from '../store';
|
||||
import SpreadsheetContainer from './spreadsheet.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { debounce } from '$lib/helpers/debounce';
|
||||
import { columnOptions } from '../columns/store';
|
||||
|
||||
type Mode = 'rows' | 'rows-filtered' | 'indexes';
|
||||
|
||||
interface Action {
|
||||
text?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
mode,
|
||||
showActions = true,
|
||||
customColumns = [],
|
||||
title,
|
||||
actions
|
||||
subtitle,
|
||||
actions,
|
||||
showActions
|
||||
} = $props<{
|
||||
mode: Mode;
|
||||
showActions?: boolean;
|
||||
customColumns?: Column[];
|
||||
title?: string;
|
||||
actions?: {
|
||||
primary?: Action;
|
||||
random?: Action;
|
||||
};
|
||||
subtitle?: Snippet;
|
||||
actions?: Snippet;
|
||||
showActions?: boolean;
|
||||
}>();
|
||||
|
||||
let spreadsheetContainer: HTMLElement;
|
||||
let headerElement: HTMLElement | null = null;
|
||||
|
||||
let resizeObserver: ResizeObserver;
|
||||
let overlayOffsetHandler: ResizeObserver;
|
||||
|
||||
let overlayLeftOffset = $state('0px');
|
||||
let overlayTopOffset = $state('auto');
|
||||
let dynamicOverlayHeight = $state('60.5vh');
|
||||
|
||||
const baseColProps = { draggable: false, resizable: false };
|
||||
|
||||
const updateOverlayLeftOffset = () => {
|
||||
if (spreadsheetContainer) {
|
||||
const containerRect = spreadsheetContainer.getBoundingClientRect();
|
||||
overlayLeftOffset = `${containerRect.left}px`;
|
||||
}
|
||||
|
||||
// calculate vertical top position
|
||||
if (!headerElement || !headerElement.isConnected) {
|
||||
headerElement = spreadsheetContainer?.querySelector('[role="rowheader"]');
|
||||
}
|
||||
|
||||
if (headerElement) {
|
||||
const headerRect = headerElement.getBoundingClientRect();
|
||||
overlayTopOffset = `${headerRect.bottom}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const updateOverlayHeight = () => {
|
||||
if (!spreadsheetContainer) return;
|
||||
|
||||
@@ -82,6 +96,9 @@
|
||||
if (spreadsheetContainer) {
|
||||
resizeObserver = new ResizeObserver(debouncedUpdateOverlayHeight);
|
||||
resizeObserver.observe(spreadsheetContainer);
|
||||
|
||||
overlayOffsetHandler = new ResizeObserver(updateOverlayLeftOffset);
|
||||
overlayOffsetHandler.observe(spreadsheetContainer);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,74 +106,141 @@
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
if (overlayOffsetHandler) {
|
||||
overlayOffsetHandler.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
const getCustomColumns = (): Column[] =>
|
||||
customColumns.map((col: Column) => ({
|
||||
...col,
|
||||
width: 180,
|
||||
hide: false,
|
||||
icon: columnOptions.find((colOpt) => colOpt.type === col?.type)?.icon,
|
||||
...baseColProps
|
||||
}));
|
||||
|
||||
const getRowColumns = (): Column[] => [
|
||||
{
|
||||
id: '$id',
|
||||
title: '$id',
|
||||
type: 'string',
|
||||
width: 180,
|
||||
icon: IconFingerPrint,
|
||||
...baseColProps
|
||||
},
|
||||
...getCustomColumns(),
|
||||
{
|
||||
id: '$createdAt',
|
||||
title: '$createdAt',
|
||||
type: 'datetime',
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: '$updatedAt',
|
||||
title: '$updatedAt',
|
||||
type: 'datetime',
|
||||
width: 180,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
title: '',
|
||||
type: 'string',
|
||||
icon: IconPlus,
|
||||
width: customColumns.length ? 555 : 832,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: 'empty',
|
||||
title: '',
|
||||
type: 'string',
|
||||
...baseColProps
|
||||
}
|
||||
];
|
||||
const getRowColumns = (): Column[] => {
|
||||
const minColumnWidth = 180;
|
||||
const fixedWidths = { id: 180, actions: 40 };
|
||||
const hasCustomColumns = customColumns.length > 0;
|
||||
|
||||
const getIndexesColumns = (): Column[] =>
|
||||
[
|
||||
const customColumnsData = getCustomColumns();
|
||||
|
||||
// Calculate column widths based on whether we have custom columns
|
||||
let columnWidths = {
|
||||
id: fixedWidths.id,
|
||||
createdAt: fixedWidths.id,
|
||||
updatedAt: fixedWidths.id,
|
||||
custom: minColumnWidth,
|
||||
actions: hasCustomColumns ? fixedWidths.actions : 1387
|
||||
};
|
||||
|
||||
if (hasCustomColumns) {
|
||||
const equalWidthColumns = [
|
||||
...customColumnsData,
|
||||
{ id: '$createdAt' },
|
||||
{ id: '$updatedAt' }
|
||||
];
|
||||
|
||||
const totalBaseWidth =
|
||||
fixedWidths.id + fixedWidths.actions + equalWidthColumns.length * minColumnWidth;
|
||||
|
||||
const viewportWidth =
|
||||
spreadsheetContainer?.clientWidth ||
|
||||
(typeof window !== 'undefined' ? window.innerWidth : totalBaseWidth);
|
||||
|
||||
const excessSpace = Math.max(0, viewportWidth - totalBaseWidth);
|
||||
const extraPerColumn =
|
||||
equalWidthColumns.length > 0 ? excessSpace / equalWidthColumns.length : 0;
|
||||
const distributedWidth = minColumnWidth + extraPerColumn;
|
||||
|
||||
columnWidths.createdAt = distributedWidth;
|
||||
columnWidths.updatedAt = distributedWidth;
|
||||
columnWidths.custom = distributedWidth;
|
||||
}
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
id: '$id',
|
||||
title: '$id',
|
||||
type: 'string',
|
||||
width: columnWidths.id,
|
||||
icon: IconFingerPrint,
|
||||
...baseColProps
|
||||
}
|
||||
];
|
||||
|
||||
if (hasCustomColumns) {
|
||||
columns.push(
|
||||
...customColumnsData.map((col) => ({
|
||||
...col,
|
||||
width: columnWidths.custom
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
id: '$createdAt',
|
||||
title: '$createdAt',
|
||||
type: 'datetime',
|
||||
width: columnWidths.createdAt,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: '$updatedAt',
|
||||
title: '$updatedAt',
|
||||
type: 'datetime',
|
||||
width: columnWidths.updatedAt,
|
||||
icon: IconCalendar,
|
||||
...baseColProps
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
title: '',
|
||||
type: 'string',
|
||||
icon: IconPlus,
|
||||
isAction: hasCustomColumns,
|
||||
width: columnWidths.actions,
|
||||
...baseColProps
|
||||
}
|
||||
);
|
||||
|
||||
if (!hasCustomColumns) {
|
||||
columns.push({
|
||||
id: 'empty',
|
||||
title: '',
|
||||
type: 'string',
|
||||
...baseColProps
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
const getIndexesColumns = (): Column[] => {
|
||||
const columns = [
|
||||
{ id: 'key', title: 'Key', icon: null, isPrimary: false },
|
||||
{ id: 'type', title: 'Type', icon: null, isPrimary: false },
|
||||
{ id: 'columns', title: 'Columns', icon: null, isPrimary: false },
|
||||
{
|
||||
{ id: 'columns', title: 'Columns', icon: null, isPrimary: false }
|
||||
] as Column[];
|
||||
|
||||
if (!$isSmallViewport) {
|
||||
columns.push({
|
||||
id: 'empty',
|
||||
title: '',
|
||||
width: 40,
|
||||
isAction: true,
|
||||
isPrimary: false
|
||||
}
|
||||
] as Column[];
|
||||
} as Column);
|
||||
}
|
||||
|
||||
const spreadsheetColumns = $derived(mode === 'rows' ? getRowColumns() : getIndexesColumns());
|
||||
return columns;
|
||||
};
|
||||
|
||||
const spreadsheetColumns = $derived(mode === 'indexes' ? getIndexesColumns() : getRowColumns());
|
||||
|
||||
const emptyCells = $derived(
|
||||
($isSmallViewport ? 14 : $isTabletViewport ? 17 : 24) + (!$expandTabs ? 2 : 0)
|
||||
@@ -164,9 +248,10 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="databases-spreadsheet spreadsheet-container-outer"
|
||||
data-mode={mode}
|
||||
bind:this={spreadsheetContainer}>
|
||||
bind:this={spreadsheetContainer}
|
||||
class:custom-columns={customColumns.length > 0}
|
||||
class="databases-spreadsheet spreadsheet-container-outer">
|
||||
<SpreadsheetContainer>
|
||||
<Spreadsheet.Root
|
||||
{emptyCells}
|
||||
@@ -179,20 +264,23 @@
|
||||
}}>
|
||||
<svelte:fragment slot="header" let:root>
|
||||
{#each spreadsheetColumns as column (column.id)}
|
||||
{@const columnActionsById = column.id === 'actions'}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style:cursor={columnActionsById ? 'pointer' : null}
|
||||
onclick={() => {
|
||||
if (columnActionsById && mode === 'rows') {
|
||||
$showCreateColumnSheet.show = true;
|
||||
$showCreateColumnSheet.title = 'Create column';
|
||||
$showCreateColumnSheet.columns = $tableColumns;
|
||||
$showCreateColumnSheet.columnsOrder = $columnsOrder;
|
||||
}
|
||||
}}>
|
||||
{#if column.isAction}
|
||||
<Spreadsheet.Header.Cell column="actions" {root}>
|
||||
<Button.Button
|
||||
icon
|
||||
variant="extra-compact"
|
||||
onclick={() => {
|
||||
if (mode === 'rows') {
|
||||
$showCreateColumnSheet.show = true;
|
||||
$showCreateColumnSheet.title = 'Create column';
|
||||
$showCreateColumnSheet.columns = $tableColumns;
|
||||
$showCreateColumnSheet.columnsOrder = $columnsOrder;
|
||||
}
|
||||
}}>
|
||||
<Icon icon={IconPlus} color="--fgcolor-neutral-primary" />
|
||||
</Button.Button>
|
||||
</Spreadsheet.Header.Cell>
|
||||
{:else}
|
||||
<Spreadsheet.Header.Cell
|
||||
{root}
|
||||
column={column.id}
|
||||
@@ -215,7 +303,7 @@
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
</Spreadsheet.Header.Cell>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
|
||||
@@ -236,48 +324,36 @@
|
||||
{#if !$spreadsheetLoading}
|
||||
<div
|
||||
class="spreadsheet-fade-bottom"
|
||||
class:custom-columns={customColumns.length > 0}
|
||||
data-collapsed-tabs={!$expandTabs}
|
||||
style:--overlay-left={overlayLeftOffset}
|
||||
style:--overlay-top={overlayTopOffset}
|
||||
style:--dynamic-overlay-height={dynamicOverlayHeight}>
|
||||
<div class="empty-actions">
|
||||
<Layout.Stack gap="xl" alignItems="center">
|
||||
<Typography.Title>{title ?? `You have no ${mode} yet`}</Typography.Title>
|
||||
<Layout.Stack
|
||||
gap="xl"
|
||||
alignItems="center"
|
||||
alignContent="center"
|
||||
style="width: 653px; max-width: {$isSmallViewport ? '353px' : undefined}">
|
||||
<Layout.Stack gap="xs" alignItems="center" alignContent="center">
|
||||
<Typography.Title>{title ?? `You have no ${mode} yet`}</Typography.Title>
|
||||
|
||||
{#if showActions}
|
||||
<Layout.Stack
|
||||
inline
|
||||
gap="s"
|
||||
alignItems="center"
|
||||
direction={$isSmallViewport ? 'column' : 'row'}>
|
||||
{#if mode !== 'rows-filtered'}
|
||||
<Button.Button
|
||||
icon
|
||||
size="s"
|
||||
variant="secondary"
|
||||
disabled={actions?.primary?.disabled}
|
||||
onclick={actions?.primary?.onClick}>
|
||||
<Icon icon={IconPlus} size="s" />
|
||||
{actions?.primary?.text ?? `Create ${mode}`}
|
||||
</Button.Button>
|
||||
{@render subtitle?.()}
|
||||
</Layout.Stack>
|
||||
|
||||
{#if mode === 'rows'}
|
||||
<Button.Button
|
||||
size="s"
|
||||
variant="secondary"
|
||||
disabled={actions?.random?.disabled}
|
||||
onclick={actions?.random?.onClick}>
|
||||
{actions?.random?.text ?? `Generate sample data`}
|
||||
</Button.Button>
|
||||
{#if showActions && actions}
|
||||
{@const inline = mode === 'rows-filtered'}
|
||||
<div class="controlled-width">
|
||||
<Layout.Stack {inline}>
|
||||
{#if inline}
|
||||
{@render actions?.()}
|
||||
{:else}
|
||||
<Layout.Grid columns={2} columnsXS={1}>
|
||||
{@render actions?.()}
|
||||
</Layout.Grid>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button.Button
|
||||
size="s"
|
||||
variant="secondary"
|
||||
disabled={actions?.primary?.disabled}
|
||||
onclick={actions?.primary?.onClick}>
|
||||
{actions?.primary?.text}
|
||||
</Button.Button>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
</div>
|
||||
@@ -291,6 +367,31 @@
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
|
||||
& :global(.spreadsheet-container) {
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
& :global([data-select='true']) {
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.custom-columns {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
&:not(.custom-columns) :global(.spreadsheet-container) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* alternative selector for header selection */
|
||||
& :global(.sticky-header [data-select='true']) {
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&[data-mode='rows'] {
|
||||
& :global([role='rowheader'] :nth-last-child(2) [role='presentation']) {
|
||||
display: none;
|
||||
@@ -298,6 +399,10 @@
|
||||
}
|
||||
|
||||
&[data-mode='indexes'] {
|
||||
& :global([role='cell']:last-child [role='presentation']) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& :global([role='rowheader'] [role='cell']:nth-last-child(1)) {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -306,22 +411,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& :global(.spreadsheet-container) {
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
& :global([data-select='true']) {
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.spreadsheet-fade-bottom {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
top: var(--overlay-top, auto);
|
||||
left: var(--overlay-left, 0px);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
@@ -330,17 +427,21 @@
|
||||
);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: none !important;
|
||||
|
||||
height: var(--dynamic-overlay-height, 70.5vh);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
height: var(--dynamic-overlay-height, 63.35vh);
|
||||
&.custom-columns {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
height: var(--dynamic-overlay-height, 70.35vh);
|
||||
.controlled-width {
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: 538px;
|
||||
max-width: 538px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,36 +455,12 @@
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
left: 50%;
|
||||
bottom: 35%;
|
||||
position: fixed;
|
||||
|
||||
@media (max-width: 768px) and (max-height: 768px) {
|
||||
left: unset;
|
||||
bottom: 12.5% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (max-height: 1024px) {
|
||||
left: unset;
|
||||
bottom: 15% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) and (max-height: 1024px) {
|
||||
left: unset;
|
||||
bottom: 15%;
|
||||
}
|
||||
margin-bottom: 10%;
|
||||
pointer-events: auto;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
left: unset;
|
||||
bottom: 30%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
bottom: 37.5%;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
bottom: 40%;
|
||||
// experiment
|
||||
margin-bottom: 15%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||