Merge remote-tracking branch 'origin/main' into feat-csv-export

# Conflicts:
#	package.json
#	pnpm-lock.yaml
#	src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte
This commit is contained in:
Jake Barnby
2025-11-19 21:03:49 +13:00
46 changed files with 2710 additions and 763 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Dockerize Profiles
on:
push:
branches: [feat-profiles]
pull_request:
types: [opened, synchronize, reopened]
branches: [feat-profiles]
workflow_dispatch:
jobs:
dockerize-profiles:
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: appwrite/console-profiles
tags: |
type=ref,event=branch,prefix=branch-
type=ref,event=pr
type=sha,prefix=sha-
type=raw,value=gh-${{ github.run_id}}
flavor: |
latest=false
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -2
View File
@@ -24,9 +24,9 @@
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@d0c5a57",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@c4d7bc0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bd82d9a",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@c4d7bc0",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bd82d9a",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
+10 -10
View File
@@ -18,14 +18,14 @@ importers:
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@c4d7bc0
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@c4d7bc0(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bd82d9a
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bd82d9a(svelte@5.25.3)
'@appwrite.io/pink-legacy':
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@c4d7bc0
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@c4d7bc0(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bd82d9a
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bd82d9a(svelte@5.25.3)
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
@@ -269,8 +269,8 @@ packages:
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@c4d7bc0':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@c4d7bc0}
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bd82d9a':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bd82d9a}
version: 2.0.0-RC.1
peerDependencies:
svelte: ^4.0.0
@@ -284,8 +284,8 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@c4d7bc0':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@c4d7bc0}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bd82d9a':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bd82d9a}
version: 2.0.0-RC.2
peerDependencies:
svelte: ^4.0.0
@@ -3709,7 +3709,7 @@ snapshots:
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@c4d7bc0(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bd82d9a(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3722,7 +3722,7 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@c4d7bc0(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@bd82d9a(svelte@5.25.3)':
dependencies:
'@appwrite.io/pink-icons-svelte': 2.0.0-RC.1(svelte@5.25.3)
'@floating-ui/dom': 1.6.13
+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>
+11 -5
View File
@@ -6,8 +6,8 @@
</script>
<script lang="ts">
import { InputDateTime, InputSelect } from '$lib/elements/forms';
import { isSameDay, isValidDate, toLocaleDate } from '$lib/helpers/date';
import { InputDate, InputSelect } from '$lib/elements/forms';
import { isSameDay, isValidDate, toLocaleDate, toLocaleDateISO } from '$lib/helpers/date';
function incrementToday(value: number, type: 'day' | 'month' | 'year'): string {
const date = new Date();
@@ -74,6 +74,10 @@
export let resourceType: string | 'key' | 'token' | undefined = 'key';
export let expiryOptions: 'default' | 'limited' | ExpirationOptions[] = 'default';
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
let minDate: string = toLocaleDateISO(tomorrow.getTime());
const options = Array.isArray(expiryOptions)
? expiryOptions
: expiryOptions === 'default'
@@ -129,6 +133,8 @@
if (hasUserInteracted && !isSameDay(new Date(expirationSelect), new Date(value))) {
value = expirationSelect === 'custom' ? expirationCustom : expirationSelect;
}
value = toLocaleDateISO(new Date(value).getTime());
}
$: helper =
@@ -147,11 +153,11 @@
on:change={() => (hasUserInteracted = true)} />
{#if expirationSelect === 'custom'}
<InputDateTime
<InputDate
required
type="date"
id="expire"
min={minDate}
label={dateSelectorLabel}
bind:value={expirationCustom}
on:change={() => (hasUserInteracted = true)} />
on:input={() => (hasUserInteracted = true)} />
{/if}
@@ -112,7 +112,6 @@
repository.set(e);
repositoryName = e.name;
selectedRepository = e.id;
connectRepo();
}} />
{/if}
</Layout.Stack>
@@ -140,7 +140,7 @@
{#each [...$groups] as [role, permission] (role)}
<Table.Row.Base {root}>
<Table.Cell column="role" {root}>
<Row {role} onNotFound={() => deleteRole(role)} />
<Row {role} />
</Table.Cell>
<Table.Cell column="create" {root}>
<Selector.Checkbox
+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} />
+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}
+45 -4
View File
@@ -1,10 +1,51 @@
<script>
import { Container } from '$lib/layout';
import { base } from '$app/paths';
import { loading } from '$routes/store';
import { app } from '$lib/stores/app';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
loading.set(false);
</script>
<Container>
<slot />
</Container>
<Layout.Stack
height="100vh"
direction="column"
alignItems="center"
justifyContent="center"
style="background: var(--bgcolor-neutral-primary, #fff);">
<section class="console-container">
<slot />
</section>
<footer>
<Typography.Eyebrow color="--fgcolor-neutral-secondary">POWERED BY</Typography.Eyebrow>
{#if $app.themeInUse === 'dark'}
<img
src="{base}/images/appwrite-logo-dark.svg"
width="120"
height="22"
alt="Appwrite Logo" />
{:else}
<img
src="{base}/images/appwrite-logo-light.svg"
width="120"
height="22"
alt="Appwrite Logo" />
{/if}
</footer>
</Layout.Stack>
<style lang="scss">
section {
flex: 1;
display: flex;
align-items: center;
}
footer {
padding: 2rem 1rem;
display: flex;
gap: 0.5rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
}
</style>
@@ -1,30 +1,21 @@
<script lang="ts">
import { app } from '$lib/stores/app';
import AppwriteLogoDark from '$lib/images/appwrite-logo-dark.svg';
import AppwriteLogoLight from '$lib/images/appwrite-logo-light.svg';
import { Vcs, Client } from '@appwrite.io/console';
import { onMount } from 'svelte';
import { getApiEndpoint } from '$lib/stores/sdk';
import { Badge, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Button } from '$lib/elements/forms';
export let data;
const { data } = $props();
const endpoint = getApiEndpoint();
const client = new Client();
const vcs = new Vcs(client);
let installationId: string;
let repositoryId: string;
let providerPullRequestId: string;
let error = '';
let success = '';
let loading = false;
let error = $state('');
let success = $state('');
let loading = $state(false);
onMount(async () => {
repositoryId = data.repositoryId;
installationId = data.installationId;
providerPullRequestId = data.providerPullRequestId;
client.setEndpoint(endpoint).setProject(data.projectId).setMode('admin');
});
@@ -39,9 +30,9 @@
try {
await vcs.updateExternalDeployments({
installationId,
repositoryId,
providerPullRequestId
installationId: data.installationId,
repositoryId: data.repositoryId,
providerPullRequestId: data.providerPullRequestId
});
success = 'Deployment approved successfully! Build will start soon.';
} catch (e) {
@@ -52,49 +43,15 @@
}
</script>
<section class="container" style="display: grid; place-items: center; min-height: 100vh;">
<div class="u-flex u-flex-vertical u-cross-center" style="width: 100%">
<div class="card" style="min-width: 600px; max-width: 100%;">
<h1 class="heading-level-2">Authorize External Deployment</h1>
<small style="margin-block-start: 8px;display: block;"
>The deployment for pull request <code class="inline-code"
>#{providerPullRequestId}</code> is awaiting approval. When authorized, deployments
will be started.
</small>
<div class="with-borders" style="margin-block-start: 1rem;display: block;">
<button disabled={loading} on:click={approveDeployment} class="button" type="button"
>Approve Deployment</button>
</div>
{#if error}
<p style="margin-block-start: 1rem" class="u-color-text-danger">{error}</p>
{/if}
{#if success}
<p style="margin-block-start: 1rem" class="u-color-text-success">{success}</p>
{/if}
</div>
<div class="u-gap-4 u-flex u-main-center u-cross-center" style="margin-block-start: 2rem;">
<span class="text">Powered by</span>
<a
href="https://appwrite.io/"
target="_blank"
style="display: grid;place-items: center;">
{#if $app.themeInUse === 'dark'}
<img
src={AppwriteLogoDark}
width="120"
class="u-block u-only-dark"
alt="Appwrite Logo" />
{:else}
<img
src={AppwriteLogoLight}
width="120"
class="u-block u-only-light"
alt="Appwrite Logo" />
{/if}
</a>
</div>
</div>
</section>
<Layout.Stack gap="l" alignItems="center" style="max-width: 500px;">
{#if success}
<Badge type="success" variant="secondary" content={success} />
{:else if error}
<Badge type="error" variant="secondary" content={error} />
{/if}
<Typography.Title size="l" align="center">
The deployment for pull request #{data.providerPullRequestId}
is awaiting approval. When authorized, deployments will be started.
</Typography.Title>
<Button on:click={approveDeployment} secondary>Approve Deployment</Button>
</Layout.Stack>
@@ -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(
@@ -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,9 +131,11 @@ export type ColumnInput = {
min?: number;
max?: number;
format?: string;
elements?: string[];
formatOptions?: {
min?: number;
max?: number;
elements?: string[];
};
};
@@ -134,6 +144,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 +155,11 @@ 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.format === 'enum'
? (col.elements ?? col.formatOptions?.elements ?? undefined)
: undefined
}));
}
@@ -74,6 +74,8 @@
import { isTabletViewport } from '$lib/stores/viewport';
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
import ColumnsSuggestions from '../(suggestions)/columns.svelte';
import { showColumnsSuggestionsModal } from '../(suggestions)';
let editRow: EditRow;
let editRelatedRow: EditRelatedRow;
@@ -608,6 +610,8 @@
</svelte:fragment>
</Dialog>
<ColumnsSuggestions bind:show={$showColumnsSuggestionsModal} />
<IndexesSuggestions />
<style lang="scss">
@@ -8,7 +8,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,
@@ -29,9 +29,11 @@
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { isSmallViewport } from '$lib/stores/viewport';
import {
IconBookOpen,
IconChevronDown,
IconChevronUp,
IconPlus,
IconViewBoards,
IconRefresh,
IconUpload,
IconDownload
@@ -41,9 +43,15 @@
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;
@@ -95,6 +103,8 @@
$tableColumnSuggestions.table &&
$tableColumnSuggestions.table.id === page.params.table;
$: disableButton = canShowSuggestionsSheet;
async function onSelect(file: Models.File, localFile = false) {
$isCsvImportInProgress = true;
@@ -156,7 +166,8 @@
columns={tableColumns}
hideView
showAnyway
isCustomTable />
isCustomTable
{disableButton} />
</div>
<svelte:fragment slot="tooltip">Columns</svelte:fragment>
@@ -167,7 +178,7 @@
onlyIcon
query={data.query}
columns={filterColumns}
disabled={!(hasColumns && hasValidColumns)}
disabled={!(hasColumns && hasValidColumns) || disableButton}
analyticsSource="database_tables" />
<svelte:fragment slot="tooltip">Filters</svelte:fragment>
@@ -183,7 +194,7 @@
<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
@@ -243,7 +254,8 @@
secondary
disabled={isRefreshing ||
!data.rows.total ||
!(hasColumns && hasValidColumns)}
!(hasColumns && hasValidColumns) ||
disableButton}
class="small-button-dimensions"
on:click={async () => {
isRefreshing = true;
@@ -265,7 +277,7 @@
<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
@@ -275,7 +287,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} />
@@ -283,58 +295,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}
@@ -382,8 +382,9 @@
{column.key}{column.array ? '[]' : undefined}
{/if}
</Typography.Text>
{#if isString(column) && column.encrypt}
<Tooltip>
<Tooltip portal>
<Icon
size="s"
icon={IconLockClosed}
@@ -391,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"
@@ -42,6 +42,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnBoolean> = {
required: false,
array: false,
@@ -77,7 +78,7 @@
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 },
@@ -85,4 +86,8 @@
]}
bind:value={data.default} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<RequiredArrayCheckboxes
{editing}
{disabled}
bind:array={data.array}
bind:required={data.required} />
@@ -46,6 +46,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnDatetime>;
let savedDefault = data.default;
@@ -77,7 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<RequiredArrayCheckboxes
{editing}
{disabled}
bind:array={data.array}
bind:required={data.required} />
@@ -42,6 +42,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnEmail>;
let savedDefault = data.default;
@@ -74,7 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<RequiredArrayCheckboxes
{editing}
{disabled}
bind:array={data.array}
bind:required={data.required} />
@@ -46,6 +46,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnEnum>;
let savedDefault = data.default;
@@ -67,6 +68,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -103,9 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<RequiredArrayCheckboxes
{editing}
{disabled}
bind:array={data.array}
bind:required={data.required} />
@@ -47,6 +47,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnFloat> = {
required: false,
min: 0,
@@ -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,8 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<RequiredArrayCheckboxes
{editing}
{disabled}
bind:array={data.array}
bind:required={data.required} />
@@ -47,7 +47,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnInteger> = {
required: false,
min: 0,
@@ -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,7 +105,11 @@
min={data.min}
max={data.max}
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array} />
disabled={data.required || data.array || disabled}
nullable={(!data.required && !data.array) || disabled} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<RequiredArrayCheckboxes
{editing}
{disabled}
bind:array={data.array}
bind:required={data.required} />
@@ -41,6 +41,7 @@
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnIp>;
let savedDefault = data.default;
@@ -73,7 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<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}
@@ -4,22 +4,24 @@
let {
required = $bindable(false),
array = $bindable(false),
editing = false
editing = false,
disabled = false
}: {
required: boolean;
array: boolean;
editing: boolean;
editing?: boolean;
disabled?: boolean;
} = $props();
</script>
<Tooltip disabled={!array} maxWidth="275px" placement="bottom-start">
<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={array || disabled}
description="Indicate whether this column is required." />
</div>
@@ -28,14 +30,14 @@
</svelte:fragment>
</Tooltip>
<Tooltip disabled={!(required || editing)} maxWidth="275px" placement="bottom-start">
<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={required || editing || disabled}
description="Indicate whether this column is an array. Defaults to an empty array." />
</div>
@@ -56,6 +56,8 @@
};
export let editing = false;
export let disabled = false;
export let autoIncreaseSize = false;
let savedDefault = data.default;
@@ -83,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}
@@ -103,30 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<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);
@@ -41,8 +41,9 @@
import { createConservative } from '$lib/helpers/stores';
import RequiredArrayCheckboxes from './requiredArrayCheckboxes.svelte';
export let data: Partial<Models.ColumnUrl>;
export let editing = false;
export let disabled = false;
export let data: Partial<Models.ColumnUrl>;
let savedDefault = data.default;
@@ -74,7 +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} />
<RequiredArrayCheckboxes {editing} bind:array={data.array} bind:required={data.required} />
<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,10 @@
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';
@@ -66,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
},
{
@@ -296,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>
@@ -0,0 +1,48 @@
<script lang="ts">
import { Card } from '$lib/components';
import type { ComponentType } from 'svelte';
import { Icon, Layout, Typography } from '@appwrite.io/pink-svelte';
let {
icon,
title,
subtitle,
onClick,
href,
disabled
}: {
icon?: ComponentType;
title: string;
subtitle?: string;
onClick?: () => Promise<void> | void;
href?: string;
disabled?: boolean;
} = $props();
</script>
<Card
{href}
{disabled}
external
radius="m"
padding="xs"
variant="primary"
isButton={!href}
on:click={() => onClick?.()}>
<Layout.Stack direction="row" gap="m">
{#if icon}
<Icon {icon} size="m" color="--fgcolor-neutral-tertiary" />
{/if}
<Layout.Stack direction="column" gap="none">
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary">
{title}
</Typography.Text>
{#if subtitle}
<Typography.Text color="--fgcolor-neutral-secondary">
{subtitle}
</Typography.Text>
{/if}
</Layout.Stack>
</Layout.Stack>
</Card>
@@ -898,6 +898,7 @@
id={row?.$id}
virtualItem={item}
select={rowSelection}
hoverEffect
showSelectOnHover
valueWithoutHover={row.$sequence}>
{#each $tableColumns as { id: columnId, isEditable } (columnId)}
@@ -1159,7 +1160,8 @@
gap="xs"
direction="row"
alignItems="center"
alignContent="center">
alignContent="center"
class="footer-input-select-wrapper">
<span style:white-space="nowrap"> Page </span>
<InputSelect
@@ -1302,6 +1304,11 @@
transform: translateX(-50%);
}
:global(.footer-input-select-wrapper button.input) {
height: 30px;
background-color: var(--bgcolor-neutral-primary);
}
// very weird because the library already has this!
:global(.virtual-row:has([data-editing-mode='true'])) {
z-index: 1 !important;
+145 -25
View File
@@ -1,11 +1,17 @@
<script lang="ts">
import { Wizard } from '$lib/layout';
import { Icon, Layout, Tag, Typography, Button, Card } from '@appwrite.io/pink-svelte';
import { Icon, Input, Layout, Popover, Tag, Typography, Card } from '@appwrite.io/pink-svelte';
import { supportData, isSupportOnline } from './wizard/support/store';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { sdk } from '$lib/stores/sdk';
import { Form, InputSelect, InputText, InputTextarea } from '$lib/elements/forms/index.js';
import {
Form,
InputSelect,
InputText,
InputTextarea,
Button
} from '$lib/elements/forms/index.js';
import { Query } from '@appwrite.io/console';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
localeTimezoneName,
@@ -18,29 +24,83 @@
import { user } from '$lib/stores/user';
import { wizard } from '$lib/stores/wizard';
import { VARS } from '$lib/system';
import { onDestroy } from 'svelte';
import { IconCheckCircle, IconXCircle } from '@appwrite.io/pink-icons-svelte';
import { IconCheckCircle, IconXCircle, IconInfo } from '@appwrite.io/pink-icons-svelte';
let projectOptions: Array<{ value: string; label: string }>;
let projectOptions = $state<Array<{ value: string; label: string }>>([]);
// Category options with display names
const categories = [
{ value: 'general', label: 'General' },
{ value: 'billing', label: 'Billing' },
{ value: 'technical', label: 'Technical' }
];
// Topic options based on category
const topicsByCategory = {
general: ['Security', 'Compliance', 'Performance'],
billing: ['Invoices', 'Plans'],
technical: [
'Auth',
'Databases',
'Storage',
'Functions',
'Realtime',
'Messaging',
'Migrations',
'Webhooks',
'SDKs',
'Console'
]
};
// Severity options
const severityOptions = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
{ value: 'question', label: 'Question' }
];
onMount(async () => {
const projectList = await sdk.forConsole.projects.list();
// Filter projects by organization ID using server-side queries
const projectList = await sdk.forConsole.projects.list({
queries: $organization?.$id ? [Query.equal('teamId', $organization.$id)] : []
});
projectOptions = projectList.projects.map((project) => ({
value: project.$id,
label: project.name
}));
});
// Cleanup on component destroy
onDestroy(() => {
$supportData = {
message: null,
subject: null,
category: 'general',
category: 'technical',
topic: undefined,
severity: 'question',
file: null
};
});
// Update topic options when category changes
const topicOptions = $derived(
($supportData.category ? topicsByCategory[$supportData.category] || [] : []).map(
(topic) => ({
value: topic.toLowerCase(),
label: topic
})
)
);
async function handleSubmit() {
// Create category-topic tag
const categoryTopicTag = $supportData.topic
? `${$supportData.category}-${$supportData.topic}`.toLowerCase()
: $supportData.category.toLowerCase();
const response = await fetch(`${VARS.GROWTH_ENDPOINT}/support`, {
method: 'POST',
headers: {
@@ -51,13 +111,13 @@
subject: $supportData.subject,
firstName: ($user?.name || 'Unknown').slice(0, 40),
message: $supportData.message,
tags: ['cloud'],
tags: [categoryTopicTag],
customFields: [
{ id: '41612', value: $supportData.category },
{ id: '48493', value: $user?.name ?? '' },
{ id: '48492', value: $organization?.$id ?? '' },
{ id: '48491', value: $supportData?.project ?? '' },
{ id: '48490', value: $user?.$id ?? '' }
{ id: '56023', value: $supportData?.severity ?? '' },
{ id: '56024', value: $organization?.billingPlan ?? '' }
]
})
});
@@ -84,7 +144,9 @@
$supportData = {
message: null,
subject: null,
category: 'general',
category: 'technical',
topic: undefined,
severity: undefined,
file: null,
project: null
};
@@ -99,10 +161,43 @@
endDay: 'Friday' as WeekDay
};
$: supportTimings = `${utcHourToLocaleHour(workTimings.start)} - ${utcHourToLocaleHour(workTimings.end)} ${localeTimezoneName()}`;
$: supportWeekDays = `${utcWeekDayToLocaleWeekDay(workTimings.startDay, workTimings.start)} - ${utcWeekDayToLocaleWeekDay(workTimings.endDay, workTimings.end)}`;
const supportTimings = $derived(
`${utcHourToLocaleHour(workTimings.start)} - ${utcHourToLocaleHour(workTimings.end)} ${localeTimezoneName()}`
);
const supportWeekDays = $derived(
`${utcWeekDayToLocaleWeekDay(workTimings.startDay, workTimings.start)} - ${utcWeekDayToLocaleWeekDay(workTimings.endDay, workTimings.end)}`
);
</script>
{#snippet severityPopover()}
<Popover let:toggle>
<Button extraCompact size="s" on:click={toggle}>
<Icon size="s" icon={IconInfo} />
</Button>
<div slot="tooltip" style="max-width: 400px;">
<Layout.Stack gap="s">
<Typography.Text>
<b>Critical:</b> System is down or a critical component is non-functional, causing
a complete stoppage of work or significant business impact.
</Typography.Text>
<Typography.Text>
<b>High:</b> Major functionality is impaired, but a workaround is available, or a
critical component is significantly degraded.
</Typography.Text>
<Typography.Text>
<b>Medium:</b> Minor functionality is impaired without significant business impact.
</Typography.Text>
<Typography.Text>
<b>Low:</b> Issue has minor impact on business operations; workaround is not necessary.
</Typography.Text>
<Typography.Text>
<b>Question:</b> Requests for information, general guidance, or feature requests.
</Typography.Text>
</Layout.Stack>
</div>
</Popover>
{/snippet}
<Wizard title="Contact us" confirmExit={true}>
<Form onSubmit={handleSubmit}>
<Layout.Stack gap="xl">
@@ -113,24 +208,48 @@
</Layout.Stack>
<Layout.Stack gap="s">
<Typography.Text color="--fgcolor-neutral-secondary"
>Choose a topic</Typography.Text>
>Choose a category</Typography.Text>
<Layout.Stack gap="s" direction="row">
{#each ['general', 'billing', 'technical'] as category}
{#each categories as category}
<Tag
on:click={() => {
$supportData.category = category;
if ($supportData.category !== category.value) {
$supportData.topic = undefined;
}
$supportData.category = category.value;
}}
selected={$supportData.category === category}>{category}</Tag>
selected={$supportData.category === category.value}
>{category.label}</Tag>
{/each}
</Layout.Stack>
</Layout.Stack>
<InputSelect
required
{#if topicOptions.length > 0}
{#key $supportData.category}
<Input.ComboBox
id="topic"
label="Choose a topic"
placeholder="Select topic"
bind:value={$supportData.topic}
options={topicOptions} />
{/key}
{/if}
<Input.ComboBox
id="project"
label="Choose a project"
options={projectOptions ?? []}
bind:value={$supportData.project}
placeholder="Select project" />
<InputSelect
id="severity"
label="Severity"
options={severityOptions}
bind:value={$supportData.severity}
required
placeholder="Select severity">
<div slot="info">
{@render severityPopover()}
</div>
</InputSelect>
<InputText
id="subject"
label="Subject"
@@ -143,15 +262,16 @@
bind:value={$supportData.message}
placeholder="Type here..."
label="Tell us a bit more"
required
maxlength={4096} />
<Layout.Stack direction="row" justifyContent="flex-end" gap="s">
<Button.Button
<Button
size="s"
variant="secondary"
secondary
on:click={() => {
wizard.hide();
}}>Cancel</Button.Button>
<Button.Button size="s">Submit</Button.Button>
}}>Cancel</Button>
<Button submit size="s">Submit</Button>
</Layout.Stack>
</Layout.Stack>
</Form>
+4 -1
View File
@@ -4,6 +4,8 @@ export type SupportData = {
message: string;
subject: string;
category: string;
topic?: string;
severity?: string;
file?: File | null;
project?: string;
};
@@ -11,7 +13,8 @@ export type SupportData = {
export const supportData = writable<SupportData>({
message: '',
subject: '',
category: 'general',
category: 'technical',
severity: 'question',
file: null
});