mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
Add CSV export box
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user