mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Merge branch 'main' into 'revamp-designs'.
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
Generated
+10
-10
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Base padding="none">
|
||||
<Empty title="You have no teams. Create a team to see them here." type="secondary">
|
||||
<Empty title="No teams yet. Create a team to see it here." type="secondary">
|
||||
<Typography.Text slot="description">
|
||||
Need a hand? Learn more in our <Link.Anchor
|
||||
href="https://appwrite.io/docs/products/auth/teams"
|
||||
|
||||
@@ -176,10 +176,10 @@
|
||||
</div>
|
||||
{:else}
|
||||
<Card.Base padding="none">
|
||||
<Empty title="You have no users. Create a user to see them here." type="secondary">
|
||||
<Empty title="No users yet. Create a user to see it here." type="secondary">
|
||||
<Typography.Text slot="description">
|
||||
Need a hand? Learn more in our <Link.Anchor
|
||||
href="https://appwrite.io/docs/products/auth/quick-start"
|
||||
href="https://appwrite.io/docs/products/auth/users"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
documentation</Link.Anchor
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+75
-121
@@ -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)} />
|
||||
|
||||
+57
-91
@@ -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>
|
||||
|
||||
+62
-111
@@ -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>
|
||||
|
||||
+52
-87
@@ -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>
|
||||
|
||||
+3
-4
@@ -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({
|
||||
|
||||
+216
-208
@@ -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>
|
||||
|
||||
+10
-7
@@ -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
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
$effect(() => {
|
||||
if (row) {
|
||||
work = initWork();
|
||||
focusFirstInput();
|
||||
requestAnimationFrame(() => focusFirstInput());
|
||||
} else {
|
||||
work = null;
|
||||
}
|
||||
|
||||
+8
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+232
-129
@@ -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}
|
||||
|
||||
+52
-77
@@ -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>
|
||||
|
||||
+81
-124
@@ -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>
|
||||
|
||||
+140
-176
@@ -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>
|
||||
|
||||
+61
-101
@@ -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>
|
||||
|
||||
+6
-1
@@ -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' : ''}?
|
||||
|
||||
+64
-66
@@ -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} />
|
||||
|
||||
+82
-115
@@ -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>
|
||||
|
||||
+67
-99
@@ -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>
|
||||
|
||||
+121
-161
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user