Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,5 @@
"svelte-preprocess"
]
},
"packageManager": "pnpm@10.20.0"
"packageManager": "pnpm@10.18.3"
}
2 changes: 2 additions & 0 deletions src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export enum Click {
DatabaseTableDelete = 'click_table_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 @@ -277,6 +278,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',

ColumnCreate = 'submit_column_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;
}
Comment on lines +34 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against missing payload.errors before indexing.

If payload.errors is undefined, the catch block still accesses payload.errors[0] and can throw. Add a defensive guard.

💡 Suggested fix
 async function showErrorNotification(payload: Payload) {
     let errorMessage = 'Export failed. Please try again.';
+    const rawError = payload.errors?.[0];
     try {
-        const parsed = JSON.parse(payload.errors[0]);
-        errorMessage = parsed?.message || errorMessage;
+        if (rawError) {
+            const parsed = JSON.parse(rawError);
+            errorMessage = parsed?.message || rawError || errorMessage;
+        }
     } catch {
-        errorMessage = payload.errors[0] || errorMessage;
+        errorMessage = rawError || errorMessage;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
async function showErrorNotification(payload: Payload) {
let errorMessage = 'Export failed. Please try again.';
const rawError = payload.errors?.[0];
try {
if (rawError) {
const parsed = JSON.parse(rawError);
errorMessage = parsed?.message || rawError || errorMessage;
}
} catch {
errorMessage = rawError || errorMessage;
}
🤖 Prompt for AI Agents
In `@src/lib/components/csvExportBox.svelte` around lines 34 - 41, The
showErrorNotification function currently indexes payload.errors[0] without
confirming payload.errors exists which can throw; update showErrorNotification
to defensively check that payload.errors is an array and has at least one
element before attempting to parse or read payload.errors[0] (e.g., use
Array.isArray(payload.errors) && payload.errors.length > 0), and fall back to
the default errorMessage when that check fails; ensure both the try and catch
branches reference the guarded value so neither JSON.parse nor the fallback
reads an undefined index.


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