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 10 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"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@6b17cb23",
"@appwrite.io/pink-legacy": "^1.0.3",
Expand Down
1,576 changes: 678 additions & 898 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
232 changes: 232 additions & 0 deletions src/lib/components/csvImportBox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
<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) {
const projectId = page.params.project;
await invalidate(Dependencies.DOCUMENTS);
const url = `${base}/project-${projectId}/databases/database-${databaseId}/collection-${collectionId}`;

addNotification({
type: 'success',
isHtml: true,
message: `CSV import finished successfully.`,
buttons:
collectionId === page.params.collection
? 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') {
await showCompletionNotification(databaseId, collectionId);
}
}

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) {
if (status === 'completed') {
return 'CSV import complete';
} else if (status === 'failed') {
return 'CSV import failed';
} else {
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>
{text(value.status)}
</Typography.Text>

{#if value.collection}
<Typography.Caption variant="400">
{value.collection}
</Typography.Caption>
{/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}
</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>
10 changes: 4 additions & 6 deletions src/lib/components/filePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,10 @@
disabled={uploading}
on:click={() => fileSelector.click()}>
<input
tabindex="-1"
type="file"
accept="image/*"
tabindex="-1"
class="u-hide"
accept={mimeTypeQuery}
on:change={uploadFile}
bind:this={fileSelector} />
{#if uploading}
Expand Down Expand Up @@ -282,7 +282,7 @@
Created
</Table.Header.Cell>
</svelte:fragment>
{#each response?.files as file}
{#each response?.files as file (file.$id)}
<Table.Row.Button
{root}
on:click={() => selectFile(file)}>
Expand All @@ -291,10 +291,8 @@
class="u-inline-flex u-cross-center u-gap-12">
<Selector.Radio
name="file"
group="file"
value={file.$id}
checked={file.$id ===
selectedFile} />
bind:group={selectedFile} />
<img
style:border-radius="var(--border-radius-xsmall)"
width="28"
Expand Down
2 changes: 1 addition & 1 deletion src/lib/elements/forms/inputFilePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<Label {optionalText} {tooltip} hide={!label}>
{label}{#if $$slots.popover && isPopoverDefined}
<Drop bind:show display="inline-block">
<!-- TODO: make unclicked icon greyed out and hover and clicked filled -->
<!-- TODO: make un-clicked icon greyed out and hover and clicked filled -->
&nbsp;<button
type="button"
on:click={() => (show = !show)}
Expand Down
2 changes: 2 additions & 0 deletions src/routes/(console)/project-[project]/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
canWriteProjects,
canWriteSites
} from '$lib/stores/roles';
import CsvImportBox from '$lib/components/csvImportBox.svelte';

onMount(() => {
return sdk.forConsole.client.subscribe(['project', 'console'], (response) => {
Expand Down Expand Up @@ -117,6 +118,7 @@
<UploadBox />
<MigrationBox />
<BackupRestoreBox />
<CsvImportBox />
</div>

<style>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@
import CreateAttributeDropdown from './attributes/createAttributeDropdown.svelte';
import type { Option } from './attributes/store';
import CreateAttribute from './createAttribute.svelte';
import { collection, columns } from './store';
import { collection, columns, isCsvImportInProgress } from './store';
import Table from './table.svelte';
import { IconPlus } from '@appwrite.io/pink-icons-svelte';
import { base } from '$app/paths';
import { Submit, trackEvent } from '$lib/actions/analytics';
import { Click, Submit, trackError, trackEvent } from '$lib/actions/analytics';
import FilePicker from '$lib/components/filePicker.svelte';
import type { Models } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { addNotification } from '$lib/stores/notifications';

export let data: PageData;

let showImportCSV = false;
let showCreateAttribute = false;
let selectedAttribute: Option['name'] = null;

Expand All @@ -38,6 +43,33 @@
);
$: hasAttributes = !!$collection.attributes.length;
$: hasValidAttributes = $collection?.attributes?.some((attr) => attr.status === 'available');

async function onSelect(file: Models.File) {
$isCsvImportInProgress = true;

try {
await sdk.forProject.migrations.createCsvMigration(
file.bucketId,
file.$id,
`${page.params.database}:${page.params.collection}`
);

addNotification({
type: 'success',
message: 'Documents import from csv has started'
});

trackEvent(Submit.DatabaseImportCsv);
} catch (e) {
trackError(e, Submit.DatabaseImportCsv);
addNotification({
type: 'error',
message: e.message
});
} finally {
$isCsvImportInProgress = false;
}
}
</script>

{#key page.params.collection}
Expand All @@ -50,6 +82,13 @@
analyticsSource="database_documents" />
<Layout.Stack direction="row" alignItems="center" justifyContent="flex-end">
<ViewSelector view={data.view} {columns} hideView />
<Button
secondary
event={Click.DatabaseImportCsv}
disabled={!(hasAttributes && hasValidAttributes)}
on:click={() => (showImportCSV = true)}>
Import CSV
</Button>
<Button
disabled={!(hasAttributes && hasValidAttributes)}
href={`${base}/project-${page.params.project}/databases/database-${page.params.database}/collection-${page.params.collection}/create`}
Expand Down Expand Up @@ -149,3 +188,8 @@
bind:showCreate={showCreateAttribute}
bind:selectedOption={selectedAttribute} />
{/if}

{#if showImportCSV}
<!-- CSVs can be text/plain or text/csv sometimes! -->
<FilePicker {onSelect} mimeTypeQuery="text/" bind:show={showImportCSV} />
{/if}
Loading