Merge branch 'main' into 'revamp-designs'.

This commit is contained in:
Darshan
2025-10-28 19:18:04 +05:30
35 changed files with 2067 additions and 2323 deletions
+2 -2
View File
@@ -24,9 +24,9 @@
"@ai-sdk/svelte": "^1.1.24",
"@appwrite.io/console": "https://pkg.pr.new/appwrite-labs/cloud/@appwrite.io/console@fe3277e",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@85d1b43",
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@46f65c7",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@85d1b43",
"@appwrite.io/pink-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@46f65c7",
"@faker-js/faker": "^9.9.0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
+10 -10
View File
@@ -18,14 +18,14 @@ importers:
specifier: 0.25.0
version: 0.25.0
'@appwrite.io/pink-icons-svelte':
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@85d1b43
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@85d1b43(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@46f65c7
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@46f65c7(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@85d1b43
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@85d1b43(svelte@5.25.3)
specifier: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@46f65c7
version: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@46f65c7(svelte@5.25.3)
'@faker-js/faker':
specifier: ^9.9.0
version: 9.9.0
@@ -269,8 +269,8 @@ packages:
peerDependencies:
svelte: ^4.0.0
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@85d1b43':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@85d1b43}
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@46f65c7':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@46f65c7}
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@85d1b43':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@85d1b43}
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@46f65c7':
resolution: {tarball: https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@46f65c7}
version: 2.0.0-RC.2
peerDependencies:
svelte: ^4.0.0
@@ -3709,7 +3709,7 @@ snapshots:
dependencies:
svelte: 5.25.3
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@85d1b43(svelte@5.25.3)':
'@appwrite.io/pink-icons-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@46f65c7(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@85d1b43(svelte@5.25.3)':
'@appwrite.io/pink-svelte@https://pkg.vc/-/@appwrite/@appwrite.io/pink-svelte@46f65c7(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
+100 -44
View File
@@ -6,18 +6,25 @@
import { Dependencies } from '$lib/constants';
import { goto, invalidate } from '$app/navigation';
import { getProjectId } from '$lib/helpers/project';
import { writable, type Writable } from 'svelte/store';
import { addNotification } from '$lib/stores/notifications';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
import { Layout, Typography, Icon } from '@appwrite.io/pink-svelte';
import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte';
import { Modal, Code } from '$lib/components';
import { type Models, type Payload, Query } from '@appwrite.io/console';
// re-render the key for sheet UI.
import { hash } from '$lib/helpers/string';
import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store';
import { Link } from '$lib/elements';
type CsvImportError = {
[key: string]: number | string | null;
};
type ImportItem = {
status: string;
table?: string;
errors?: string[];
};
type ImportItemsMap = Map<string, ImportItem>;
@@ -28,7 +35,7 @@
* The structure is as follows -
* `{ migrationId: { status: status, table: table } }`
*/
const importItems: Writable<ImportItemsMap> = writable(new Map());
let importItems = $state<ImportItemsMap>(new Map());
async function showCompletionNotification(database: string, table: string, payload: Payload) {
const isSuccess = payload.status === 'completed';
@@ -37,13 +44,9 @@
if (!isSuccess && !isError) return;
let errorMessage = 'Import failed. Check your CSV for correct fields and required values.';
if (isError && Array.isArray(payload.errors)) {
try {
// the `errors` is a list of json encoded string.
errorMessage = JSON.parse(payload.errors[0]).message;
} catch {
// do nothing, fallback to default message.
}
const errors = getErrors(payload);
if (errors) {
errorMessage = extractErrorMessage(errors);
}
const type = isSuccess ? 'success' : 'error';
@@ -73,7 +76,7 @@
const resourceId = importData.resourceId ?? '';
const [databaseId, tableId] = resourceId.split(':') ?? [];
const current = $importItems.get(importData.$id);
const current = importItems.get(importData.$id);
let tableName = current?.table ?? null;
if (!tableName && tableId) {
@@ -91,30 +94,27 @@
}
if (tableId && tableName === null) {
importItems.update((items) => {
const next = new Map(items);
next.delete(importData.$id);
return next;
});
const next = new Map(importItems);
next.delete(importData.$id);
importItems = next;
return;
}
importItems.update((items) => {
const existing = items.get(importData.$id);
const existing = importItems.get(importData.$id);
const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);
const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;
const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;
if (shouldSkip) return items;
const next = new Map(items);
next.set(importData.$id, { status, table: tableName ?? undefined });
return next;
});
if (!shouldSkip) {
const next = new Map(importItems);
const errors = getErrors(importData);
next.set(importData.$id, { status, table: tableName ?? undefined, errors });
importItems = next;
}
if (status === 'completed' || status === 'failed') {
await showCompletionNotification(databaseId, tableId, importData);
@@ -122,10 +122,27 @@
}
function clear() {
importItems.update((items) => {
items.clear();
return items;
});
importItems = new Map();
}
function getErrors(importData: Payload | Models.Migration): string[] | undefined {
return Array.isArray(importData.errors) ? importData.errors : undefined;
}
function parseError(error: string): string | CsvImportError {
try {
return JSON.parse(error) as CsvImportError;
} catch {
return error;
}
}
function extractErrorMessage(errors: string[]): string {
try {
return JSON.parse(errors[0]).message;
} catch {
return 'Import failed. Check your CSV for correct fields and required values.';
}
}
function graphSize(status: string): number {
@@ -148,8 +165,9 @@
const name = collectionName ? `<b>${collectionName}</b>` : '';
switch (status) {
case 'completed':
return `CSV import completed${name ? ` to ${name}` : ''}`;
case 'failed':
return `Import to ${name} ${status}`;
return `CSV import failed${name ? ` to ${name}` : ''}`;
case 'processing':
return `Importing CSV file${name ? ` to ${name}` : ''}`;
default:
@@ -177,8 +195,18 @@
});
});
$: isOpen = true;
$: showCsvImportBox = $importItems.size > 0;
let isOpen = $state(true);
let showCsvImportBox = $derived(importItems.size > 0);
let showDetails = $state(false);
let selectedErrors = $state<string[]>([]);
let parsedErrors = $state<Array<string | CsvImportError>>([]);
function openDetails(errors: string[] | undefined) {
selectedErrors = errors ?? [];
parsedErrors = selectedErrors.map(parseError);
showDetails = true;
}
</script>
{#if showCsvImportBox}
@@ -187,26 +215,23 @@
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
Importing rows ({$importItems.size})
Importing rows ({importItems.size})
</Typography.Text>
</h4>
<button
class="upload-box-button"
class:is-open={isOpen}
aria-label="toggle upload box"
on:click={() => (isOpen = !isOpen)}>
onclick={() => (isOpen = !isOpen)}>
<span class="icon-cheveron-up" aria-hidden="true"></span>
</button>
<button
class="upload-box-button"
aria-label="close backup restore box"
on:click={clear}>
<button class="upload-box-button" aria-label="close CSV import box" onclick={clear}>
<span class="icon-x" aria-hidden="true"></span>
</button>
</header>
<div class="upload-box-content-list">
{#each [...$importItems.entries()] as [key, value] (key)}
{#each [...importItems.entries()] as [key, value] (key)}
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
@@ -222,6 +247,25 @@
class:is-danger={value.status === 'failed'}
style="--graph-size:{graphSize(value.status)}%">
</div>
{#if value.status === 'failed'}
<Layout.Stack
direction="row"
gap="xs"
alignItems="center"
inline>
<Icon
icon={IconExclamationCircle}
color="--fgcolor-error"
size="s" />
<Typography.Text color="--fgcolor-error">
There was an import issue.
<Link
style="color: inherit"
onclick={() => openDetails(value.errors)}
>View details</Link>
</Typography.Text>
</Layout.Stack>
{/if}
</section>
</li>
</ul>
@@ -232,6 +276,18 @@
</Layout.Stack>
{/if}
<Modal title="Import error" bind:show={showDetails} hideFooter>
<Layout.Stack gap="m">
<Layout.Stack>
<Code
language="json"
code={JSON.stringify(parsedErrors, null, 2)}
withCopy
allowScroll />
</Layout.Stack>
</Layout.Stack>
</Modal>
<style lang="scss">
.upload-box {
display: flex;
@@ -252,7 +308,7 @@
}
.upload-box-content {
width: 304px;
width: 324px;
}
.upload-box-button {
+2
View File
@@ -87,3 +87,5 @@ export { default as ExpirationInput } from './expirationInput.svelte';
export { default as EstimatedCard } from './estimatedCard.svelte';
export { default as SortButton, type SortDirection } from './sortButton.svelte';
export { default as SendVerificationEmailModal } from './account/sendVerificationEmailModal.svelte';
export { default as MultiSelectionTable } from './multiSelectTable.svelte';
export * from './multiSelectTable.svelte';
+164
View File
@@ -0,0 +1,164 @@
<script lang="ts" module>
export type DeleteOperationState = Error | void;
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import {
Table,
Badge,
Typography,
FloatingActionBar,
type TableColumn,
type TableRootProps
} from '@appwrite.io/pink-svelte';
import Confirm from './confirm.svelte';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
let {
columns,
resource,
allowSelection = true,
confirmDeletion = true,
showSuccessNotification = true,
computeKey = 'multiSelectionTable',
header,
children,
onDelete,
onCancel,
deleteContent,
deleteContentNotice
}: {
resource: string;
allowSelection?: boolean;
confirmDeletion?: boolean;
computeKey?: string | number;
showSuccessNotification?: boolean;
columns: Array<TableColumn> | number;
header: Snippet<[root: TableRootProps]>;
children: Snippet<[root: TableRootProps]>;
deleteContent?: Snippet<[count: number]>;
deleteContentNotice?: Snippet;
onDelete?: (selectedRows: string[]) => Promise<DeleteOperationState> | DeleteOperationState;
onCancel?: () => Promise<void> | void;
} = $props();
let selectedRows: string[] = $state([]);
let disableModal: boolean = $state(false);
let onDeleteError: string | null = $state(null);
let showConfirmDeletion: boolean = $state(false);
function notifySuccess() {
if (!showSuccessNotification) return;
const count = selectedRows.length;
if (count === 0) return;
const label = `${resource}${count > 1 ? 's' : ''}`;
addNotification({
type: 'success',
message: `${count} ${label} deleted`
});
}
// this is kept very basic!
function getPluralResource() {
if (resource.endsWith('ty')) {
return `${resource}ies`;
}
return `${resource}s`;
}
</script>
{#key computeKey}
<Table.Root let:root {columns} {allowSelection} bind:selectedRows>
<svelte:fragment slot="header" let:root>
{@render header?.(root)}
</svelte:fragment>
{@render children(root)}
</Table.Root>
{#if allowSelection && selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{selectedRows.length > 1 ? getPluralResource() : resource}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button
text
on:click={() => {
onCancel?.();
selectedRows = [];
}}>Cancel</Button>
<Button
secondary
on:click={async () => {
if (confirmDeletion) {
showConfirmDeletion = true;
} else {
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
// user should handle error on their own!
} else {
notifySuccess();
selectedRows = [];
}
}
}}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#if allowSelection && confirmDeletion}
<Confirm
submissionLoader
confirmDeletion
error={onDeleteError}
disabled={disableModal}
title="Delete {getPluralResource()}"
bind:open={showConfirmDeletion}
onSubmit={async () => {
disableModal = true;
onDeleteError = null;
const state = await onDelete?.(selectedRows);
if (state instanceof Error) {
disableModal = false;
onDeleteError = state.message || `Failed to delete ${resource}s`;
} else {
notifySuccess();
selectedRows = [];
disableModal = false;
showConfirmDeletion = false;
}
}}>
<Typography.Text>
{@const selectionCount = selectedRows.length}
{#if deleteContent}
<!-- some show extra info -->
{@render deleteContent(selectionCount)}
{:else}
Are you sure you want to delete <strong>{selectionCount}</strong>
{selectionCount > 1 ? getPluralResource() : resource}?
{/if}
</Typography.Text>
<Typography.Text variant="m-500">
{#if deleteContentNotice}
<!-- some show extra info -->
{@render deleteContentNotice()}
{:else}
This action is irreversible.
{/if}
</Typography.Text>
</Confirm>
{/if}
{/key}
@@ -113,26 +113,12 @@
}, []);
}
function sortRoles([a]: [string, Permission], [b]: [string, Permission]) {
if ((a === 'any') !== (b === 'any')) {
return a === 'any' ? -1 : 1;
}
if ((a === 'users') !== (b === 'users')) {
return a === 'users' ? -1 : 1;
}
if ((a === 'guests') !== (b === 'guests')) {
return a === 'guests' ? -1 : 1;
}
return a.localeCompare(b);
}
const columns: PinkColumn[] = [
{ id: 'role', width: { min: 80 } },
{ id: 'create', width: { min: 80 }, hide: !withCreate },
{ id: 'read', width: { min: 80 } },
{ id: 'update', width: { min: 80 } },
{ id: 'delete', width: { min: 80 } },
{ id: 'role', width: { min: 220 } },
{ id: 'create', width: { min: 64 }, hide: !withCreate },
{ id: 'read', width: { min: 64 } },
{ id: 'update', width: { min: 64 } },
{ id: 'delete', width: { min: 64 } },
{ id: 'action', width: 40 }
];
</script>
@@ -150,13 +136,14 @@
<Table.Header.Cell column="delete" {root}>Delete</Table.Header.Cell>
<Table.Header.Cell column="action" {root} />
</svelte:fragment>
{#each [...$groups].sort(sortRoles) as [role, permission] (role)}
{#each [...$groups] as [role, permission] (role)}
<Table.Row.Base {root}>
<Table.Cell column="role" {root}>
<Row {role} />
</Table.Cell>
<Table.Cell column="create" {root}>
<Selector.Checkbox
size="s"
checked={permission.create}
on:change={() => togglePermission(role, 'create')} />
</Table.Cell>
+10 -5
View File
@@ -140,6 +140,9 @@
{:else}
<Popover let:show let:hide {placement} portal>
<button
type="button"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
onmouseenter={() => {
if (!$menuOpen) {
setTimeout(show, 150);
@@ -159,7 +162,7 @@
{:then data}
{formatName(
data.name ?? data?.email ?? data?.phone ?? '-',
$isSmallViewport ? 5 : 7
$isSmallViewport ? 16 : 18
)}
{/await}
</Typography.Text>
@@ -200,7 +203,7 @@
<Layout.Stack alignItems="flex-start" gap="xxs">
<Layout.Stack style="padding-left: 0.25rem;">
<Typography.Text
size="s"
size="m"
color="--fgcolor-neutral-primary">
{data.customName}
</Typography.Text>
@@ -254,14 +257,14 @@
? `${base}/project-${page.params.region}-${page.params.project}/auth/user-${id}`
: `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${id}`}>
<Typography.Text
size="s"
size="m"
color="--fgcolor-neutral-primary">
{formatName(
data.name ??
data?.email ??
data?.phone ??
'-',
$isSmallViewport ? 12 : 20
$isSmallViewport ? 18 : 24
)}
</Typography.Text>
</Link.Anchor>
@@ -276,10 +279,11 @@
{#if isUser && (data.email || data.phone)}
<Divider />
<Layout.Stack gap="xs" alignItems="flex-start">
<Layout.Stack gap="xxs" alignItems="flex-start">
{#if data.email}
<Typography.Text
size="xs"
variant="m-400"
color="--fgcolor-neutral-secondary">
Email: {data.email}
</Typography.Text>
@@ -287,6 +291,7 @@
{#if data.phone}
<Typography.Text
size="xs"
variant="m-400"
color="--fgcolor-neutral-secondary">
Phone: {data.phone}
</Typography.Text>
+1 -1
View File
@@ -141,7 +141,7 @@
</div>
{:else}
<Card.Base padding="none">
<Empty title="You have no teams. Create a team to see them here." type="secondary">
<Empty title="No teams yet. Create a team to see it here." type="secondary">
<Typography.Text slot="description">
Need a hand? Learn more in our <Link.Anchor
href="https://appwrite.io/docs/products/auth/teams"
+2 -2
View File
@@ -176,10 +176,10 @@
</div>
{:else}
<Card.Base padding="none">
<Empty title="You have no users. Create a user to see them here." type="secondary">
<Empty title="No users yet. Create a user to see it here." type="secondary">
<Typography.Text slot="description">
Need a hand? Learn more in our <Link.Anchor
href="https://appwrite.io/docs/products/auth/quick-start"
href="https://appwrite.io/docs/products/auth/users"
target="_blank"
rel="noopener noreferrer">
documentation</Link.Anchor
+11 -1
View File
@@ -29,6 +29,7 @@
{#if href}
<Link.Anchor
{...$$restProps}
on:click
on:mousedown
on:click={track}
@@ -42,7 +43,16 @@
<slot />
</Link.Anchor>
{:else}
<Link.Button on:click on:mousedown on:click={track} {type} {disabled} {variant} {size} {icon}>
<Link.Button
{...$$restProps}
on:click
on:mousedown
on:click={track}
{type}
{disabled}
{variant}
{size}
{icon}>
<slot />
</Link.Button>
{/if}
+4 -1
View File
@@ -488,6 +488,9 @@ export async function paymentExpired(org: Organization) {
const nots = get(notifications);
const expiredNotification = nots.some((n) => n.message === expiredMessage);
const expiringNotification = nots.some((n) => n.message === expiringMessage);
const cardExpiry = new Date(payment.expiryYear, payment.expiryMonth, 1);
const nextMonth = new Date(year, month + 1, 1);
const isExpiringNextMonth = cardExpiry.getTime() === nextMonth.getTime();
if (payment.expired && !expiredNotification) {
addNotification({
type: 'error',
@@ -503,7 +506,7 @@ export async function paymentExpired(org: Organization) {
}
]
});
} else if (!expiringNotification && payment.expiryYear <= year && payment.expiryMonth < month) {
} else if (!expiringNotification && !payment.expired && isExpiringNextMonth) {
addNotification({
type: 'warning',
isHtml: true,
@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export let showCreateUser = writable(false);
</script>
@@ -9,8 +9,10 @@
import {
AvatarInitials,
Copy,
type DeleteOperationState,
Empty,
EmptySearch,
MultiSelectionTable,
PaginationWithLimit,
SearchQuery
} from '$lib/components';
@@ -20,14 +22,7 @@
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import Create from './createUser.svelte';
import {
Badge,
Icon,
Table,
Layout,
Typography,
FloatingActionBar
} from '@appwrite.io/pink-svelte';
import { Badge, Icon, Table, Layout, Typography } from '@appwrite.io/pink-svelte';
import { Tag } from '@appwrite.io/pink-svelte';
import { IconDuplicate, IconPlus } from '@appwrite.io/pink-icons-svelte';
import { canWriteUsers } from '$lib/stores/roles';
@@ -37,11 +32,11 @@
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
export let data;
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const columns = writable<Column[]>([
{ id: '$id', title: 'User ID', type: 'string', width: 200 },
@@ -59,44 +54,25 @@
}
]);
let selectedUsers: string[] = [];
let showDelete = false;
let deleting = false;
async function userCreated(event: CustomEvent<Models.User<Record<string, unknown>>>) {
await goto(
`${base}/project-${page.params.region}-${page.params.project}/auth/user-${event.detail.$id}`
);
}
async function handleDelete() {
showDelete = false;
deleting = true;
const promises = selectedUsers.map((userId) =>
sdk.forProject(page.params.region, page.params.project).users.delete(userId)
);
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((userId) => {
return sdk.forProject(page.params.region, page.params.project).users.delete({ userId });
});
try {
await Promise.all(promises);
trackEvent(Submit.UserDelete, {
total: selectedUsers.length
});
addNotification({
type: 'success',
message: `${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.USERS);
trackEvent(Submit.UserDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.UserDelete);
return error;
} finally {
selectedUsers = [];
showDelete = false;
deleting = false;
await invalidate(Dependencies.USERS);
}
}
</script>
@@ -116,101 +92,111 @@
</Layout.Stack>
{#if data.users.total}
<Table.Root
<MultiSelectionTable
resource="user"
columns={$columns}
allowSelection={$canWriteUsers}
bind:selectedRows={selectedUsers}
let:root>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteUsers}>
{#snippet header(root)}
{#each $columns as { id, title } (id)}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.users.users as user}
<Table.Row.Link
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${user.$id}`}
{root}
id={user.$id}>
{#each $columns as { id } (id)}
<Table.Cell column={id} {root}>
{#if id === '$id'}
<Copy value={user.$id} event="user">
<Tag size="xs" variant="code">
<Icon size="s" icon={IconDuplicate} slot="start" />
{user.$id}
</Tag>
</Copy>
{:else if id === 'name'}
<Layout.Stack direction="row" alignItems="center" gap="s">
{#if user.email || user.phone}
{#if user.name}
<AvatarInitials size="xs" name={user.name} />
{/snippet}
{#snippet children(root)}
{#each data.users.users as user}
<Table.Row.Link
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${user.$id}`}
{root}
id={user.$id}>
{#each $columns as { id } (id)}
<Table.Cell column={id} {root}>
{#if id === '$id'}
<Copy value={user.$id} event="user">
<Tag size="xs" variant="code">
<Icon size="s" icon={IconDuplicate} slot="start" />
{user.$id}
</Tag>
</Copy>
{:else if id === 'name'}
<Layout.Stack direction="row" alignItems="center" gap="s">
{#if user.email || user.phone}
{#if user.name}
<AvatarInitials size="xs" name={user.name} />
<Typography.Text truncate>
{user.name}
</Typography.Text>
{:else}
<div class="avatar is-size-small">
<span class="icon-minus-sm" aria-hidden="true"
></span>
</div>
{/if}
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"
></span>
</div>
<Typography.Text truncate>
{user.name}
</Typography.Text>
{:else}
<div class="avatar is-size-small">
<span class="icon-minus-sm" aria-hidden="true"
></span>
</div>
{/if}
</Layout.Stack>
{:else if id === 'identifiers'}
<Typography.Text truncate>
{user.email && user.phone
? [user.email, user.phone].join(',')
: user.email || user.phone}
</Typography.Text>
{:else if id === 'status'}
{#if user.status}
{@const success =
user.emailVerification || user.phoneVerification}
<Badge
size="xs"
variant="secondary"
type={success ? 'success' : undefined}
content={user.emailVerification &&
user.phoneVerification
? 'Verified'
: user.emailVerification
? 'Verified email'
: user.phoneVerification
? 'Verified phone'
: 'Unverified'} />
{:else}
<div class="avatar is-size-small">
<span class="icon-anonymous" aria-hidden="true"></span>
</div>
<Typography.Text truncate>
{user.name}
</Typography.Text>
<Badge
size="xs"
variant="secondary"
type="error"
content="blocked" />
{/if}
{:else if id === 'labels'}
<Typography.Text truncate>
{user.labels.join(', ')}
</Typography.Text>
{:else if id === 'joined'}
<DualTimeView time={user.registration} />
{:else if id === 'lastActivity'}
{#if user.accessedAt}
<DualTimeView time={user.accessedAt} />
{:else}
never
{/if}
</Layout.Stack>
{:else if id === 'identifiers'}
<Typography.Text truncate>
{user.email && user.phone
? [user.email, user.phone].join(',')
: user.email || user.phone}
</Typography.Text>
{:else if id === 'status'}
{#if user.status}
{@const success =
user.emailVerification || user.phoneVerification}
<Badge
size="xs"
variant="secondary"
type={success ? 'success' : undefined}
content={user.emailVerification && user.phoneVerification
? 'Verified'
: user.emailVerification
? 'Verified email'
: user.phoneVerification
? 'Verified phone'
: 'Unverified'} />
{:else}
<Badge
size="xs"
variant="secondary"
type="error"
content="blocked" />
{user[id]}
{/if}
{:else if id === 'labels'}
<Typography.Text truncate>
{user.labels.join(', ')}
</Typography.Text>
{:else if id === 'joined'}
<DualTimeView time={user.registration} />
{:else if id === 'lastActivity'}
{#if user.accessedAt}
<DualTimeView time={user.accessedAt} />
{:else}
never
{/if}
{:else}
{user[id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will permanently remove the selected users and all
their data.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Users"
@@ -220,9 +206,10 @@
{:else if data.search}
<EmptySearch target="users" hidePagination>
<Button
href={`${base}/project-${page.params.region}-${page.params.project}/auth`}
size="s"
secondary>Clear Search</Button>
secondary
href={`${base}/project-${page.params.region}-${page.params.project}/auth`}
>Clear Search</Button>
</EmptySearch>
{:else}
<Empty
@@ -232,33 +219,6 @@
allowCreate={$canWriteUsers}
on:click={() => showCreateUser.set(true)} />
{/if}
{#if selectedUsers.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedUsers.length.toString()} />
<span>
{selectedUsers.length > 1 ? 'users' : 'user'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedUsers = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</Container>
<Create bind:showCreate={$showCreateUser} on:created={userCreated} />
<Confirm title="Delete users" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedUsers.length}</b>
{selectedUsers.length > 1 ? 'users' : 'user'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will permanently remove the selected users and all their
data.
</Typography.Text>
</Confirm>
@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export let showCreateTeam = writable(false);
</script>
@@ -10,7 +10,9 @@
EmptySearch,
AvatarInitials,
SearchQuery,
PaginationWithLimit
PaginationWithLimit,
type DeleteOperationState,
MultiSelectionTable
} from '$lib/components';
import Create from '../createTeam.svelte';
import { goto } from '$app/navigation';
@@ -19,31 +21,16 @@
import type { Models } from '@appwrite.io/console';
import { writable } from 'svelte/store';
import { canWriteTeams } from '$lib/stores/roles';
import {
Icon,
Layout,
Table,
FloatingActionBar,
Badge,
Typography
} from '@appwrite.io/pink-svelte';
import { Icon, Layout, Table } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
import type { PageProps } from './$types';
export let data;
const region = page.params.region;
const project = page.params.project;
let selectedTeams: string[] = [];
let showDelete = false;
let deleting = false;
let { data }: PageProps = $props();
const columns = writable([
{ id: 'name', title: 'Name', type: 'string', width: { min: 200, max: 300 } },
@@ -52,37 +39,24 @@
]);
const teamCreated = async (event: CustomEvent<Models.Team<Record<string, unknown>>>) => {
await goto(`${base}/project-${region}-${project}/auth/teams/team-${event.detail.$id}`);
await goto(
`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${event.detail.$id}`
);
};
async function handleDelete() {
showDelete = false;
deleting = true;
const promises = selectedTeams.map((teamId) =>
sdk.forProject(page.params.region, page.params.project).teams.delete(teamId)
);
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((teamId) => {
return sdk.forProject(page.params.region, page.params.project).teams.delete({ teamId });
});
try {
await Promise.all(promises);
trackEvent(Submit.TeamDelete, {
total: selectedTeams.length
});
addNotification({
type: 'success',
message: `${selectedTeams.length} team${selectedTeams.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.TEAMS);
trackEvent(Submit.TeamDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.TeamDelete);
return error;
} finally {
selectedTeams = [];
showDelete = false;
deleting = false;
await invalidate(Dependencies.TEAMS);
}
}
</script>
@@ -101,54 +75,47 @@
</Layout.Stack>
{#if data.teams.total}
<Table.Root
<MultiSelectionTable
resource="team"
columns={$columns}
allowSelection={$canWriteTeams}
bind:selectedRows={selectedTeams}
let:root>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteTeams}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.teams.teams as team (team.$id)}
<Table.Row.Link
{root}
href={`${base}/project-${region}-${project}/auth/teams/team-${team.$id}`}
id={team.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'name'}
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={team.name} />
<span class="u-trim">{team.name}</span>
</Layout.Stack>
{:else if column.id === 'members'}
{team.total} members
{:else if column.id === 'created'}
<DualTimeView time={team.$createdAt} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedTeams.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedTeams.length.toString()} />
<span>
{selectedTeams.length > 1 ? 'teams' : 'team'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedTeams = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#snippet children(root)}
{@const TableRowComponent = $canWriteTeams ? Table.Row.Link : Table.Row.Base}
{#each data.teams.teams as team (team.$id)}
{@const href = $canWriteTeams
? `${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${team.$id}`
: undefined}
<TableRowComponent {root} {href} id={team.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === 'name'}
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={team.name} />
<span class="u-trim">{team.name}</span>
</Layout.Stack>
{:else if column.id === 'members'}
{team.total} members
{:else if column.id === 'created'}
<DualTimeView time={team.$createdAt} />
{/if}
</Table.Cell>
{/each}
</TableRowComponent>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will permanently remove the selected teams and all
their memberships.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Teams"
@@ -157,7 +124,10 @@
total={data.teams.total} />
{:else if data.search}
<EmptySearch target="teams" search={data.search} hidePagination={data.teams.total === 0}>
<Button secondary size="s" href={`${base}/project-${region}-${project}/auth/teams`}>
<Button
size="s"
secondary
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams`}>
Clear Search
</Button>
</EmptySearch>
@@ -172,14 +142,3 @@
</Container>
<Create bind:showCreate={$showCreateTeam} on:created={teamCreated} />
<Confirm title="Delete teams" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedTeams.length}</b>
{selectedTeams.length > 1 ? 'teams' : 'team'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will permanently remove the selected teams and all their
memberships.
</Typography.Text>
</Confirm>
@@ -1,77 +1,50 @@
<script lang="ts">
import { page } from '$app/state';
import { Empty, EmptySearch, AvatarInitials, PaginationWithLimit } from '$lib/components';
import {
Empty,
EmptySearch,
AvatarInitials,
PaginationWithLimit,
MultiSelectionTable,
type DeleteOperationState
} from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import type { PageData } from './$types';
import type { PageProps } from './$types';
import CreateMember from '../createMembership.svelte';
import DeleteMembership from '../deleteMembership.svelte';
import { Dependencies } from '$lib/constants';
import { Click, trackEvent, Submit, trackError } from '$lib/actions/analytics';
import {
Table,
Layout,
Icon,
FloatingActionBar,
Badge,
Typography
} from '@appwrite.io/pink-svelte';
import { Table, Layout, Icon } from '@appwrite.io/pink-svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
import Confirm from '$lib/components/confirm.svelte';
export let data: PageData;
const { data }: PageProps = $props();
let showCreate = false;
let showDelete = false;
let selectedMembership: Models.Membership;
let selectedMemberships: string[] = [];
let showBulkDelete = false;
let deleting = false;
let showCreate = $state(false);
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
const region = page.params.region;
const project = page.params.project;
async function memberCreated() {
invalidate(Dependencies.MEMBERSHIPS);
}
async function handleBulkDelete() {
showBulkDelete = false;
deleting = true;
const promises = selectedMemberships.map((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((membershipId) => {
return sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: page.params.team,
membershipId
})
);
});
});
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, {
total: selectedMemberships.length
});
addNotification({
type: 'success',
message: `${selectedMemberships.length} membership${selectedMemberships.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.MEMBERSHIPS);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MembershipUpdate);
return error;
} finally {
selectedMemberships = [];
showBulkDelete = false;
deleting = false;
await invalidate(Dependencies.MEMBERSHIPS);
}
}
</script>
@@ -85,71 +58,62 @@
</Layout.Stack>
{#if data.memberships.total}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedMemberships}
<MultiSelectionTable
resource="membership"
onDelete={handleBulkDelete}
columns={[
{ id: 'name' },
{ id: 'roles' },
{ id: 'joined' },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="roles" {root}>Roles</Table.Header.Cell>
<Table.Header.Cell column="joined" {root}>Joined</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.memberships.memberships as membership (membership.$id)}
{@const username = membership.userName ? membership.userName : '-'}
<Table.Row.Link
{root}
href={`${base}/project-${region}-${project}/auth/user-${membership.userId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={username} />
<span>{username}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
on:click|preventDefault={() => {
selectedMembership = membership;
showDelete = true;
trackEvent(Click.MembershipDeleteClick);
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedMemberships.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedMemberships.length.toString()} />
<span>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedMemberships = [])}>Cancel</Button>
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#snippet children(root)}
{#each data.memberships.memberships as membership (membership.$id)}
{@const username = membership.userName ? membership.userName : '-'}
<Table.Row.Link
{root}
href={`${base}/project-${page.params.region}-${page.params.project}/auth/user-${membership.userId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={username} />
<span>{username}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
onclick={(event) => {
event.preventDefault();
showDelete = true;
selectedMembership = membership;
trackEvent(Click.MembershipDeleteClick);
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will remove the selected members from this team.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Memberships"
@@ -179,22 +143,12 @@
{/if}
</Container>
<CreateMember teamId={page.params.team} bind:showCreate on:created={memberCreated} />
<DeleteMembership
{selectedMembership}
bind:showDelete
on:deleted={() => invalidate(Dependencies.MEMBERSHIPS)} />
<CreateMember
bind:showCreate
teamId={page.params.team}
on:created={() => invalidate(Dependencies.MEMBERSHIPS)} />
<Confirm
title="Delete memberships"
bind:open={showBulkDelete}
onSubmit={handleBulkDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedMemberships.length}</b>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will remove the selected members from this team.
</Typography.Text>
</Confirm>
<DeleteMembership
bind:showDelete
{selectedMembership}
on:deleted={() => invalidate(Dependencies.MEMBERSHIPS)} />
@@ -1,122 +1,88 @@
<script lang="ts">
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import type { Column } from '$lib/helpers/types';
import { oAuthProviders } from '$lib/stores/oauth-providers';
import { app } from '$lib/stores/app';
import { base } from '$app/paths';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
import { page } from '$app/state';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.users.deleteIdentity({ identityId: id })
);
.users.deleteIdentity({ identityId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.UserIdentityDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} target${selectedIds.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.USER_IDENTITIES);
trackEvent(Submit.UserIdentityDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.UserIdentityDelete);
return error;
} finally {
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.USER_IDENTITIES);
}
}
</script>
<Table.Root {columns} allowSelection let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable {columns} resource="identity" onDelete={handleDelete}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.identities.identities as identity (identity.$id)}
<Table.Row.Base {root} id={identity.$id}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key columns}
<Id value={identity[column.id]}>
{identity[column.id]}
</Id>
{/key}
{:else if column.id === 'provider'}
{@const provider = oAuthProviders[identity[column.id]]}
<div class="u-inline-flex u-cross-center u-gap-8">
<div class="avatar is-size-small">
<img
style="--p-text-size: 1rem"
height="20"
width="20"
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
alt={provider.name} />
{/snippet}
{#snippet children(root)}
{#each data.identities.identities as identity (identity.$id)}
<Table.Row.Base {root} id={identity.$id}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key columns}
<Id value={identity[column.id]}>
{identity[column.id]}
</Id>
{/key}
{:else if column.id === 'provider'}
{@const provider = oAuthProviders[identity[column.id]]}
<div class="u-inline-flex u-cross-center u-gap-8">
<div class="avatar is-size-small">
<img
style="--p-text-size: 1rem"
height="20"
width="20"
src={`${base}/icons/${$app.themeInUse}/color/${provider.icon}.svg`}
alt={provider.name} />
</div>
{provider.name}
</div>
{provider.name}
</div>
{:else if column.type === 'datetime'}
{#if !identity[column.id]}
-
{:else if column.type === 'datetime'}
{#if !identity[column.id]}
-
{:else}
<DualTimeView time={identity[column.id]} />
{/if}
{:else}
<DualTimeView time={identity[column.id]} />
{identity[column.id]}
{/if}
{:else}
{identity[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'identities' : 'identity'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete Identity" bind:open={showDelete} onSubmit={handleDelete}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'identities' : 'identity'}?
</Typography.Text>
</Confirm>
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -1,50 +1,36 @@
<script lang="ts">
import { page } from '$app/state';
import { base } from '$app/paths';
import { AvatarInitials } from '$lib/components';
import {
AvatarInitials,
type DeleteOperationState,
MultiSelectionTable
} from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Container } from '$lib/layout';
import DeleteMembership from '../deleteMembership.svelte';
import type { Models } from '@appwrite.io/console';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
Table,
Layout,
Empty,
Card,
FloatingActionBar,
Badge,
Typography
} from '@appwrite.io/pink-svelte';
import { Table, Layout, Empty, Card } from '@appwrite.io/pink-svelte';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import Confirm from '$lib/components/confirm.svelte';
import type { PageProps } from './$types';
export let data;
const { data }: PageProps = $props();
let selectedMembership: Models.Membership;
let showDelete = false;
let selectedMemberships: string[] = [];
let showBulkDelete = false;
let deleting = false;
const region = page.params.region;
const project = page.params.project;
async function handleBulkDelete() {
showBulkDelete = false;
deleting = true;
let showDelete = $state(false);
let selectedMembership: Models.Membership | null = $state(null);
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
// Precompute a lookup map from membershipId to teamId for efficient access
const membershipIdToTeamId: Record<string, string> = {};
for (const m of data.memberships.memberships) {
membershipIdToTeamId[m.$id] = m.teamId;
for (const membership of data.memberships.memberships) {
membershipIdToTeamId[membership.$id] = membership.teamId;
}
const promises = selectedMemberships.map((membershipId) =>
const promises = selectedRows.map((membershipId) =>
sdk.forProject(page.params.region, page.params.project).teams.deleteMembership({
teamId: membershipIdToTeamId[membershipId] || '',
membershipId
@@ -53,78 +39,73 @@
try {
await Promise.all(promises);
trackEvent(Submit.MembershipUpdate, {
total: selectedMemberships.length
});
addNotification({
type: 'success',
message: `${selectedMemberships.length} membership${selectedMemberships.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.MEMBERSHIPS);
trackEvent(Submit.MembershipUpdate, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MembershipUpdate);
return error;
} finally {
selectedMemberships = [];
showBulkDelete = false;
deleting = false;
await invalidate(Dependencies.MEMBERSHIPS);
}
}
</script>
<Container>
{#if data.memberships.total}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedMemberships}
<MultiSelectionTable
resource="membership"
onDelete={handleBulkDelete}
columns={[
{ id: 'name' },
{ id: 'roles' },
{ id: 'joined' },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
<Table.Header.Cell column="name" {root}>Name</Table.Header.Cell>
<Table.Header.Cell column="roles" {root}>Roles</Table.Header.Cell>
<Table.Header.Cell column="joined" {root}>Joined</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.memberships.memberships as membership}
<Table.Row.Link
{root}
href={`${base}/project-${region}-${project}/auth/teams/team-${membership.teamId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={membership.teamName} />
<span>{membership.teamName ? membership.teamName : 'n/a'}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
on:click|preventDefault={() => {
selectedMembership = membership;
showDelete = true;
trackEvent('click_delete_membership');
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#snippet children(root)}
{#each data.memberships.memberships as membership}
<Table.Row.Link
{root}
href={`${base}/project-${page.params.region}-${page.params.project}/auth/teams/team-${membership.teamId}`}
id={membership.$id}>
<Table.Cell column="name" {root}>
<Layout.Stack direction="row" alignItems="center">
<AvatarInitials size="xs" name={membership.teamName} />
<span>{membership.teamName ? membership.teamName : 'n/a'}</span>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="roles" {root}>
{membership.roles}
</Table.Cell>
<Table.Cell column="joined" {root}>
<DualTimeView time={membership.joined} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<button
class="button is-only-icon is-text"
aria-label="Delete item"
onclick={(event) => {
event.preventDefault();
selectedMembership = membership;
showDelete = true;
trackEvent('click_delete_membership');
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</Table.Cell>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will remove the user from the selected teams.
{/snippet}
</MultiSelectionTable>
{:else}
<Card.Base padding="none">
<Empty
@@ -142,36 +123,6 @@
</Empty>
</Card.Base>
{/if}
{#if selectedMemberships.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedMemberships.length.toString()} />
<span>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedMemberships = [])}>Cancel</Button>
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</Container>
<DeleteMembership {selectedMembership} bind:showDelete />
<Confirm
title="Delete memberships"
bind:open={showBulkDelete}
onSubmit={handleBulkDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedMemberships.length}</b>
{selectedMemberships.length > 1 ? 'memberships' : 'membership'}?
</Typography.Text>
<Typography.Text>
This action is irreversible and will remove the user from the selected teams.
</Typography.Text>
</Confirm>
@@ -1,6 +1,5 @@
<script lang="ts">
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -10,108 +9,74 @@
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { MessagingProviderType } from '@appwrite.io/console';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.users.deleteTarget({ userId: page.params.user, targetId: id })
);
.users.deleteTarget({ userId: page.params.user, targetId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.UserTargetDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} target${selectedIds.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.USER_TARGETS);
trackEvent(Submit.UserTargetDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.UserTargetDelete);
return error;
} finally {
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.USER_TARGETS);
}
}
</script>
<Table.Root columns={$columns} allowSelection let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable resource="target" columns={$columns} onDelete={handleDelete}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.targets.targets as target (target.$id)}
{@const provider = data.providersById[target.providerId]}
<Table.Row.Base {root} id={target.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={target[column.id]}>
{target[column.id]}
</Id>
{/key}
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{/snippet}
{#snippet children(root)}
{#each data.targets.targets as target (target.$id)}
{@const provider = data.providersById[target.providerId]}
<Table.Row.Base {root} id={target.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={target[column.id]}>
{target[column.id]}
</Id>
{/key}
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else}
{target.identifier}
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={target.providerType} size="s" />
{:else if column.id === 'provider'}
{#if provider}
<Provider provider={provider.provider} />
{/if}
{:else if column.id === '$createdAt'}
<DualTimeView time={target[column.id]} />
{:else}
{target.identifier}
{target[column.id]}
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={target.providerType} size="s" />
{:else if column.id === 'provider'}
{#if provider}
<Provider provider={provider.provider} />
{/if}
{:else if column.id === '$createdAt'}
<DualTimeView time={target[column.id]} />
{:else}
{target[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'targets' : 'target'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete Target" bind:open={showDelete} onSubmit={handleDelete}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'targets' : 'target'}?
</Typography.Text>
</Confirm>
</Table.Cell>
{/each}
</Table.Row.Base>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -69,11 +69,11 @@
await sdk
.forProject(page.params.region, page.params.project)
.backups.createArchive(['databases'], data.database.$id);
await invalidate(Dependencies.BACKUPS);
addNotification({
type: 'success',
message: 'Database backup has started'
});
invalidate(Dependencies.BACKUPS);
trackEvent('click_manual_submit');
showFeedbackNotification();
} catch (error) {
@@ -86,7 +86,7 @@
}
};
const trackEvents = (policies) => {
const trackEvents = (policies: UserBackupPolicy[]) => {
policies.forEach((policy) => {
let actualDay = null;
const monthlyBackupFrequency = policy.monthlyBackupFrequency;
@@ -139,7 +139,6 @@
? `Backup policies have been created`
: `<b>${totalPolicies[0].label}</b> policy has been created`;
// TODO: html isn't yet supported on Toast.
addNotification({
isHtml: true,
type: 'success',
@@ -148,7 +147,7 @@
trackEvents(totalPolicies);
invalidate(Dependencies.BACKUPS);
await invalidate(Dependencies.BACKUPS);
showFeedbackNotification();
} catch (err) {
addNotification({
@@ -1,5 +1,11 @@
<script lang="ts">
import { Card, Modal } from '$lib/components';
import {
Card,
Confirm,
type DeleteOperationState,
Modal,
MultiSelectionTable
} from '$lib/components';
import { Button, InputCheckbox, InputText } from '$lib/elements/forms';
import RestoreModal from './restoreModal.svelte';
import type { PageData } from './$types';
@@ -11,17 +17,14 @@
import { ID } from '@appwrite.io/console';
import { columns } from './store';
import { database } from '../store';
import type { BackupArchive } from '$lib/sdk/backups';
import type { BackupArchive, BackupPolicy } from '$lib/sdk/backups';
import { Click, trackEvent } from '$lib/actions/analytics';
import { copy } from '$lib/helpers/copy';
import { LabelCard } from '$lib/components/index.js';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { Dependencies } from '$lib/constants';
import {
ActionMenu,
Badge,
FloatingActionBar,
Icon,
Layout,
Popover,
@@ -40,24 +43,29 @@
} from '@appwrite.io/pink-icons-svelte';
import { capitalize } from '$lib/helpers/string';
import Ellipse from './components/Ellipse.svelte';
import Confirm from '$lib/components/confirm.svelte';
import { page } from '$app/state';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let showDelete = false;
let selectedBackup: BackupArchive = null;
let showDelete = $state(false);
let selectedBackup: BackupArchive | null = $state(null);
let showDropdown = [];
let selectedBackups: string[] = [];
let showRestore = false;
let showCustomId = false;
let newDatabaseInfo: { name: string; id: string } = { name: null, id: null };
let showRestore = $state(false);
let showCustomId = $state(false);
let newDatabaseInfo: { name: string | null; id: string | null } = $state({
name: null,
id: null
});
let confirmSameDbRestore = false;
let selectedRestoreOption = 'new';
let restoreOptions = [
let confirmSameDbRestore = $state(false);
let selectedRestoreOption = $state('new');
const restoreOptions = [
{
id: 'new',
title: 'Restore in new database',
@@ -70,89 +78,21 @@
}
];
const deleteBackups = async () => {
if (!selectedBackups.length && selectedBackup) {
selectedBackups.push(selectedBackup.$id);
}
const message = `${selectedBackups.length} backup${selectedBackups.length > 1 ? 's have been' : ''} deleted`;
const promises = selectedBackups.map((archiveId) =>
sdk.forProject(page.params.region, page.params.project).backups.deleteArchive(archiveId)
const disableRestoreButton = $derived.by(() => {
return (
(selectedRestoreOption === 'new' &&
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
(selectedRestoreOption === 'same' && !confirmSameDbRestore)
);
});
try {
await Promise.all(promises);
addNotification({
message,
type: 'success'
});
invalidate(Dependencies.BACKUPS);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showDelete = false;
selectedBackup = null;
selectedBackups = [];
}
};
const restoreBackup = async () => {
if (selectedRestoreOption === 'same') {
newDatabaseInfo.id = $database.$id;
newDatabaseInfo.name = $database.name;
}
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
addNotification({
type: 'success',
message: 'Database restore initiated'
});
invalidate(Dependencies.BACKUPS);
trackEvent('backup_restore_submit', {
newDatabaseName: newDatabaseInfo.name
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showRestore = false;
}
};
const policyDetails = (policyId: string | null) =>
data.policies.policies.find((policy) => policy.$id === policyId);
const cleanBackupName = (backup: BackupArchive) =>
toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
$: if (!showRestore && !showDelete) {
showCustomId = false;
selectedBackup = null;
confirmSameDbRestore = false;
selectedRestoreOption = 'new';
newDatabaseInfo = { name: null, id: null };
function getPolicyDetails(policyId: string | null): BackupPolicy | null {
return data.policies.policies.find((policy) => policy.$id === policyId);
}
$: disableButton =
(selectedRestoreOption === 'new' &&
(!newDatabaseInfo.name || $database.$id === newDatabaseInfo.id)) ||
(selectedRestoreOption === 'same' && !confirmSameDbRestore);
function getCleanBackupName(backup: BackupArchive): string {
return toLocaleDateTime(backup.$createdAt).replaceAll(',', '');
}
function getBackupStatus(backup: BackupArchive) {
switch (backup.status) {
@@ -169,142 +109,210 @@
return 'waiting';
}
}
async function deleteBackups(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((archiveId) => {
return sdk
.forProject(page.params.region, page.params.project)
.backups.deleteArchive(archiveId);
});
try {
await Promise.all(promises);
if (selectedBackup) {
addNotification({
type: 'success',
message: '1 backup deleted'
});
}
} catch (error) {
if (selectedBackup) {
addNotification({
type: 'error',
message: error.message
});
} else {
return error;
}
} finally {
if (selectedBackup) {
showDelete = false;
selectedBackup = null;
}
await invalidate(Dependencies.BACKUPS);
}
}
async function restoreBackup() {
if (selectedRestoreOption === 'same') {
newDatabaseInfo.id = $database.$id;
newDatabaseInfo.name = $database.name;
}
try {
await sdk
.forProject(page.params.region, page.params.project)
.backups.createRestoration(
selectedBackup.$id,
['databases'],
newDatabaseInfo.id ?? ID.unique(),
newDatabaseInfo.name
);
await invalidate(Dependencies.BACKUPS);
addNotification({
type: 'success',
message: 'Database restore initiated'
});
trackEvent('backup_restore_submit', { newDatabaseName: newDatabaseInfo.name });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
} finally {
showRestore = false;
}
}
$effect(() => {
if (!showRestore && !showDelete) {
showCustomId = false;
selectedBackup = null;
confirmSameDbRestore = false;
selectedRestoreOption = 'new';
newDatabaseInfo = { name: null, id: null };
}
});
</script>
<Table.Root let:root allowSelection columns={$columns} bind:selectedRows={selectedBackups}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable
resource="backup"
columns={$columns}
onDelete={deleteBackups}
computeKey={data.backups.archives.length}>
{#snippet header(root)}
{#each $columns as column}
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{/snippet}
{#each data.backups.archives as backup, index}
{@const policy = policyDetails(backup.policyId)}
{@const retainedUntil = new Date(
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
)}
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
<Table.Row.Base id={backup.$id} {root}>
<Table.Cell column="backups" {root}>
<DualTimeView time={backup.$createdAt}>
{cleanBackupName(backup)}
</DualTimeView>
</Table.Cell>
<Table.Cell column="size" {root}>
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="status" {root}>
{@const backupStatus = getBackupStatus(backup)}
<Status status={backupStatus} label={capitalize(backupStatus)} />
<!--{#if backup.status === 'Failed'}-->
<!-- <span class="u-underline">Get support</span>-->
<!--{/if}-->
</Table.Cell>
<Table.Cell column="policy" {root}>
<div class="u-flex u-cross-baseline">
<Tooltip maxWidth="fit-content">
<span>
{policy?.name || 'Manual'}
</span>
<span slot="tooltip"
>{policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="action-cell u-flex u-main-end u-width-full-line">
<Popover let:toggle padding="m" placement="bottom-end">
<Button extraCompact on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root width="180px" noPadding>
{#if backup.status === 'completed'}
{#snippet children(root)}
{#each data.backups.archives as backup, index}
{@const policy = getPolicyDetails(backup.policyId)}
{@const retainedUntil = new Date(
new Date(policy?.$createdAt).getTime() + policy?.retention * 24 * 60 * 60 * 1000
)}
{@const formattedRetainedUntil = `${retainedUntil.getDate()} ${retainedUntil.toLocaleString('en-US', { month: 'short' })}, ${retainedUntil.getFullYear()} ${retainedUntil.toLocaleTimeString('en-US', { hour12: false })}`}
<Table.Row.Base id={backup.$id} {root}>
<Table.Cell column="backups" {root}>
<DualTimeView time={backup.$createdAt}>
{getCleanBackupName(backup)}
</DualTimeView>
</Table.Cell>
<Table.Cell column="size" {root}>
{#if backup.status === 'completed'}
{calculateSize(backup.size)}
{:else}
-
{/if}
</Table.Cell>
<Table.Cell column="status" {root}>
{@const backupStatus = getBackupStatus(backup)}
<Status status={backupStatus} label={capitalize(backupStatus)} />
<!--{#if backup.status === 'Failed'}-->
<!-- <span class="u-underline">Get support</span>-->
<!--{/if}-->
</Table.Cell>
<Table.Cell column="policy" {root}>
<div class="u-flex u-cross-baseline">
<Tooltip maxWidth="fit-content">
<span>
{policy?.name || 'Manual'}
</span>
<span slot="tooltip"
>{policy
? `Retained until: ${formattedRetainedUntil}`
: `Retained forever`}</span>
</Tooltip>
</div>
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="action-cell u-flex u-main-end u-width-full-line">
<Popover let:toggle padding="m" placement="bottom-end">
<Button extraCompact on:click={toggle}>
<Icon icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="tooltip" let:toggle>
<ActionMenu.Root width="180px" noPadding>
{#if backup.status === 'completed'}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
on:click={(e) => {
toggle(e);
showRestore = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupRestoreClick);
}}>
Restore
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
trailingIcon={IconDuplicate}
on:click={(e) => {
toggle(e);
showRestore = true;
copy(backup.$id);
showDropdown[index] = false;
trackEvent(Click.BackupCopyIdClick);
}}>
Copy ID
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
status="danger"
trailingIcon={IconTrash}
on:click={(e) => {
toggle(e);
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupRestoreClick);
trackEvent(Click.BackupDeleteClick);
}}>
Restore
Delete
</ActionMenu.Item.Button>
{/if}
<ActionMenu.Item.Button
trailingIcon={IconDuplicate}
on:click={(e) => {
toggle(e);
copy(backup.$id);
showDropdown[index] = false;
trackEvent(Click.BackupCopyIdClick);
}}>
Copy ID
</ActionMenu.Item.Button>
<ActionMenu.Item.Button
status="danger"
trailingIcon={IconTrash}
on:click={(e) => {
toggle(e);
showDelete = true;
selectedBackup = backup;
showDropdown[index] = false;
trackEvent(Click.BackupDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</div>
</Table.Cell>
</Table.Row.Base>
{/each}
</Table.Root>
{#if selectedBackups.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedBackups.length.toString()} />
<span>
{selectedBackups.length > 1 ? 'backups' : 'backup'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedBackups = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Popover>
</div>
</Table.Cell>
</Table.Row.Base>
{/each}
{/snippet}
</MultiSelectionTable>
<!-- this is for single backup delete -->
<Confirm
title="Delete {selectedBackups.length ? 'backups' : 'backup'}"
title="Delete backup"
bind:open={showDelete}
onSubmit={deleteBackups}>
onSubmit={async () => {
if (!selectedBackup) return;
await deleteBackups([selectedBackup.$id]);
}}>
<Typography.Text>
Are you sure you want to delete
{#if selectedBackups.length}
<b>{selectedBackups.length}</b> {selectedBackups.length > 1 ? 'backups' : 'backup'}?
{:else}
the <b>{cleanBackupName(selectedBackup)}</b> backup?
{/if}
<br />This action is irreversible.
Are you sure you want to delete the <b>{getCleanBackupName(selectedBackup)}</b> backup?
</Typography.Text>
<Typography.Text variant="m-500">This action is irreversible.</Typography.Text>
</Confirm>
<Modal title="Restore backup" bind:show={showRestore} onSubmit={restoreBackup}>
<Card radius="m" padding="s">
<Layout.Stack gap="xxs">
<Typography.Text variant="m-500">
{cleanBackupName(selectedBackup)}
{getCleanBackupName(selectedBackup)}
</Typography.Text>
<Typography.Caption variant="500">
@@ -374,6 +382,6 @@
<svelte:fragment slot="footer">
<Button text on:click={() => (showRestore = false)}>Cancel</Button>
<Button submit disabled={disableButton}>Restore</Button>
<Button submit disabled={disableRestoreButton}>Restore</Button>
</svelte:fragment>
</Modal>
@@ -405,6 +405,8 @@
$: if (!$showCreateColumnSheet.show) {
createMoreColumns = false;
}
$: currentRowId = $databaseRowSheetOptions.row?.$id ?? $databaseRowSheetOptions.rowId;
</script>
<svelte:head>
@@ -461,7 +463,6 @@
</SideSheet>
<SideSheet
closeOnBlur
title={$databaseRowSheetOptions.title}
bind:show={$databaseRowSheetOptions.show}
submit={{
@@ -472,13 +473,15 @@
topAction={{
mode: 'copy-tag',
text: 'Row URL',
show: !!($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id),
value: buildRowUrl($databaseRowSheetOptions.rowId ?? $databaseRowSheetOptions.row?.$id)
show: !!currentRowId,
value: buildRowUrl(currentRowId)
}}>
<EditRow
bind:this={editRow}
bind:row={$databaseRowSheetOptions.row}
bind:rowId={$databaseRowSheetOptions.rowId} />
{#key currentRowId}
<EditRow
bind:this={editRow}
bind:row={$databaseRowSheetOptions.row}
bind:rowId={$databaseRowSheetOptions.rowId} />
{/key}
</SideSheet>
<SideSheet
@@ -76,7 +76,7 @@
$effect(() => {
if (row) {
work = initWork();
focusFirstInput();
requestAnimationFrame(() => focusFirstInput());
} else {
work = null;
}
@@ -117,15 +117,20 @@
if (type === 'header') {
if (isSequence) {
return ['sort-asc', 'sort-desc'].includes(item.action ?? '');
return ['sort-asc', 'sort-desc'].includes(item?.action);
}
if (['delete', 'update', 'duplicate-header'].includes(item.action) && isSystemColumn) {
if (['delete', 'update', 'duplicate-header'].includes(item?.action) && isSystemColumn) {
return false;
}
// hide column-left and create-index for $id (first column, already indexed)
if (columnId === '$id' && ['column-left', 'create-index'].includes(item?.action)) {
return false;
}
// hide sort options for relationship columns
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item.action ?? '')) {
if (isRelationship(column) && ['sort-asc', 'sort-desc'].includes(item?.action)) {
return false;
}
}
@@ -53,6 +53,8 @@
Button,
Layout,
Link,
Popover,
Keyboard,
Badge,
FloatingActionBar,
Icon,
@@ -78,7 +80,8 @@
IconSwitchHorizontal,
IconText,
IconToggle,
IconViewList
IconViewList,
IconArrowExpand
} from '@appwrite.io/pink-icons-svelte';
import type { HeaderCellAction, RowCellAction } from './sheetOptions.svelte';
import SheetOptions from './sheetOptions.svelte';
@@ -120,7 +123,9 @@
: 24;
let selectedRows = [];
let showExpandIconForId: number | null = null;
let spreadsheetContainer: SpreadsheetContainer;
let previouslyFocusedElement: Element | null = null;
let currentPage = 1;
let jumpToPageReactive = 0;
@@ -170,8 +175,8 @@
{
id: '$id',
title: '$id',
width: getColumnWidth('$id', 225),
minimumWidth: 225,
width: getColumnWidth('$id', 250),
minimumWidth: 250,
draggable: false,
type: 'string',
icon: IconFingerPrint,
@@ -537,9 +542,14 @@
}
} else if (type === 'row') {
if (action === 'update') {
$databaseRowSheetOptions.show = true;
$databaseRowSheetOptions.row = row;
$databaseRowSheetOptions.title = 'Update row';
databaseRowSheetOptions.update((opts) => {
return {
...opts,
row,
show: true,
title: 'Update row'
};
});
}
if (action === 'duplicate-row') {
@@ -747,6 +757,13 @@
$: totalPages = Math.ceil($rows.total / SPREADSHEET_PAGE_LIMIT) || 1;
$: rowSelection = !$spreadsheetLoading && !$paginatedRowsLoading ? true : ('disabled' as const);
$: if (!$databaseRowSheetOptions.show && previouslyFocusedElement) {
requestAnimationFrame(() => {
(previouslyFocusedElement as HTMLElement)?.focus();
previouslyFocusedElement = null;
});
}
</script>
<SpreadsheetContainer bind:this={spreadsheetContainer}>
@@ -776,6 +793,14 @@
bottomActionTooltip={{
text: 'Create row',
placement: 'top-end'
}}
expandKbdShortcut="Cmd+Enter"
on:expandKbdShortcut={({ detail }) => {
const focusedRowId = detail.rowId;
const focusedRow = $rows.rows.find((row) => row.$id === focusedRowId);
previouslyFocusedElement = document.activeElement;
onSelectSheetOption('update', null, 'row', focusedRow);
}}>
<svelte:fragment slot="header" let:root>
{#each $tableColumns as column (column.id)}
@@ -868,56 +893,133 @@
valueWithoutHover={row.$sequence}>
{#each $tableColumns as { id: columnId, isEditable } (columnId)}
{@const rowColumn = $columns.find((col) => col.key === columnId)}
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
{#if columnId === '$id'}
<Id value={row.$id} tooltipPortal tooltipDelay={200}
>{row.$id}</Id>
{:else if columnId === '$createdAt' || columnId === '$updatedAt'}
<DualTimeView
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
<SheetOptions
type="row"
column={rowColumn}
onSelect={(option) =>
onSelectSheetOption(option, null, 'row', row)}
onVisibilityChanged={(visible) => {
canShowDatetimePopover = !visible;
}}>
{#snippet children(toggle)}
<Button.Button
icon
variant="extra-compact"
on:click={toggle}>
<Icon
icon={IconDotsHorizontal}
color="--fgcolor-neutral-primary" />
</Button.Button>
{/snippet}
</SheetOptions>
{:else if isRelationship(rowColumn)}
{@const args = getDisplayNamesForTable(row[columnId])}
{#if !isRelationshipToMany(rowColumn)}
{#if row[columnId]}
{@const displayValue = args
.map((arg) => row[columnId]?.[arg])
.filter(Boolean)
.join(' | ')}
{#if columnId === '$id'}
<button
on:mouseenter={() => {
showExpandIconForId = index;
}}
on:mouseleave={() => {
showExpandIconForId = null;
}}>
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
<Layout.Stack
gap="none"
direction="row"
alignItems="center"
alignContent="center"
justifyContent="space-between">
<Id value={row.$id} tooltipPortal tooltipDelay={200}
>{row.$id}</Id>
{#if displayValue}
<Link.Button
variant="muted"
on:click={() => {
$databaseRelatedRowSheetOptions.tableId =
row[columnId]?.['$tableId'];
$databaseRelatedRowSheetOptions.rows =
row[columnId]?.['$id'];
$databaseRelatedRowSheetOptions.show = true;
}}>
{displayValue}
</Link.Button>
<Popover let:show let:hide portal padding="none">
{@const opacityValue =
showExpandIconForId === index ? '1' : '0'}
<button
on:mouseenter={show}
on:mouseleave={hide}
style:opacity={opacityValue}
style:transition="opacity 225ms ease-in-out">
<Button.Button
size="xs"
icon
variant="secondary"
on:click={() => {
hide();
previouslyFocusedElement =
document.activeElement;
onSelectSheetOption(
'update',
null,
'row',
row
);
}}>
<Icon icon={IconArrowExpand} size="s" />
</Button.Button>
</button>
<svelte:fragment slot="tooltip">
<Layout.Stack
inline
gap="xxs"
direction="row"
alignItems="center"
alignContent="center"
style="padding: var(--gap-XS, 6px) var(--gap-S, 8px);">
Expand row
<Layout.Stack
inline
gap="xxxs"
direction="row"
alignItems="center"
alignContent="center">
<Keyboard size="s" key="⌘" />
<Keyboard
key={'Enter'}
autoWidth
size="s" />
</Layout.Stack>
</Layout.Stack>
</svelte:fragment>
</Popover>
</Layout.Stack>
</Spreadsheet.Cell>
</button>
{:else}
<Spreadsheet.Cell {root} {isEditable} column={columnId}>
{#if columnId === '$createdAt' || columnId === '$updatedAt'}
<DualTimeView
showDatetime
time={row[columnId]}
canShowPopover={canShowDatetimePopover} />
{:else if columnId === 'actions'}
<SheetOptions
type="row"
column={rowColumn}
onSelect={(option) =>
onSelectSheetOption(option, null, 'row', row)}
onVisibilityChanged={(visible) => {
canShowDatetimePopover = !visible;
}}>
{#snippet children(toggle)}
<Button.Button
icon
variant="extra-compact"
on:click={toggle}>
<Icon
icon={IconDotsHorizontal}
color="--fgcolor-neutral-primary" />
</Button.Button>
{/snippet}
</SheetOptions>
{:else if isRelationship(rowColumn)}
{@const args = getDisplayNamesForTable(row[columnId])}
{#if !isRelationshipToMany(rowColumn)}
{#if row[columnId]}
{@const displayValue = args
.map((arg) => row[columnId]?.[arg])
.filter(Boolean)
.join(' | ')}
{#if displayValue}
<Link.Button
variant="muted"
on:click={() => {
$databaseRelatedRowSheetOptions.tableId =
row[columnId]?.['$tableId'];
$databaseRelatedRowSheetOptions.rows =
row[columnId]?.['$id'];
$databaseRelatedRowSheetOptions.show = true;
}}>
{displayValue}
</Link.Button>
{:else}
<Badge
variant="secondary"
content="NULL"
size="xs" />
{/if}
{:else}
<Badge
variant="secondary"
@@ -925,89 +1027,90 @@
size="xs" />
{/if}
{:else}
<Badge variant="secondary" content="NULL" size="xs" />
{@const itemsNum = row[columnId]?.length}
Items <Badge
content={itemsNum}
variant="secondary"
size="s" />
{/if}
{:else if isSpatialType(rowColumn) && row[columnId] !== null}
<Typography.Text truncate>
{JSON.stringify(row[columnId])}
</Typography.Text>
{:else}
{@const itemsNum = row[columnId]?.length}
Items <Badge
content={itemsNum}
variant="secondary"
size="s" />
{/if}
{:else if isSpatialType(rowColumn) && row[columnId] !== null}
<Typography.Text truncate>
{JSON.stringify(row[columnId])}
</Typography.Text>
{:else}
{@const value = row[columnId]}
{@const formatted = formatColumn(row[columnId])}
{@const isEmptyArray = formatted === 'Empty'}
{@const isDatetimeAttribute = rowColumn.type === 'datetime'}
{@const isEncryptedAttribute =
isString(rowColumn) && rowColumn.encrypt}
{#if isDatetimeAttribute}
<DualTimeView time={value}>
<span slot="title">Timestamp</span>
{toLocaleDateTime(value, true)}
</DualTimeView>
{:else if isEncryptedAttribute}
<button on:click={(e) => e.preventDefault()}>
<InteractiveText
copy={false}
variant="secret"
isVisible={false}
text={formatted} />
</button>
{:else if formatted.length > 20}
<Tooltip placement="bottom" portal disabled>
{@const value = row[columnId]}
{@const formatted = formatColumn(row[columnId])}
{@const isEmptyArray = formatted === 'Empty'}
{@const isDatetimeAttribute = rowColumn.type === 'datetime'}
{@const isEncryptedAttribute =
isString(rowColumn) && rowColumn.encrypt}
{#if isDatetimeAttribute}
<DualTimeView time={value}>
<span slot="title">Timestamp</span>
{toLocaleDateTime(value, true)}
</DualTimeView>
{:else if isEncryptedAttribute}
<button on:click={(e) => e.preventDefault()}>
<InteractiveText
copy={false}
variant="secret"
isVisible={false}
text={formatted} />
</button>
{:else if formatted.length > 20}
<Tooltip placement="bottom" portal>
<Typography.Text truncate>
{formatted}
</Typography.Text>
<span
slot="tooltip"
style:white-space="pre-wrap"
style:word-break="break-word">
{formatted}
</span>
</Tooltip>
{:else if formatted === 'null'}
<Badge variant="secondary" content="NULL" size="xs" />
{:else if isEmptyArray}
<Badge
variant="secondary"
content={formatted}
size="xs" />
{:else}
<Typography.Text truncate>
{formatted}
</Typography.Text>
<span
slot="tooltip"
style:white-space="pre-wrap"
style:word-break="break-word">
{formatted}
</span>
</Tooltip>
{:else if formatted === 'null'}
<Badge variant="secondary" content="NULL" size="xs" />
{:else if isEmptyArray}
<Badge variant="secondary" content={formatted} size="xs" />
{:else}
<Typography.Text truncate>
{formatted}
</Typography.Text>
{/if}
{/if}
{/if}
<svelte:fragment slot="cell-editor" let:close>
{@const isRelatedToMany = isRelationshipToMany(rowColumn)}
{@const hasItems = isRelatedToMany
? row[columnId]?.length
: false}
<svelte:fragment slot="cell-editor" let:close>
{@const isRelatedToMany = isRelationshipToMany(rowColumn)}
{@const hasItems = isRelatedToMany
? row[columnId]?.length
: false}
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={updateRowContents}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
<EditRowCell
{row}
column={rowColumn}
onRowStructureUpdate={updateRowContents}
noInlineEdit={isRelatedToMany && hasItems}
onChange={(row) => paginatedRows.update(index, row)}
onRevert={(row) => paginatedRows.update(index, row)}
openSideSheet={() => {
close(); /* closes the editor */
if (isRelationshipToMany(rowColumn)) {
openSideSheetForRelationsToMany(
row[columnId],
rowColumn
);
} else {
onSelectSheetOption('update', null, 'row', row);
}
}} />
</svelte:fragment>
</Spreadsheet.Cell>
if (isRelationshipToMany(rowColumn)) {
openSideSheetForRelationsToMany(
row[columnId],
rowColumn
);
} else {
onSelectSheetOption('update', null, 'row', row);
}
}} />
</svelte:fragment>
</Spreadsheet.Cell>
{/if}
{/each}
</Spreadsheet.Row.Base>
{/if}
@@ -1,110 +1,85 @@
<script lang="ts">
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { Id, MultiSelectionTable, type DeleteOperationState } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
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';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import { Table } from '@appwrite.io/pink-svelte';
import type { PageData } from './$types';
import { tableViewColumns } from './store';
import Confirm from '$lib/components/confirm.svelte';
import { subNavigation } from '$lib/stores/database';
import type { Models } from '@appwrite.io/console';
export let data: PageData;
const databaseId = page.params.database;
let deleting = false;
let showDelete = false;
let selectedTables: string[] = [];
async function handleDelete() {
showDelete = false;
const {
data
}: {
data: PageData;
} = $props();
async function onDelete(selectedTables: string[]): Promise<DeleteOperationState> {
const promises = selectedTables.map((tableId) =>
sdk.forProject(page.params.region, page.params.project).tablesDB.deleteTable({
databaseId,
databaseId: page.params.database,
tableId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.TableDelete);
addNotification({
type: 'success',
message: `${selectedTables.length} table${selectedTables.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
trackError(error, Submit.TableDelete);
return error;
} finally {
await invalidate(Dependencies.TABLES);
subNavigation.update();
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.TableDelete);
} finally {
selectedTables = [];
showDelete = false;
}
}
function getTableHref(table: Models.Table) {
return resolve(
'/(console)/project-[region]-[project]/databases/database-[database]/table-[table]',
{
region: page.params.region,
project: page.params.project,
database: page.params.database,
table: table.$id
}
);
}
</script>
<Table.Root
<MultiSelectionTable
resource="table"
columns={$tableViewColumns}
allowSelection={$canWriteTables}
bind:selectedRows={selectedTables}
let:root>
<svelte:fragment slot="header" let:root>
{onDelete}>
{#snippet header(root)}
{#each $tableViewColumns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.tables.tables as table (table.$id)}
<Table.Row.Link
{root}
id={table.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/databases/database-${databaseId}/table-${table.$id}`}>
{#each $tableViewColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $tableViewColumns}
<Id value={table.$id}>{table.$id}</Id>
{/key}
{:else if column.id === 'name'}
{table.name}
{:else}
<DualTimeView time={table[column.id]} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedTables.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedTables.length.toString()} />
<span>
{selectedTables.length > 1 ? 'tables' : 'table'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedTables = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete tables" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedTables.length}</b>
{selectedTables.length > 1 ? 'tables' : 'table'}?
</Typography.Text>
</Confirm>
{#snippet children(root)}
{#each data.tables.tables as table (table.$id)}
<Table.Row.Link {root} id={table.$id} href={getTableHref(table)}>
{#each $tableViewColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $tableViewColumns}
<Id value={table.$id}>{table.$id}</Id>
{/key}
{:else if column.id === 'name'}
{table.name}
{:else}
<DualTimeView time={table[column.id]} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -1,17 +1,10 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import { toLocaleDateTime } from '$lib/helpers/date';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
import { ExecutionStatus } from '@appwrite.io/console';
import {
Badge,
FloatingActionBar,
Status,
Table,
Tooltip,
Typography
} from '@appwrite.io/pink-svelte';
import { Badge, Status, Table, Tooltip, Typography } from '@appwrite.io/pink-svelte';
import Sheet from './sheet.svelte';
import { capitalize } from '$lib/helpers/string';
import { calculateTime } from '$lib/helpers/timeConversion';
@@ -22,146 +15,110 @@
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { timer } from '$lib/actions/timer';
export let columns: Column[];
export let executions: Models.ExecutionList;
let {
columns,
executions
}: {
columns: Column[];
executions: Models.ExecutionList;
} = $props();
let open = false;
let selectedLogId: string = null;
let selectedRows = [];
let showBatchDeletion = false;
async function deleteExecutions() {
showBatchDeletion = false;
let open = $state(false);
let selectedLogId: string | null = $state(null);
async function deleteExecutions(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((executionId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteExecution({
functionId: page.params.function,
executionId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.ExecutionDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} execution${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.ExecutionDelete);
return error;
} finally {
selectedRows = [];
showBatchDeletion = false;
invalidate(Dependencies.EXECUTIONS);
await invalidate(Dependencies.EXECUTIONS);
}
}
</script>
<Table.Root let:root {columns} allowSelection bind:selectedRows>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable {columns} resource="execution" onDelete={deleteExecutions}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each executions.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
open = true;
selectedLogId = log.$id;
}}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === 'responseStatusCode'}
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'trigger'}
{capitalize(log.trigger)}
{:else if column.id === 'status'}
{@const status = log.status}
<Tooltip
disabled={!log?.scheduledAt || status !== ExecutionStatus.Scheduled}
maxWidth="400px">
<div>
<Status
status={logStatusConverter(status)}
label={capitalize(status)}>
</Status>
</div>
<span slot="tooltip">
{`Scheduled to execute on ${toLocaleDateTime(log.scheduledAt)}`}
</span>
</Tooltip>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/snippet}
{#snippet children(root)}
{#each executions.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
open = true;
selectedLogId = log.$id;
}}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === 'responseStatusCode'}
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'trigger'}
{capitalize(log.trigger)}
{:else if column.id === 'status'}
{@const status = log.status}
<Tooltip
disabled={!log?.scheduledAt || status !== ExecutionStatus.Scheduled}
maxWidth="400px">
<div>
<Status
status={logStatusConverter(status)}
label={capitalize(status)}>
</Status>
</div>
<span slot="tooltip">
{`Scheduled to execute on ${toLocaleDateTime(log.scheduledAt)}`}
</span>
</Tooltip>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/if}
{/if}
{/if}
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
</Table.Root>
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
{/snippet}
</MultiSelectionTable>
<Sheet bind:open bind:selectedLogId logs={executions.executions} logging={$func.logging} />
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{selectedRows.length > 1 ? 'executions' : 'execution'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete executions"
confirmDeletion
onSubmit={deleteExecutions}
bind:open={showBatchDeletion}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'executions' : 'execution'}?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -1,5 +1,5 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
@@ -14,15 +14,7 @@
import { Dependencies } from '$lib/constants';
import Cancel from './(modals)/cancelDeploymentModal.svelte';
import { base } from '$app/paths';
import {
ActionMenu,
Badge,
FloatingActionBar,
Icon,
Status,
Table,
Tooltip
} from '@appwrite.io/pink-svelte';
import { ActionMenu, Icon, Status, Table, Tooltip } from '@appwrite.io/pink-svelte';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import {
IconDotsHorizontal,
@@ -39,28 +31,26 @@
import DownloadActionMenuItem from './(components)/downloadActionMenuItem.svelte';
import { Menu } from '$lib/components/menu';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let showDelete = false;
let showActivate = false;
let showRedeploy = false;
let showCancel = false;
let selectedDeployment: Models.Deployment = null;
let showDelete = $state(false);
let showCancel = $state(false);
let showActivate = $state(false);
let showRedeploy = $state(false);
let selectedDeployment: Models.Deployment | null = $state(null);
function handleActivate() {
invalidate(Dependencies.DEPLOYMENTS);
}
let selectedRows = [];
let showBatchDeletion = false;
async function deleteDeployments() {
showBatchDeletion = false;
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).functions.deleteDeployment({
functionId: page.params.function,
@@ -70,186 +60,160 @@
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} deployment${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DeploymentDelete);
return error;
} finally {
selectedRows = [];
showBatchDeletion = false;
invalidate(Dependencies.DEPLOYMENTS);
await invalidate(Dependencies.DEPLOYMENTS);
}
}
</script>
<Table.Root
let:root
<MultiSelectionTable
allowSelection
bind:selectedRows
resource="deployment"
onDelete={deleteDeployments}
columns={[...columns, { id: 'actions', width: 40 }]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>
{title}
</Table.Header.Cell>
{/each}
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/functions/function-${page.params.function}/deployment-${deployment.$id}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
{/snippet}
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment.totalSize)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Menu>
<Button text icon size="s">
<Icon size="s" icon={IconDotsHorizontal} />
</Button>
{#snippet children(root)}
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/functions/function-${page.params.function}/deployment-${deployment.$id}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
<svelte:fragment slot="menu" let:toggle>
<ActionMenu.Root>
<Tooltip disabled={deployment.sourceSize !== 0} placement={'bottom'}>
<div>
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment.totalSize)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Menu>
<Button text icon size="s">
<Icon size="s" icon={IconDotsHorizontal} />
</Button>
<svelte:fragment slot="menu" let:toggle>
<ActionMenu.Root>
<Tooltip
disabled={deployment.sourceSize !== 0}
placement={'bottom'}>
<div>
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
disabled={deployment.sourceSize === 0}
on:click={() => {
selectedDeployment = deployment;
showRedeploy = true;
toggle();
trackEvent(Click.FunctionsRedeployClick);
}}
style="width: 100%">
Redeploy
</ActionMenu.Item.Button>
</div>
<div slot="tooltip">Source is empty</div>
</Tooltip>
{#if deployment.status === 'ready' && deployment.$id !== $func.deploymentId}
<ActionMenu.Item.Button
trailingIcon={IconRefresh}
disabled={deployment.sourceSize === 0}
trailingIcon={IconLightningBolt}
on:click={() => {
selectedDeployment = deployment;
showRedeploy = true;
showActivate = true;
toggle();
trackEvent(Click.FunctionsRedeployClick);
}}
style="width: 100%">
Redeploy
}}>
Activate
</ActionMenu.Item.Button>
</div>
<div slot="tooltip">Source is empty</div>
</Tooltip>
{#if deployment.status === 'ready' && deployment.$id !== $func.deploymentId}
<ActionMenu.Item.Button
trailingIcon={IconLightningBolt}
on:click={() => {
selectedDeployment = deployment;
showActivate = true;
toggle();
}}>
Activate
</ActionMenu.Item.Button>
{/if}
{/if}
<DownloadActionMenuItem {deployment} {toggle} />
<DownloadActionMenuItem {deployment} {toggle} />
{#if deployment.status === 'processing' || deployment.status === 'building' || deployment.status === 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconXCircle}
on:click={() => {
selectedDeployment = deployment;
toggle();
{#if deployment.status === 'processing' || deployment.status === 'building' || deployment.status === 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconXCircle}
on:click={() => {
selectedDeployment = deployment;
toggle();
showCancel = true;
trackEvent(Click.FunctionsDeploymentCancelClick);
}}>
Cancel
</ActionMenu.Item.Button>
{/if}
{#if deployment.status !== 'building' && deployment.status !== 'processing' && deployment.status !== 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconTrash}
status="danger"
on:click={() => {
selectedDeployment = deployment;
toggle();
showCancel = true;
trackEvent(Click.FunctionsDeploymentCancelClick);
}}>
Cancel
</ActionMenu.Item.Button>
{/if}
{#if deployment.status !== 'building' && deployment.status !== 'processing' && deployment.status !== 'waiting'}
<ActionMenu.Item.Button
trailingIcon={IconTrash}
status="danger"
on:click={() => {
selectedDeployment = deployment;
toggle();
showDelete = true;
trackEvent(Click.FunctionsDeploymentDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Menu>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
showDelete = true;
trackEvent(Click.FunctionsDeploymentDeleteClick);
}}>
Delete
</ActionMenu.Item.Button>
{/if}
</ActionMenu.Root>
</svelte:fragment>
</Menu>
</Table.Cell>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContent(count)}
<p>
Are you sure you want to delete <strong>{count}</strong>
{count > 1 ? 'deployments' : 'deployment'} from your function -
<strong>{page.data.function.name}</strong>?
</p>
{/snippet}
</MultiSelectionTable>
{#if selectedDeployment}
<Delete {selectedDeployment} bind:showDelete />
<Activate {selectedDeployment} bind:showActivate on:activated={handleActivate} />
<Cancel {selectedDeployment} bind:showCancel />
<RedeployModal {selectedDeployment} bind:show={showRedeploy} />
<Activate {selectedDeployment} bind:showActivate on:activated={handleActivate} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span style="white-space: nowrap">
{selectedRows.length > 1 ? 'deployments' : 'deployment'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete deployments"
bind:open={showBatchDeletion}
confirmDeletion
onSubmit={deleteDeployments}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'deployments' : 'deployment'} from your function -
<strong>{$func.name}</strong>?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -1,7 +1,15 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/state';
import { Empty, EmptyFilter, EmptySearch, Id, PaginationWithLimit } from '$lib/components';
import {
type DeleteOperationState,
Empty,
EmptyFilter,
EmptySearch,
Id,
MultiSelectionTable,
PaginationWithLimit
} from '$lib/components';
import { hasPageQueries } from '$lib/components/filters';
import { Button } from '$lib/elements/forms';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -16,28 +24,18 @@
import { invalidate } from '$app/navigation';
import { trackEvent, Submit, trackError } from '$lib/actions/analytics';
import { Dependencies } from '$lib/constants';
import { addNotification } from '$lib/stores/notifications';
import type { Column } from '$lib/helpers/types';
import { writable } from 'svelte/store';
import { canWriteMessages } from '$lib/stores/roles';
import {
Badge,
FloatingActionBar,
Layout,
Link,
Table,
Typography
} from '@appwrite.io/pink-svelte';
import { Confirm } from '$lib/components';
import { Layout, Link, Table } from '@appwrite.io/pink-svelte';
import { onDestroy, onMount } from 'svelte';
import { stopPolling, pollMessagesStatus } from './helper';
import type { PageProps } from './$types';
export let data;
let selected: string[] = [];
let showDelete = false;
let deleting = false;
let showFailed = false;
let errors: string[] = [];
const { data }: PageProps = $props();
let showFailed = $state(false);
let errors: string[] = $state([]);
const columns = writable<Column[]>([
{ id: '$id', title: 'Message ID', type: 'string', width: 200 },
@@ -101,35 +99,21 @@
}
]);
const region = page.params.region;
const project = page.params.project;
async function handleDelete() {
showDelete = false;
const promises = selected.map((id) =>
sdk.forProject(region, project).messaging.delete({ messageId: id })
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) =>
sdk
.forProject(page.params.region, page.params.project)
.messaging.delete({ messageId: id })
);
try {
await Promise.all(promises);
trackEvent(Submit.MessagingMessageDelete, {
total: selected.length
});
addNotification({
type: 'success',
message: `${selected.length} message${selected.length > 1 ? 's' : ''} deleted`
});
trackEvent(Submit.MessagingMessageDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingMessageDelete);
return error;
} finally {
invalidate(Dependencies.MESSAGING_MESSAGES);
selected = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_MESSAGES);
}
}
@@ -147,10 +131,10 @@
<Container>
<ResponsiveContainerHeader
{columns}
bind:view={data.view}
hideView
hasFilters
hasSearch
hasFilters
bind:view={data.view}
analyticsSource="messaging_messages"
searchPlaceholder="Search by description, type, status, or ID">
{#if $canWriteMessages}
@@ -159,81 +143,68 @@
</ResponsiveContainerHeader>
{#if data.messages.total}
<Table.Root
<MultiSelectionTable
resource="message"
columns={$columns}
allowSelection={$canWriteMessages}
let:root
bind:selectedRows={selected}>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteMessages}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Cell column={id} {root}>{title}</Table.Cell>
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.messages.messages as message (message.$id)}
<Table.Row.Link
{root}
id={message.$id}
href={`${base}/project-${region}-${project}/messaging/message-${message.$id}`}>
{#each $columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={message.$id}>{message.$id}</Id>
{/key}
{:else if column.id === 'message'}
{#if message.providerType === MessagingProviderType.Push}
{message.data.title}
{:else if message.providerType === MessagingProviderType.Sms}
{message.data.content}
{:else if message.providerType === MessagingProviderType.Email}
{message.data.subject}
{:else}
Invalid provider
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={message.providerType} size="xs" />
{:else if column.id === 'status'}
<Layout.Stack direction="row" gap="s">
<MessageStatusPill status={message.status} />
{#if message.status === 'failed'}
<Link.Button
on:click={(e) => {
e.preventDefault();
errors = message.deliveryErrors;
showFailed = true;
}}>Details</Link.Button>
{/if}
</Layout.Stack>
{:else if column.type === 'datetime'}
{#if !message[column.id]}
-
{:else}
<DualTimeView time={message[column.id]} />
{/if}
{:else}
{message[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selected.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selected.length.toString()} />
<span>
{selected.length > 1 ? 'messages' : 'message'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selected = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
{#snippet children(root)}
{#each data.messages.messages as message (message.$id)}
<Table.Row.Link
{root}
id={message.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/messaging/message-${message.$id}`}>
{#each $columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={message.$id}>{message.$id}</Id>
{/key}
{:else if column.id === 'message'}
{#if message.providerType === MessagingProviderType.Push}
{message.data.title}
{:else if message.providerType === MessagingProviderType.Sms}
{message.data.content}
{:else if message.providerType === MessagingProviderType.Email}
{message.data.subject}
{:else}
Invalid provider
{/if}
{:else if column.id === 'providerType'}
<ProviderType type={message.providerType} size="xs" />
{:else if column.id === 'status'}
<Layout.Stack direction="row" gap="s">
<MessageStatusPill status={message.status} />
{#if message.status === 'failed'}
<Link.Button
on:click={(e) => {
e.preventDefault();
errors = message.deliveryErrors;
showFailed = true;
}}>Details</Link.Button>
{/if}
</Layout.Stack>
{:else if column.type === 'datetime'}
{#if !message[column.id]}
-
{:else}
<DualTimeView time={message[column.id]} />
{/if}
{:else}
{message[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Messages"
@@ -248,7 +219,9 @@
<Button external href="https://appwrite.io/docs/products/messaging/messages" text>
Documentation
</Button>
<Button secondary href={`${base}/project-${region}-${project}/messaging`}>
<Button
secondary
href={`${base}/project-${page.params.region}-${page.params.project}/messaging`}>
Clear search
</Button>
</div>
@@ -258,10 +231,10 @@
<svelte:fragment slot="actions">
<Button
external
href="https://appwrite.io/docs/products/messaging/messages"
text
ariaLabel="create message"
event="empty_documentation"
ariaLabel={`create message`}>
href="https://appwrite.io/docs/products/messaging/messages">
Documentation
</Button>
{#if $canWriteMessages}
@@ -277,10 +250,3 @@
</Container>
<FailedModal bind:show={showFailed} {errors} />
<Confirm title="Delete messages" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selected.length}</b>
{selected.length > 1 ? 'messages' : 'message'}?
</Typography.Text>
</Confirm>
@@ -1,134 +1,89 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { columns } from './store';
import { project } from '$routes/(console)/project-[region]-[project]/store';
import Provider from '../provider.svelte';
import ProviderType from '../providerType.svelte';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { sdk } from '$lib/stores/sdk';
import { canWriteProviders } from '$lib/stores/roles';
import { Badge, FloatingActionBar, Icon, Table, Typography } from '@appwrite.io/pink-svelte';
import { Badge, Icon, Table } from '@appwrite.io/pink-svelte';
import { IconCheckCircle } from '@appwrite.io/pink-icons-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { page } from '$app/state';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
let deleting = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteProvider({ providerId: id })
);
.messaging.deleteProvider({ providerId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.MessagingProviderDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} provider${
selectedIds.length > 1 ? 's' : ''
} deleted`
});
trackEvent(Submit.MessagingProviderDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingProviderDelete);
return error;
} finally {
invalidate(Dependencies.MESSAGING_PROVIDERS);
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_PROVIDERS);
}
}
</script>
<Table.Root
<MultiSelectionTable
resource="provider"
columns={$columns}
allowSelection={$canWriteProviders}
let:root
bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
onDelete={handleDelete}
allowSelection={$canWriteProviders}>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.providers.providers as provider (provider.$id)}
<svelte:component
this={$canWriteProviders ? Table.Row.Link : Table.Row.Base}
id={provider.$id}
href={$canWriteProviders
? `${base}/project-${$project.region}-${$project.$id}/messaging/providers/provider-${provider.$id}`
{/snippet}
{#snippet children(root)}
{@const TableRowComponent = $canWriteProviders ? Table.Row.Link : Table.Row.Base}
{#each data.providers.providers as provider (provider.$id)}
{@const href = $canWriteProviders
? `${base}/project-${page.params.region}-${page.params.project}/messaging/providers/provider-${provider.$id}`
: undefined}
{root}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={provider.$id}>{provider.$id}</Id>
{/key}
{:else if column.id === 'provider'}
<Provider provider={provider.provider} />
{:else if column.id === 'type'}
<ProviderType type={provider.type} size="xs" />
{:else if column.id === 'enabled'}
<Badge
variant="secondary"
type={provider.enabled ? 'success' : undefined}
content={provider.enabled ? 'enabled' : 'disabled'}>
<svelte:fragment slot="start">
{#if provider.enabled}
<Icon icon={IconCheckCircle} size="s" />
{/if}
</svelte:fragment>
</Badge>
{:else}
{provider[column.id]}
{/if}
</Table.Cell>
{/each}
</svelte:component>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'providers' : 'provider'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete providers"
bind:open={showDelete}
onSubmit={handleDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'providers' : 'provider'}?
</Typography.Text>
</Confirm>
<TableRowComponent {href} {root} id={provider.$id}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key $columns}
<Id value={provider.$id}>{provider.$id}</Id>
{/key}
{:else if column.id === 'provider'}
<Provider provider={provider.provider} />
{:else if column.id === 'type'}
<ProviderType type={provider.type} size="xs" />
{:else if column.id === 'enabled'}
<Badge
variant="secondary"
type={provider.enabled ? 'success' : undefined}
content={provider.enabled ? 'enabled' : 'disabled'}>
<svelte:fragment slot="start">
{#if provider.enabled}
<Icon icon={IconCheckCircle} size="s" />
{/if}
</svelte:fragment>
</Badge>
{:else}
{provider[column.id]}
{/if}
</Table.Cell>
{/each}
</TableRowComponent>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -1,9 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
@@ -12,99 +10,70 @@
import type { Column } from '$lib/helpers/types';
import { page } from '$app/state';
import { canWriteTopics } from '$lib/stores/roles';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let selectedIds: string[] = [];
let showDelete = false;
let deleting = false;
async function handleDelete() {
showDelete = false;
const promises = selectedIds.map((id) =>
sdk
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((id) => {
return sdk
.forProject(page.params.region, page.params.project)
.messaging.deleteTopic({ topicId: id })
);
.messaging.deleteTopic({ topicId: id });
});
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} topic${selectedIds.length > 1 ? 's' : ''} deleted`
});
trackEvent(Submit.MessagingTopicDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingTopicDelete);
return error;
} finally {
invalidate(Dependencies.MESSAGING_TOPICS);
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_TOPICS);
}
}
</script>
<Table.Root {columns} allowSelection={$canWriteTopics} let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable
{columns}
resource="topic"
onDelete={handleDelete}
allowSelection={$canWriteTopics}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.topics.topics as topic (topic.$id)}
<Table.Row.Link
{root}
id={topic.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/messaging/topics/topic-${topic.$id}`}>
{#each columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={topic.$id}>{topic.$id}</Id>
{/key}
{:else if column.type === 'datetime'}
{#if topic[column.id]}
<DualTimeView time={topic[column.id]} />
{:else}-{/if}
{:else if column.id === 'total'}
{topic.emailTotal + topic.smsTotal + topic.pushTotal}
{:else}
{topic[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{/snippet}
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'topics' : 'topic'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete topics" bind:open={showDelete} onSubmit={handleDelete} disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'topics' : 'topic'}?
</Typography.Text>
</Confirm>
{#snippet children(root)}
{#each data.topics.topics as topic (topic.$id)}
<Table.Row.Link
{root}
id={topic.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/messaging/topics/topic-${topic.$id}`}>
{#each columns as column (column.id)}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={topic.$id}>{topic.$id}</Id>
{/key}
{:else if column.type === 'datetime'}
{#if topic[column.id]}
<DualTimeView time={topic[column.id]} />
{:else}-{/if}
{:else if column.id === 'total'}
{topic.emailTotal + topic.smsTotal + topic.pushTotal}
{:else}
{topic[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -2,10 +2,8 @@
import { invalidate } from '$app/navigation';
import { base } from '$app/paths';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import type { PageData } from './$types';
import ProviderType from '../../providerType.svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
@@ -15,21 +13,26 @@
import { targetsById } from '../../store';
import { MessagingProviderType, type Models } from '@appwrite.io/console';
import type { Column } from '$lib/helpers/types';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import Confirm from '$lib/components/confirm.svelte';
import { Table } from '@appwrite.io/pink-svelte';
export let columns: Column[];
export let data: PageData;
let {
data,
columns
}: {
data: PageData;
columns: Column[];
} = $props();
let subscribers: Record<string, Models.Subscriber> = {};
let selectedIds: string[] = [];
let selected: Record<string, Models.Subscriber> = {};
let showDelete = false;
let deleting = false;
const subscribers = $derived.by(() => {
const record: Record<string, Models.Subscriber> = {};
for (const subscriber of data.subscribers.subscribers) {
record[subscriber.$id] = subscriber;
}
async function handleDelete() {
showDelete = false;
return record;
});
async function handleDelete(selectedRows: string[]): Promise<DeleteOperationState> {
async function deleteSubscriber(subscriberId: string) {
await sdk
.forProject(page.params.region, page.params.project)
@@ -37,111 +40,68 @@
topicId: page.params.topic,
subscriberId
});
const { target } = subscribers[subscriberId];
const { [target.$id]: _, ...rest } = $targetsById;
$targetsById = rest;
}
const promises = selectedIds.map((id) => deleteSubscriber(id));
const promises = selectedRows.map((id) => deleteSubscriber(id));
try {
await Promise.all(promises);
trackEvent(Submit.MessagingTopicSubscriberDelete, {
total: selectedIds.length
});
addNotification({
type: 'success',
message: `${selectedIds.length} subscriber${
selectedIds.length > 1 ? 's' : ''
} deleted`
});
invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
trackEvent(Submit.MessagingTopicSubscriberDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingTopicSubscriberDelete);
return error;
} finally {
selectedIds = [];
showDelete = false;
await invalidate(Dependencies.MESSAGING_TOPIC_SUBSCRIBERS);
}
}
$: data.subscribers.subscribers.forEach((s) => {
subscribers[s.$id] = s;
});
$: selectedIds.forEach((id) => {
selected[id] = subscribers[id];
});
</script>
<Table.Root {columns} allowSelection let:root bind:selectedRows={selectedIds}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable {columns} resource="subscriber" onDelete={handleDelete}>
{#snippet header(root)}
{#each columns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each data.subscribers.subscribers as subscriber (subscriber.$id)}
{@const target = subscriber.target}
<Table.Row.Link
{root}
id={subscriber.$id}
href={`${base}/project-${$project.region}-${$project.$id}/auth/user-${subscriber.target.userId}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={subscriber.$id}>
{subscriber.$id}
{/snippet}
{#snippet children(root)}
{#each data.subscribers.subscribers as subscriber (subscriber.$id)}
{@const target = subscriber.target}
<Table.Row.Link
{root}
id={subscriber.$id}
href={`${base}/project-${$project.region}-${$project.$id}/auth/user-${subscriber.target.userId}`}>
{#each columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={subscriber.$id}>
{subscriber.$id}
</Id>
{/key}
{:else if column.id === 'targetId'}
<Id value={subscriber[column.id]}>
{subscriber[column.id]}
</Id>
{/key}
{:else if column.id === 'targetId'}
<Id value={subscriber[column.id]}>
{subscriber[column.id]}
</Id>
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else if column.id === 'target'}
{#if target.providerType === MessagingProviderType.Push}
{target.name}
{:else}
{target.identifier}
{/if}
{:else if column.id === 'type'}
<ProviderType type={subscriber.target.providerType} size="xs" />
{:else if column.id === '$createdAt'}
<DualTimeView time={subscriber[column.id]} />
{:else}
{target.identifier}
{subscriber[column.id]}
{/if}
{:else if column.id === 'type'}
<ProviderType type={subscriber.target.providerType} size="xs" />
{:else if column.id === '$createdAt'}
<DualTimeView time={subscriber[column.id]} />
{:else}
{subscriber[column.id]}
{/if}
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
</Table.Root>
{#if selectedIds.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedIds.length.toString()} />
<span>
{selectedIds.length > 1 ? 'subscribers' : 'subscriber'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedIds = [])}>Cancel</Button>
<Button secondary on:click={() => (showDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete subscribers"
bind:open={showDelete}
onSubmit={handleDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedIds.length}</b>
{selectedIds.length > 1 ? 'subscribers' : 'subscriber'}?
</Typography.Text>
</Confirm>
</Table.Cell>
{/each}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
@@ -59,7 +59,12 @@
}
</script>
<Confirm onSubmit={handleDelete} title={`Delete ${label} key`} bind:open={showDelete} bind:error>
<Confirm
bind:error
bind:open={showDelete}
submissionLoader
onSubmit={handleDelete}
title={`Delete ${label} key${keyIds.length > 1 ? 's' : ''}`}>
<p>
Are you sure you want to delete <b>{keyIds.length}</b>
{label} key{keyIds.length > 1 ? 's' : ''}?
@@ -2,27 +2,32 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Empty } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { Empty, MultiSelectionTable } from '$lib/components';
import { canWriteKeys } from '$lib/stores/roles';
import type { Models } from '@appwrite.io/console';
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 { Badge, Layout, Table } from '@appwrite.io/pink-svelte';
import DeleteBatch from './deleteBatch.svelte';
import { capitalize } from '$lib/helpers/string';
import { getEffectiveScopes } from '../api-keys/scopes.svelte';
export let keyType: 'api' | 'dev' = 'api';
export let keys: Models.KeyList | Models.DevKeyList;
let {
keyType = 'api',
keys
}: {
keyType?: 'api' | 'dev';
keys: Models.KeyList | Models.DevKeyList;
} = $props();
let showDeleteModal = false;
let selectedRows: string[] = [];
let selectedKeys = $state([]);
let showDeleteModal = $state(false);
const isApiKey = keyType === 'api';
const label = isApiKey ? 'API' : 'dev';
const slug = isApiKey ? 'api-keys' : 'dev-keys';
const columns = isApiKey ? $keyColumns : $devKeyColumns;
function getApiKeyScopeCount(key: Models.Key | Models.DevKey) {
const apiKey = key as Models.Key;
@@ -53,55 +58,65 @@
else
return 'Dev keys allow bypassing rate limits and CORS errors in your development environment.';
}
const columns = isApiKey ? $keyColumns : $devKeyColumns;
</script>
{#if keys.total}
<Table.Root let:root {columns} bind:selectedRows allowSelection={$canWriteKeys}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable
{columns}
confirmDeletion={false}
allowSelection={$canWriteKeys}
showSuccessNotification={false}
resource={`${capitalize(label)} key`}
onDelete={(selectedRows) => {
showDeleteModal = true;
selectedKeys = selectedRows;
}}>
{#snippet header(root)}
{#each columns as column}
<Table.Header.Cell column={column.id} {root}>{column.title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each getKeys() as key (key.$id)}
<Table.Row.Link id={key.$id} href={`${slug}/${key.$id}`} {root}>
<Table.Cell {root}>
{key.name}
</Table.Cell>
<Table.Cell {root}>
{#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">
{#if key.expire}
<DualTimeView time={key.expire} />
{/snippet}
{#snippet children(root)}
{#each getKeys() as key (key.$id)}
<Table.Row.Link id={key.$id} href={`${slug}/${key.$id}`} {root}>
<Table.Cell {root}>
{key.name}
</Table.Cell>
<Table.Cell {root}>
{#if key.accessedAt}
<DualTimeView time={key.accessedAt} />
{:else}
never
{/if}
{#if expiration.status}
<Badge
size="s"
variant="secondary"
type={expiration.status}
content={expiration.message} />
{/if}
</Layout.Stack>
</Table.Cell>
{#if isApiKey}
<Table.Cell {root}>
{getApiKeyScopeCount(key)} Scopes
</Table.Cell>
{/if}
</Table.Row.Link>
{/each}
</Table.Root>
<Table.Cell {root}>
{@const expiration = getExpiryDetails(key)}
<Layout.Stack gap="s" direction="row">
{#if key.expire}
<DualTimeView time={key.expire} />
{:else}
never
{/if}
{#if expiration.status}
<Badge
size="s"
variant="secondary"
type={expiration.status}
content={expiration.message} />
{/if}
</Layout.Stack>
</Table.Cell>
{#if isApiKey}
<Table.Cell {root}>
{getApiKeyScopeCount(key)} Scopes
</Table.Cell>
{/if}
</Table.Row.Link>
{/each}
{/snippet}
</MultiSelectionTable>
{:else}
<Empty
single
@@ -109,9 +124,9 @@
href="https://appwrite.io/docs/advanced/platform/{slug}"
target="{label} key"
description={getDescription()}
on:click={() => {
on:click={async () => {
if (isApiKey) {
goto(
await goto(
`${base}/project-${page.params.region}-${page.params.project}/overview/${slug}/create`
);
} else {
@@ -120,21 +135,4 @@
}} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{capitalize(label)}
{selectedRows.length > 1 ? 'keys' : 'key'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showDeleteModal = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<DeleteBatch {keyType} bind:keyIds={selectedRows} bind:showDelete={showDeleteModal} />
<DeleteBatch {keyType} bind:keyIds={selectedKeys} bind:showDelete={showDeleteModal} />
@@ -1,5 +1,5 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { PageData } from './$types';
import { type Models } from '@appwrite.io/console';
import { formatTimeDetailed } from '$lib/helpers/timeConversion';
@@ -11,7 +11,7 @@
import RedeployModal from '../../redeployModal.svelte';
import Cancel from './cancelDeploymentModal.svelte';
import { base } from '$app/paths';
import { Badge, FloatingActionBar, Layout, Status, Table } from '@appwrite.io/pink-svelte';
import { Layout, Status, Table } from '@appwrite.io/pink-svelte';
import { columns } from './store';
import ActivateDeploymentModal from '../../activateDeploymentModal.svelte';
import { capitalize } from '$lib/helpers/string';
@@ -19,26 +19,23 @@
import { deploymentStatusConverter } from '$lib/stores/git';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { Button } from '$lib/elements/forms';
import { site } from '$routes/(console)/project-[region]-[project]/sites/site-[site]/store';
export let data: PageData;
const {
data
}: {
data: PageData;
} = $props();
let showDelete = false;
let showActivate = false;
let showRedeploy = false;
let showCancel = false;
let showDelete = $state(false);
let showActivate = $state(false);
let showRedeploy = $state(false);
let showCancel = $state(false);
let selectedRows = [];
let showBatchDeletion = false;
let selectedDeployment: Models.Deployment = null;
async function deleteDeployments() {
showBatchDeletion = false;
let selectedDeployment: Models.Deployment | null = $state(null);
async function deleteDeployments(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((deploymentId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteDeployment({
siteId: page.params.site,
@@ -48,18 +45,10 @@
try {
await Promise.all(promises);
trackEvent(Submit.DeploymentDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} deployment${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DeploymentDelete);
return error;
} finally {
selectedRows = [];
await Promise.all([
invalidate(Dependencies.DEPLOYMENTS),
invalidate(Dependencies.SITE)
@@ -68,75 +57,83 @@
}
</script>
<Table.Root
let:root
allowSelection
bind:selectedRows
<MultiSelectionTable
resource="deployment"
onDelete={deleteDeployments}
columns={[...$columns, { id: 'actions', width: 40 }]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
{#each $columns as { id, title }}
<Table.Header.Cell column={id} {root}>
{title}
</Table.Header.Cell>
{/each}
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/sites/site-${page.params.site}/deployments/deployment-${deployment.$id}`}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/snippet}
{#snippet children(root)}
{#each data.deploymentList.deployments as deployment (deployment.$id)}
<Table.Row.Link
{root}
id={deployment.$id}
href={`${base}/project-${page.params.region}-${page.params.project}/sites/site-${page.params.site}/deployments/deployment-${deployment.$id}`}>
{#each $columns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={deployment.$id}>{deployment.$id}</Id>
{/key}
{:else if column.id === 'status'}
{@const status = deployment.status}
{#if data?.activeDeployment?.$id === deployment?.$id}
<Status status="complete" label="Active" />
{:else}
<Status
status={deploymentStatusConverter(status)}
label={capitalize(status)} />
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment?.totalSize ?? 0)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
{:else if column.id === 'type'}
<DeploymentSource {deployment} />
{:else if column.id === '$updatedAt'}
<DeploymentCreatedBy {deployment} />
{:else if column.id === 'buildDuration'}
{#if ['waiting'].includes(deployment.status)}
-
{:else if ['processing', 'building'].includes(deployment.status)}
<span use:timer={{ start: deployment.$createdAt }}></span>
{:else}
{formatTimeDetailed(deployment.buildDuration)}
{/if}
{:else if column.id === 'totalSize'}
{calculateSize(deployment?.totalSize ?? 0)}
{:else if column.id === 'sourceSize'}
{calculateSize(deployment.sourceSize)}
{:else if column.id === 'buildSize'}
{calculateSize(deployment.buildSize)}
{/if}
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Layout.Stack alignItems="flex-end">
<DeploymentActionMenu
{deployment}
bind:selectedDeployment
bind:showRedeploy
bind:showActivate
bind:showDelete
bind:showCancel
activeDeployment={data.site.deploymentId} />
</Layout.Stack>
</Table.Cell>
{/each}
<Table.Cell column="actions" {root}>
<Layout.Stack alignItems="flex-end">
<DeploymentActionMenu
{deployment}
bind:selectedDeployment
bind:showRedeploy
bind:showActivate
bind:showDelete
bind:showCancel
activeDeployment={data.site.deploymentId} />
</Layout.Stack>
</Table.Cell>
</Table.Row.Link>
{/each}
</Table.Root>
</Table.Row.Link>
{/each}
{/snippet}
{#snippet deleteContent(count)}
Are you sure you want to delete <strong>{count}</strong>
{count > 1 ? 'deployments' : 'deployment'} from your site -
<strong>{page.data.site.name}</strong>?
{/snippet}
</MultiSelectionTable>
{#if selectedDeployment}
<Delete {selectedDeployment} activeDeployment={data.site.deploymentId} bind:showDelete />
@@ -154,33 +151,3 @@
selectedDeploymentId={selectedDeployment.$id}
bind:show={showActivate} />
{/if}
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span style="white-space: nowrap">
{selectedRows.length > 1 ? 'deployments' : 'deployment'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm
title="Delete deployments"
confirmDeletion
onSubmit={deleteDeployments}
bind:open={showBatchDeletion}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'deployments' : 'deployment'} from your site -
<strong>{$site.name}</strong>?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -1,137 +1,105 @@
<script lang="ts">
import { Confirm, Id } from '$lib/components';
import { type DeleteOperationState, Id, MultiSelectionTable } from '$lib/components';
import type { Column } from '$lib/helpers/types';
import type { Models } from '@appwrite.io/console';
import { Badge, FloatingActionBar, Table, Typography } from '@appwrite.io/pink-svelte';
import { Badge, Table, Typography } from '@appwrite.io/pink-svelte';
import Sheet from './sheet.svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import { sdk } from '$lib/stores/sdk';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { invalidate } from '$app/navigation';
import { Dependencies } from '$lib/constants';
import { calculateTime } from '$lib/helpers/timeConversion';
import { getBadgeTypeFromStatusCode } from '$lib/helpers/httpStatus';
import { Button } from '$lib/elements/forms';
import { timer } from '$lib/actions/timer';
export let columns: Column[];
export let logs: Models.ExecutionList;
let {
columns,
logs
}: {
columns: Column[];
logs: Models.ExecutionList;
} = $props();
let openSheet = false;
let selectedLogId: string = null;
$: filteredColumns = columns.filter((c) => !c.exclude);
let selectedRows = [];
let showBatchDeletion = false;
async function deleteLogs() {
showBatchDeletion = false;
let openSheet = $state(false);
let selectedLogId = $state<string | null>(null);
const filteredColumns = $derived(columns.filter((c) => !c.exclude));
async function deleteLogs(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((logId) =>
sdk.forProject(page.params.region, page.params.project).sites.deleteLog({
siteId: page.params.site,
logId
})
);
try {
await Promise.all(promises);
trackEvent(Submit.LogDelete);
addNotification({
type: 'success',
message: `${selectedRows.length} log${selectedRows.length > 1 ? 's' : ''} deleted`
});
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.LogDelete);
return error;
} finally {
selectedRows = [];
invalidate(Dependencies.EXECUTIONS);
await invalidate(Dependencies.EXECUTIONS);
}
}
</script>
<Table.Root let:root allowSelection bind:selectedRows columns={filteredColumns}>
<svelte:fragment slot="header" let:root>
<MultiSelectionTable resource="log" columns={filteredColumns} onDelete={deleteLogs}>
{#snippet header(root)}
{#each filteredColumns as { id, title }}
<Table.Header.Cell column={id} {root}>{title}</Table.Header.Cell>
{/each}
</svelte:fragment>
{#each logs.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
openSheet = true;
selectedLogId = log.$id;
}}>
{#each filteredColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/snippet}
{#snippet children(root)}
{#each logs.executions as log (log.$id)}
<Table.Row.Button
{root}
id={log.$id}
on:click={(e) => {
e.stopPropagation();
openSheet = true;
selectedLogId = log.$id;
}}>
{#each filteredColumns as column}
<Table.Cell column={column.id} {root}>
{#if column.id === '$id'}
{#key column.id}
<Id value={log.$id}>{log.$id}</Id>
{/key}
{:else if column.id === 'deploymentId'}
<Id value={log.deploymentId}>{log.deploymentId}</Id>
{:else if column.id === 'requestMethod'}
<Typography.Code size="m">
{log.requestMethod}
</Typography.Code>
{:else if column.id === 'duration'}
{#if ['processing', 'waiting'].includes(log.status)}
<span use:timer={{ start: log.$createdAt }}></span>
{:else}
{calculateTime(log.duration)}
{/if}
{:else if column.id === 'responseStatusCode'}
<div>
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
</div>
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{/if}
{:else if column.id === 'responseStatusCode'}
<div>
<Badge
variant="secondary"
type={getBadgeTypeFromStatusCode(log.responseStatusCode)}
content={log.responseStatusCode.toString()} />
</div>
{:else if column.id === 'requestPath'}
<Typography.Code size="m">
{log.requestPath}
</Typography.Code>
{:else if column.id === '$createdAt'}
<DualTimeView time={log.$createdAt} />
{/if}
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
</Table.Root>
</Table.Cell>
{/each}
</Table.Row.Button>
{/each}
{/snippet}
</MultiSelectionTable>
<Sheet bind:open={openSheet} bind:selectedLogId logs={logs.executions} />
{#if selectedRows.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedRows.length.toString()} />
<span>
{selectedRows.length > 1 ? 'logs' : 'log'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedRows = [])}>Cancel</Button>
<Button secondary on:click={() => (showBatchDeletion = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
<Confirm title="Delete logs" confirmDeletion onSubmit={deleteLogs} bind:open={showBatchDeletion}>
<p>
Are you sure you want to delete <strong>{selectedRows.length}</strong>
{selectedRows.length > 1 ? 'logs' : 'log'}?
</p>
<p class="u-bold">This action is irreversible.</p>
</Confirm>
@@ -3,28 +3,25 @@
import { base } from '$app/paths';
import { page } from '$app/state';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { Avatar, Empty, EmptySearch, PaginationWithLimit, SearchQuery } from '$lib/components';
import {
Avatar,
type DeleteOperationState,
Empty,
EmptySearch,
MultiSelectionTable,
PaginationWithLimit,
SearchQuery
} from '$lib/components';
import { Dependencies } from '$lib/constants';
import { Badge } from '@appwrite.io/pink-svelte';
import { Button } from '$lib/elements/forms';
import { calculateSize } from '$lib/helpers/sizeConvertion';
import { Container } from '$lib/layout';
import { ImageFormat, type Models } from '@appwrite.io/console';
import { addNotification } from '$lib/stores/notifications';
import { uploader } from '$lib/stores/uploader';
import { sdk } from '$lib/stores/sdk.js';
import DeleteFile from './deleteFile.svelte';
import { bucket } from './store';
import Confirm from '$lib/components/confirm.svelte';
import {
Layout,
Table,
Icon,
Popover,
ActionMenu,
FloatingActionBar,
Typography
} from '@appwrite.io/pink-svelte';
import { Layout, Table, Icon, Popover, ActionMenu } from '@appwrite.io/pink-svelte';
import { onMount } from 'svelte';
import DualTimeView from '$lib/components/dualTimeView.svelte';
import {
@@ -34,23 +31,20 @@
IconTrash
} from '@appwrite.io/pink-icons-svelte';
import { isSmallViewport } from '$lib/stores/viewport';
import type { PageProps } from './$types';
export let data;
const { data }: PageProps = $props();
let showDelete = false;
let selectedFile: Models.File = null;
let selectedFiles: string[] = [];
let showBulkDelete = false;
let deleting = false;
const bucketId = page.params.bucket;
let showDelete = $state(false);
let isUploading = $state(false);
let selectedFile: Models.File | null = $state(null);
function getPreview(fileId: string) {
return (
sdk
.forProject(page.params.region, page.params.project)
.storage.getFilePreview({
bucketId,
bucketId: page.params.bucket,
fileId,
height: 128,
width: 128,
@@ -71,42 +65,25 @@
showDelete = true;
}
async function handleBulkDelete() {
showBulkDelete = false;
deleting = true;
const promises = selectedFiles.map((fileId) =>
sdk.forProject(page.params.region, page.params.project).storage.deleteFile({
bucketId,
async function handleBulkDelete(selectedRows: string[]): Promise<DeleteOperationState> {
const promises = selectedRows.map((fileId) => {
return sdk.forProject(page.params.region, page.params.project).storage.deleteFile({
bucketId: page.params.bucket,
fileId
})
);
});
});
try {
await Promise.all(promises);
trackEvent(Submit.FileDelete, {
total: selectedFiles.length
});
addNotification({
type: 'success',
message: `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''} deleted`
});
invalidate(Dependencies.FILES);
trackEvent(Submit.FileDelete, { total: selectedRows.length });
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.FileDelete);
return error;
} finally {
selectedFiles = [];
showBulkDelete = false;
deleting = false;
await invalidate(Dependencies.FILES);
}
}
let isUploading = false;
const beforeunload = (event: BeforeUnloadEvent) => {
// legacy browser **may** support showing a custom message.
const message = 'An upload is in progress. Are you sure you want to leave?';
@@ -147,10 +124,10 @@
</Layout.Stack>
{#if data.files.total}
<Table.Root
let:root
allowSelection
bind:selectedRows={selectedFiles}
<MultiSelectionTable
resource="file"
computeKey={data.files.total}
onDelete={handleBulkDelete}
columns={[
{ id: 'filename', width: $isSmallViewport ? 24 : undefined },
{ id: 'type', width: { min: 140 } },
@@ -158,93 +135,106 @@
{ id: 'created', width: { min: 120 } },
{ id: 'actions', width: 40 }
]}>
<svelte:fragment slot="header" let:root>
{#snippet header(root)}
<Table.Header.Cell column="filename" {root}>Filename</Table.Header.Cell>
<Table.Header.Cell column="type" {root}>Type</Table.Header.Cell>
<Table.Header.Cell column="size" {root}>Size</Table.Header.Cell>
<Table.Header.Cell column="created" {root}>Created</Table.Header.Cell>
<Table.Header.Cell column="actions" {root} />
</svelte:fragment>
{#each data.files.files as file}
{#if file.chunksTotal / file.chunksUploaded !== 1}
<Table.Row.Base {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<Layout.Stack direction="row" alignItems="center">
<span class="avatar is-size-small is-color-empty"></span>
<span class="text u-trim">{file.name}</span>
<div>
<Badge variant="secondary" type="warning" content="Pending" />
{/snippet}
{#snippet children(root)}
{#each data.files.files as file}
{#if file.chunksTotal / file.chunksUploaded !== 1}
<Table.Row.Base {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<Layout.Stack direction="row" alignItems="center">
<span class="avatar is-size-small is-color-empty"></span>
<span class="text u-trim">{file.name}</span>
<div>
<Badge
variant="secondary"
type="warning"
content="Pending" />
</div>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="u-flex u-main-center">
<button
class="button is-only-icon is-text"
aria-label="Delete item"
onclick={(event) => {
event.preventDefault();
deleteFile(file);
}}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</div>
</Layout.Stack>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<div class="u-flex u-main-center">
<button
class="button is-only-icon is-text"
aria-label="Delete item"
on:click|preventDefault={() => deleteFile(file)}>
<span class="icon-trash" aria-hidden="true"></span>
</button>
</div>
</Table.Cell>
</Table.Row.Base>
{:else}
{@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}/file-${file.$id}`}
<Table.Row.Link {href} {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<div class="u-flex u-gap-12 u-cross-center">
<Avatar size="xs" src={getPreview(file.$id)} alt={file.name} />
<span class="text u-trim">{file.name}</span>
</div>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button
text
icon
ariaLabel="more options"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
toggle();
}}>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Anchor {href} leadingIcon={IconPencil}>
Update
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Button
leadingIcon={IconTrash}
</Table.Cell>
</Table.Row.Base>
{:else}
{@const href = `${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/file-${file.$id}`}
<Table.Row.Link {href} {root} id={file.$id}>
<Table.Cell column="filename" {root}>
<div class="u-flex u-gap-12 u-cross-center">
<Avatar size="xs" src={getPreview(file.$id)} alt={file.name} />
<span class="text u-trim">{file.name}</span>
</div>
</Table.Cell>
<Table.Cell column="type" {root}>{file.mimeType}</Table.Cell>
<Table.Cell column="size" {root}>
{calculateSize(file.sizeOriginal)}
</Table.Cell>
<Table.Cell column="created" {root}>
<DualTimeView time={file.$createdAt} />
</Table.Cell>
<Table.Cell column="actions" {root}>
<Popover let:toggle placement="bottom-start" padding="none">
<Button
text
icon
ariaLabel="more options"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedFile = file;
showDelete = true;
toggle();
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Table.Cell>
</Table.Row.Link>
{/if}
{/each}
</Table.Root>
<Icon icon={IconDotsHorizontal} size="s" />
</Button>
<ActionMenu.Root slot="tooltip">
<ActionMenu.Item.Anchor {href} leadingIcon={IconPencil}>
Update
</ActionMenu.Item.Anchor>
<ActionMenu.Item.Button
leadingIcon={IconTrash}
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
selectedFile = file;
showDelete = true;
}}>
Delete
</ActionMenu.Item.Button>
</ActionMenu.Root>
</Popover>
</Table.Cell>
</Table.Row.Link>
{/if}
{/each}
{/snippet}
{#snippet deleteContentNotice()}
This action is irreversible and will permanently remove the selected files.
{/snippet}
</MultiSelectionTable>
<PaginationWithLimit
name="Files"
@@ -256,7 +246,7 @@
<Button
secondary
size="s"
href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}`}>
href={`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}`}>
Clear Search
</Button>
</EmptySearch>
@@ -268,39 +258,9 @@
allowCreate
on:click={() =>
goto(
`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}/create`
`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${page.params.bucket}/create`
)} />
{/if}
{#if selectedFiles.length > 0}
<FloatingActionBar>
<svelte:fragment slot="start">
<Badge content={selectedFiles.length.toString()} />
<span>
{selectedFiles.length > 1 ? 'files' : 'file'}
selected
</span>
</svelte:fragment>
<svelte:fragment slot="end">
<Button text on:click={() => (selectedFiles = [])}>Cancel</Button>
<Button secondary on:click={() => (showBulkDelete = true)}>Delete</Button>
</svelte:fragment>
</FloatingActionBar>
{/if}
</Container>
<DeleteFile bind:showDelete file={selectedFile} on:deleted={fileDeleted} />
<Confirm
title="Delete files"
bind:open={showBulkDelete}
onSubmit={handleBulkDelete}
disabled={deleting}>
<Typography.Text>
Are you sure you want to delete <b>{selectedFiles.length}</b>
{selectedFiles.length > 1 ? 'files' : 'file'} from <b>{$bucket?.name}</b>?
</Typography.Text>
<Typography.Text>
This action is irreversible and will permanently remove the selected files.
</Typography.Text>
</Confirm>