Merge remote-tracking branch 'origin/main' into feat-SER-191-Implement-Email-OTP-Flow

This commit is contained in:
Harsh Mahajan
2025-10-10 12:31:35 +05:30
100 changed files with 2113 additions and 684 deletions
+5 -1
View File
@@ -41,6 +41,7 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY }}"
@@ -82,6 +83,7 @@ jobs:
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_APPWRITE_MULTI_REGION=true"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
@@ -120,7 +122,8 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=self-hosted"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=false"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=true"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_GROWTH_ENDPOINT=${{ secrets.PUBLIC_GROWTH_ENDPOINT }}"
@@ -159,6 +162,7 @@ jobs:
build-args: |
"PUBLIC_CONSOLE_MODE=cloud"
"PUBLIC_APPWRITE_MULTI_REGION=false"
"PUBLIC_CONSOLE_EMAIL_VERIFICATION=true"
"PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=false"
"PUBLIC_CONSOLE_FEATURE_FLAGS="
"PUBLIC_STRIPE_KEY=${{ secrets.PUBLIC_STRIPE_KEY_STAGE }}"
+2
View File
@@ -23,6 +23,7 @@ ADD ./static /app/static
ARG PUBLIC_CONSOLE_MODE
ARG PUBLIC_CONSOLE_FEATURE_FLAGS
ARG PUBLIC_APPWRITE_MULTI_REGION
ARG PUBLIC_CONSOLE_EMAIL_VERIFICATION
ARG PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
ARG PUBLIC_APPWRITE_ENDPOINT
ARG PUBLIC_GROWTH_ENDPOINT
@@ -35,6 +36,7 @@ ENV PUBLIC_GROWTH_ENDPOINT=$PUBLIC_GROWTH_ENDPOINT
ENV PUBLIC_CONSOLE_MODE=$PUBLIC_CONSOLE_MODE
ENV PUBLIC_CONSOLE_FEATURE_FLAGS=$PUBLIC_CONSOLE_FEATURE_FLAGS
ENV PUBLIC_APPWRITE_MULTI_REGION=$PUBLIC_APPWRITE_MULTI_REGION
ENV PUBLIC_CONSOLE_EMAIL_VERIFICATION=$PUBLIC_CONSOLE_EMAIL_VERIFICATION
ENV PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS=$PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS
ENV PUBLIC_STRIPE_KEY=$PUBLIC_STRIPE_KEY
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
+2
View File
@@ -28,6 +28,8 @@ async function main() {
logEnv('MULTI REGION', env?.PUBLIC_APPWRITE_MULTI_REGION);
logEnv('APPWRITE ENDPOINT', env?.PUBLIC_APPWRITE_ENDPOINT, 'relative');
logEnv('GROWTH ENDPOINT', env?.PUBLIC_GROWTH_ENDPOINT);
logEnv('CONSOLE EMAIL VERIFICATION', env?.PUBLIC_CONSOLE_EMAIL_VERIFICATION);
logEnv('CONSOLE MOCK AI SUGGESTIONS', env?.PUBLIC_CONSOLE_MOCK_AI_SUGGESTIONS);
log();
logDelimiter();
await build();
+3 -3
View File
@@ -22,11 +22,11 @@
},
"dependencies": {
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
+15 -15
View File
@@ -12,20 +12,20 @@ importers:
specifier: ^1.1.24
version: 1.1.24(svelte@5.25.3)(zod@3.24.3)
'@appwrite.io/console':
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74
specifier: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39
version: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39
'@appwrite.io/pink-icons':
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877(svelte@5.25.3)
'@appwrite.io/pink-legacy':
specifier: ^1.0.3
version: 1.0.3
'@appwrite.io/pink-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877(svelte@5.25.3)
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
@@ -260,8 +260,8 @@ packages:
'@analytics/type-utils@0.6.2':
resolution: {integrity: sha512-TD+xbmsBLyYy/IxFimW/YL/9L2IEnM7/EoV9Aeh56U64Ify8o27HJcKjo38XY9Tcn0uOq1AX3thkKgvtWvwFQg==}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74':
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39':
resolution: {tarball: https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39}
version: 1.10.0
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1':
@@ -269,8 +269,8 @@ packages:
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c}
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877}
version: 2.0.0-RC.1
peerDependencies:
svelte: ^4.0.0
@@ -284,8 +284,8 @@ packages:
'@appwrite.io/pink-legacy@1.0.3':
resolution: {integrity: sha512-GGde5fmPhs+s6/3aFeMPc/kKADG/gTFkYQSy6oBN8pK0y0XNCLrZZgBv+EBbdhwdtqVEWXa0X85Mv9w7jcIlwQ==}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877}
version: 2.0.0-RC.2
peerDependencies:
svelte: ^4.0.0
@@ -3703,13 +3703,13 @@ snapshots:
'@analytics/type-utils@0.6.2': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@f08cb74': {}
'@appwrite.io/console@https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@636ed39': {}
'@appwrite.io/pink-icons-svelte@2.0.0-RC.1(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@077179c(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@8f82877(svelte@5.25.3)':
dependencies:
svelte: 5.25.3
@@ -3722,7 +3722,7 @@ snapshots:
'@appwrite.io/pink-icons': 1.0.0
the-new-css-reset: 1.11.3
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@077179c(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@8f82877(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
+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%
@@ -1,79 +1,194 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { invalidate, goto } from '$app/navigation';
import { Modal } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { user } from '$lib/stores/user';
import { get } from 'svelte/store';
import { page } from '$app/state';
import Link from '$lib/elements/link.svelte';
import { Card, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Dependencies } from '$lib/constants';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import { slide } from 'svelte/transition';
let { show = $bindable(false) } = $props();
let {
show = $bindable(false),
email
}: {
show?: boolean;
email?: string;
} = $props();
let error = $state(null);
let creating = $state(false);
let emailSent = $state(false);
let resendTimer = $state(0);
let timerInterval: ReturnType<typeof setInterval> | null = null;
let cleanUrl = $derived(page.url.origin + page.url.pathname);
async function logout() {
error = null;
try {
await sdk.forConsole.account.deleteSession({ sessionId: 'current' });
await invalidate(Dependencies.ACCOUNT);
await goto(resolve('/login'));
} catch (err) {
error = err.message;
}
}
const cleanUrl = $derived(page.url.origin + page.url.pathname);
// manage resend timer in localStorage
const EMAIL_SENT_KEY = 'email_verification_sent';
const TIMER_END_KEY = 'email_verification_timer_end';
function startResendTimer() {
resendTimer = 60;
emailSent = true;
const timerEndTime = Date.now() + 60 * 1000;
if (browser) {
localStorage.setItem(EMAIL_SENT_KEY, 'true');
localStorage.setItem(TIMER_END_KEY, timerEndTime.toString());
}
startTimerCountdown(timerEndTime);
}
function restoreTimerState() {
if (!browser) return;
const savedTimerEnd = localStorage.getItem(TIMER_END_KEY);
const savedEmailSent = localStorage.getItem(EMAIL_SENT_KEY);
if (savedTimerEnd && savedEmailSent) {
const timerEndTime = parseInt(savedTimerEnd);
const now = Date.now();
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000));
if (remainingTime > 0) {
resendTimer = remainingTime;
emailSent = true;
startTimerCountdown(timerEndTime);
} else {
// timer has expired, clean up
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
resendTimer = 0;
emailSent = false;
}
}
}
function startTimerCountdown(timerEndTime: number) {
timerInterval = setInterval(() => {
const now = Date.now();
const remainingTime = Math.max(0, Math.ceil((timerEndTime - now) / 1000));
resendTimer = remainingTime;
if (remainingTime <= 0) {
clearInterval(timerInterval);
timerInterval = null;
if (browser) {
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
}
}
}, 1000);
}
async function onSubmit() {
if (creating) return;
if (creating || resendTimer > 0) return;
error = null;
creating = true;
try {
await sdk.forConsole.account.createVerification({ url: cleanUrl });
addNotification({ message: 'Verification email has been sent', type: 'success' });
emailSent = true;
show = false;
} catch (error) {
addNotification({ message: error.message, type: 'error' });
startResendTimer();
} catch (err) {
error = err.message;
} finally {
creating = false;
}
}
async function updateEmailVerification() {
const searchParams = page.url.searchParams;
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
onMount(restoreTimerState);
if (userId && secret) {
try {
await sdk.forConsole.account.updateVerification({ userId, secret });
addNotification({
message: 'Email verified successfully',
type: 'success'
});
await Promise.all([
invalidate(Dependencies.ACCOUNT),
invalidate(Dependencies.FACTORS)
]);
} catch (error) {
addNotification({
message: error.message,
type: 'error'
});
}
onDestroy(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
}
onMount(() => {
updateEmailVerification();
if (browser) {
localStorage.removeItem(TIMER_END_KEY);
localStorage.removeItem(EMAIL_SENT_KEY);
}
});
</script>
<Modal bind:show title="Send verification email" {onSubmit}>
<Card.Base variant="secondary" padding="s">
<Layout.Stack gap="m">
<Typography.Text gap="m">
To continue using Appwrite Cloud, please verify your email address. An email will be
sent to <Typography.Text variant="m-600" style="display: inline;"
>{get(user)?.email}</Typography.Text>
</Typography.Text>
</Layout.Stack>
</Card.Base>
<div class="email-verification-scrim">
<Modal
bind:show
bind:error
title="Verify your email address"
{onSubmit}
dismissible={false}
autoClose={false}>
<Card.Base variant="secondary" padding="s">
<Layout.Stack gap="xxs">
<Typography.Text gap="m">
To continue using Appwrite Cloud, please verify your email address. An email
will be sent to <Typography.Text
variant="m-600"
color="neutral-secondary"
style="display: inline;">{email || get(user)?.email}</Typography.Text>
</Typography.Text>
<svelte:fragment slot="footer">
<Button submit disabled={creating}>{emailSent ? 'Resend email' : 'Send email'}</Button>
</svelte:fragment>
</Modal>
<Link variant="default" on:click={() => logout()}>Switch account</Link>
{#if emailSent && resendTimer > 0}
<div transition:slide={{ duration: 150 }}>
<Typography.Text
color="neutral-secondary"
style="margin-block-start: var(--gap-L, 16px);">
Didn't get the email? Try again in {resendTimer}s
</Typography.Text>
</div>
{/if}
</Layout.Stack>
</Card.Base>
<svelte:fragment slot="footer">
<Button
submit
submissionLoader
forceShowLoader={creating}
disabled={creating || resendTimer > 0}>
{emailSent ? 'Resend email' : 'Send email'}
</Button>
</svelte:fragment>
</Modal>
</div>
<style>
.email-verification-scrim {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: hsl(240 5% 8% / 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
}
/* avoids the background scroll and bars */
:global(html:has(.email-verification-scrim)) {
height: 100%;
overflow: hidden !important;
}
</style>
@@ -1,42 +0,0 @@
<script lang="ts">
import { Button } from '$lib/elements/forms';
import { HeaderAlert } from '$lib/layout';
import { Typography } from '@appwrite.io/pink-svelte';
import { user } from '$lib/stores/user';
import SendVerificationEmailModal from '../account/sendVerificationEmailModal.svelte';
import { page } from '$app/stores';
import { wizard, isNewWizardStatusOpen } from '$lib/stores/wizard';
import { isCloud, VARS } from '$lib/system';
const hasUser = $derived(!!$user);
const needsEmailVerification = $derived(hasUser && !$user.emailVerification);
const notOnOnboarding = $derived(!$page.route.id.includes('/onboarding'));
const notOnWizard = $derived(!$wizard.show && !$isNewWizardStatusOpen);
const isEnabledViaEnvConfig = $derived(VARS.EMAIL_VERIFICATION);
const shouldShowEmailBanner = $derived(
isEnabledViaEnvConfig &&
isCloud &&
hasUser &&
needsEmailVerification &&
notOnOnboarding &&
notOnWizard
);
let showSendVerification = $state(false);
</script>
{#if shouldShowEmailBanner}
<HeaderAlert type="warning" title="Your email address needs to be verified">
<svelte:fragment>
To avoid losing access to your projects, make sure <Typography.Text
variant="m-500"
style="display:inline">{$user.email}</Typography.Text> is valid and up to date. Email
verification will be required soon.
</svelte:fragment>
<svelte:fragment slot="buttons">
<Button secondary size="s" on:click={() => (showSendVerification = true)}
>Verify email</Button>
</svelte:fragment>
</HeaderAlert>
<SendVerificationEmailModal bind:show={showSendVerification} />
{/if}
+3 -1
View File
@@ -27,10 +27,12 @@
id = null;
}
if (!id?.length) {
if (id !== null && !id.length) {
id = null;
}
});
$effect(() => {
if (show) {
trackEvent(Click.ShowCustomIdClick);
}
@@ -13,6 +13,7 @@
import { Click, trackEvent } from '$lib/actions/analytics';
import RepositoryBehaviour from '$lib/components/git/repositoryBehaviour.svelte';
import { page } from '$app/state';
import { connectGitHub } from '$lib/stores/git';
let {
show = $bindable(false),
@@ -121,7 +122,7 @@
<svelte:fragment slot="footer">
{#if repositoryBehaviour === 'existing'}
<Layout.Stack>
<Link variant="quiet" href="#/">
<Link variant="quiet" href={connectGitHub(callbackState).toString()}>
<Layout.Stack direction="row" gap="xs">
Missing a repository? check your permissions <Icon
icon={IconArrowSmRight} />
+1 -1
View File
@@ -85,5 +85,5 @@ export { default as ViewToggle } from './viewToggle.svelte';
export { default as RegionEndpoint } from './regionEndpoint.svelte';
export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as EmailVerificationBanner } from './alerts/emailVerificationBanner.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
+18 -17
View File
@@ -4,7 +4,7 @@
export let id: string;
export let label: string = '';
export let value: string;
export let value: string | null;
export let required = false;
export let nullable = false;
export let disabled = false;
@@ -17,6 +17,7 @@
let error: string;
let element: HTMLInputElement;
let previousValue: string | null = null;
onMount(() => {
if (element && autofocus) {
@@ -24,14 +25,16 @@
}
});
let prevValue = '';
function handleNullChange(e: CustomEvent<boolean>) {
const isNull = e.detail;
if (isNull) {
prevValue = value;
if (value !== null) {
previousValue = value;
}
value = null;
} else {
value = prevValue;
value = previousValue;
}
}
@@ -39,9 +42,7 @@
error = null;
}
function onChange(event: CustomEvent) {
value = (event.target as HTMLInputElement).value;
}
$: isValueNull = value === null;
</script>
<Layout.Stack gap="s" direction="row">
@@ -51,20 +52,20 @@
{disabled}
{readonly}
{required}
{value}
bind:value
{step}
{type}
helper={error}
{leadingIcon}
on:change={onChange}
autocomplete={autocomplete ? 'on' : 'off'}>
{#if nullable}
<Selector.Checkbox
size="s"
slot="end"
label="NULL"
checked={value === null}
on:change={handleNullChange} />
{/if}
<svelte:fragment slot="end">
{#if nullable}
<Selector.Checkbox
size="s"
label="NULL"
checked={isValueNull}
on:change={handleNullChange} />
{/if}
</svelte:fragment>
</Input.DateTime>
</Layout.Stack>
+1 -5
View File
@@ -12,11 +12,7 @@
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
event.preventDefault();
if (event.currentTarget.validity.patternMismatch) {
error = true;
return;
}
error = true; // show on any error
};
$: if (value) {
+1 -5
View File
@@ -12,11 +12,7 @@
const handleInvalid = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
event.preventDefault();
if (event.currentTarget.validity.patternMismatch) {
error = true;
return;
}
error = true; // show on any error
};
$: if (value) {
+10 -4
View File
@@ -198,15 +198,21 @@ function generateSingleValue(
case 'integer': {
const intAttr = column as Models.ColumnInteger;
const min = !isWithinSafeRange(intAttr.min) ? 0 : intAttr.min;
const max = !isWithinSafeRange(intAttr.max) ? 100 : intAttr.max;
const min = isWithinSafeRange(intAttr.min) ? intAttr.min : 0;
const fallbackMax = Math.max(min + 100, 100);
const max = isWithinSafeRange(intAttr.max)
? intAttr.max
: Math.min(fallbackMax, Number.MAX_SAFE_INTEGER);
return faker.number.int({ min, max });
}
case 'double': {
const floatAttr = column as Models.ColumnFloat;
const min = !isWithinSafeRange(floatAttr.min) ? 0 : floatAttr.min;
const max = !isWithinSafeRange(floatAttr.max) ? 100 : floatAttr.max;
const min = isWithinSafeRange(floatAttr.min) ? floatAttr.min : 0;
const fallbackMax = Math.max(min + 100, 100);
const max = isWithinSafeRange(floatAttr.max)
? floatAttr.max
: Math.min(fallbackMax, Number.MAX_SAFE_INTEGER);
const precision = 4;
return faker.number.float({ min, max, fractionDigits: precision });
+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.

Before

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+5
View File
@@ -181,6 +181,11 @@
&.hide {
display: none;
}
& :global(i) {
// because the `IconCloud` shows above floating action bars.
position: unset;
}
}
:global(main:has(.sub-navigation)) footer {
+4 -1
View File
@@ -11,13 +11,16 @@
{#each $notifications as notification (notification.id)}
<span animate:flip={{ duration: 500 }} transition:fly|global={{ x: 50 }}>
<Toast
isHtml={notification.isHtml}
title={notification.title}
status={notification.type}
icon={notification.icon}
description={notification.message}
actions={notification.buttons?.map((button) => {
return {
label: button.name,
onClick: button.method
onClick: button.method,
isHtml: button.isHtml
};
})}
on:dismiss={() => dismissNotification(notification.id)} />
-8
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { navigationCancelled } from '$lib/stores/navigation';
import { afterNavigate, beforeNavigate } from '$app/navigation';
const minimum = 0.08;
@@ -98,13 +97,6 @@
complete();
});
navigationCancelled.subscribe((cancelled) => {
if (cancelled) {
complete();
navigationCancelled.set(false);
}
});
$: barStyle = (width && width * 100 && `width: ${width * 100}%;`) || '';
</script>
+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>
-3
View File
@@ -1,3 +0,0 @@
import { writable } from 'svelte/store';
export const navigationCancelled = writable(false);
+31 -10
View File
@@ -1,13 +1,14 @@
import { writable } from 'svelte/store';
import type { ComponentType } from 'svelte';
export type Notification = {
id: number;
type: 'success' | 'error' | 'info' | 'warning';
type?: 'success' | 'error' | 'info' | 'warning';
dismissible?: boolean;
timeout?: number;
message: string;
title?: string;
icon?: string;
icon?: ComponentType;
buttons?: Buttons[];
isHtml?: boolean;
};
@@ -15,32 +16,52 @@ export type Notification = {
export type Buttons = {
method: () => void | Promise<void>;
name: string;
isHtml?: boolean;
};
let counter = 0;
const activeTimeouts = new Map<number, NodeJS.Timeout>();
export const notifications = writable<Notification[]>([]);
export const dismissNotification = (id: number) => {
const timeoutId = activeTimeouts.get(id);
if (timeoutId) {
clearTimeout(timeoutId);
activeTimeouts.delete(id);
}
notifications.update((all) => all.filter((t) => t.id !== id));
};
export const dismissAllNotifications = () => {
activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
activeTimeouts.clear();
notifications.set([]);
};
export const addNotification = (notification: Omit<Notification, 'id'>) => {
const defaults = {
export const addNotification = (notification: Omit<Notification, 'id'>): number => {
const defaults: Notification = {
id: counter++,
type: 'info',
dismissible: true,
timeout: 6000
timeout: 6000,
...notification
};
const n = { ...defaults, ...notification };
notifications.update((all) => {
return [n, ...all.slice(0, 4)];
});
notifications.update((all) => [defaults, ...all.slice(0, 4)]);
if (n.timeout) setTimeout(() => dismissNotification(n.id), n.timeout);
if (defaults.timeout) {
const timeoutId = setTimeout(() => {
activeTimeouts.delete(defaults.id);
dismissNotification(defaults.id);
}, defaults.timeout);
activeTimeouts.set(defaults.id, timeoutId);
}
return defaults.id;
};
+2
View File
@@ -224,6 +224,8 @@ function createPreferences() {
delete teamPreferences?.displayNames?.[tableId];
delete teamPreferences?.columnOrder?.[tableId];
delete teamPreferences?.columnWidths?.[tableId];
delete teamPreferences?.columnWidths?.[tableId + '#columns'];
delete teamPreferences?.columnWidths?.[tableId + '#indexes'];
const removeTablePreferences = sdk.forConsole.teams.updatePrefs({
teamId: orgId,
+1 -3
View File
@@ -45,7 +45,7 @@
import { headerAlert } from '$lib/stores/headerAlert';
import { UsageRates } from '$lib/components/billing';
import { canSeeProjects } from '$lib/stores/roles';
import { BottomModalAlert, EmailVerificationBanner } from '$lib/components';
import { BottomModalAlert } from '$lib/components';
import {
IconAnnotation,
IconBookOpen,
@@ -346,8 +346,6 @@
<Footer slot="footer" />
</Shell>
<EmailVerificationBanner />
{#if $wizard.show && $wizard.component}
<svelte:component this={$wizard.component} {...$wizard.props} />
{:else if $wizard.cover}
+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 TransactionsApiDark from '$lib/images/promos/transactions-api-dark.png';
import TransactionsApiLight from '$lib/images/promos/transactions-api-light.png';
const listOfPromotions: BottomModalAlertItem[] = [];
if (isCloud) {
const spatialColumnsPromo: BottomModalAlertItem = {
id: 'modal:spatial_columns_announcement',
const transactionsApiPromo: BottomModalAlertItem = {
id: 'modal:transactions_api_announcement',
src: {
dark: SpatialColumnsDark,
light: SpatialColumnsLight
dark: TransactionsApiDark,
light: TransactionsApiLight
},
title: 'Announcing API for spatial columns',
message: 'Store and query geo data directly in your database.',
title: 'Announcing Transactions API',
message: 'Ensure data consistency across tables with atomic, all-or-nothing commits.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-spatial-columns',
link: () => 'https://appwrite.io/blog/post/announcing-transactions-api',
external: true,
hideOnClick: true
},
show: true
};
const inversionQueriesPromo: BottomModalAlertItem = {
id: 'modal:inversion_queries_announcement',
src: {
dark: InversionQueriesDark,
light: InversionQueriesLight
},
title: 'Announcing inversion queries',
message: 'New NOT operators to exclude data directly in queries.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-inversion-queries',
external: true,
hideOnClick: true
},
show: true
};
const timeHelperQueriesPromo: BottomModalAlertItem = {
id: 'modal:time_helper_queries_announcement',
src: {
dark: TimeHelperQueriesDark,
light: TimeHelperQueriesLight
},
title: 'Announcing Time helper queries',
message: 'New before/after filters for simpler time-based queries.',
plan: 'free',
importance: 8,
scope: 'project',
cta: {
text: 'Read announcement',
link: () => 'https://appwrite.io/blog/post/announcing-time-helper-queries',
external: true,
hideOnClick: true
},
show: true
};
listOfPromotions.push(spatialColumnsPromo, inversionQueriesPromo, timeHelperQueriesPromo);
listOfPromotions.push(transactionsApiPromo);
}
export function addBottomModalAlerts() {
@@ -3,7 +3,7 @@
import { onMount } from 'svelte';
import { CardGrid, PaginationInline } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { formatCurrency } from '$lib/helpers/numbers';
import type { Invoice, InvoiceList } from '$lib/sdk/billing';
import { getApiEndpoint, sdk } from '$lib/stores/sdk';
@@ -112,8 +112,9 @@
{#each invoiceList?.invoices as invoice (invoice.$id)}
{@const status = invoice.status}
<Table.Row.Base {root}>
<Table.Cell column="dueDate" {root}
>{toLocaleDate(invoice.dueAt)}</Table.Cell>
<Table.Cell column="dueDate" {root}>
<DualTimeView time={invoice.dueAt} />
</Table.Cell>
<Table.Cell column="status" {root}>
{@const isDanger =
status === 'overdue' ||
@@ -4,7 +4,7 @@
import { EmptySearch, PaginationWithLimit, ViewSelector } from '$lib/components/index.js';
import { Button } from '$lib/elements/forms';
import Link from '$lib/elements/link.svelte';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import Container from '$lib/layout/container.svelte';
import { protocol } from '$routes/(console)/store.js';
import {
@@ -103,9 +103,13 @@
{:else if column.id === 'nameservers'}
{domain.nameservers || '-'}
{:else if column.id === 'expiry_date'}
{domain?.expire ? toLocaleDateTime(domain.expire) : '-'}
{#if domain?.expire}
<DualTimeView time={domain.expire} />
{:else}-{/if}
{:else if column.id === 'renewal'}
{domain?.renewal ? toLocaleDateTime(domain.renewal) : '-'}
{#if domain?.renewal}
<DualTimeView time={domain.renewal} />
{:else}-{/if}
{:else if column.id === 'auto_renewal'}
{domain?.autoRenewal ? 'On' : 'Off'}
{/if}
@@ -15,7 +15,7 @@
SearchQuery
} from '$lib/components';
import { Button } from '$lib/elements/forms';
import { toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Container } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
@@ -196,9 +196,13 @@
{user.labels.join(', ')}
</Typography.Text>
{:else if id === 'joined'}
{toLocaleDateTime(user.registration)}
<DualTimeView time={user.registration} />
{:else if id === 'lastActivity'}
{user.accessedAt ? toLocaleDate(user.accessedAt) : 'never'}
{#if user.accessedAt}
<DualTimeView time={user.accessedAt} />
{:else}
never
{/if}
{:else}
{user[id]}
{/if}
@@ -6,7 +6,7 @@
import type { Models } from '@appwrite.io/console';
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { PageData } from './$types';
import CreateMember from '../createMembership.svelte';
import DeleteMembership from '../deleteMembership.svelte';
@@ -117,7 +117,7 @@
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
{toLocaleDateTime(membership.joined)}
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
@@ -54,7 +54,7 @@
</p>
{#if error}
<Alert.Inline status="warning">{error}</Alert.Inline>
<Alert.Inline status="warning" title={error} />
{/if}
</Layout.Stack>
@@ -2,7 +2,7 @@
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import type { PageData } from './$types';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
@@ -87,7 +87,7 @@
{#if !identity[column.id]}
-
{:else}
{toLocaleDateTime(identity[column.id])}
<DualTimeView time={identity[column.id]} />
{/if}
{:else}
{identity[column.id]}
@@ -7,7 +7,7 @@
import DeleteMembership from '../deleteMembership.svelte';
import type { Models } from '@appwrite.io/console';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
Table,
Layout,
@@ -108,7 +108,7 @@
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
{toLocaleDateTime(membership.joined)}
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
@@ -4,7 +4,7 @@
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal, CustomId } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Badge, Icon } from '@appwrite.io/pink-svelte';
import { Tag, Icon } from '@appwrite.io/pink-svelte';
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
import { Button, InputText, InputSelect, InputPhone } from '$lib/elements/forms';
import InputEmail from '$lib/elements/forms/inputEmail.svelte';
@@ -18,7 +18,7 @@
let identifier = '';
let name = '';
let providerId = '';
let id: string = null;
let id: string | null = null;
let showCustomId = false;
const providerTypeOptions = [
@@ -115,12 +115,10 @@
{#if !showCustomId}
<div>
<Badge
variant="secondary"
content="Target ID"
on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" slot="start" />
</Badge>
<Tag size="s" on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" />
Target ID
</Tag>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Target" bind:id autofocus={false} />
@@ -3,7 +3,7 @@
import { Button } from '$lib/elements/forms';
import type { PageData } from './$types';
import { columns } from './store';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import ProviderType from '$routes/(console)/project-[region]-[project]/messaging/providerType.svelte';
import Provider from '$routes/(console)/project-[region]-[project]/messaging/provider.svelte';
import { sdk } from '$lib/stores/sdk';
@@ -83,7 +83,7 @@
<Provider provider={provider.provider} />
{/if}
{:else if column.id === '$createdAt'}
{toLocaleDateTime(target[column.id])}
<DualTimeView time={target[column.id]} />
{:else}
{target[column.id]}
{/if}
@@ -31,7 +31,10 @@
</script>
<svelte:head>
<title>Databases - Appwrite</title>
<!-- svelte bug, the table header just stays! -->
{#key page.url.pathname}
<title>Databases - Appwrite</title>
{/key}
</svelte:head>
<slot />
@@ -10,7 +10,7 @@
FloatingActionBar,
Popover
} from '@appwrite.io/pink-svelte';
import { IconCalendar, IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { IconFingerPrint, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport, isTabletViewport } from '$lib/stores/viewport';
import type { Column } from '$lib/helpers/types';
import { expandTabs } from '../table-[table]/store';
@@ -25,23 +25,21 @@
tableColumnSuggestions,
basicColumnOptions,
mockSuggestions,
createTableRequest
showIndexesSuggestions
} from './store';
import { addNotification } from '$lib/stores/notifications';
import { addNotification, dismissNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { sleep } from '$lib/helpers/promises';
import { invalidate, beforeNavigate, goto } from '$app/navigation';
import { showCreateTable } from '../store';
import { showSubNavigation } from '$lib/stores/layout';
import { navigationCancelled } from '$lib/stores/navigation';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { isWithinSafeRange } from '$lib/helpers/numbers';
import type { Columns } from '../table-[table]/store';
import { columnOptions } from '../table-[table]/columns/store';
import Options from './options.svelte';
import { InputSelect, InputText } from '$lib/elements/forms';
import { Confirm } from '$lib/components';
import { VARS } from '$lib/system';
import { isCloud, VARS } from '$lib/system';
import IconAINotification from './icon/aiNotification.svelte';
let resizeObserver: ResizeObserver;
let spreadsheetContainer: HTMLElement;
@@ -51,18 +49,19 @@
let rangeOverlayEl: HTMLDivElement | null = null;
let fadeBottomOverlayEl: HTMLDivElement | null = null;
let customColumns = $state([]);
let customColumns = $state<
(SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean })[]
>(Array.from({ length: 7 }, (_, index) => createPlaceholderColumn(index)));
let showFloatingBar = $state(true);
let hasTransitioned = $state(false);
let scrollAnimationFrame: number | null = null;
let confirmDismiss = $state(false);
let confirmNavigation = $state(false);
let pendingNavigationUrl: string | null = null;
let creatingColumns = $state(false);
const baseColProps = { draggable: false, resizable: false };
const NOTIFICATION_AND_MOCK_DELAY = 1250;
const getColumnWidth = (columnKey: string) => Math.max(180, columnKey.length * 8 + 60);
const safeNumericValue = (value: number | undefined) =>
value !== undefined && isWithinSafeRange(value) ? value : undefined;
@@ -128,12 +127,6 @@
rangeOverlayEl.style.display = 'block';
}
if (customColumns.length === 0) {
spreadsheetContainer.style.setProperty('--group-left', '40px');
spreadsheetContainer.style.setProperty('--group-width', '100%');
return;
}
// Custom columns mode: calculate precise overlay bounds
const containerRect = spreadsheetContainer.getBoundingClientRect();
const getById = (id: string) =>
@@ -141,6 +134,31 @@
`[role="cell"][data-header="true"][data-column-id="${id}"]`
);
const hasRealColumns = customColumns.some((col) => !col.isPlaceholder);
if (!hasRealColumns) {
// For placeholders or no columns, position overlay to cover custom columns area
const idCell = getById('$id');
const actionsCell = headerElement!.querySelector<HTMLElement>(
'[role="cell"][data-column-id="actions"]'
);
if (idCell && actionsCell) {
const idRect = idCell.getBoundingClientRect();
const actionsRect = actionsCell.getBoundingClientRect();
const left = Math.round(idRect.right - containerRect.left);
const actionsLeft = actionsRect.left - containerRect.left;
const width = actionsLeft - left;
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
spreadsheetContainer.style.setProperty('--group-width', `${width + 2}px`);
} else {
spreadsheetContainer.style.setProperty('--group-left', '40px');
spreadsheetContainer.style.setProperty('--group-width', 'calc(100% - 80px)');
}
return;
}
// Calculate visible viewport bounds
const scrollerRect = hScroller ? hScroller.getBoundingClientRect() : containerRect;
const visibleRight = scrollerRect.right;
@@ -190,27 +208,22 @@
const left = Math.round(startLeft - containerRect.left);
// maximum possible width of all custom columns
const totalFullWidth = customColumns.reduce(
(total, col) => total + getColumnWidth(col.key),
0
);
// get the actions column and use its left border as the boundary
const actionsCell = headerElement!.querySelector<HTMLElement>(
'[role="cell"][data-column-id="actions"]'
);
const rawVisibleWidth = Math.round(visibleRight - idRect.right);
let maxAllowedWidth = rawVisibleWidth;
if (actionsCell) {
const actionsRect = actionsCell.getBoundingClientRect();
const actionsLeft = actionsRect.left - containerRect.left;
maxAllowedWidth = Math.min(rawVisibleWidth, actionsLeft - left);
if (!actionsCell) {
if (rangeOverlayEl) {
rangeOverlayEl.style.display = 'none';
}
return;
}
// Set overlay width to not exceed actions column boundary
const width = Math.min(totalFullWidth, maxAllowedWidth);
const actionsRect = actionsCell.getBoundingClientRect();
const actionsLeft = actionsRect.left - containerRect.left;
const width = actionsLeft - left;
// Apply overlay positioning
spreadsheetContainer.style.setProperty('--group-left', `${left - 2}px`);
@@ -219,7 +232,7 @@
// only for mobile, we can remove if not needed!
const scrollToFirstCustomColumn = () => {
if (!$isSmallViewport || customColumns.length === 0) return;
if (!$isSmallViewport) return;
if (!headerElement || !headerElement.isConnected) {
headerElement = spreadsheetContainer.querySelector('[role="rowheader"]');
@@ -231,12 +244,19 @@
`[role="cell"][data-header="true"][data-column-id="${customColumns[0]?.key}"]`
);
if (firstCustomColumnCell && hScroller) {
const cellRect = firstCustomColumnCell.getBoundingClientRect();
const scrollerRect = hScroller.getBoundingClientRect();
const scrollLeft = hScroller.scrollLeft + cellRect.left - scrollerRect.left - 40;
const directAccessScroller =
hScroller ??
findHorizontalScroller(headerElement) ??
// internal spreadsheet root main container!
spreadsheetContainer.querySelector('.spreadsheet-container');
hScroller.scrollTo({
if (firstCustomColumnCell && directAccessScroller) {
const cellRect = firstCustomColumnCell.getBoundingClientRect();
const scrollerRect = directAccessScroller.getBoundingClientRect();
const scrollLeft =
directAccessScroller.scrollLeft + cellRect.left - scrollerRect.left - 40;
directAccessScroller.scrollTo({
left: Math.max(0, scrollLeft),
behavior: 'smooth'
});
@@ -260,25 +280,6 @@
});
};
// Handle create table requests from subNavigation
const unsubscribeCreateTable = createTableRequest.subscribe((requested) => {
if (requested) {
if (customColumns.length > 0 && !creatingColumns) {
confirmNavigation = true;
pendingNavigationUrl = 'create-table';
} else {
executeCreateTable();
}
createTableRequest.set(false);
}
});
function executeCreateTable() {
$showCreateTable = true;
$showSubNavigation = false;
}
const customSuggestedColumns = $derived.by(() => {
return customColumns.map((col: SuggestedColumnSchema) => {
const columnOption = getColumnOption(col.type, col.format);
@@ -301,54 +302,54 @@
});
});
const getRowColumns = (): Column[] => [
{
id: '$id',
title: '$id',
type: 'string',
width: 180,
icon: IconFingerPrint,
...baseColProps
},
...customSuggestedColumns,
...(customColumns.length === 0
? [
{
id: '$createdAt',
title: '$createdAt',
type: 'datetime' as Column['type'],
width: 180,
icon: IconCalendar,
...baseColProps
},
{
id: '$updatedAt',
title: '$updatedAt',
type: 'datetime' as Column['type'],
width: 180,
icon: IconCalendar,
...baseColProps
}
]
: []),
{
id: 'actions',
title: '',
type: 'string' as Column['type'],
width: 40,
isAction: true,
...baseColProps
}
];
const getRowColumns = (): Column[] => {
const minColumnWidth = 180;
const fixedWidths = { id: minColumnWidth, actions: 40, selection: 40 };
// Handle browser back/forward navigation
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (customColumns.length > 0 && !creatingColumns) {
event.preventDefault();
event.returnValue =
'You have unsaved column suggestions. Are you sure you want to leave?';
return event.returnValue;
}
// calculate base widths and total
const columnsWithBase = customSuggestedColumns.map((col) => ({
...col,
baseWidth: Math.max(minColumnWidth, getColumnWidth(col.id))
}));
const totalUsed =
fixedWidths.id +
fixedWidths.actions +
fixedWidths.selection +
columnsWithBase.reduce((sum, col) => sum + col.baseWidth, 0);
// distribute excess space equally across custom columns
const viewportWidth =
spreadsheetContainer?.clientWidth ||
(typeof window !== 'undefined' ? window.innerWidth : totalUsed);
const extraPerColumn =
Math.max(0, viewportWidth - totalUsed) / (columnsWithBase.length || 1);
const finalCustomColumns = columnsWithBase.map((col) => ({
...col,
width: { min: col.baseWidth + extraPerColumn }
}));
return [
{
id: '$id',
title: '$id',
type: 'string',
width: fixedWidths.id,
icon: IconFingerPrint,
...baseColProps
},
...finalCustomColumns,
{
id: 'actions',
title: '',
type: 'string' as Column['type'],
width: fixedWidths.actions,
isAction: true,
...baseColProps
}
];
};
const spreadsheetColumns = $derived(getRowColumns());
@@ -364,15 +365,6 @@
await suggestColumns();
});
beforeNavigate(({ cancel, to }) => {
if (customColumns.length > 0 && !creatingColumns) {
cancel();
confirmNavigation = true;
$navigationCancelled = true;
pendingNavigationUrl = to?.url?.pathname || null;
}
});
function resetSuggestionsStore(fullReset: boolean = true) {
if ($tableColumnSuggestions.table?.id !== page.params.table) {
return;
@@ -391,6 +383,12 @@
async function suggestColumns() {
$tableColumnSuggestions.thinking = true;
if ($isSmallViewport) {
await tick();
scrollToFirstCustomColumn();
}
let suggestedColumns: {
total: number;
columns: ColumnInput[];
@@ -402,7 +400,7 @@
try {
if (VARS.MOCK_AI_SUGGESTIONS) {
/* animation */
await sleep(1250);
await sleep(NOTIFICATION_AND_MOCK_DELAY);
suggestedColumns = mockSuggestions;
} else {
suggestedColumns = (await sdk
@@ -410,7 +408,8 @@
.console.suggestColumns({
databaseId: page.params.database,
tableId: page.params.table,
context: $tableColumnSuggestions.context ?? undefined
context: $tableColumnSuggestions.context ?? undefined,
min: 6
})) as unknown as {
total: number;
columns: ColumnInput[];
@@ -423,21 +422,54 @@
total: suggestedColumns.total
});
customColumns = mapSuggestedColumns(suggestedColumns.columns);
const mappedColumns = mapSuggestedColumns(suggestedColumns.columns);
if (customColumns.length > 0) {
setTimeout(scrollToFirstCustomColumn, 100);
setTimeout(() => (hasTransitioned = true), 300);
// replace with actual columns and trim excess
if (mappedColumns.length < customColumns.length) {
customColumns = customColumns.slice(0, mappedColumns.length);
}
} catch (error) {
addNotification({
type: 'error',
message: error.message
// replace existing placeholders and
// add any additional columns if needed
mappedColumns.forEach((column, index) => {
setTimeout(() => {
if (index < customColumns.length) {
// replace existing placeholder
customColumns[index] = { ...column, isPlaceholder: false };
} else {
// new column directly if we have more than expected
// just added in case the max ever changes on backend!
customColumns.push({ ...column, isPlaceholder: false });
}
// recalculate overlay bounds
// after each column is populated!
requestAnimationFrame(() => updateOverlayBounds());
}, index * 150);
});
trackError(error, Submit.ColumnSuggestions);
} finally {
if (mappedColumns.length > 0) {
setTimeout(
() => {
hasTransitioned = true;
// final recal after
// all animations complete
requestAnimationFrame(() => {
recalcAll();
});
},
mappedColumns.length * 150 + 300
);
}
resetSuggestionsStore(false);
} catch (error) {
// remove completely!
resetSuggestionsStore();
// track & notify!
trackError(error, Submit.ColumnSuggestions);
addNotification({ type: 'error', message: error.message });
}
}
@@ -483,6 +515,31 @@
return !['$id', '$createdAt', '$updatedAt', 'actions'].includes(id);
}
function showIndexSuggestionsNotification() {
// safeguard anyways!
if (!isCloud) return;
setTimeout(() => {
const notifId = addNotification({
isHtml: true,
title: '<b>Next step: add indexes</b>',
message: 'See suggested indexes based on your columns',
dismissible: true,
icon: IconAINotification,
timeout: 10000, // ten seconds
buttons: [
{
name: 'Create indexes',
method: () => {
dismissNotification(notifId);
showIndexesSuggestions.update(() => true);
}
}
]
});
}, NOTIFICATION_AND_MOCK_DELAY);
}
async function createColumns() {
creatingColumns = true;
const client = sdk.forProject(page.params.region, page.params.project);
@@ -589,9 +646,13 @@
addNotification({
type: 'success',
message: 'Columns created successfully'
message: 'Columns created successfully',
timeout: NOTIFICATION_AND_MOCK_DELAY
});
// show index notification!
showIndexSuggestionsNotification();
trackEvent(Submit.ColumnCreate, { type: 'suggestions' });
} catch (error) {
trackError(error, Submit.ColumnCreate);
@@ -603,6 +664,23 @@
}
}
function createPlaceholderColumn(
index: number
): SuggestedColumnSchema & { elements?: []; isPlaceholder?: boolean } {
return {
key: `column${index + 1}`,
type: 'string',
required: false,
default: null,
format: null,
size: undefined,
min: undefined,
max: undefined,
elements: undefined,
isPlaceholder: true
};
}
onDestroy(() => {
resizeObserver?.disconnect();
hScroller?.removeEventListener('scroll', recalcAllThrottled);
@@ -612,15 +690,15 @@
customColumns = [];
resetSuggestionsStore();
unsubscribeCreateTable();
});
</script>
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} on:beforeunload={handleBeforeUnload} />
<svelte:window on:resize={recalcAll} on:scroll={recalcAll} />
<div
bind:this={spreadsheetContainer}
class:custom-columns={customColumns.length > 0}
class:thinking={$tableColumnSuggestions.thinking}
class="databases-spreadsheet spreadsheet-container-outer"
style:--overlay-icon-color="#fd366e99"
style:--non-overlay-icon-color="--fgcolor-neutral-weak">
@@ -643,7 +721,6 @@
bottomActionClick={() => {}}>
<svelte:fragment slot="header" let:root>
{#each spreadsheetColumns as column, index (index)}
{@const isColumnInteractable = isCustomColumn(column.id)}
{#if column.isAction}
<Spreadsheet.Header.Cell column="actions" {root}>
<Button.Button icon variant="extra-compact">
@@ -658,6 +735,8 @@
{@const columnIconColor = !columnObj?.type
? '--non-overlay-icon-color'
: '--overlay-icon-color'}
{@const isColumnInteractable =
isCustomColumn(column.id) && !columnObj.isPlaceholder}
<Options
enabled={isColumnInteractable}
@@ -680,30 +759,42 @@
alignItems="center"
alignContent="center"
justifyContent="space-between">
{column.title}
<span
class="column-title"
class:animate-in={!columnObj?.isPlaceholder}
style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`}>
{column.title}
</span>
<Popover
let:toggle
portal
padding="none"
placement="bottom-start">
<Button.Button
size="xs"
variant="extra-compact"
disabled={!isColumnInteractable}
on:click={(event) => {
if (
isColumnInteractable &&
!$isTabletViewport
) {
toggle(event);
}
}}>
<Icon
size="s"
color={columnIconColor}
icon={column.icon ?? undefined} />
</Button.Button>
<div
class="column-icon-wrapper"
class:animate-in={!columnObj?.isPlaceholder}
style:--animation-delay={`${isColumnInteractable ? (index - 1) * 100 : 0}ms`}>
<Button.Button
size="xs"
variant="extra-compact"
disabled={!isColumnInteractable}
on:click={(event) => {
if (
isColumnInteractable &&
!$isTabletViewport
) {
toggle(event);
}
}}>
{#if !columnObj?.isPlaceholder}
<Icon
size="s"
color={columnIconColor}
icon={column.icon ?? undefined} />
{/if}
</Button.Button>
</div>
<div
let:toggle
@@ -832,9 +923,12 @@
<svelte:fragment slot="start">
<Layout.Stack direction="row" gap="xxs" alignItems="center">
<Spinner size="s" />
<Typography.Text style="white-space: nowrap">
<Typography.Caption
variant="500"
color="--fgcolor-neutral-secondary"
style="white-space: nowrap">
Thinking of column suggestions
</Typography.Text>
</Typography.Caption>
</Layout.Stack>
</svelte:fragment>
<svelte:fragment slot="end">
@@ -847,7 +941,7 @@
</svelte:fragment>
</FloatingActionBar>
</div>
{:else if customColumns.length > 0 && showFloatingBar}
{:else if customColumns.some((col) => !col.isPlaceholder) && showFloatingBar}
<div
class="floating-action-wrapper"
class:expanded={!creatingColumns}
@@ -878,7 +972,10 @@
size="xs"
variant="text"
disabled={creatingColumns}
on:click={() => (confirmDismiss = true)}
on:click={() => {
customColumns = [];
resetSuggestionsStore();
}}
style="opacity: {creatingColumns ? '0' : '1'}"
>Dismiss
</Button.Button>
@@ -897,48 +994,13 @@
{/if}
</div>
<Confirm
confirmDeletion
action="Dismiss"
title="Dismiss columns"
bind:open={confirmDismiss}
onSubmit={() => {
customColumns = [];
resetSuggestionsStore();
}}>
Are you sure you want to dismiss these columns suggested by AI? This action is irreversible.
</Confirm>
<Confirm
confirmDeletion
action="Leave"
title="Leave page"
bind:open={confirmNavigation}
onSubmit={() => {
customColumns = [];
resetSuggestionsStore();
confirmNavigation = false;
if (pendingNavigationUrl) {
if (pendingNavigationUrl === 'create-table') {
executeCreateTable();
} else {
goto(pendingNavigationUrl);
}
pendingNavigationUrl = null;
}
}}>
You have unsaved column suggestions. If you leave this page, you'll lose these suggestions. Are
you sure you want to continue?
</Confirm>
<style lang="scss">
.spreadsheet-container-outer {
width: 100%;
position: fixed;
overflow: hidden;
overflow: visible;
scrollbar-width: none;
--columns-range-pink-border-color: rgba(253, 54, 110, 0.24);
&.custom-columns {
width: unset;
@@ -948,19 +1010,19 @@
display: none;
}
&:not(:has(.columns-range-overlay.thinking)) {
&:has(.columns-range-overlay) {
&
:global(
[role='cell']:has(.column-resizer-disabled):not([data-column-id^='$']):not(
[data-column-id='actions']
)
) {
box-shadow: 0 -1px 0 0 rgba(253, 54, 110, 0.24) inset !important;
box-shadow: 0 -1px 0 0 var(--columns-range-pink-border-color) inset !important;
transition: box-shadow 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
& :global(.column-resizer-disabled) {
border-left: var(--border-width-s, 1px) solid rgba(253, 54, 110, 0.24) !important;
& :global([role='cell']:not([data-column-id='actions']) .column-resizer-disabled) {
border-left: var(--border-width-s, 1px) solid var(--columns-range-pink-border-color) !important;
transition: border-color 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
}
@@ -1010,7 +1072,7 @@
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.8),
transparent
);
animation: inner-shimmer 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite;
@@ -1021,7 +1083,17 @@
& .floating-action-wrapper {
& :global(:first-child) {
z-index: 21;
left: calc(65% - 525px / 2);
transition: all 600ms cubic-bezier(0.4, 0, 0.2, 1);
@media (max-width: 1024px) {
left: calc(50% - 525px / 2);
}
@media (max-width: 768px) {
left: calc(50% - 400px / 2);
max-width: 400px !important;
}
}
&.expanded :global(:first-child) {
@@ -1054,11 +1126,17 @@
}
& :global(.spreadsheet-container) {
overflow-x: hidden;
overflow-y: hidden;
overflow-x: auto;
overflow-y: auto;
scrollbar-width: none;
}
&.thinking {
& :global(.spreadsheet-container) {
overflow: hidden !important;
}
}
& :global([data-select='true']) {
opacity: 0.85;
pointer-events: none;
@@ -1079,7 +1157,7 @@
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.855) 21%,
rgba(255, 255, 255, 0.86) 32.25%,
#fff 100%
);
z-index: 20; /* under overlay */
@@ -1093,10 +1171,9 @@
:global(.theme-dark) .spreadsheet-fade-bottom {
background: linear-gradient(
180deg,
rgba(25, 25, 28, 0.38) 13%,
rgba(25, 25, 28, 0.7) 21%,
rgba(25, 25, 28, 0.95) 38%,
var(--bgcolor-neutral-default, #19191c) 100%
rgba(29, 29, 33, 0) 0%,
rgba(29, 29, 33, 0.86) 21%,
#1d1d21 100%
);
}
@@ -1117,6 +1194,10 @@
}
}
:global(.theme-dark) .spreadsheet-container-outer {
--columns-range-pink-border-color: rgba(253, 54, 110, 0.12) !important;
}
:global(.theme-dark) .columns-range-overlay.thinking {
&::before {
background: linear-gradient(
@@ -1146,7 +1227,8 @@
border: var(--border-width-L, 2px) solid rgba(253, 54, 110, 0.24) !important;
& :global(i) {
margin-inline-end: 8px !important;
margin-inline-end: 6px !important;
margin-block-start: -4px !important;
}
& :global(::selection) {
@@ -1157,4 +1239,21 @@
:global(.filter-modal-actions-menu.variant) {
max-height: 184px;
}
/* Sequential animation for column titles and icons */
.column-title,
.column-icon-wrapper {
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.4s ease-out,
transform 0.4s ease-out;
}
.column-title.animate-in,
.column-icon-wrapper.animate-in {
opacity: 1;
transform: translateY(0);
transition-delay: var(--animation-delay, 0ms);
}
</style>
@@ -1,8 +1,18 @@
<script lang="ts">
import { Layout } from '@appwrite.io/pink-svelte';
let {
notification = false
}: {
notification?: boolean;
} = $props();
</script>
<Layout.Stack inline alignItems="center" justifyContent="center" class="ai-icon-holder">
<Layout.Stack
inline
alignItems="center"
justifyContent="center"
class="ai-icon-holder {notification ? 'notification' : ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30" fill="none">
<path
d="M11.0156 6.20801C11.3539 5.29392 12.6461 5.29392 12.9844 6.20801L14.5801 10.5186C14.7776 11.052 15.198 11.4724 15.7314 11.6699L20.042 13.2656C20.9561 13.6039 20.9561 14.8961 20.042 15.2344L15.7314 16.8301C15.198 17.0276 14.7776 17.448 14.5801 17.9814L12.9844 22.292C12.6461 23.2061 11.3539 23.2061 11.0156 22.292L9.41992 17.9814C9.2224 17.448 8.80197 17.0276 8.26855 16.8301L3.95801 15.2344C3.04392 14.8961 3.04392 13.6039 3.95801 13.2656L8.26855 11.6699C8.80196 11.4724 9.2224 11.052 9.41992 10.5186L11.0156 6.20801Z"
@@ -64,4 +74,9 @@
width: 40px !important;
height: 40px !important;
}
:global(.ai-icon-holder.notification) {
width: 36px !important;
height: 32px !important;
}
</style>
@@ -0,0 +1,5 @@
<script lang="ts">
import IconAI from './ai.svelte';
</script>
<IconAI notification />
@@ -0,0 +1,567 @@
<script lang="ts">
import { Alert, Accordion, Icon, Layout, Skeleton, Typography } from '@appwrite.io/pink-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { Button, InputSelect /*InputNumber*/ } from '$lib/elements/forms';
import {
showIndexesSuggestions,
IndexOrder,
mockSuggestions,
type SuggestedIndexSchema
} from './store';
import { Modal, Confirm } from '$lib/components';
import SideSheet from '../table-[table]/layout/sidesheet.svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { IndexType, type Models } from '@appwrite.io/console';
import { capitalize } from '$lib/helpers/string';
import { type Columns, table } from '../table-[table]/store';
import { isRelationship } from '../table-[table]/rows/store';
import { VARS } from '$lib/system';
import { sleep } from '$lib/helpers/promises';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { type ComponentType, onDestroy, onMount } from 'svelte';
import { columnOptions as baseColumnOptions } from '../table-[table]/columns/store';
const MAX_INDEXES = 5;
let modalError = $state(null);
let creatingIndexes = $state(false);
let loadingSuggestions = $state(false);
let indexes = $state<SuggestedIndexSchema[]>([]);
let confirmDismiss = $state(false);
let columnOptions: Array<{
value: string;
label: string;
leadingIcon?: ComponentType;
}> = $state();
const tableId = page.params.table;
const databaseId = page.params.database;
function makeColumnOptions() {
if (VARS.MOCK_AI_SUGGESTIONS) {
columnOptions = mockSuggestions.columns.map((column) => ({
value: column.name,
label: column.name,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)?.icon
}));
} else {
columnOptions = $table.columns
.filter((column) => !isRelationship(column))
.map((column) => ({
value: column.key,
label: column.key,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)
?.icon
}));
}
}
async function loadIndexSuggestions(): Promise<SuggestedIndexSchema[]> {
modalError = null;
loadingSuggestions = true;
if (VARS.MOCK_AI_SUGGESTIONS) {
await sleep(1250);
indexes = mockSuggestions.columns.slice(0, 3).map((column, index) => ({
key: column.name,
type: IndexType.Key,
columns: [column.name],
orders: index === 2 ? IndexOrder.DESC : IndexOrder.ASC,
lengths: []
}));
} else {
try {
const suggestions = await sdk
.forProject(page.params.region, page.params.project)
.console.suggestIndexes({
databaseId,
tableId: $table.$id
});
indexes = suggestions.indexes.map((index) => {
return {
key: index.columns[0],
type: index.type as IndexType,
orders: (index.orders?.[0] as IndexOrder) || IndexOrder.ASC,
columns: index.columns,
lengths: index.lengths ?? []
};
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
await closeAndInvalidate();
return []; // formality, UI is closed by now!
}
}
makeColumnOptions();
loadingSuggestions = false;
return indexes;
}
function addIndex() {
if (indexes.length < MAX_INDEXES) {
indexes.push({
key: '',
type: IndexType.Key,
orders: IndexOrder.ASC,
columns: [],
lengths: null
});
}
}
function removeIndex(index: number) {
indexes.splice(index, 1);
}
function syncIndexState(event: CustomEvent, index: SuggestedIndexSchema) {
const selected = event.detail;
index.key = selected;
index.columns = selected ? [selected] : [];
if (index.lengths) {
index.lengths = index.lengths.slice(0, index.columns.length);
}
}
function getOrderOptions(selectedType: IndexType) {
const base = [IndexOrder.ASC, IndexOrder.DESC];
const values = selectedType === IndexType.Spatial ? [...base, IndexOrder.NONE] : base;
return values.map((order) => ({
label: capitalize(String(order)),
value: order
}));
}
function generateUniqueIndexKey(index: SuggestedIndexSchema, usedKeys: Set<string>): string {
const existingKeys = $table.indexes.map((idx) => idx.key);
let suggestedKey = `${index.key || index.columns[0]}_${index.type.toLowerCase()}`;
let uniqueKey = suggestedKey;
let counter = 1;
while (existingKeys.includes(uniqueKey) || usedKeys.has(uniqueKey)) {
uniqueKey = `${suggestedKey}_${counter}`;
counter++;
}
usedKeys.add(uniqueKey);
return uniqueKey;
}
function prepareIndexForCreation(index: SuggestedIndexSchema, columnMap: Map<string, Columns>) {
// prepare orders array
const orders = index.orders !== null ? index.columns.map(() => String(index.orders)) : [];
// prepare lengths array
let lengths: (number | null)[];
if (index.type === IndexType.Key) {
// only validate if it's a key index
lengths = index.columns.map((columnKey, i) => {
const column = columnMap.get(columnKey);
if (column?.type === 'string') {
const stringColumn = column as Models.ColumnString;
const requestedLength = index.lengths?.[i];
if (
requestedLength &&
stringColumn.size &&
requestedLength > stringColumn.size
) {
return stringColumn.size;
}
return requestedLength || null;
}
return null;
});
} else {
// non-key indexes, lengths are null!
lengths = Array(index.columns.length).fill(null);
}
return { orders, lengths };
}
function dismissIndexes() {
indexes = [];
confirmDismiss = false;
$showIndexesSuggestions = false;
}
async function closeAndInvalidate() {
// close modal/sheet.
$showIndexesSuggestions = false;
/**
* Invalidate dependencies after modal/sheet close,
* because otherwise the `await` block will re-run in the modal.
*/
await invalidate(Dependencies.TABLE);
}
async function applySuggestedIndexes(): Promise<boolean> {
modalError = null;
creatingIndexes = true;
for (const [i, index] of indexes.entries()) {
if (!index.key || !index.type || !index.columns || index.columns.length === 0) {
modalError = `Index ${i + 1}: Selected column or type invalid`;
creatingIndexes = false;
return true; // keep sheet open!
}
}
let successCount = 0;
const usedKeys = new Set<string>();
const columnMap = new Map($table.columns.map((col) => [col.key, col]));
const sdkClient = sdk.forProject(page.params.region, page.params.project);
for (const [_, index] of indexes.entries()) {
try {
// prepare and validate index data
const { orders, lengths } = prepareIndexForCreation(index, columnMap);
// generate unique key name for the index
const uniqueIndexKey = generateUniqueIndexKey(index, usedKeys);
await sdkClient.tablesDB.createIndex({
databaseId,
tableId,
key: uniqueIndexKey,
type: index.type,
columns: index.columns,
lengths,
...(orders.length ? { orders } : {})
});
successCount++;
} catch (error) {
if (!modalError) {
modalError = error.message;
}
trackError(error, Submit.IndexCreate);
creatingIndexes = false;
return true; // keep sheet open!
}
}
creatingIndexes = false;
if (successCount > 0) {
trackEvent(Submit.IndexCreate, { type: 'suggestions', count: successCount });
}
if (successCount === indexes.length) {
// all succeeded
addNotification({
message: `${successCount} index${successCount > 1 ? 'es are' : ' is'} being created`,
type: 'success'
});
await closeAndInvalidate();
} else if (successCount > 0) {
// some succeeded, some failed
addNotification({
message: 'Some indexes could not be created',
type: 'warning'
});
await closeAndInvalidate();
} else {
// all failed
addNotification({
message: 'Failed to create indexes',
type: 'error'
});
return true; // keep sheet open!
}
return false; // close the sheet!
}
const typeOptions = Object.values(IndexType).map((type) => ({
label: capitalize(type),
value: type
}));
onMount(() => showIndexesSuggestions.set(false));
onDestroy(() => showIndexesSuggestions.set(false));
</script>
{#if !$isSmallViewport}
<Modal
dismissible={false}
title="Suggested indexes"
bind:error={modalError}
bind:show={$showIndexesSuggestions}
onSubmit={async () => {
await applySuggestedIndexes();
}}>
<Layout.Stack gap="m">
{#await loadIndexSuggestions()}
<Layout.Stack gap="xs">
<Layout.Stack gap="m">
{#each Array(3) as _, index}
{@const firstItem = index === 0}
<Layout.Stack gap="m" direction="row" alignItems="center">
{@render fieldSkeleton({ label: 'Column', showLabel: firstItem })}
{@render fieldSkeleton({ label: 'Type', showLabel: firstItem })}
{@render fieldSkeleton({ label: 'Order', showLabel: firstItem })}
<!--{@render fieldSkeleton({ label: 'Length', showLabel: firstItem })}-->
<div style:margin-top={firstItem ? '27.6px' : '0'}>
<Skeleton
variant="square"
width="33px"
height="33px"
style="opacity: 0.25" />
</div>
</Layout.Stack>
{/each}
</Layout.Stack>
{@render addIndexButton()}
</Layout.Stack>
{:then suggestedIndexes}
<Layout.Stack gap="xs">
<Layout.Stack gap="m">
{#each suggestedIndexes as index, count}
{@render indexEditForm({ index, count, isDesktop: true })}
{/each}
</Layout.Stack>
{@render addIndexButton()}
</Layout.Stack>
{/await}
</Layout.Stack>
<svelte:fragment slot="footer">
<Layout.Stack direction="row" justifyContent="flex-end" alignItems="center" inline>
<Layout.Stack direction="row" gap="m">
<Button
text
size="s"
disabled={loadingSuggestions || creatingIndexes}
on:click={() => {
if (indexes.length > 0 && !creatingIndexes) {
confirmDismiss = true;
} else {
$showIndexesSuggestions = false;
}
}}>Cancel</Button>
<Button
size="s"
submit
submissionLoader
disabled={indexes.length === 0 || loadingSuggestions}
forceShowLoader={creatingIndexes}>
Create
</Button>
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Modal>
{/if}
{#if $isSmallViewport}
<SideSheet
closeOnBlur={false}
title="Suggested indexes"
data-index-edit-form-sheet
bind:show={$showIndexesSuggestions}
submit={{
text: 'Create',
disabled: indexes.length === 0 || creatingIndexes || loadingSuggestions,
onClick: async () => await applySuggestedIndexes()
}}
cancel={{
disabled: loadingSuggestions || creatingIndexes,
onClick: () => {
if (indexes.length > 0 && !creatingIndexes) {
confirmDismiss = true;
} else {
$showIndexesSuggestions = false;
}
}
}}>
{#if modalError}
<Alert.Inline status="error" title={modalError} />
{/if}
{#await loadIndexSuggestions()}
<Layout.Stack gap="m" alignItems="center">
{#each Array(3) as _}
{@render fieldSkeleton({
label: undefined,
showLabel: false,
isDesktop: false
})}
{/each}
</Layout.Stack>
{:then suggestedIndexes}
<Layout.Stack gap="s">
{#each suggestedIndexes as index, count}
<Accordion title="{index.key}_index">
{@render indexEditForm({ index, count, isDesktop: false })}
</Accordion>
{/each}
{@render addIndexButton()}
</Layout.Stack>
{/await}
</SideSheet>
{/if}
{#snippet fieldSkeleton({ label, showLabel, isDesktop = true })}
<Layout.Stack gap="xxxs">
{#if showLabel}
<Typography.Text variant="m-500" style="margin-block-end: var(--gap-s);"
>{label}</Typography.Text>
{/if}
<Skeleton
variant="line"
width="100%"
height={isDesktop ? 33.602 : 39.398}
style="opacity: 0.25" />
</Layout.Stack>
{/snippet}
{#snippet indexEditForm({ index, count, isDesktop = true })}
{@const firstItem = count === 0}
{#if isDesktop}
<Layout.Stack gap="m" direction="row" alignItems="center" class="desktop-selects-form">
<InputSelect
id="key-{count}"
label={firstItem ? 'Column' : undefined}
bind:value={index.key}
on:change={(event) => syncIndexState(event, index)}
options={columnOptions}
placeholder="Select column"
required
disabled={creatingIndexes} />
<InputSelect
id="type-{count}"
label={firstItem ? 'Type' : ''}
bind:value={index.type}
options={typeOptions}
required
disabled={creatingIndexes} />
<InputSelect
id="order-{count}"
label={firstItem ? 'Order' : ''}
bind:value={index.orders}
options={getOrderOptions(index.type)}
required
disabled={creatingIndexes} />
{@render removeIndexButton({ count, isDesktop: true })}
</Layout.Stack>
{:else}
<!-- Mobile: Vertical layout -->
<Layout.Stack gap="m">
<Layout.Stack gap="s">
<InputSelect
id="key-{count}-mobile"
bind:value={index.key}
on:change={(event) => syncIndexState(event, index)}
options={columnOptions}
placeholder="Select column"
required />
<InputSelect
id="type-{count}-mobile"
bind:value={index.type}
options={typeOptions}
required />
<InputSelect
id="order-{count}-mobile"
bind:value={index.orders}
options={getOrderOptions(index.type)}
required />
</Layout.Stack>
{@render removeIndexButton({ count, isDesktop: false })}
</Layout.Stack>
{/if}
{/snippet}
{#snippet addIndexButton()}
{#if indexes.length < MAX_INDEXES}
<Layout.Stack direction="row" justifyContent="flex-start">
<Button
icon
size="s"
compact
on:click={addIndex}
disabled={loadingSuggestions || creatingIndexes}>
<Icon icon={IconPlus} size="s" />
Add Index
</Button>
</Layout.Stack>
{/if}
{/snippet}
{#snippet removeIndexButton({ count, isDesktop = true })}
{#if isDesktop}
<div style:margin-top={count === 0 ? '27.6px' : '0'}>
<Button
icon
size="xs"
secondary
on:click={() => removeIndex(count)}
disabled={indexes.length <= 1 || creatingIndexes}>
<Icon icon={IconX} color="--fgcolor-danger-primary" />
</Button>
</div>
{:else if indexes.length > 1}
<Layout.Stack direction="row" justifyContent="flex-start">
<Button
size="s"
secondary
on:click={() => removeIndex(count)}
disabled={indexes.length <= 1 || creatingIndexes}>Delete</Button>
</Layout.Stack>
{/if}
{/snippet}
<Confirm
confirmDeletion
action="Dismiss"
title="Dismiss indexes"
bind:open={confirmDismiss}
onSubmit={dismissIndexes}>
Are you sure you want to dismiss these suggested indexes? This action cannot be undone.
</Confirm>
<style lang="scss">
// Custom logic to hide the Sheet's
// `X` close button (not configurable via props)
:global([data-index-edit-form-sheet] header) {
& :global(.divider),
& :global(button) {
visibility: hidden !important;
}
}
// stack > div > input-select button!
:global(.desktop-selects-form :first-child button:first-of-type) {
width: 164px;
max-width: 164px;
}
</style>
@@ -1,42 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
import { isCloud } from '$lib/system';
import IconAI from './icon/ai.svelte';
import { slide } from 'svelte/transition';
import { tableColumnSuggestions } from './store';
import { InputTextarea } from '$lib/elements/forms';
import { Button, InputTextarea } from '$lib/elements/forms';
import { Card, Layout, Selector, Typography } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
onMount(() => {
// enable by default!
$tableColumnSuggestions.enabled = true;
if (featureActive) {
$tableColumnSuggestions.enabled = true;
}
});
const featureActive = $derived(isCloud);
const title = $derived.by(() => {
return featureActive
? 'Smart column suggestions'
: 'Smart column suggestions available on Cloud';
});
const subtitle = $derived.by(() => {
return featureActive
? 'Enable AI to suggest useful columns based on your table name'
: 'Sign up for Cloud to generate columns based on your table name';
});
</script>
<Card.Base variant="secondary" radius="s" padding="xs">
<Layout.Stack gap="m">
<Layout.Stack direction="row" gap="s" alignItems="center">
<Layout.Stack gap={featureActive ? 'm' : 'l'}>
<Layout.Stack gap="s" direction="row" alignItems="flex-start">
<IconAI />
<Layout.Stack direction="column" gap="none">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
>Smart column suggestions</Typography.Text>
<div class="suggestions-switch">
<Selector.Switch
id="suggestions"
label={undefined}
bind:checked={$tableColumnSuggestions.enabled} />
</div>
</Layout.Stack>
<Typography.Text variant="m-500" color="--fgcolor-neutral-primary"
>{title}</Typography.Text>
<Typography.Text color="--fgcolor-neutral-secondary">
Enable AI to suggest useful columns based on your table name
{subtitle}
</Typography.Text>
</Layout.Stack>
{#if featureActive}
<div class="suggestions-switch">
<Selector.Switch
id="suggestions"
label={undefined}
bind:checked={$tableColumnSuggestions.enabled} />
</div>
{/if}
</Layout.Stack>
{#if $tableColumnSuggestions.enabled}
{#if !featureActive}
<Layout.Stack>
<Button external secondary href="https://cloud.appwrite.io/register">
Sign up
</Button>
</Layout.Stack>
{/if}
<!-- just being safe with extra guard! -->
{#if $tableColumnSuggestions.enabled && featureActive}
<div transition:slide={{ duration: 200 }}>
<InputTextarea
id="context"
@@ -50,7 +75,7 @@
</Card.Base>
<style lang="scss">
.suggestions-switch :global(button) {
.suggestions-switch :global(button):not(:disabled) {
cursor: pointer;
}
</style>
@@ -1,4 +1,5 @@
import { writable } from 'svelte/store';
import { IndexType } from '@appwrite.io/console';
import { columnOptions } from '../table-[table]/columns/store';
export type TableColumnSuggestions = {
@@ -24,6 +25,20 @@ export type SuggestedColumnSchema = {
format?: string | null;
};
export enum IndexOrder {
ASC = 'ASC',
DESC = 'DESC',
NONE = null
}
export type SuggestedIndexSchema = {
key: string;
type: IndexType;
orders: IndexOrder;
columns: string[];
lengths?: number[] | undefined;
};
export const tableColumnSuggestions = writable<TableColumnSuggestions>({
enabled: false,
context: null,
@@ -31,7 +46,7 @@ export const tableColumnSuggestions = writable<TableColumnSuggestions>({
table: null
});
export const createTableRequest = writable<boolean>(false);
export const showIndexesSuggestions = writable<boolean>(false);
export const mockSuggestions: { total: number; columns: ColumnInput[] } = {
total: 7,
@@ -138,7 +138,10 @@
</script>
<svelte:head>
<title>Database - Appwrite</title>
<!-- svelte bug, the table header just stays! -->
{#key page.url.pathname}
<title>Database - Appwrite</title>
{/key}
</svelte:head>
<slot />
@@ -23,6 +23,7 @@
import { trackEvent } from '$lib/actions/analytics';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
import IconQuestionMarkCircle from './components/questionIcon.svelte';
let policyCreateError: string;
let totalPolicies: UserBackupPolicy[] = [];
@@ -40,7 +41,7 @@
if (parsedCounter === showOnCount || !counter) {
addNotification({
type: 'info',
icon: 'question-mark-circle',
icon: IconQuestionMarkCircle,
message:
'How was your experience with our new Backups feature? Give us your feedback and help us improve!',
timeout: 15000,
@@ -0,0 +1,6 @@
<script lang="ts">
import { Icon } from '@appwrite.io/pink-svelte';
import { IconQuestionMarkCircle } from '@appwrite.io/pink-icons-svelte';
</script>
<Icon icon={IconQuestionMarkCircle} />
@@ -19,6 +19,7 @@
} = $props();
const databaseId = page.params.database;
const isOnTablesPage = $derived(page.route?.id.endsWith('table-[table]'));
let name = $state('');
let id: string = $state(null);
@@ -27,6 +28,20 @@
let creatingTable = $state(false);
function enableThinkingModeForSuggestions(table: Models.Table) {
if ($tableColumnSuggestions.enabled) {
// if enabled, trigger thinking mode!
tableColumnSuggestions.update((store) => ({
...store,
thinking: true,
table: {
id: table.$id,
name: table.name
}
}));
}
}
async function createTable() {
error = null;
try {
@@ -38,14 +53,6 @@
name
});
tableColumnSuggestions.update((store) => ({
...store,
table: {
id: table.$id,
name: table.name
}
}));
updateAndCleanup();
await onTableCreated(table);
@@ -53,6 +60,9 @@
name = id = null;
showCreate = false;
creatingTable = false;
// don't wait for UI to mount!
enableThinkingModeForSuggestions(table);
} catch (e) {
error = e.message;
trackError(e, Submit.TableCreate);
@@ -93,6 +103,15 @@
touchedId = false;
}
});
$effect(() => {
if (showCreate && isOnTablesPage && $tableColumnSuggestions.table) {
tableColumnSuggestions.update((store) => ({
...store,
table: null
}));
}
});
</script>
<Modal title="Create table" size="m" bind:show={showCreate} onSubmit={createTable} bind:error>
@@ -8,7 +8,7 @@
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { database } from './store';
import { toLocaleDate } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { type Models, Query } from '@appwrite.io/console';
import { Spinner, Table } from '@appwrite.io/pink-svelte';
@@ -126,7 +126,9 @@
{#each tableItems as table}
<Table.Row.Base {root}>
<Table.Cell {root}>{table.name}</Table.Cell>
<Table.Cell {root}>{toLocaleDate(table.updatedAt)}</Table.Cell>
<Table.Cell {root}>
<DualTimeView time={table.updatedAt} />
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
@@ -28,14 +28,13 @@
import { sdk } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { subNavigation } from '$lib/stores/database';
import { createTableRequest, tableColumnSuggestions } from './(suggestions)/store';
let data = $derived(page.data) as PageData;
const data = $derived(page.data) as PageData;
let region = $derived(page.params.region);
let project = $derived(page.params.project);
let tableId = $derived(page.params.table);
let databaseId = $derived(page.params.database);
const region = $derived(page.params.region);
const project = $derived(page.params.project);
const tableId = $derived(page.params.table);
const databaseId = $derived(page.params.database);
let openBottomSheet = $state(false);
@@ -56,10 +55,11 @@
const isMainDatabaseScreen = $derived(page.route.id.endsWith('database-[database]'));
// If banner open, `-1rem` to adjust banner size, else `-70.5px`.
// If banner open, adjust bottom position to account for banner container.
// 70.5px is the size of the container of the banner holder and not just the banner!
// Needed because things vary a bit much on how different browsers treat bottom layouts.
const bottomNavHeight = $derived(`calc(20% ${$bannerSpacing ? '- 1rem' : '- 70.5px'})`);
const bottomNavOffset = $derived($bannerSpacing ? '70.5px' : '0px');
const tableContentPadding = $derived($bannerSpacing ? '210px' : '140px');
async function loadTables() {
tables = await sdk.forProject(region, project).tablesDB.listTables({
@@ -93,7 +93,7 @@
{data.database?.name}
</a>
<div class="table-content">
<div class="table-content" style:padding-bottom={tableContentPadding}>
{#if tables?.total}
<ul class="drop-list u-margin-inline-start-8 u-margin-block-start-4">
{#each sortedTables as table, index}
@@ -147,25 +147,15 @@
<Button
compact
on:click={() => {
if (
$tableColumnSuggestions.enabled &&
$tableColumnSuggestions.table?.id
) {
$createTableRequest = true;
} else {
$showCreateTable = true;
$showSubNavigation = false;
}
$showCreateTable = true;
$showSubNavigation = false;
}}>
Create table
</Button>
</Layout.Stack>
</div>
<Layout.Stack
gap="xxs"
direction="column"
style="bottom: 1rem; position: relative; height: {bottomNavHeight}">
<div class="bottom-nav-container" style:bottom={bottomNavOffset}>
<div class="action-menu-divider">
<Divider />
</div>
@@ -177,7 +167,7 @@
{@const href = `${base}/project-${region}-${project}/databases/database-${databaseId}/${action.href}`}
<Layout.Stack gap="s" direction="row" alignItems="center">
<li>
<li class="bottom-nav-item">
<a
{href}
class="u-padding-block-8 u-padding-inline-end-4 u-padding-inline-start-8 u-flex u-cross-center u-gap-8">
@@ -191,7 +181,7 @@
</Layout.Stack>
{/each}
</ul>
</Layout.Stack>
</div>
</section>
</Sidebar.Base>
{:else if data?.database?.name && !isMainDatabaseScreen}
@@ -271,11 +261,15 @@
overflow-x: hidden;
min-height: 0;
margin-bottom: auto;
padding-bottom: 16px;
scrollbar-width: thin;
scrollbar-color: var(--border-neutral, #ededf0) transparent;
color: var(--fgcolor-neutral-secondary, #56565c);
/* hide scrollbars */
scrollbar-width: none;
-ms-overflow-style: none;
// scrollbar-width: thin;
// scrollbar-color: var(--border-neutral, #ededf0) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
@@ -304,6 +298,10 @@
font-size: var(--font-size-sm);
color: var(--fgcolor-neutral-secondary);
&::-webkit-scrollbar {
display: none;
}
&:not(.bottom-nav)::before {
content: '';
right: 99%;
@@ -335,12 +333,16 @@
position: relative;
padding-inline-end: 0.5rem;
margin-inline-start: 0.5rem;
}
li:hover {
color: var(--fgcolor-neutral-primary);
border-radius: var(--border-radius-s, 6px);
background: var(--bgcolor-neutral-secondary);
&:hover {
color: var(--fgcolor-neutral-primary);
border-radius: var(--border-radius-s, 6px);
background: var(--bgcolor-neutral-secondary);
}
&.bottom-nav-item:hover {
margin-inline-end: 1.25rem;
}
}
.table-name {
@@ -382,8 +384,16 @@
line-height: 150%; /* 21px */
}
.bottom-nav-container {
right: 0;
bottom: 0;
left: 1.25rem;
position: absolute;
padding-block-end: 1rem;
}
.action-menu-divider {
margin-inline: -1.2rem;
padding-block-end: 0.25rem;
margin-inline-start: -1.25rem;
}
</style>
@@ -64,6 +64,9 @@
import { chunks } from '$lib/helpers/array';
import { Submit, trackEvent } from '$lib/actions/analytics';
import IndexesSuggestions from '../(suggestions)/indexes.svelte';
import { showIndexesSuggestions, tableColumnSuggestions } from '../(suggestions)';
let editRow: EditRow;
let editRelatedRow: EditRelatedRow;
let editRowPermissions: EditRowPermissions;
@@ -89,7 +92,15 @@
response.events.includes('databases.*.tables.*.columns.*') ||
response.events.includes('databases.*.tables.*.indexes.*')
) {
if (!isWaterfallFromFaker) {
// don't invalidate when -
// 1. from faker
// 2. ai columns creation
// 3. ai indexes creation
if (
!isWaterfallFromFaker &&
!$showIndexesSuggestions &&
!$tableColumnSuggestions.table
) {
invalidate(Dependencies.TABLE);
}
}
@@ -442,7 +453,7 @@
title="Row permissions"
bind:show={$rowPermissionSheet.show}
submit={{
text: 'Create',
text: 'Update',
disabled: editRowPermissions?.disableSubmit(),
onClick: async () => editRowPermissions?.updatePermissions()
}}>
@@ -470,3 +481,5 @@
</Layout.Stack>
</svelte:fragment>
</Dialog>
<IndexesSuggestions />
@@ -6,7 +6,7 @@
import { Container } from '$lib/layout';
import { preferences } from '$lib/stores/preferences';
import { canWriteTables, canWriteRows } from '$lib/stores/roles';
import { Icon, Layout, Divider } from '@appwrite.io/pink-svelte';
import { Icon, Layout, Divider, Tooltip } from '@appwrite.io/pink-svelte';
import type { PageData } from './$types';
import {
table,
@@ -31,6 +31,7 @@
import EmptySheet from './layout/emptySheet.svelte';
import CreateRow from './rows/create.svelte';
import { onDestroy } from 'svelte';
import { isCloud } from '$lib/system';
import { Empty as SuggestionsEmptySheet, tableColumnSuggestions } from '../(suggestions)';
export let data: PageData;
@@ -75,6 +76,12 @@
$: hasColumns = !!$table.columns.length;
$: hasValidColumns = $table?.columns?.some((col) => col.status === 'available');
$: canShowSuggestionsSheet =
// enabled, has table details
// and it matches current table
$tableColumnSuggestions.enabled &&
$tableColumnSuggestions.table &&
$tableColumnSuggestions.table.id === page.params.table;
async function onSelect(file: Models.File, localFile = false) {
$isCsvImportInProgress = true;
@@ -114,21 +121,31 @@
<Layout.Stack direction="column" gap="xl">
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" gap="s">
<ViewSelector
onlyIcon
ui="new"
view={data.view}
columns={tableColumns}
hideView
showAnyway
isCustomTable />
<Tooltip>
<div>
<ViewSelector
onlyIcon
ui="new"
view={data.view}
columns={tableColumns}
hideView
showAnyway
isCustomTable />
</div>
<Filters
onlyIcon
query={data.query}
columns={filterColumns}
disabled={!(hasColumns && hasValidColumns)}
analyticsSource="database_rows" />
<svelte:fragment slot="tooltip">Columns</svelte:fragment>
</Tooltip>
<Tooltip>
<Filters
onlyIcon
query={data.query}
columns={filterColumns}
disabled={!(hasColumns && hasValidColumns)}
analyticsSource="database_tables" />
<svelte:fragment slot="tooltip">Filters</svelte:fragment>
</Tooltip>
</Layout.Stack>
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<Button
@@ -192,7 +209,7 @@
queries.clearAll();
queries.apply();
trackEvent(Submit.FilterClear, {
source: 'database_rows'
source: 'database_tables'
});
}
}
@@ -216,7 +233,7 @@
}
}} />
{/if}
{:else if $tableColumnSuggestions.enabled && $tableColumnSuggestions.table && $tableColumnSuggestions.table.id === page.params.table}
{:else if isCloud && canShowSuggestionsSheet}
<SuggestionsEmptySheet />
{:else}
<EmptySheet
@@ -20,6 +20,7 @@
import {
columns,
type Columns,
type ColumnsWidth,
indexes,
isCsvImportInProgress,
reorderItems,
@@ -51,6 +52,14 @@
import { type Models } from '@appwrite.io/console';
import { preferences } from '$lib/stores/preferences';
import { page } from '$app/state';
import { debounce } from '$lib/helpers/debounce';
import type { PageData } from './$types';
const {
data
}: {
data: PageData;
} = $props();
const updatedColumnsForSheet = $derived.by(() => {
const baseAttrs = [
@@ -104,8 +113,11 @@
let selectedColumn: Columns = $state(null);
let columnIndexMap: Record<string, boolean> = $state({});
let columnsOrder = $state([]);
let columnsOrder = $state<string[]>([]);
let columnsWidth = $state<ColumnsWidth | null>(null);
const tableId = page.params.table;
const organizationId = data.organization.$id ?? data.project.teamId;
let showEdit = $state(false);
let editColumn: EditColumn;
@@ -126,6 +138,7 @@
onMount(() => {
columnsOrder = preferences.getColumnOrder(tableId);
columnsWidth = preferences.getColumnWidths(tableId + '#columns');
});
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
@@ -182,8 +195,74 @@
return `Type: ${formattedType}`;
}
function isSystemColumnKey(column: Columns) {
return column.key.startsWith('$');
}
function getColumnWidth(columnId: string, defaultWidth: number): number {
const savedWidth = columnsWidth?.[columnId];
if (!savedWidth) return defaultWidth;
return savedWidth.resized;
}
function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) {
const existing = columnsWidth?.[columnId];
const fixed = existing
? typeof existing?.fixed === 'number'
? existing.fixed
: existing?.fixed?.min
: newWidth;
columnsWidth = {
...(columnsWidth ?? {}),
[columnId]: {
fixed,
resized: Math.ceil(newWidth)
}
};
saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed });
}
const saveColumnWidthsToPreferences = debounce(
(column: { columnId: string; newWidth: number; fixedWidth: number }) => {
if (!organizationId) return;
preferences.saveColumnWidths(organizationId, tableId + '#columns', {
[column.columnId]: {
fixed: column.fixedWidth,
resized: Math.ceil(column.newWidth)
}
});
},
1000
);
onDestroy(() => ($showCreateColumnSheet.show = false));
const spreadsheetColumns = $derived([
{
id: 'key',
width: getColumnWidth('key', 300),
minimumWidth: 300,
resizable: true
},
{
id: 'indexed',
width: getColumnWidth('indexed', 150),
minimumWidth: 150,
resizable: true
},
{
id: 'default',
width: getColumnWidth('default', 200),
minimumWidth: 200,
resizable: true
},
{ id: 'actions', width: 40, isAction: true, resizable: false }
]);
$effect(() => {
if (!$showCreateIndexSheet.show && $showCreateIndexSheet.column) {
const columnKey = $showCreateIndexSheet.column;
@@ -224,14 +303,9 @@
height="100%"
emptyCells={emptyCellsCount}
bind:selectedRows={selectedColumns}
columns={[
// more size until we decide if we want a new column!
{ id: 'key', width: { min: $isSmallViewport ? 250 : 200 } },
{ id: 'indexed', width: { min: 150 } },
{ id: 'default', width: { min: 200 } },
{ id: 'actions', width: 40, isAction: true }
]}
bottomActionClick={() => ($showCreateColumnSheet.show = true)}>
columns={spreadsheetColumns}
bottomActionClick={() => ($showCreateColumnSheet.show = true)}
on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}>
<svelte:fragment slot="header" let:root>
<Spreadsheet.Header.Cell column="key" {root}>Column name</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="indexed" {root}>Indexed</Spreadsheet.Header.Cell>
@@ -240,14 +314,23 @@
<Spreadsheet.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each updatedColumnsForSheet as column, index}
{#each updatedColumnsForSheet as column, index (column.key)}
{@const option = columnOptions.find((option) => option.type === column.type)}
{@const isSelectable =
column['system'] || column.type === 'relationship' ? 'disabled' : true}
<Spreadsheet.Row.Base {root} select={isSelectable} id={column.key}>
<Spreadsheet.Cell column="key" {root} isEditable={false}>
<Layout.Stack direction="row" justifyContent="space-between">
<Layout.Stack direction="row" alignItems="center" inline>
<Layout.Stack
direction="row"
alignItems="center"
justifyContent="space-between"
style="min-width:0">
<Layout.Stack
gap="s"
inline
direction="row"
alignItems="center"
style="min-width:0; flex:1 1 auto;">
{#if isRelationship(column)}
<Icon
size="s"
@@ -263,29 +346,35 @@
<Icon icon={option.icon} size="s" />
{/if}
<Layout.Stack direction="row" alignItems="center" gap="s">
<Layout.Stack
inline
direction="row"
alignItems="center"
gap="xxs">
<span class="text u-trim-1" data-private>
{#if column.key === '$id' || column.key === '$sequence' || column.key === '$createdAt' || column.key === '$updatedAt'}
{column['name']}
{:else}
{column.key} {column.array ? '[]' : undefined}
{/if}
</span>
{#if isString(column) && column.encrypt}
<Tooltip>
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
<div slot="tooltip">Encrypted</div>
</Tooltip>
<Layout.Stack
gap="s"
inline
direction="row"
alignItems="center"
style="min-width:0; flex:1 1 auto; overflow:hidden;">
<Typography.Text truncate>
{#if isSystemColumnKey(column)}
{column.key}
{:else}
{column.key}{column.array ? '[]' : undefined}
{/if}
</Layout.Stack>
</Typography.Text>
{#if isString(column) && column.encrypt}
<Tooltip>
<Icon
size="s"
icon={IconLockClosed}
color="--fgcolor-neutral-tertiary" />
<div slot="tooltip">Encrypted</div>
</Tooltip>
{/if}
</Layout.Stack>
<Layout.Stack
gap="s"
inline
direction="row"
alignItems="center"
style="flex:0 0 auto; white-space:nowrap;">
{#if column.status !== 'available'}
<Badge
size="s"
@@ -458,7 +547,7 @@
</div>
{#if selectedColumn}
<DeleteColumn bind:showDelete {selectedColumn} />
<DeleteColumn bind:showDelete bind:selectedColumn />
{:else if selectedColumns && selectedColumns.length}
<DeleteColumn bind:showDelete bind:selectedColumn={selectedColumns} />
{/if}
@@ -476,3 +565,13 @@
{#if showFailed}
<FailedModal bind:show={showFailed} title="Create attribute" header="Creation failed" {error} />
{/if}
<style>
.floating-action-bar {
left: 50%;
width: 100%;
z-index: 14;
position: absolute;
transform: translateX(-50%);
}
</style>
@@ -68,6 +68,7 @@
array: false,
...data
});
$: listen(data);
$: handleDefaultState($required || $array);
@@ -61,7 +61,7 @@
await invalidate(Dependencies.TABLE);
showDelete = false;
selectedColumn = [];
selectedColumn = Array.isArray(selectedColumn) ? [] : null;
} catch (e) {
error = e.message;
trackError(e, Submit.ColumnDelete);
@@ -85,14 +85,14 @@
label="Min"
placeholder="Enter size"
bind:value={data.min}
step="any"
step={0.1}
required={editing} />
<InputNumber
id="max"
label="Max"
placeholder="Enter size"
bind:value={data.max}
step="any"
step={0.1}
required={editing} />
</Layout.Stack>
<InputNumber
@@ -104,7 +104,7 @@
bind:value={data.default}
disabled={data.required || data.array}
nullable={!data.required && !data.array}
step="any" />
step={0.1} />
<Selector.Checkbox
size="s"
id="required"
@@ -1,6 +1,6 @@
<script lang="ts">
import { Container } from '$lib/layout';
import { table } from '../store';
import { table, type ColumnsWidth } from '../store';
import Delete from './deleteIndex.svelte';
import CreateIndex from './createIndex.svelte';
import Overview from './overviewIndex.svelte';
@@ -8,6 +8,8 @@
import { Button } from '$lib/elements/forms';
import FailedModal from '../failedModal.svelte';
import { canWriteTables } from '$lib/stores/roles';
import { preferences } from '$lib/stores/preferences';
import { debounce } from '$lib/helpers/debounce';
import {
ActionMenu,
Badge,
@@ -26,7 +28,7 @@
IconPlus,
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { type ComponentProps, onDestroy } from 'svelte';
import { type ComponentProps, onDestroy, onMount } from 'svelte';
import { Click, trackEvent } from '$lib/actions/analytics';
import EmptySheet from '../layout/emptySheet.svelte';
import SpreadsheetContainer from '../layout/spreadsheet.svelte';
@@ -34,6 +36,7 @@
import type { PageData } from './$types';
import { showCreateColumnSheet } from '../store';
import { isSmallViewport } from '$lib/stores/viewport';
import { page } from '$app/state';
let {
data
@@ -52,15 +55,49 @@
let showDelete = $state(false);
let showOverview = $state(false);
let columns = $state([
{ id: 'key' },
{ id: 'type' },
{ id: 'columns' },
let columnsWidth = $state<ColumnsWidth | null>(null);
const tableId = page.params.table;
const organizationId = data.organization.$id ?? data.project.teamId;
const spreadsheetColumns = $derived([
{
id: 'key',
width: getColumnWidth('key', $isSmallViewport ? 250 : 200),
minimumWidth: $isSmallViewport ? 250 : 200,
resizable: true
},
{
id: 'type',
width: getColumnWidth('type', 120),
minimumWidth: 120,
resizable: true
},
{
id: 'columns',
width: getColumnWidth('columns', 200),
minimumWidth: 200,
resizable: true
},
// { id: 'orders' }, // design doesn't have orders atm
{ id: 'lengths' },
{ id: 'actions', width: 40, isAction: true }
{
id: 'lengths',
width: getColumnWidth('lengths', 180),
minimumWidth: 180,
resizable: true
},
{
id: 'actions',
width: 40,
isAction: true,
resizable: false
}
]);
onMount(() => {
columnsWidth = preferences.getColumnWidths(tableId + '#indexes');
});
function getColumnStatusBadge(status: string): ComponentProps<Badge>['type'] {
switch (status) {
case 'processing':
@@ -74,6 +111,46 @@
}
}
function getColumnWidth(columnId: string, defaultWidth: number): number {
const savedWidth = columnsWidth?.[columnId];
if (!savedWidth) return defaultWidth;
return savedWidth.resized;
}
function saveColumnsWidth({ columnId, newWidth }: { columnId: string; newWidth: number }) {
const existing = columnsWidth?.[columnId];
const fixed = existing
? typeof existing?.fixed === 'number'
? existing.fixed
: existing?.fixed?.min
: newWidth;
columnsWidth = {
...(columnsWidth ?? {}),
[columnId]: {
fixed,
resized: Math.ceil(newWidth)
}
};
saveColumnWidthsToPreferences({ columnId, newWidth, fixedWidth: fixed });
}
const saveColumnWidthsToPreferences = debounce(
(column: { columnId: string; newWidth: number; fixedWidth: number }) => {
if (!organizationId) return;
preferences.saveColumnWidths(organizationId, tableId + '#indexes', {
[column.columnId]: {
fixed: column.fixedWidth,
resized: Math.ceil(column.newWidth)
}
});
},
1000
);
onDestroy(() => ($showCreateColumnSheet.show = false));
const emptyCellsLimit = $derived($isSmallViewport ? 14 : 17);
@@ -108,12 +185,13 @@
<SpreadsheetContainer>
<Spreadsheet.Root
let:root
{columns}
columns={spreadsheetColumns}
height="100%"
allowSelection
emptyCells={emptyCellsCount}
bind:selectedRows={selectedIndexes}
bottomActionClick={() => (showCreateIndex = true)}>
bottomActionClick={() => (showCreateIndex = true)}
on:columnsResize={(resize) => saveColumnsWidth(resize.detail)}>
<svelte:fragment slot="header" let:root>
<Spreadsheet.Header.Cell column="key" {root}>Key</Spreadsheet.Header.Cell>
<Spreadsheet.Header.Cell column="type" {root}>Type</Spreadsheet.Header.Cell>
@@ -12,8 +12,9 @@
import { isRelationship, isSpatialType } from '../rows/store';
import { table, indexes } from '../store';
import { Icon, Layout } from '@appwrite.io/pink-svelte';
import { IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { IconCalendar, IconFingerPrint, IconPlus, IconX } from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import { columnOptions as baseColumnOptions } from '../columns/store';
let {
showCreateIndex = $bindable(false),
@@ -37,7 +38,11 @@
}
return !isRelationship(column) && !isSpatialType(column); // keep non-relationship and non-spatial
})
.map((column) => ({ value: column.key, label: column.key }))
.map((column) => ({
value: column.key,
label: column.key,
leadingIcon: baseColumnOptions.find((option) => option.type === column.type)?.icon
}))
);
let columnList = $state([{ value: '', order: '', length: null }]);
@@ -154,7 +159,7 @@
]
: undefined
});
trackEvent(Submit.IndexCreate);
trackEvent(Submit.IndexCreate, { type: 'manual' });
showCreateIndex = false;
} catch (err) {
addNotification({
@@ -196,9 +201,17 @@
...(selectedType === IndexType.Spatial
? []
: [
{ value: '$id', label: '$id' },
{ value: '$createdAt', label: '$createdAt' },
{ value: '$updatedAt', label: '$updatedAt' }
{ value: '$id', label: '$id', leadingIcon: IconFingerPrint },
{
value: '$createdAt',
label: '$createdAt',
leadingIcon: IconCalendar
},
{
value: '$updatedAt',
label: '$updatedAt',
leadingIcon: IconCalendar
}
]),
...columnOptions
]}
@@ -34,8 +34,6 @@
})
)
);
await invalidate(Dependencies.TABLE);
showDelete = false;
addNotification({
type: 'success',
message:
@@ -43,7 +41,13 @@
? 'Index has been deleted'
: `${selectedKeys.length} indexes have been deleted`
});
trackEvent(Submit.IndexDelete);
await invalidate(Dependencies.TABLE);
showDelete = false;
selectedIndex = Array.isArray(selectedIndex) ? [] : null;
} catch (e) {
error = e.message;
trackError(e, Submit.IndexDelete);
@@ -325,7 +325,7 @@
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.855) 21%,
rgba(255, 255, 255, 0.86) 32.25%,
#ffffff 100%
);
z-index: 20;
@@ -347,10 +347,9 @@
:global(.theme-dark) .spreadsheet-fade-bottom {
background: linear-gradient(
180deg,
rgba(25, 25, 28, 0.38) 13%,
rgba(25, 25, 28, 0.7) 21%,
rgba(25, 25, 28, 0.95) 38%,
var(--bgcolor-neutral-default, #19191c) 100%
rgba(29, 29, 33, 0) 0%,
rgba(29, 29, 33, 0.86) 21%,
#1d1d21 100%
);
}
@@ -5,16 +5,20 @@
import { Button, Form } from '$lib/elements/forms';
import { isTabletViewport } from '$lib/stores/viewport';
import { Badge, Divider, Layout, Sheet, Tag, Typography } from '@appwrite.io/pink-svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { beforeNavigate } from '$app/navigation';
let {
show = $bindable(false),
title,
closeOnBlur = false,
submit,
cancel,
children = null,
footer = null,
titleBadge = null,
topAction = null
topAction = null,
...restProps
}: {
show: boolean;
title: string;
@@ -36,17 +40,27 @@
onClick?: () => boolean | void | Promise<boolean | void>;
}
| undefined;
cancel?:
| {
text?: string;
disabled?: boolean;
onClick?: () => void;
}
| undefined;
children?: Snippet;
footer?: Snippet | null;
} = $props();
} & HTMLAttributes<HTMLDivElement> = $props();
let form: Form;
let submitting = $state(writable(false));
let copyText = $state(undefined);
beforeNavigate(() => {
show = false;
});
</script>
<div class="sheet-container" data-side-sheet-visible={show}>
<div class="sheet-container" data-side-sheet-visible={show} {...restProps}>
<Sheet bind:open={show} {closeOnBlur}>
<div slot="header" style:width="100%">
<Layout.Stack direction="row" justifyContent="space-between" alignItems="center">
@@ -110,8 +124,19 @@
{#if footer}
{@render footer?.()}
{/if}
<Button size="s" secondary on:click={() => (show = false)}
>Cancel</Button>
<Button
size="s"
secondary
disabled={cancel?.disabled}
on:click={() => {
if (cancel?.onClick) {
cancel.onClick();
} else {
show = false;
}
}}>{cancel?.text ?? 'Cancel'}</Button>
<Button
size="s"
submit
@@ -31,5 +31,5 @@
min={column.min}
max={column.max}
required={column.required}
step={column.type === 'double' ? 'any' : 1}
step={column.type === 'double' ? 0.1 : 1}
leadingIcon={!limited ? IconHashtag : undefined} />
@@ -58,7 +58,7 @@
},
{} as Record<string, unknown>
),
permissions: [],
permissions: existingData?.$permissions ?? [],
columns: availableColumns
};
}
@@ -97,7 +97,7 @@
export let data: PageData;
export let showRowCreateSheet: {
show: boolean;
row: Models.Row | null;
row: Partial<Models.Row> | null;
};
$: rows = writable(data.rows);
@@ -297,7 +297,7 @@
switch (type) {
case 'string':
return IconText;
case 'float':
case 'double':
case 'integer':
return IconHashtag;
case 'boolean':
@@ -543,7 +543,13 @@
}
if (action === 'duplicate-row') {
showRowCreateSheet.row = row;
/**
* remove dates because
* console can override timestamps!
*/
const { $createdAt, $updatedAt, ...rowWithoutDates } = row;
showRowCreateSheet.row = rowWithoutDates;
showRowCreateSheet.show = true;
}
@@ -1147,7 +1153,7 @@
</Table.Root>
<Layout.Stack direction="column" gap="m">
<Alert.Inline>To change the selection edit the relationship settings.</Alert.Inline>
<Alert.Inline title="To change the selection edit the relationship settings." />
</Layout.Stack>
{:else}
<p class="u-bold">This action is irreversible.</p>
@@ -1211,6 +1217,10 @@
padding-inline: 8px !important;
}
& :global(.input:has([type^='date'])) {
padding: 12px !important;
}
& :global(.input:focus-within) {
top: 0 !important;
}
@@ -33,9 +33,12 @@ export const tableColumns = writable<Column[]>([]);
export const isCsvImportInProgress = writable(false);
export const columnsOrder = writable<string[]>([]);
export const columnsWidth = writable<{
export type ColumnsWidth = {
[columnId: string]: { fixed: number | { min: number }; resized: number };
}>();
};
export const columnsWidth = writable<ColumnsWidth>();
type DatabaseSheetOptions = {
show: boolean;
@@ -6,7 +6,7 @@
import { Id } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { addNotification } from '$lib/stores/notifications';
import { canWriteTables } from '$lib/stores/roles';
import { sdk } from '$lib/stores/sdk';
@@ -14,6 +14,7 @@
import type { PageData } from './$types';
import { tableViewColumns } from './store';
import Confirm from '$lib/components/confirm.svelte';
import { subNavigation } from '$lib/stores/database';
export let data: PageData;
const databaseId = page.params.database;
@@ -38,7 +39,8 @@
type: 'success',
message: `${selectedTables.length} table${selectedTables.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.TABLES);
await invalidate(Dependencies.TABLES);
subNavigation.update();
} catch (error) {
addNotification({
type: 'error',
@@ -76,7 +78,7 @@
{:else if column.id === 'name'}
{table.name}
{:else}
{toLocaleDateTime(table[column.id])}
<DualTimeView time={table[column.id]} />
{/if}
</Table.Cell>
{/each}
@@ -86,10 +86,11 @@
</svelte:fragment>
</Alert.Inline>
{:else}
<Alert.Inline status="info" dismissible on:dismiss={() => (showAlert = false)}>
Some configuration changes are not live yet. Your function is redeploying —
changes will be applied once the build is complete.
</Alert.Inline>
<Alert.Inline
status="info"
dismissible
on:dismiss={() => (showAlert = false)}
title="Some configuration changes are not live yet. Your function is redeploying — changes will be applied once the build is complete." />
{/if}
{/if}
<Layout.Stack gap="xxxl">
@@ -4,7 +4,7 @@
import { Empty, EmptyFilter, EmptySearch, Id, PaginationWithLimit } from '$lib/components';
import { hasPageQueries } from '$lib/components/filters';
import { Button } from '$lib/elements/forms';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Container, ResponsiveContainerHeader } from '$lib/layout';
import { MessagingProviderType } from '@appwrite.io/console';
import CreateMessageDropdown from './createMessageDropdown.svelte';
@@ -208,7 +208,7 @@
{#if !message[column.id]}
-
{:else}
{toLocaleDateTime(message[column.id])}
<DualTimeView time={message[column.id]} />
{/if}
{:else}
{message[column.id]}
@@ -23,7 +23,7 @@
let formComponent: Form;
let isSubmitting = writable(false);
let showCustomId = false;
let id: string;
let id: string | null = null;
let subject: string;
let content: string;
let topics: string[];
@@ -24,7 +24,7 @@
let formComponent: Form;
let isSubmitting = writable(false);
let showCustomId = false;
let id: string;
let id: string | null = null;
let file: Models.File;
let data: Writable<[string, string][]> = writable([['', '']]);
let title: string;
@@ -22,7 +22,7 @@
let formComponent: Form;
let isSubmitting = writable(false);
let showCustomId = false;
let id: string;
let id: string | null = null;
let content: string;
let topics: string[];
let users: string[];
@@ -1,6 +1,7 @@
<script lang="ts">
import { type Models, MessagingProviderType } from '@appwrite.io/console';
import { CardGrid, Empty, PaginationInline, EmptySearch } from '$lib/components';
import { CardGrid, Empty, PaginationInline } from '$lib/components';
import { Card, Empty as PinkEmpty } from '@appwrite.io/pink-svelte';
import TopicsModal from '../topicsModal.svelte';
import { sdk } from '$lib/stores/sdk';
import { invalidate } from '$app/navigation';
@@ -139,17 +140,21 @@
{:else if message.status === 'draft'}
<Empty on:click={() => (showTopics = true)}>Add a topic</Empty>
{:else}
<EmptySearch hidePagination>
<div class="u-text-center">
No topics have been selected.
<p>
Need a hand? Check out our <Button
href="https://appwrite.io/docs/products/messaging/topics">
documentation</Button
>.
</p>
</div>
</EmptySearch>
<Card.Base padding="none">
<PinkEmpty
type="secondary"
title="No topics were selected"
description="No topics were selected for this message. Need a hand? Check out our documentation.">
<svelte:fragment slot="actions">
<Button
secondary
href="https://appwrite.io/docs/products/messaging/topics"
external>
Documentation
</Button>
</svelte:fragment>
</PinkEmpty>
</Card.Base>
{/if}
</svelte:fragment>
<svelte:fragment slot="actions">
@@ -4,14 +4,14 @@
import { CustomId, LabelCard } from '$lib/components';
import { providers } from '../store';
import { InputText } from '$lib/elements/forms';
import { Badge, Icon } from '@appwrite.io/pink-svelte';
import { Tag, Icon } from '@appwrite.io/pink-svelte';
import { IconPencil } from '@appwrite.io/pink-icons-svelte';
import { Providers } from '../../provider.svelte';
import { SmtpEncryption } from '@appwrite.io/console';
let name = '';
let showCustomId = false;
let id: string = null;
let id: string | null = null;
$: {
name;
@@ -152,12 +152,10 @@
{#if !showCustomId}
<div>
<Badge
variant="secondary"
content="Provider ID"
on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" slot="start" />
</Badge>
<Tag size="s" on:click={() => (showCustomId = !showCustomId)}>
<Icon icon={IconPencil} size="s" />
Provider ID
</Tag>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Provider" bind:id autofocus={false} />
@@ -14,7 +14,9 @@
const dispatch = createEventDispatcher();
let name: string, id: string, error: string;
let name: string,
id: string | null = null,
error: string | null = null;
let showCustomId = false;
const create = async () => {
@@ -8,7 +8,7 @@
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { Column } from '$lib/helpers/types';
import { page } from '$app/state';
import { canWriteTopics } from '$lib/stores/roles';
@@ -72,7 +72,9 @@
<Id value={topic.$id}>{topic.$id}</Id>
{/key}
{:else if column.type === 'datetime'}
{topic[column.id] ? toLocaleDateTime(topic[column.id]) : '-'}
{#if topic[column.id]}
<DualTimeView time={topic[column.id]} />
{:else}-{/if}
{:else if column.id === 'total'}
{topic.emailTotal + topic.smsTotal + topic.pushTotal}
{:else}
@@ -8,7 +8,7 @@
import { addNotification } from '$lib/stores/notifications';
import type { PageData } from './$types';
import ProviderType from '../../providerType.svelte';
import { toLocaleDateTime } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { project } from '$routes/(console)/project-[region]-[project]/store';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
@@ -109,7 +109,7 @@
{:else if column.id === 'type'}
<ProviderType type={subscriber.target.providerType} size="xs" />
{:else if column.id === '$createdAt'}
{toLocaleDateTime(subscriber[column.id])}
<DualTimeView time={subscriber[column.id]} />
{:else}
{subscriber[column.id]}
{/if}
@@ -6,7 +6,8 @@
import { Button } from '$lib/elements/forms';
import { canWriteKeys } from '$lib/stores/roles';
import type { Models } from '@appwrite.io/console';
import { diffDays, toLocaleDate, toLocaleDateTime } from '$lib/helpers/date';
import { diffDays } from '$lib/helpers/date';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { devKeyColumns, keyColumns, showDevKeysCreateModal } from '../store';
import { Badge, FloatingActionBar, Layout, Table } from '@appwrite.io/pink-svelte';
import DeleteBatch from './deleteBatch.svelte';
@@ -69,12 +70,20 @@
{key.name}
</Table.Cell>
<Table.Cell {root}>
{key.accessedAt ? toLocaleDate(key.accessedAt) : 'never'}
{#if key.accessedAt}
<DualTimeView time={key.accessedAt} />
{:else}
never
{/if}
</Table.Cell>
<Table.Cell {root}>
{@const expiration = getExpiryDetails(key)}
<Layout.Stack gap="s" direction="row">
{key.expire ? toLocaleDateTime(key.expire) : 'never'}
{#if key.expire}
<DualTimeView time={key.expire} />
{:else}
never
{/if}
{#if expiration.status}
<Badge
@@ -1,10 +1,11 @@
<script lang="ts" context="module">
import { total } from '$lib/helpers/array';
import { clampMin } from '$lib/helpers/numbers';
import type { Metric } from '$lib/sdk/usage';
export function totalMetrics(set: Array<unknown>): number {
if (!set) return 0;
return total((set as Metric[]).map((c) => c.value));
return clampMin(total((set as Metric[]).map((c) => c.value)));
}
</script>
@@ -10,7 +10,7 @@
import { total } from '$lib/layout/usage.svelte';
import { BillingPlan } from '$lib/constants.js';
import { base } from '$app/paths';
import { formatCurrency, formatNumberWithCommas } from '$lib/helpers/numbers';
import { formatCurrency, formatNumberWithCommas, clampMin } from '$lib/helpers/numbers';
import { getCountryName } from '$lib/helpers/diallingCodes.js';
import { Accordion, Icon, Layout, Link, Table, Typography } from '@appwrite.io/pink-svelte';
import { IconChartSquareBar } from '@appwrite.io/pink-icons-svelte';
@@ -36,11 +36,11 @@
$: legendData = [
{
name: 'Reads',
value: data.usage.databasesReads.reduce((sum, item) => sum + item.value, 0)
value: clampMin(data.usage.databasesReads.reduce((sum, item) => sum + item.value, 0))
},
{
name: 'Writes',
value: data.usage.databasesWrites.reduce((sum, item) => sum + item.value, 0)
value: clampMin(data.usage.databasesWrites.reduce((sum, item) => sum + item.value, 0))
}
];
@@ -89,9 +89,12 @@
</svelte:fragment>
</Alert.Inline>
{:else}
<Alert.Inline status="info" dismissible on:dismiss={() => (showAlert = false)}>
Some configuration changes are not live yet. Your site is redeploying — changes
will be applied once the build is complete.
<Alert.Inline
status="info"
title="Some configuration changes are not live yet. Your site is redeploying — changes
will be applied once the build is complete."
dismissible
on:dismiss={() => (showAlert = false)}>
</Alert.Inline>
{/if}
{/if}
@@ -22,7 +22,10 @@
siteId: selectedDeployment.resourceId,
deploymentId: selectedDeployment.$id
});
await invalidate(Dependencies.SITE);
await Promise.all([
invalidate(Dependencies.DEPLOYMENTS),
invalidate(Dependencies.SITE)
]);
if (page.url.href.includes(`deployment-${selectedDeployment.$id}`)) {
await goto(
`${base}/project-${page.params.region}-${page.params.project}/sites/site-${page.params.site}/deployments`
@@ -9,6 +9,7 @@
import { UsageCard } from '$lib/components';
import { humanFileSize } from '$lib/helpers/sizeConvertion';
import { formatTimeDetailed } from '$lib/helpers/timeConversion';
import { clampMin } from '$lib/helpers/numbers';
const now = new Date();
const rangeOptions = [
@@ -84,7 +85,10 @@
metric.value = time !== '0s' ? time : '-';
} else {
metric.value = usage[metric.id] ?? '-';
metric.value =
usage[metric.id] !== undefined
? clampMin(usage[metric.id]).toString()
: '-';
}
return metric;
});
@@ -60,7 +60,10 @@
trackError(error, Submit.DeploymentDelete);
} finally {
selectedRows = [];
invalidate(Dependencies.DEPLOYMENTS);
await Promise.all([
invalidate(Dependencies.DEPLOYMENTS),
invalidate(Dependencies.SITE)
]);
}
}
</script>
@@ -92,11 +92,11 @@
</script>
<Wizard
title="Create file"
href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/`}
bind:showExitModal
column
confirmExit>
confirmExit
title="Create file"
bind:showExitModal
href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/`}>
<Form bind:this={formComponent} onSubmit={create} bind:isSubmitting>
<Layout.Stack gap="xxl">
<Fieldset legend="Data">
@@ -115,6 +115,7 @@
>.
</Alert.Inline>
{/if}
<Upload.Dropzone
bind:files
required
@@ -123,6 +124,7 @@
<Typography.Text variant="l-500"
>Drag and drop files here or click to upload</Typography.Text>
</Upload.Dropzone>
{#if files}
<Upload.List
files={Array.from(files).map((f) => {
@@ -70,15 +70,15 @@
</Confirm>
{:else}
<Modal bind:show size="s" onSubmit={handleFileToken} title={getModalTitle()}>
<!-- TODO: docs link needed-->
<svelte:fragment slot="description">
{#if fileToken}
{@const formattedDate = cleanFormattedDate(fileToken.$createdAt)}
Edit the expiry of the file token created on <b>{formattedDate}</b>.
{:else}
Create a file token to grant access to a file.
<!-- TODO: @itznotabug, @adityaoberai need documentations link -->
<Link.Anchor href="https://appwrite.com/docs/">Learn more</Link.Anchor>.
<Link.Anchor href="https://appwrite.io/docs/products/storage/file-tokens"
>Learn more</Link.Anchor
>.
{/if}
</svelte:fragment>
@@ -0,0 +1,93 @@
<script lang="ts">
import { page } from '$app/state';
import { writable } from 'svelte/store';
import type { Models } from '@appwrite.io/console';
import { Navbar, SendVerificationEmailModal, Sidebar } from '$lib/components';
let sideBarIsOpen = writable(false);
let showAccountMenu = writable(false);
let showVerificationModal = $state(!page.data.account?.emailVerification);
// fake props!
const project = {
region: 'fra',
$id: 'appwrite',
name: 'Appwrite Project'
} as Models.Project;
const progressCard = { title: 'Get started', percentage: 33 };
const navbarProps = {
logo: {
src: 'https://appwrite.io/images/logos/logo.svg',
alt: 'Logo Appwrite'
},
avatar: undefined,
organizations: []
};
</script>
<svelte:head>
<title>Verify Email - Appwrite</title>
</svelte:head>
<div class="verify-email-page">
<Navbar
{...navbarProps}
bind:sideBarIsOpen={$sideBarIsOpen}
bind:showAccountMenu={$showAccountMenu} />
<Sidebar
bind:sideBarIsOpen={$sideBarIsOpen}
bind:showAccountMenu={$showAccountMenu}
{project}
avatar={navbarProps.avatar}
{progressCard}
state="open" />
<!-- email verification modal -->
<SendVerificationEmailModal
bind:show={showVerificationModal}
email={page.data.account?.email} />
</div>
<style lang="scss">
.verify-email-page {
display: flex;
min-height: 100vh;
position: relative;
width: 100%;
margin: 0;
padding: 0;
:global {
.sidebar {
position: fixed !important;
left: 0 !important;
top: 0 !important;
height: 100vh !important;
z-index: 1000 !important;
filter: blur(4px);
opacity: 0.6;
}
.navbar,
[data-pink-navbar],
header {
filter: blur(2px);
opacity: 0.4;
z-index: 1;
}
.email-verification-scrim {
z-index: 9999 !important;
filter: none !important;
opacity: 1 !important;
* {
filter: none !important;
opacity: 1 !important;
}
}
}
}
</style>
@@ -0,0 +1,45 @@
import { resolve } from '$app/paths';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import { VARS } from '$lib/system';
export const load: PageLoad = async ({ parent, depends, url }) => {
if (!VARS.EMAIL_VERIFICATION) {
redirect(303, resolve('/'));
}
const { account } = await parent();
depends(Dependencies.ACCOUNT);
const user = url.searchParams.get('userId') ?? null;
const secret = url.searchParams.get('secret') ?? null;
if (account && account.emailVerification === true) {
redirect(303, resolve('/'));
} else if (user && secret) {
try {
await sdk.forConsole.account.updateVerification({
userId: user,
secret
});
addNotification({
type: 'success',
message: 'Email has been verified',
timeout: 10000 // 10 seconds max, because if succeeded, loads takes some time!
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
redirect(303, resolve('/'));
}
} else {
redirect(303, resolve('/'));
}
};
@@ -0,0 +1,105 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { isSupportOnline } from './store';
describe('isSupportOnline', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
// Helper function to create a mock date
const mockDate = (hour: number, day: number) => {
const mockDate = new Date();
vi.spyOn(mockDate, 'getUTCDay').mockReturnValue(day);
vi.spyOn(mockDate, 'getUTCHours').mockReturnValue(hour);
vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
return mockDate;
};
describe('weekday support hours (Monday-Friday)', () => {
it('should be online at 16:00 UTC (Monday)', () => {
mockDate(16, 1); // Monday, 16:00 UTC
expect(isSupportOnline()).toBe(true);
});
it('should be online at 20:00 UTC (Tuesday)', () => {
mockDate(20, 2); // Tuesday, 20:00 UTC
expect(isSupportOnline()).toBe(true);
});
it('should be online at 23:59 UTC (Wednesday)', () => {
mockDate(23, 3); // Wednesday, 23:59 UTC
expect(isSupportOnline()).toBe(true);
});
it('should be online at 23:00 UTC (Thursday)', () => {
mockDate(23, 4); // Thursday, 23:00 UTC
expect(isSupportOnline()).toBe(true);
});
it('should be online at 22:00 UTC (Friday)', () => {
mockDate(22, 5); // Friday, 22:00 UTC
expect(isSupportOnline()).toBe(true);
});
});
describe('outside support hours on weekdays', () => {
it('should be offline at 15:59 UTC (Monday)', () => {
mockDate(15, 1); // Monday, 15:59 UTC
expect(isSupportOnline()).toBe(false);
});
it('should be offline at 00:00 UTC (Tuesday)', () => {
mockDate(0, 2); // Tuesday, 00:00 UTC
expect(isSupportOnline()).toBe(false);
});
it('should be offline at 10:00 UTC (Wednesday)', () => {
mockDate(10, 3); // Wednesday, 10:00 UTC
expect(isSupportOnline()).toBe(false);
});
it('should be offline at 14:00 UTC (Friday)', () => {
mockDate(14, 5); // Friday, 14:00 UTC
expect(isSupportOnline()).toBe(false);
});
});
describe('weekends', () => {
it('should be offline on Saturday regardless of hour', () => {
mockDate(18, 6); // Saturday, 18:00 UTC (in support hours for weekdays)
expect(isSupportOnline()).toBe(false);
});
it('should be offline on Sunday regardless of hour', () => {
mockDate(0, 0); // Sunday, 00:00 UTC
expect(isSupportOnline()).toBe(false);
});
it('should be offline on Saturday at midnight', () => {
mockDate(0, 6); // Saturday, 00:00 UTC
expect(isSupportOnline()).toBe(false);
});
it('should be offline on Sunday at 16:00', () => {
mockDate(16, 0); // Sunday, 16:00 UTC
expect(isSupportOnline()).toBe(false);
});
});
describe('edge cases', () => {
it('should handle boundary at 15:59 (just before support hours)', () => {
mockDate(15, 1); // Monday, 15:59 UTC
expect(isSupportOnline()).toBe(false);
});
it('should handle boundary at 16:00 (start of support hours)', () => {
mockDate(16, 1); // Monday, 16:00 UTC
expect(isSupportOnline()).toBe(true);
});
it('should handle late night hours on Friday', () => {
mockDate(23, 5); // Friday, 23:00 UTC
expect(isSupportOnline()).toBe(true);
});
});
});
+7 -4
View File
@@ -20,15 +20,18 @@ export function isSupportOnline() {
const day = currentDate.getUTCDay();
const hour = currentDate.getUTCHours();
if (day === 1 || day === 6) {
// Support is offline on weekends (Sunday = 0, Saturday = 6)
if (day === 0 || day === 6) {
return false;
}
if (hour < 14) {
return false;
// Support hours are 16:00 UTC to 23:59 UTC on weekdays
// This roughly covers 09:00 PT to 17:00 PT (depending on PST/PDT)
if (hour >= 16) {
return true;
}
return true;
return false;
}
export const showSupportModal = writable(false);
+11 -2
View File
@@ -6,10 +6,10 @@ import { redirect } from '@sveltejs/kit';
import { Dependencies } from '$lib/constants';
import type { LayoutLoad } from './$types';
import { redirectTo } from './store';
import { base } from '$app/paths';
import { base, resolve } from '$app/paths';
import type { Account } from '$lib/stores/user';
import type { AppwriteException } from '@appwrite.io/console';
import { isCloud } from '$lib/system';
import { isCloud, VARS } from '$lib/system';
import { checkPricingRefAndRedirect } from '$lib/helpers/pricingRedirect';
export const ssr = false;
@@ -29,6 +29,15 @@ export const load: LayoutLoad = async ({ depends, url, route }) => {
}
if (account) {
if (isCloud && !account.emailVerification && VARS.EMAIL_VERIFICATION) {
const isConsoleRoute = route.id?.startsWith('/(console)');
const isVerifyEmailPage = url.pathname === resolve('/verify-email');
if (isConsoleRoute && !isVerifyEmailPage) {
redirect(303, resolve('/verify-email'));
}
}
return {
account: account,
organizations: !isCloud
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB