Add CSV export box

This commit is contained in:
Jake Barnby
2025-10-22 02:09:42 +13:00
parent 5d25f8a9f5
commit b129bdfd4f
3 changed files with 318 additions and 1 deletions
+315
View File
@@ -0,0 +1,315 @@
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { goto } 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 { type Models, type Payload } from '@appwrite.io/console';
type ExportItem = {
status: string;
table?: string;
bucketId?: string;
fileName?: string;
};
type ExportItemsMap = Map<string, ExportItem>;
const exportItems: Writable<ExportItemsMap> = writable(new Map());
async function showCompletionNotification(
_database: string,
table: string,
bucketId: string,
fileName: string,
payload: Payload
) {
const isSuccess = payload.status === 'completed';
const isError = !isSuccess && !!payload.errors;
if (!isSuccess && !isError) return;
let errorMessage = 'Export failed. Please try again.';
if (isError && Array.isArray(payload.errors)) {
try {
errorMessage = JSON.parse(payload.errors[0]).message;
} catch {
// fallback to default message
}
}
const type = isSuccess ? 'success' : 'error';
const message = isError ? errorMessage : `"${table}" has been exported`;
addNotification({
type,
message,
isHtml: true,
timeout: 10000,
buttons: isSuccess
? [
{
name: 'View bucket',
method: () =>
goto(
`${base}/project-${page.params.region}-${page.params.project}/storage/bucket-${bucketId}`
)
},
{
name: 'Download',
method: async () => {
try {
const files = await sdk
.forProject(page.params.region, page.params.project)
.storage.listFiles({ bucketId });
const file = files.files.find((f) => f.name === fileName);
if (file) {
const downloadUrl =
sdk
.forProject(page.params.region, page.params.project)
.storage.getFileDownload({
bucketId,
fileId: file.$id
})
.toString() + '&mode=admin';
window.open(downloadUrl, '_blank');
}
} catch (e) {
addNotification({
type: 'error',
message: 'Failed to download file'
});
}
}
}
]
: undefined
});
}
async function updateOrAddItem(exportData: Payload | Models.Migration) {
// TODO: Update this check when export source is defined
if (exportData.source.toLowerCase() !== 'csv-export') return;
const status = exportData.status;
const resourceId = exportData.resourceId ?? '';
const [databaseId, tableId] = resourceId.split(':') ?? [];
const current = $exportItems.get(exportData.$id);
let tableName = current?.table ?? null;
let bucketId = current?.bucketId ?? '';
let fileName = current?.fileName ?? '';
// TODO: Extract bucket and file info from migration data
// bucketId = exportData.bucketId
// fileName = exportData.fileName
if (!tableName && tableId) {
try {
const table = await sdk
.forProject(page.params.region, page.params.project)
.tablesDB.getTable({
databaseId,
tableId
});
tableName = table.name;
} catch {
tableName = null;
}
}
if (tableId && tableName === null) {
exportItems.update((items) => {
const next = new Map(items);
next.delete(exportData.$id);
return next;
});
return;
}
exportItems.update((items) => {
const existing = items.get(exportData.$id);
const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing'].includes(s);
const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;
if (shouldSkip) return items;
const next = new Map(items);
next.set(exportData.$id, {
status,
table: tableName ?? undefined,
bucketId,
fileName
});
return next;
});
if (status === 'completed' || status === 'failed') {
await showCompletionNotification(databaseId, tableId, bucketId, fileName, exportData);
}
}
function clear() {
exportItems.update((items) => {
items.clear();
return items;
});
}
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 = '', bucketName = '') {
const table = tableName ? `<b>${tableName}</b>` : '';
const bucket = bucketName ? `<b>${bucketName}</b>` : 'bucket';
switch (status) {
case 'completed':
return `Export to ${bucket} completed`;
case 'failed':
return `Export to ${bucket} failed`;
case 'processing':
return `Exporting ${table} to ${bucket}`;
default:
return 'Preparing export...';
}
}
onMount(() => {
// TODO: Query for active CSV exports when SDK method is available
// sdk.forProject(page.params.region, page.params.project)
// .migrations.list({
// queries: [
// Query.equal('source', 'CSV-EXPORT'),
// Query.equal('status', ['pending', 'processing'])
// ]
// })
// .then((migrations) => {
// migrations.migrations.forEach(updateOrAddItem);
// });
return sdk.forConsoleIn(page.params.region).client.subscribe('console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
updateOrAddItem(response.payload as Payload);
}
});
});
$: isOpen = true;
$: showCsvExportBox = $exportItems.size > 0;
</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"
on:click={() => (isOpen = !isOpen)}>
<span class="icon-cheveron-up" aria-hidden="true"></span>
</button>
<button class="upload-box-button" aria-label="close export box" on:click={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, 'bucket')}
</Typography.Text>
</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}
<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
View File
@@ -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';
@@ -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';
@@ -121,6 +121,7 @@
<MigrationBox />
<BackupRestoreBox />
<CsvImportBox />
<CsvExportBox />
</div>
<style>