Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export enum Click {
DatabaseRowDelete = 'click_row_delete',
DatabaseDatabaseDelete = 'click_database_delete',
DatabaseImportCsv = 'click_database_import_csv',

DatabaseExportCsv = 'click_database_export_csv',
DomainCreateClick = 'click_domain_create',
DomainDeleteClick = 'click_domain_delete',
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
Expand Down Expand Up @@ -281,6 +281,7 @@ export enum Submit {
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
DatabaseExportCsv = 'submit_database_export_csv',
DatabaseBackupDelete = 'submit_database_backup_delete',
DatabaseBackupPolicyCreate = 'submit_database_backup_policy_create',

Expand Down
292 changes: 292 additions & 0 deletions src/lib/components/csvExportBox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { realtime, sdk } from '$lib/stores/sdk';
import { getProjectId } from '$lib/helpers/project';
import { addNotification } from '$lib/stores/notifications';
import { Layout, Typography, Code } from '@appwrite.io/pink-svelte';
import { type Models, type Payload } from '@appwrite.io/console';
import { Modal } from '$lib/components';
import { Query } from '@appwrite.io/console';

type ExportItem = {
status: string;
table?: string;
bucketId?: string;
bucketName?: string;
fileName?: string;
downloadUrl?: string;
errors?: string[];
};

type ExportItemsMap = Map<string, ExportItem>;

let exportItems = $state<ExportItemsMap>(new Map());

function downloadExportedFile(downloadUrl: string) {
if (!downloadUrl) {
return;
}

window.open(downloadUrl, '_blank');
}

async function showErrorNotification(payload: Payload) {
let errorMessage = 'Export failed. Please try again.';
try {
const parsed = JSON.parse(payload.errors[0]);
errorMessage = parsed?.message || errorMessage;
} catch {
errorMessage = payload.errors[0] || errorMessage;
}

addNotification({
type: 'error',
message: errorMessage,
isHtml: true,
timeout: 10000
});
}

async function updateOrAddItem(exportData: Payload | Models.Migration) {
if (exportData.destination?.toLowerCase() !== 'csv') return;

const status = exportData.status;
const current = exportItems.get(exportData.$id);
let tableName = current?.table;

// Get bucket, filename, and download URL from migration options
const options = ('options' in exportData ? exportData.options : {}) || {};
const bucketId = options.bucketId || '';
const fileName = options.filename || '';
const downloadUrl = options.downloadUrl || '';
let bucketName = current?.bucketName;

const existing = exportItems.get(exportData.$id);

const isDone = (s: string) => ['completed', 'failed'].includes(s);
const isInProgress = (s: string) => ['pending', 'processing'].includes(s);

// Skip if we're trying to set an in-progress status on a completed migration
const shouldSkip = existing && isDone(existing.status) && isInProgress(status);

const hasNewData =
downloadUrl && (!existing?.downloadUrl || existing.downloadUrl !== downloadUrl);
const shouldSkipDuplicate = existing?.status === status && !hasNewData;

if (shouldSkip || shouldSkipDuplicate) return;

exportItems.set(exportData.$id, {
status,
table: tableName ?? current?.table,
bucketId: bucketId,
bucketName: bucketName,
fileName: fileName,
downloadUrl: downloadUrl,
errors: exportData.errors || []
});

exportItems = new Map(exportItems);

switch (status) {
case 'completed':
if (downloadUrl) {
downloadExportedFile(downloadUrl);
addNotification({
type: 'success',
message: `Export completed`,
timeout: 10000,
buttons: [
{
name: 'Download',
method: () => downloadExportedFile(downloadUrl)
}
]
});
}
break;
case 'failed':
await showErrorNotification(exportData);
break;
}
}

function clear() {
exportItems = new Map();
}

function graphSize(status: string): number {
switch (status) {
case 'pending':
return 10;
case 'processing':
return 60;
case 'completed':
case 'failed':
return 100;
default:
return 30;
}
}

function text(status: string, tableName = '') {
const table = tableName ? `<b>${tableName}</b>` : '';
switch (status) {
case 'completed':
return `Exporting ${table} completed`;
case 'failed':
return `Exporting ${table} failed`;
case 'processing':
return `Exporting ${table}`;
default:
return 'Preparing export...';
}
}

onMount(() => {
sdk.forProject(page.params.region, page.params.project)
.migrations.list({
queries: [
Query.equal('destination', 'CSV'),
Query.equal('status', ['pending', 'processing'])
]
})
.then((migrations) => {
migrations.migrations.forEach(updateOrAddItem);
});

return realtime.forConsole(page.params.region, 'console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
updateOrAddItem(response.payload as Payload);
}
});
});

let isOpen = $state(true);
let showCsvExportBox = $derived(exportItems.size > 0);
let showErrorModal = $state(false);
let selectedErrors = $state<string[]>([]);
</script>

{#if showCsvExportBox}
<Layout.Stack direction="column" gap="l" alignItems="flex-end">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
Exporting rows ({exportItems.size})
</Typography.Text>
</h4>
<button
class="upload-box-button"
class:is-open={isOpen}
aria-label="toggle upload box"
onclick={() => (isOpen = !isOpen)}>
<span class="icon-cheveron-up" aria-hidden="true"></span>
</button>
<button class="upload-box-button" aria-label="close export box" onclick={clear}>
<span class="icon-x" aria-hidden="true"></span>
</button>
</header>

<div class="upload-box-content-list">
{#each [...exportItems.entries()] as [key, value] (key)}
<div class="upload-box-content" class:is-open={isOpen}>
<ul class="upload-box-list">
<li class="upload-box-item">
<section class="progress-bar u-width-full-line">
<div
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
<Typography.Text>
{@html text(value.status, value.table)}
</Typography.Text>
{#if value.status === 'failed' && value.errors && value.errors.length > 0}
<button
class="link"
type="button"
onclick={() => {
selectedErrors = value.errors;
showErrorModal = true;
}}>
more details
</button>
{/if}
</div>
<div
class="progress-bar-container"
class:is-danger={value.status === 'failed'}
style="--graph-size:{graphSize(value.status)}%">
</div>
</section>
</li>
</ul>
</div>
{/each}
</div>
</section>
</Layout.Stack>
{/if}

<Modal bind:show={showErrorModal} title="Export error details" hideFooter>
{#if selectedErrors.length > 0}
<Code
code={JSON.stringify(
selectedErrors.map((err) => {
try {
return JSON.parse(err);
} catch {
return err;
}
}),
null,
2
)}
lang="json"
hideHeader />
{/if}
</Modal>

<style lang="scss">
.upload-box {
display: flex;
max-height: 320px;
flex-direction: column;
}

.upload-box-header {
flex-shrink: 0;
}

.upload-box-title {
font-size: 11px;
}

.upload-box-content-list {
overflow-y: auto;
}

.upload-box-content {
width: 304px;
}

.upload-box-button {
display: flex;
align-items: center;
justify-content: center;
}

.progress-bar-container {
height: 4px;

&::before {
height: 4px;
background-color: var(--bgcolor-neutral-invert);
}

&.is-danger::before {
height: 4px;
background-color: var(--bgcolor-error);
}
}
</style>
1 change: 1 addition & 0 deletions src/lib/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { default as Copy } from './copy.svelte';
export { default as CopyInput } from './copyInput.svelte';
export { default as UploadBox } from './uploadBox.svelte';
export { default as BackupRestoreBox } from './backupRestoreBox.svelte';
export { default as CsvExportBox } from './csvExportBox.svelte';
export { default as List } from './list.svelte';
export { default as ListItem } from './listItem.svelte';
export { default as Empty } from './empty.svelte';
Expand Down
3 changes: 3 additions & 0 deletions src/lib/elements/forms/inputCheckbox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
indeterminate?: boolean;
size?: 's' | 'm';
description?: string;
truncate?: boolean;
}

export let id: string = '';
Expand All @@ -22,6 +23,7 @@
export let element: HTMLInputElement | undefined = undefined;
export let size: $$Props['size'] = 's';
export let description = '';
export let truncate: boolean = false;
let error: string;

const handleInvalid = (event: Event) => {
Expand Down Expand Up @@ -50,6 +52,7 @@
{label}
{required}
{description}
{truncate}
on:invalid={handleInvalid}
on:click
on:change />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { BackupRestoreBox, MigrationBox, UploadBox } from '$lib/components';
import { BackupRestoreBox, MigrationBox, UploadBox, CsvExportBox } from '$lib/components';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
Expand Down Expand Up @@ -119,6 +119,7 @@
<MigrationBox />
<BackupRestoreBox />
<CsvImportBox />
<CsvExportBox />
</div>

<style>
Expand Down
Loading