Skip to content

Commit 4a95f3a

Browse files
authored
Merge pull request #2496 from appwrite/feat-csv-export
CSV export
2 parents cb856cb + abbe7e1 commit 4a95f3a

8 files changed

Lines changed: 609 additions & 24 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,5 @@
9595
"svelte-preprocess"
9696
]
9797
},
98-
"packageManager": "pnpm@10.20.0"
98+
"packageManager": "pnpm@10.18.3"
9999
}

src/lib/actions/analytics.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ export enum Click {
153153
DatabaseTableDelete = 'click_table_delete',
154154
DatabaseDatabaseDelete = 'click_database_delete',
155155
DatabaseImportCsv = 'click_database_import_csv',
156+
DatabaseExportCsv = 'click_database_export_csv',
156157
DomainCreateClick = 'click_domain_create',
157158
DomainDeleteClick = 'click_domain_delete',
158159
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
@@ -277,6 +278,7 @@ export enum Submit {
277278
DatabaseDelete = 'submit_database_delete',
278279
DatabaseUpdateName = 'submit_database_update_name',
279280
DatabaseImportCsv = 'submit_database_import_csv',
281+
DatabaseExportCsv = 'submit_database_export_csv',
280282

281283
ColumnCreate = 'submit_column_create',
282284
ColumnUpdate = 'submit_column_update',
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
<script lang="ts">
2+
import { onMount } from 'svelte';
3+
import { page } from '$app/state';
4+
import { realtime, sdk } from '$lib/stores/sdk';
5+
import { getProjectId } from '$lib/helpers/project';
6+
import { addNotification } from '$lib/stores/notifications';
7+
import { Layout, Typography, Code } from '@appwrite.io/pink-svelte';
8+
import { type Models, type Payload } from '@appwrite.io/console';
9+
import { Modal } from '$lib/components';
10+
import { Query } from '@appwrite.io/console';
11+
12+
type ExportItem = {
13+
status: string;
14+
table?: string;
15+
bucketId?: string;
16+
bucketName?: string;
17+
fileName?: string;
18+
downloadUrl?: string;
19+
errors?: string[];
20+
};
21+
22+
type ExportItemsMap = Map<string, ExportItem>;
23+
24+
let exportItems = $state<ExportItemsMap>(new Map());
25+
26+
function downloadExportedFile(downloadUrl: string) {
27+
if (!downloadUrl) {
28+
return;
29+
}
30+
31+
window.open(downloadUrl, '_blank');
32+
}
33+
34+
async function showErrorNotification(payload: Payload) {
35+
let errorMessage = 'Export failed. Please try again.';
36+
try {
37+
const parsed = JSON.parse(payload.errors[0]);
38+
errorMessage = parsed?.message || errorMessage;
39+
} catch {
40+
errorMessage = payload.errors[0] || errorMessage;
41+
}
42+
43+
addNotification({
44+
type: 'error',
45+
message: errorMessage,
46+
isHtml: true,
47+
timeout: 10000
48+
});
49+
}
50+
51+
async function updateOrAddItem(exportData: Payload | Models.Migration) {
52+
if (exportData.destination?.toLowerCase() !== 'csv') return;
53+
54+
const status = exportData.status;
55+
const current = exportItems.get(exportData.$id);
56+
let tableName = current?.table;
57+
58+
// Get bucket, filename, and download URL from migration options
59+
const options = ('options' in exportData ? exportData.options : {}) || {};
60+
const bucketId = options.bucketId || '';
61+
const fileName = options.filename || '';
62+
const downloadUrl = options.downloadUrl || '';
63+
let bucketName = current?.bucketName;
64+
65+
const existing = exportItems.get(exportData.$id);
66+
67+
const isDone = (s: string) => ['completed', 'failed'].includes(s);
68+
const isInProgress = (s: string) => ['pending', 'processing'].includes(s);
69+
70+
// Skip if we're trying to set an in-progress status on a completed migration
71+
const shouldSkip = existing && isDone(existing.status) && isInProgress(status);
72+
73+
const hasNewData =
74+
downloadUrl && (!existing?.downloadUrl || existing.downloadUrl !== downloadUrl);
75+
const shouldSkipDuplicate = existing?.status === status && !hasNewData;
76+
77+
if (shouldSkip || shouldSkipDuplicate) return;
78+
79+
exportItems.set(exportData.$id, {
80+
status,
81+
table: tableName ?? current?.table,
82+
bucketId: bucketId,
83+
bucketName: bucketName,
84+
fileName: fileName,
85+
downloadUrl: downloadUrl,
86+
errors: exportData.errors || []
87+
});
88+
89+
exportItems = new Map(exportItems);
90+
91+
switch (status) {
92+
case 'completed':
93+
if (downloadUrl) {
94+
downloadExportedFile(downloadUrl);
95+
}
96+
break;
97+
case 'failed':
98+
await showErrorNotification(exportData);
99+
break;
100+
}
101+
}
102+
103+
function clear() {
104+
exportItems = new Map();
105+
}
106+
107+
function graphSize(status: string): number {
108+
switch (status) {
109+
case 'pending':
110+
return 10;
111+
case 'processing':
112+
return 60;
113+
case 'completed':
114+
case 'failed':
115+
return 100;
116+
default:
117+
return 30;
118+
}
119+
}
120+
121+
function text(status: string, tableName = '') {
122+
const table = tableName ? `<b>${tableName}</b>` : '';
123+
switch (status) {
124+
case 'completed':
125+
return `Exporting ${table} completed`;
126+
case 'failed':
127+
return `Exporting ${table} failed`;
128+
case 'processing':
129+
return `Exporting ${table}`;
130+
default:
131+
return 'Preparing export...';
132+
}
133+
}
134+
135+
onMount(() => {
136+
sdk.forProject(page.params.region, page.params.project)
137+
.migrations.list({
138+
queries: [
139+
Query.equal('destination', 'CSV'),
140+
Query.equal('status', ['pending', 'processing'])
141+
]
142+
})
143+
.then((migrations) => {
144+
migrations.migrations.forEach(updateOrAddItem);
145+
});
146+
147+
return realtime.forConsole(page.params.region, 'console', (response) => {
148+
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
149+
if (response.events.includes('migrations.*')) {
150+
updateOrAddItem(response.payload as Payload);
151+
}
152+
});
153+
});
154+
155+
let isOpen = $state(true);
156+
let showCsvExportBox = $derived(exportItems.size > 0);
157+
let showErrorModal = $state(false);
158+
let selectedErrors = $state<string[]>([]);
159+
</script>
160+
161+
{#if showCsvExportBox}
162+
<Layout.Stack direction="column" gap="l" alignItems="flex-end">
163+
<section class="upload-box">
164+
<header class="upload-box-header">
165+
<h4 class="upload-box-title">
166+
<Typography.Text variant="m-500">
167+
Exporting rows ({exportItems.size})
168+
</Typography.Text>
169+
</h4>
170+
<button
171+
class="upload-box-button"
172+
class:is-open={isOpen}
173+
aria-label="toggle upload box"
174+
onclick={() => (isOpen = !isOpen)}>
175+
<span class="icon-cheveron-up" aria-hidden="true"></span>
176+
</button>
177+
<button class="upload-box-button" aria-label="close export box" onclick={clear}>
178+
<span class="icon-x" aria-hidden="true"></span>
179+
</button>
180+
</header>
181+
182+
<div class="upload-box-content-list">
183+
{#each [...exportItems.entries()] as [key, value] (key)}
184+
<div class="upload-box-content" class:is-open={isOpen}>
185+
<ul class="upload-box-list">
186+
<li class="upload-box-item">
187+
<section class="progress-bar u-width-full-line">
188+
<div
189+
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
190+
<Typography.Text>
191+
{@html text(value.status, value.table)}
192+
</Typography.Text>
193+
{#if value.status === 'failed' && value.errors && value.errors.length > 0}
194+
<button
195+
class="link"
196+
type="button"
197+
onclick={() => {
198+
selectedErrors = value.errors;
199+
showErrorModal = true;
200+
}}>
201+
more details
202+
</button>
203+
{/if}
204+
</div>
205+
<div
206+
class="progress-bar-container"
207+
class:is-danger={value.status === 'failed'}
208+
style="--graph-size:{graphSize(value.status)}%">
209+
</div>
210+
</section>
211+
</li>
212+
</ul>
213+
</div>
214+
{/each}
215+
</div>
216+
</section>
217+
</Layout.Stack>
218+
{/if}
219+
220+
<Modal bind:show={showErrorModal} title="Export error details" hideFooter>
221+
{#if selectedErrors.length > 0}
222+
<Code
223+
code={JSON.stringify(
224+
selectedErrors.map((err) => {
225+
try {
226+
return JSON.parse(err);
227+
} catch {
228+
return err;
229+
}
230+
}),
231+
null,
232+
2
233+
)}
234+
lang="json"
235+
hideHeader />
236+
{/if}
237+
</Modal>
238+
239+
<style lang="scss">
240+
.upload-box {
241+
display: flex;
242+
max-height: 320px;
243+
flex-direction: column;
244+
}
245+
246+
.upload-box-header {
247+
flex-shrink: 0;
248+
}
249+
250+
.upload-box-title {
251+
font-size: 11px;
252+
}
253+
254+
.upload-box-content-list {
255+
overflow-y: auto;
256+
}
257+
258+
.upload-box-content {
259+
width: 304px;
260+
}
261+
262+
.upload-box-button {
263+
display: flex;
264+
align-items: center;
265+
justify-content: center;
266+
}
267+
268+
.progress-bar-container {
269+
height: 4px;
270+
271+
&::before {
272+
height: 4px;
273+
background-color: var(--bgcolor-neutral-invert);
274+
}
275+
276+
&.is-danger::before {
277+
height: 4px;
278+
background-color: var(--bgcolor-error);
279+
}
280+
}
281+
</style>

src/lib/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { default as Copy } from './copy.svelte';
1313
export { default as CopyInput } from './copyInput.svelte';
1414
export { default as UploadBox } from './uploadBox.svelte';
1515
export { default as BackupRestoreBox } from './backupRestoreBox.svelte';
16+
export { default as CsvExportBox } from './csvExportBox.svelte';
1617
export { default as List } from './list.svelte';
1718
export { default as ListItem } from './listItem.svelte';
1819
export { default as Empty } from './empty.svelte';

src/lib/elements/forms/inputCheckbox.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
indeterminate?: boolean;
1313
size?: 's' | 'm';
1414
description?: string;
15+
truncate?: boolean;
1516
}
1617
1718
export let id: string = '';
@@ -22,6 +23,7 @@
2223
export let element: HTMLInputElement | undefined = undefined;
2324
export let size: $$Props['size'] = 's';
2425
export let description = '';
26+
export let truncate: boolean = false;
2527
let error: string;
2628
2729
const handleInvalid = (event: Event) => {
@@ -50,6 +52,7 @@
5052
{label}
5153
{required}
5254
{description}
55+
{truncate}
5356
on:invalid={handleInvalid}
5457
on:click
5558
on:change />

src/routes/(console)/project-[region]-[project]/+layout.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { BackupRestoreBox, MigrationBox, UploadBox } from '$lib/components';
2+
import { BackupRestoreBox, MigrationBox, UploadBox, CsvExportBox } from '$lib/components';
33
import { realtime } from '$lib/stores/sdk';
44
import { onMount } from 'svelte';
55
import { project, stats } from './store';
@@ -119,6 +119,7 @@
119119
<MigrationBox />
120120
<BackupRestoreBox />
121121
<CsvImportBox />
122+
<CsvExportBox />
122123
</div>
123124

124125
<style>

0 commit comments

Comments
 (0)