Files
console/src/lib/components/csvImportBox.svelte
T

248 lines
8.4 KiB
Svelte

<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 { writable, type Writable } from 'svelte/store';
import { addNotification } from '$lib/stores/notifications';
import { Layout, Typography } from '@appwrite.io/pink-svelte';
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(
database: string,
collection: string,
payload: Payload
) {
const isSuccess = payload.status === 'completed';
const isError = !isSuccess && !!payload.errors;
if (!isSuccess && !isError) return;
let errorMessage = 'Import failed. Check your CSV for correct fields and required values.';
if (isError && Array.isArray(payload.errors)) {
try {
// the `errors` is a list of json encoded string.
errorMessage = JSON.parse(payload.errors[0]).message;
} catch {
// do nothing, fallback to default message.
}
}
const type = isSuccess ? 'success' : 'error';
const message = isError ? errorMessage : 'CSV import finished successfully.';
const url = `${base}/project-${page.params.region}-${page.params.project}/databases/database-${database}/collection-${collection}`;
addNotification({
type,
message,
isHtml: true,
buttons:
isSuccess && collection !== page.params.collection
? [{ name: 'View documents', method: () => goto(url) }]
: undefined
});
if (isSuccess) {
await invalidate(Dependencies.DOCUMENTS);
}
}
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(page.params.region, page.params.project)
.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(page.params.region, page.params.project)
.migrations.list([
Query.equal('source', 'CSV'),
Query.equal('status', ['pending', 'processing'])
])
.then((migrations) => {
migrations.migrations.forEach(updateOrAddItem);
});
return sdk.forConsoleIn(page.params.region).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}
<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">
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>
</Layout.Stack>
{/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>