Skip to content

Commit 87eeb6f

Browse files
authored
Merge pull request #2797 from appwrite/feat-csv-export-2
CSV export
2 parents 9b0ea7f + 90ee9b4 commit 87eeb6f

8 files changed

Lines changed: 620 additions & 24 deletions

File tree

src/lib/actions/analytics.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export enum Click {
155155
DatabaseRowDelete = 'click_row_delete',
156156
DatabaseDatabaseDelete = 'click_database_delete',
157157
DatabaseImportCsv = 'click_database_import_csv',
158-
158+
DatabaseExportCsv = 'click_database_export_csv',
159159
DomainCreateClick = 'click_domain_create',
160160
DomainDeleteClick = 'click_domain_delete',
161161
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
@@ -281,6 +281,7 @@ export enum Submit {
281281
DatabaseDelete = 'submit_database_delete',
282282
DatabaseUpdateName = 'submit_database_update_name',
283283
DatabaseImportCsv = 'submit_database_import_csv',
284+
DatabaseExportCsv = 'submit_database_export_csv',
284285
DatabaseBackupDelete = 'submit_database_backup_delete',
285286
DatabaseBackupPolicyCreate = 'submit_database_backup_policy_create',
286287

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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+
addNotification({
96+
type: 'success',
97+
message: `Export completed`,
98+
timeout: 10000,
99+
buttons: [
100+
{
101+
name: 'Download',
102+
method: () => downloadExportedFile(downloadUrl)
103+
}
104+
]
105+
});
106+
}
107+
break;
108+
case 'failed':
109+
await showErrorNotification(exportData);
110+
break;
111+
}
112+
}
113+
114+
function clear() {
115+
exportItems = new Map();
116+
}
117+
118+
function graphSize(status: string): number {
119+
switch (status) {
120+
case 'pending':
121+
return 10;
122+
case 'processing':
123+
return 60;
124+
case 'completed':
125+
case 'failed':
126+
return 100;
127+
default:
128+
return 30;
129+
}
130+
}
131+
132+
function text(status: string, tableName = '') {
133+
const table = tableName ? `<b>${tableName}</b>` : '';
134+
switch (status) {
135+
case 'completed':
136+
return `Exporting ${table} completed`;
137+
case 'failed':
138+
return `Exporting ${table} failed`;
139+
case 'processing':
140+
return `Exporting ${table}`;
141+
default:
142+
return 'Preparing export...';
143+
}
144+
}
145+
146+
onMount(() => {
147+
sdk.forProject(page.params.region, page.params.project)
148+
.migrations.list({
149+
queries: [
150+
Query.equal('destination', 'CSV'),
151+
Query.equal('status', ['pending', 'processing'])
152+
]
153+
})
154+
.then((migrations) => {
155+
migrations.migrations.forEach(updateOrAddItem);
156+
});
157+
158+
return realtime.forConsole(page.params.region, 'console', (response) => {
159+
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
160+
if (response.events.includes('migrations.*')) {
161+
updateOrAddItem(response.payload as Payload);
162+
}
163+
});
164+
});
165+
166+
let isOpen = $state(true);
167+
let showCsvExportBox = $derived(exportItems.size > 0);
168+
let showErrorModal = $state(false);
169+
let selectedErrors = $state<string[]>([]);
170+
</script>
171+
172+
{#if showCsvExportBox}
173+
<Layout.Stack direction="column" gap="l" alignItems="flex-end">
174+
<section class="upload-box">
175+
<header class="upload-box-header">
176+
<h4 class="upload-box-title">
177+
<Typography.Text variant="m-500">
178+
Exporting rows ({exportItems.size})
179+
</Typography.Text>
180+
</h4>
181+
<button
182+
class="upload-box-button"
183+
class:is-open={isOpen}
184+
aria-label="toggle upload box"
185+
onclick={() => (isOpen = !isOpen)}>
186+
<span class="icon-cheveron-up" aria-hidden="true"></span>
187+
</button>
188+
<button class="upload-box-button" aria-label="close export box" onclick={clear}>
189+
<span class="icon-x" aria-hidden="true"></span>
190+
</button>
191+
</header>
192+
193+
<div class="upload-box-content-list">
194+
{#each [...exportItems.entries()] as [key, value] (key)}
195+
<div class="upload-box-content" class:is-open={isOpen}>
196+
<ul class="upload-box-list">
197+
<li class="upload-box-item">
198+
<section class="progress-bar u-width-full-line">
199+
<div
200+
class="progress-bar-top-line u-flex u-gap-8 u-main-space-between">
201+
<Typography.Text>
202+
{@html text(value.status, value.table)}
203+
</Typography.Text>
204+
{#if value.status === 'failed' && value.errors && value.errors.length > 0}
205+
<button
206+
class="link"
207+
type="button"
208+
onclick={() => {
209+
selectedErrors = value.errors;
210+
showErrorModal = true;
211+
}}>
212+
more details
213+
</button>
214+
{/if}
215+
</div>
216+
<div
217+
class="progress-bar-container"
218+
class:is-danger={value.status === 'failed'}
219+
style="--graph-size:{graphSize(value.status)}%">
220+
</div>
221+
</section>
222+
</li>
223+
</ul>
224+
</div>
225+
{/each}
226+
</div>
227+
</section>
228+
</Layout.Stack>
229+
{/if}
230+
231+
<Modal bind:show={showErrorModal} title="Export error details" hideFooter>
232+
{#if selectedErrors.length > 0}
233+
<Code
234+
code={JSON.stringify(
235+
selectedErrors.map((err) => {
236+
try {
237+
return JSON.parse(err);
238+
} catch {
239+
return err;
240+
}
241+
}),
242+
null,
243+
2
244+
)}
245+
lang="json"
246+
hideHeader />
247+
{/if}
248+
</Modal>
249+
250+
<style lang="scss">
251+
.upload-box {
252+
display: flex;
253+
max-height: 320px;
254+
flex-direction: column;
255+
}
256+
257+
.upload-box-header {
258+
flex-shrink: 0;
259+
}
260+
261+
.upload-box-title {
262+
font-size: 11px;
263+
}
264+
265+
.upload-box-content-list {
266+
overflow-y: auto;
267+
}
268+
269+
.upload-box-content {
270+
width: 304px;
271+
}
272+
273+
.upload-box-button {
274+
display: flex;
275+
align-items: center;
276+
justify-content: center;
277+
}
278+
279+
.progress-bar-container {
280+
height: 4px;
281+
282+
&::before {
283+
height: 4px;
284+
background-color: var(--bgcolor-neutral-invert);
285+
}
286+
287+
&.is-danger::before {
288+
height: 4px;
289+
background-color: var(--bgcolor-error);
290+
}
291+
}
292+
</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)