Skip to content

Feat: CSV Import #1767

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: feat-pink-v2
Choose a base branch
from
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@e2f082e",
"@appwrite.io/console": "https://pkg.pr.new/appwrite/appwrite/@appwrite.io/console@61868a9",
"@appwrite.io/pink-icons": "0.25.0",
"@appwrite.io/pink-icons-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-icons-svelte@bd21ff7f",
"@appwrite.io/pink-legacy": "^1.0.3",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@bd21ff7f",
"@appwrite.io/pink-svelte": "https://pkg.pr.new/appwrite/pink/@appwrite.io/pink-svelte@1c607c0",
"@popperjs/core": "^2.11.8",
"@sentry/sveltekit": "^8.38.0",
"@stripe/stripe-js": "^3.5.0",
Expand Down
1,590 changes: 685 additions & 905 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export enum Click {
DatabaseIndexDelete = 'click_index_delete',
DatabaseCollectionDelete = 'click_collection_delete',
DatabaseDatabaseDelete = 'click_database_delete',
DatabaseImportCsv = 'click_database_import_csv',
DomainCreateClick = 'click_domain_create',
DomainDeleteClick = 'click_domain_delete',
DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification',
Expand Down Expand Up @@ -267,6 +268,7 @@ export enum Submit {
DatabaseCreate = 'submit_database_create',
DatabaseDelete = 'submit_database_delete',
DatabaseUpdateName = 'submit_database_update_name',
DatabaseImportCsv = 'submit_database_import_csv',
AttributeCreate = 'submit_attribute_create',
AttributeUpdate = 'submit_attribute_update',
AttributeDelete = 'submit_attribute_delete',
Expand Down
241 changes: 241 additions & 0 deletions src/lib/components/csvImportBox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { page } from '$app/state';
import { sdk } from '$lib/stores/sdk';
import { Dependencies } from '$lib/constants';
import { goto, invalidate } from '$app/navigation';
import { getProjectId } from '$lib/helpers/project';
import { Typography } from '@appwrite.io/pink-svelte';
import { writable, type Writable } from 'svelte/store';
import { addNotification } from '$lib/stores/notifications';
import { type Models, type Payload, Query } from '@appwrite.io/console';

type ImportItem = {
status: string;
collection?: string;
};

type ImportItemsMap = Map<string, ImportItem>;

/**
* Keeps a track of the active and ongoing csv migrations.
*
* The structure is as follows -
* `{ migrationId: { status: status, collection: collection } }`
*/
const importItems: Writable<ImportItemsMap> = writable(new Map());

async function showCompletionNotification(
databaseId: string,
collectionId: string,
importData: Payload
) {
const projectId = page.params.project;
await invalidate(Dependencies.DOCUMENTS);
const url = `${base}/project-${projectId}/databases/database-${databaseId}/collection-${collectionId}`;

// extract clean message from nested backend error.
const match = importData.errors.join('').match(/message: '(.*)' Message:/i);
const actualMessage = match?.[1];

const type = importData.status === 'completed' ? 'success' : 'error';
const message =
importData.status === 'completed'
? 'CSV import finished successfully.'
: `${actualMessage}`;

addNotification({
type,
message,
isHtml: true,
buttons:
collectionId === page.params.collection || type === 'error'
? undefined
: [
{
name: 'View documents',
method: () => goto(url)
}
]
});
}

async function updateOrAddItem(importData: Payload | Models.Migration) {
if (importData.source.toLowerCase() !== 'csv') return;

const status = importData.status;
const resourceId = importData.resourceId ?? '';
const [databaseId, collectionId] = resourceId.split(':') ?? [];

const current = $importItems.get(importData.$id);
let collectionName = current?.collection ?? null;

if (!collectionName && collectionId) {
try {
const collection = await sdk.forProject.databases.getCollection(
databaseId,
collectionId
);
collectionName = collection.name;
} catch {
collectionName = null;
}
}

importItems.update((items) => {
const existing = items.get(importData.$id);

const isDone = (s: string) => s === 'completed' || s === 'failed';
const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s);

const shouldSkip =
(existing && isDone(existing.status) && isInProgress(status)) ||
existing?.status === status;

if (shouldSkip) return items;

const next = new Map(items);
next.set(importData.$id, { status, collection: collectionName ?? undefined });
return next;
});

if (status === 'completed' || status === 'failed') {
await showCompletionNotification(databaseId, collectionId, importData);
}
}

function clear() {
importItems.update((items) => {
items.clear();
return items;
});
}

function graphSize(status: string): number {
switch (status) {
case 'pending':
return 10;
case 'processing':
return 30;
case 'uploading':
return 60;
case 'completed':
case 'failed':
return 100;
default:
return 30;
}
}

function text(status: string, collectionName = '') {
const name = collectionName ? `<b>${collectionName}</b>` : '';
switch (status) {
case 'completed':
case 'failed':
return `Import to ${name} ${status}`;
case 'processing':
return `Importing CSV file${name ? ` to ${name}` : ''}`;
default:
return 'Preparing CSV for import...';
}
}

onMount(() => {
sdk.forProject.migrations
.list([Query.equal('source', 'CSV'), Query.equal('status', ['pending', 'processing'])])
.then((migrations) => {
migrations.migrations.forEach(updateOrAddItem);
});

return sdk.forConsole.client.subscribe('console', (response) => {
if (!response.channels.includes(`projects.${getProjectId()}`)) return;
if (response.events.includes('migrations.*')) {
updateOrAddItem(response.payload as Payload);
}
});
});

$: isOpen = true;
$: showCsvImportBox = $importItems.size > 0;
</script>

{#if showCsvImportBox}
<div class="box-holder u-flex u-flex-vertical u-gap-16" style="align-items: end">
<section class="upload-box">
<header class="upload-box-header">
<h4 class="upload-box-title">
<Typography.Text variant="m-500">
Importing documents ({$importItems.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 backup restore box"
on:click={clear}>
<span class="icon-x" aria-hidden="true"></span>
</button>
</header>

{#each [...$importItems.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.collection)}
</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}
</section>
</div>
{/if}

<style lang="scss">
.upload-box-title {
font-size: 11px;
}

.upload-box-content {
min-width: 400px;
max-width: 100vw;
}

.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>
Loading