Merge branch 'feat-profiles' of https://github.com/appwrite/console into feat-profiles

This commit is contained in:
Torsten Dittmann
2025-11-13 18:26:41 +04:00
208 changed files with 8238 additions and 5398 deletions
+28
View File
@@ -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
+1 -1
View File
@@ -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 }}"
+104
View File
@@ -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.
+14 -2
View File
@@ -17,10 +17,22 @@ export async function createFreeProject(page: Page): Promise<Metadata> {
await page.waitForURL(/\/organization-[^/]+/);
await page.getByRole('button', { name: 'create project' }).first().click();
const dialog = page.locator('dialog[open]');
await dialog.getByPlaceholder('Project name').fill('test project');
let region = 'fra'; // for fallback
const regionPicker = dialog.locator('button[role="combobox"]');
if (await regionPicker.isVisible()) {
await regionPicker.click();
await page.getByRole('option', { name: /New York/i }).click();
region = 'nyc';
}
await dialog.getByRole('button', { name: 'create' }).click();
await page.waitForURL(/\/project-fra-[^/]+/);
expect(page.url()).toContain('/console/project-fra-');
await page.waitForURL(new RegExp(`/project-${region}-[^/]+`));
expect(page.url()).toContain(`/console/project-${region}-`);
return getProjectIdFromUrl(page.url());
});
+13 -2
View File
@@ -50,10 +50,21 @@ export async function createProProject(page: Page): Promise<Metadata> {
await page.waitForURL(/\/organization-[^/]+/);
await page.getByRole('button', { name: 'create project' }).first().click();
const dialog = page.locator('dialog[open]');
await dialog.getByPlaceholder('Project name').fill('test project');
let region = 'fra'; // for fallback
const regionPicker = dialog.locator('button[role="combobox"]');
if (await regionPicker.isVisible()) {
await regionPicker.click();
await page.getByRole('option', { name: /New York/i }).click();
region = 'nyc';
}
await dialog.getByRole('button', { name: 'create' }).click();
await page.waitForURL(/\/project-fra-[^/]+/);
expect(page.url()).toContain('/console/project-fra-');
await page.waitForURL(new RegExp(`/project-${region}-[^/]+`));
expect(page.url()).toContain(`/console/project-${region}-`);
return getProjectIdFromUrl(page.url());
});
+4 -4
View File
@@ -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"
}
+1108 -1221
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -7,6 +7,30 @@
content="Appwrite is an open-source platform for building applications at any scale, using your preferred programming languages and tools." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/logos/appwrite-icon.svg" />
<link rel="mask-icon" type="image/png" href="%sveltekit.assets%/logos/appwrite-icon.png" />
<!-- apple touch icons for ios/ipados -->
<link
rel="apple-touch-icon"
sizes="180x180"
href="%sveltekit.assets%/logos/apple-touch-icon-180x180.png" />
<link
rel="apple-touch-icon"
sizes="167x167"
href="%sveltekit.assets%/logos/apple-touch-icon-167x167.png" />
<link
rel="apple-touch-icon"
sizes="152x152"
href="%sveltekit.assets%/logos/apple-touch-icon-152x152.png" />
<link
rel="apple-touch-icon"
sizes="120x120"
href="%sveltekit.assets%/logos/apple-touch-icon-120x120.png" />
<!-- apple web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Appwrite Console" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="stylesheet" href="%sveltekit.assets%/css/loading.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
+46 -4
View File
@@ -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">
+15
View File
@@ -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>
+10 -12
View File
@@ -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>
+16 -6
View File
@@ -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>
+1
View File
@@ -57,6 +57,7 @@
disabled={tooltipDisabled}
portal={tooltipPortal}
delay={tooltipDelay}
maxWidth="500px"
placement={tooltipPlacement}>
<span
data-private
+102 -46
View File
@@ -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} />
+2 -1
View File
@@ -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
+3
View File
@@ -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';
+8 -9
View File
@@ -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>
+2 -1
View File
@@ -14,6 +14,7 @@
};
export let title = '';
export let hideFooter = false;
export let backdrop: boolean = true;
let alert: HTMLElement;
@@ -29,7 +30,7 @@
</script>
<Form isModal {onSubmit}>
<Modal {size} {title} bind:open={show} {hideFooter} {dismissible}>
<Modal {backdrop} {size} {title} bind:open={show} {hideFooter} {dismissible}>
<slot slot="description" name="description" />
{#if error}
<div bind:this={alert}>
+164
View File
@@ -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>
+303 -83
View File
@@ -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>
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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
+24 -24
View File
@@ -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>
+1 -8
View File
@@ -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>
+4 -2
View File
@@ -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} />
+2
View File
@@ -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',
+9 -2
View File
@@ -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?.()}
+5 -2
View File
@@ -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>
+4 -1
View File
@@ -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}
+2 -7
View File
@@ -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;
}
+11 -1
View File
@@ -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}
+45
View File
@@ -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;
}
+21 -4
View File
@@ -18,21 +18,38 @@ export function toDecimals(num: number, decimals: number = 1): number {
return parseFloat(num.toFixed(decimals));
}
export function formatNumberWithCommas(number: number): string {
export function formatNumberWithCommas(number: number, min: number = 0): string {
if (isNaN(number)) return String(number);
const formatter = new Intl.NumberFormat('en');
return formatter.format(number);
return formatter.format(clampMin(number, min));
}
export function formatCurrency(number: number, locale = 'en-US', currency = 'USD'): string {
export function formatCurrency(
number: number,
locale = 'en-US',
currency = 'USD',
min: number = 0
): string {
if (isNaN(number)) return String(number);
const formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency
});
return formatter.format(number);
return formatter.format(clampMin(number, min));
}
export function isWithinSafeRange(val: number) {
return Math.abs(val) < Number.MAX_SAFE_INTEGER;
}
/**
* Clamps a number to a minimum value
*
* @export
* @param {number} value
* @param {number} min
* @returns {number}
*/
export function clampMin(value: number, min: number = 0): number {
return Math.max(min, value || 0);
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { clampMin } from './numbers';
/**
* Capitalizes the first letter of a string
*
@@ -45,8 +47,8 @@ const formatter = Intl.NumberFormat('en', {
notation: 'compact'
});
export function formatNum(number: number): string {
return formatter.format(number);
export function formatNum(number: number, min: number = 0): string {
return formatter.format(clampMin(number, min));
}
/**
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

+4 -1
View File
@@ -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}
+2 -2
View File
@@ -4,7 +4,7 @@
import { Card, SecondaryTabs, SecondaryTabsItem } from '$lib/components';
import { page } from '$app/state';
import { type Models } from '@appwrite.io/console';
import { formatNumberWithCommas } from '$lib/helpers/numbers';
import { formatNumberWithCommas, clampMin } from '$lib/helpers/numbers';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
export let title: string;
@@ -41,7 +41,7 @@
<Card>
{#if count}
{@const totalCount = total.reduce((a, b) => a + b, 0)}
{@const totalCount = clampMin(total.reduce((a, b) => a + b, 0))}
<Layout.Stack gap="xs">
<Typography.Title>{formatNumberWithCommas(totalCount)}</Typography.Title>
+19 -8
View File
@@ -21,14 +21,21 @@
</script>
<Layout.Stack>
<header class="form-header" class:hide-divider={!$$slots.subtitle}>
<Typography.Title><slot name="title" /></Typography.Title>
{#if $$slots.subtitle}
<p>
<slot name="subtitle" />
</p>
{/if}
</header>
{#if $$slots.title || $$slots.subtitle}
<header
class="form-header"
class:hide-divider={!$$slots.subtitle}
class:only-subtitle={!$$slots.title && $$slots.subtitle}>
{#if $$slots.title}
<Typography.Title><slot name="title" /></Typography.Title>
{/if}
{#if $$slots.subtitle}
<p>
<slot name="subtitle" />
</p>
{/if}
</header>
{/if}
<slot />
</Layout.Stack>
@@ -42,4 +49,8 @@
padding-block-end: 0;
border-block-end: none;
}
.only-subtitle {
margin-block-end: 0.5rem;
}
</style>
+4 -1
View File
@@ -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,
+3
View File
@@ -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',
+52 -9
View File
@@ -21,7 +21,8 @@ import {
Sites,
Tokens,
TablesDB,
Domains
Domains,
Realtime
} from '@appwrite.io/console';
import { Billing } from '../sdk/billing';
import { Backups } from '../sdk/backups';
@@ -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());
};
}
+2
View File
@@ -26,6 +26,8 @@ export function getFrameworkIcon(framework: string) {
return 'vite';
case framework.toLocaleLowerCase().includes('lynx'):
return 'lynx';
case framework.toLocaleLowerCase().includes('tanstack'):
return 'tanstack';
case framework.toLocaleLowerCase().includes('other'):
return 'empty';
+224
View File
@@ -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>
+5 -1
View File
@@ -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();
+60 -3
View File
@@ -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>
+10 -54
View File
@@ -1,76 +1,32 @@
import { isCloud } from '$lib/system';
import { isSameDay } from '$lib/helpers/date';
import { type BottomModalAlertItem, showBottomModalAlert } from '$lib/stores/bottom-alerts';
import SpatialColumnsLight from '$lib/images/promos/spatial-columns-api-light.png';
import SpatialColumnsDark from '$lib/images/promos/spatial-columns-api-dark.png';
import InversionQueriesDark from '$lib/images/promos/inversion-queries-dark.png';
import InversionQueriesLight from '$lib/images/promos/inversion-queries-light.png';
import TimeHelperQueriesDark from '$lib/images/promos/time-helper-queries-dark.png';
import TimeHelperQueriesLight from '$lib/images/promos/time-helper-queries-light.png';
import 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>

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