Revert "CSV export"

This commit is contained in:
Darshan
2025-11-27 11:13:16 +05:30
committed by GitHub
parent 7a00fac0a6
commit 07cfb72eb4
8 changed files with 24 additions and 609 deletions
+1 -1
View File
@@ -95,5 +95,5 @@
"svelte-preprocess"
]
},
"packageManager": "pnpm@10.18.3"
"packageManager": "pnpm@10.20.0"
}
-2
View File
@@ -153,7 +153,6 @@ 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',
@@ -278,7 +277,6 @@ export enum Submit {
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
DatabaseExportCsv = 'submit_database_export_csv',
ColumnCreate = 'submit_column_create',
ColumnUpdate = 'submit_column_update',
-281
View File
@@ -1,281 +0,0 @@
<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;
}
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);
}
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
View File
@@ -13,7 +13,6 @@ 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';
@@ -12,7 +12,6 @@
indeterminate?: boolean;
size?: 's' | 'm';
description?: string;
truncate?: boolean;
}
export let id: string = '';
@@ -23,7 +22,6 @@
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) => {
@@ -52,7 +50,6 @@
{label}
{required}
{description}
{truncate}
on:invalid={handleInvalid}
on:click
on:change />
@@ -1,5 +1,5 @@
<script lang="ts">
import { BackupRestoreBox, MigrationBox, UploadBox, CsvExportBox } from '$lib/components';
import { BackupRestoreBox, MigrationBox, UploadBox } from '$lib/components';
import { realtime } from '$lib/stores/sdk';
import { onMount } from 'svelte';
import { project, stats } from './store';
@@ -119,7 +119,6 @@
<MigrationBox />
<BackupRestoreBox />
<CsvImportBox />
<CsvExportBox />
</div>
<style>
@@ -2,8 +2,6 @@
import { Filters, hasPageQueries, queries } from '$lib/components/filters';
import ViewSelector from '$lib/components/viewSelector.svelte';
import { Button } from '$lib/elements/forms';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { Column, ColumnType } from '$lib/helpers/types';
import { Container } from '$lib/layout';
import { preferences } from '$lib/stores/preferences';
@@ -34,9 +32,7 @@
IconChevronUp,
IconPlus,
IconViewBoards,
IconRefresh,
IconUpload,
IconDownload
IconRefresh
} from '@appwrite.io/pink-icons-svelte';
import type { Models } from '@appwrite.io/console';
import EmptySheet from './layout/emptySheet.svelte';
@@ -135,20 +131,6 @@
}
}
function getTableExportUrl() {
const queryParam = page.url.searchParams.get('query');
const url = resolve(
'/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export',
{
region: page.params.region,
project: page.params.project,
database: page.params.database,
table: page.params.table
}
);
return queryParam ? `${url}?query=${encodeURIComponent(queryParam)}` : url;
}
onDestroy(() => ($showCreateColumnSheet.show = false));
</script>
@@ -190,10 +172,17 @@
justifyContent="flex-end"
style="padding-right: 40px;">
<Layout.Stack
gap="s"
direction="row"
alignItems="center"
justifyContent="flex-end"
gap="s">
justifyContent="flex-end">
<Button
secondary
event={Click.DatabaseImportCsv}
disabled={!(hasColumns && hasValidColumns) || disableButton}
on:click={() => (showImportCSV = true)}>
Import CSV
</Button>
{#if !$isSmallViewport}
<Button
secondary
@@ -204,37 +193,19 @@
Create row
</Button>
<Tooltip placement="top">
<Button
icon
<Button
icon
size="s"
secondary
class="small-button-dimensions"
on:click={() => {
$expandTabs = !$expandTabs;
preferences.setKey('tableHeaderExpanded', $expandTabs);
}}>
<Icon
size="s"
secondary
class="small-button-dimensions"
disabled={!(hasColumns && hasValidColumns) || disableButton}
on:click={() => (showImportCSV = true)}>
<Icon icon={IconUpload} size="s" />
</Button>
<svelte:fragment slot="tooltip">Import CSV</svelte:fragment>
</Tooltip>
<Tooltip placement="top">
<Button
icon
size="s"
secondary
class="small-button-dimensions"
disabled={!(hasColumns && hasValidColumns && data.rows.total) ||
disableButton}
on:click={() => {
trackEvent(Click.DatabaseExportCsv);
goto(getTableExportUrl());
}}>
<Icon icon={IconDownload} size="s" />
</Button>
<svelte:fragment slot="tooltip">Export CSV</svelte:fragment>
</Tooltip>
icon={!$expandTabs ? IconChevronDown : IconChevronUp} />
</Button>
<Tooltip disabled={isRefreshing || !data.rows.total} placement="top">
<Button
@@ -258,25 +229,6 @@
<svelte:fragment slot="tooltip">Refresh</svelte:fragment>
</Tooltip>
<Tooltip placement="top">
<Button
icon
size="s"
secondary
class="small-button-dimensions"
on:click={() => {
$expandTabs = !$expandTabs;
preferences.setKey('tableHeaderExpanded', $expandTabs);
}}>
<Icon
icon={!$expandTabs ? IconChevronDown : IconChevronUp}
size="s" />
</Button>
<svelte:fragment slot="tooltip"
>{!$expandTabs ? 'Expand' : 'Collapse'}</svelte:fragment>
</Tooltip>
{/if}
</Layout.Stack>
</Layout.Stack>
@@ -431,7 +383,6 @@
:global(.rotating) {
animation: rotate 1s linear infinite;
animation-direction: reverse;
}
@keyframes rotate {
@@ -1,248 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Wizard } from '$lib/layout';
import { Fieldset, Layout, Icon, Divider, Tooltip } from '@appwrite.io/pink-svelte';
import { Button, InputSelect, InputCheckbox, Form } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { sdk } from '$lib/stores/sdk';
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
import { table } from '../store';
import { queries, type TagValue } from '$lib/components/filters/store';
import { TagList } from '$lib/components/filters';
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { toLocalDateTimeISO } from '$lib/helpers/date';
import { writable } from 'svelte/store';
import { isSmallViewport } from '$lib/stores/viewport';
let showExitModal = $state(false);
let formComponent: Form;
let isSubmitting = writable(false);
let localQueries = $state<Map<TagValue, string>>(new Map());
const localTags = $derived(Array.from(localQueries.keys()));
const timestamp = toLocalDateTimeISO(Date.now())
.replace(/[:.]/g, '-')
.split('T')
.join('_')
.slice(0, -4);
const filename = `${$table.name}_${timestamp}.csv`;
let selectedColumns = $state<Record<string, boolean>>({});
let showAllColumns = $state(false);
type DelimiterOption = 'Comma' | 'Semicolon' | 'Tab' | 'Pipe';
const delimiterMap: Record<DelimiterOption, string> = {
Comma: ',',
Semicolon: ';',
Tab: '\t',
Pipe: '|'
};
let delimiter = $state<DelimiterOption>('Comma');
let includeHeader = $state(true);
let exportWithFilters = $state(false);
const columnLimit = $derived($isSmallViewport ? 6 : 9);
const visibleColumns = $derived(
showAllColumns ? $table.columns : $table.columns.slice(0, columnLimit)
);
const hasMoreColumns = $derived($table.columns.length > columnLimit);
const selectedColumnCount = $derived(Object.values(selectedColumns).filter(Boolean).length);
const tableUrl = $derived.by(() => {
const queryParam = page.url.searchParams.get('query');
const url = resolve(
'/(console)/project-[region]-[project]/databases/database-[database]/table-[table]',
{
region: page.params.region,
project: page.params.project,
database: page.params.database,
table: page.params.table
}
);
return queryParam ? `${url}?query=${queryParam}` : url;
});
function removeLocalFilter(tag: TagValue) {
localQueries.delete(tag);
localQueries = new Map(localQueries);
}
function initializeColumns() {
selectedColumns = Object.fromEntries($table.columns.map((col) => [col.key, true]));
}
function selectAllColumns() {
selectedColumns = Object.fromEntries($table.columns.map((col) => [col.key, true]));
}
function deselectAllColumns() {
selectedColumns = Object.fromEntries($table.columns.map((col) => [col.key, false]));
}
async function handleExport() {
const selectedCols = Object.entries(selectedColumns)
.filter(([_, selected]) => selected)
.map(([key]) => key);
if (selectedCols.length === 0) {
addNotification({
type: 'error',
message: 'Please select at least one column to export'
});
return;
}
try {
await sdk
.forProject(page.params.region, page.params.project)
.migrations.createCSVExport({
resourceId: `${page.params.database}:${page.params.table}`,
filename: filename,
columns: selectedCols,
queries: exportWithFilters ? Array.from(localQueries.values()) : [],
delimiter: delimiterMap[delimiter],
header: includeHeader,
notify: true
});
addNotification({
type: 'success',
message: 'CSV export has started'
});
trackEvent(Submit.DatabaseExportCsv);
await goto(tableUrl);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.DatabaseExportCsv);
}
}
onMount(() => {
initializeColumns();
localQueries = new Map($queries);
});
</script>
<Wizard title="Export CSV" columnSize="s" href={tableUrl} bind:showExitModal confirmExit column>
<Form bind:this={formComponent} bind:isSubmitting onSubmit={handleExport}>
<Layout.Stack gap="xxl">
<Fieldset legend="Columns">
<Layout.Stack gap="l">
<Layout.Stack direction="row" gap="s" alignItems="center">
<Button compact on:click={selectAllColumns}>Select all</Button>
<span style:height="20px">
<Divider vertical />
</span>
<Button compact on:click={deselectAllColumns}>Deselect all</Button>
</Layout.Stack>
<Layout.Grid columns={3} columnsS={1} gap="l">
{#each visibleColumns as column (column.key)}
<div style="min-width: 0;">
<InputCheckbox
id={`column-${column.key}`}
label={column.key}
bind:checked={selectedColumns[column.key]}
truncate />
</div>
{/each}
</Layout.Grid>
{#if hasMoreColumns}
<div style:margin-bottom="-0.5rem">
<Button compact on:click={() => (showAllColumns = !showAllColumns)}>
{showAllColumns ? 'Show less' : 'Show more'}
</Button>
</div>
{/if}
</Layout.Stack>
</Fieldset>
<Fieldset legend="Export options">
<Layout.Stack gap="l">
<InputSelect
id="delimiter"
label="Delimiter"
bind:value={delimiter}
options={[
{ value: 'Comma', label: 'Comma' },
{ value: 'Semicolon', label: 'Semicolon' },
{ value: 'Tab', label: 'Tab' },
{ value: 'Pipe', label: 'Pipe' }
]}>
<Layout.Stack direction="row" gap="none" alignItems="center" slot="info">
<Tooltip>
<Icon size="s" icon={IconInfo} />
<span slot="tooltip">
Define how to separate values in the exported file.
</span>
</Tooltip>
</Layout.Stack>
</InputSelect>
<InputCheckbox
id="includeHeader"
label="Include header row"
description="Column names will be added as the first row in the CSV"
bind:checked={includeHeader} />
<Layout.Stack gap="m">
<div class:disabled-checkbox={localTags.length === 0}>
<InputCheckbox
id="exportWithFilters"
label="Export with filters"
description="Export rows that match the current table filters"
disabled={localTags.length === 0}
bind:checked={exportWithFilters} />
</div>
{#if localTags.length > 0}
<Layout.Stack
direction="row"
gap="xs"
alignItems="center"
style="padding-left: 1.75rem;"
wrap="wrap">
<TagList
tags={localTags}
on:remove={(e) => {
removeLocalFilter(e.detail);
}} />
</Layout.Stack>
{/if}
</Layout.Stack>
</Layout.Stack>
</Fieldset>
</Layout.Stack>
</Form>
<svelte:fragment slot="footer">
<Layout.Stack justifyContent="flex-end" direction="row">
<Button fullWidthMobile secondary on:click={() => (showExitModal = true)}>
Cancel
</Button>
<Button
fullWidthMobile
on:click={() => formComponent.triggerSubmit()}
disabled={$isSubmitting || selectedColumnCount === 0}>
Export
</Button>
</Layout.Stack>
</svelte:fragment>
</Wizard>
<style>
.disabled-checkbox :global(*) {
cursor: unset;
}
</style>