|
6 | 6 | import { realtime, sdk } from '$lib/stores/sdk'; |
7 | 7 | import { goto, invalidate } from '$app/navigation'; |
8 | 8 | import { getProjectId } from '$lib/helpers/project'; |
9 | | - import { writable, type Writable } from 'svelte/store'; |
10 | 9 | import { addNotification } from '$lib/stores/notifications'; |
11 | | - import { Layout, Typography } from '@appwrite.io/pink-svelte'; |
| 10 | + import { Layout, Typography, Icon } from '@appwrite.io/pink-svelte'; |
| 11 | + import { IconExclamationCircle } from '@appwrite.io/pink-icons-svelte'; |
| 12 | + import { Modal, Code } from '$lib/components'; |
12 | 13 | import { type Models, type Payload, Query } from '@appwrite.io/console'; |
13 | 14 |
|
14 | 15 | // re-render the key for sheet UI. |
15 | 16 | import { hash } from '$lib/helpers/string'; |
16 | 17 | import { spreadsheetRenderKey } from '$routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/store'; |
| 18 | + import { Link } from '$lib/elements'; |
| 19 | +
|
| 20 | + type CsvImportError = { |
| 21 | + [key: string]: number | string | null; |
| 22 | + }; |
17 | 23 |
|
18 | 24 | type ImportItem = { |
19 | 25 | status: string; |
20 | 26 | table?: string; |
| 27 | + errors?: string[]; |
21 | 28 | }; |
22 | 29 |
|
23 | 30 | type ImportItemsMap = Map<string, ImportItem>; |
|
28 | 35 | * The structure is as follows - |
29 | 36 | * `{ migrationId: { status: status, table: table } }` |
30 | 37 | */ |
31 | | - const importItems: Writable<ImportItemsMap> = writable(new Map()); |
| 38 | + let importItems = $state<ImportItemsMap>(new Map()); |
32 | 39 |
|
33 | 40 | async function showCompletionNotification(database: string, table: string, payload: Payload) { |
34 | 41 | const isSuccess = payload.status === 'completed'; |
|
37 | 44 | if (!isSuccess && !isError) return; |
38 | 45 |
|
39 | 46 | let errorMessage = 'Import failed. Check your CSV for correct fields and required values.'; |
40 | | - if (isError && Array.isArray(payload.errors)) { |
41 | | - try { |
42 | | - // the `errors` is a list of json encoded string. |
43 | | - errorMessage = JSON.parse(payload.errors[0]).message; |
44 | | - } catch { |
45 | | - // do nothing, fallback to default message. |
46 | | - } |
| 47 | + const errors = getErrors(payload); |
| 48 | + if (errors) { |
| 49 | + errorMessage = extractErrorMessage(errors); |
47 | 50 | } |
48 | 51 |
|
49 | 52 | const type = isSuccess ? 'success' : 'error'; |
|
73 | 76 | const resourceId = importData.resourceId ?? ''; |
74 | 77 | const [databaseId, tableId] = resourceId.split(':') ?? []; |
75 | 78 |
|
76 | | - const current = $importItems.get(importData.$id); |
| 79 | + const current = importItems.get(importData.$id); |
77 | 80 | let tableName = current?.table ?? null; |
78 | 81 |
|
79 | 82 | if (!tableName && tableId) { |
|
91 | 94 | } |
92 | 95 |
|
93 | 96 | if (tableId && tableName === null) { |
94 | | - importItems.update((items) => { |
95 | | - const next = new Map(items); |
96 | | - next.delete(importData.$id); |
97 | | - return next; |
98 | | - }); |
| 97 | + const next = new Map(importItems); |
| 98 | + next.delete(importData.$id); |
| 99 | + importItems = next; |
99 | 100 | return; |
100 | 101 | } |
101 | 102 |
|
102 | | - importItems.update((items) => { |
103 | | - const existing = items.get(importData.$id); |
104 | | -
|
105 | | - const isDone = (s: string) => s === 'completed' || s === 'failed'; |
106 | | - const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s); |
| 103 | + const existing = importItems.get(importData.$id); |
107 | 104 |
|
108 | | - const shouldSkip = |
109 | | - (existing && isDone(existing.status) && isInProgress(status)) || |
110 | | - existing?.status === status; |
| 105 | + const isDone = (s: string) => s === 'completed' || s === 'failed'; |
| 106 | + const isInProgress = (s: string) => ['pending', 'processing', 'uploading'].includes(s); |
111 | 107 |
|
112 | | - if (shouldSkip) return items; |
| 108 | + const shouldSkip = |
| 109 | + (existing && isDone(existing.status) && isInProgress(status)) || |
| 110 | + existing?.status === status; |
113 | 111 |
|
114 | | - const next = new Map(items); |
115 | | - next.set(importData.$id, { status, table: tableName ?? undefined }); |
116 | | - return next; |
117 | | - }); |
| 112 | + if (!shouldSkip) { |
| 113 | + const next = new Map(importItems); |
| 114 | + const errors = getErrors(importData); |
| 115 | + next.set(importData.$id, { status, table: tableName ?? undefined, errors }); |
| 116 | + importItems = next; |
| 117 | + } |
118 | 118 |
|
119 | 119 | if (status === 'completed' || status === 'failed') { |
120 | 120 | await showCompletionNotification(databaseId, tableId, importData); |
121 | 121 | } |
122 | 122 | } |
123 | 123 |
|
124 | 124 | function clear() { |
125 | | - importItems.update((items) => { |
126 | | - items.clear(); |
127 | | - return items; |
128 | | - }); |
| 125 | + importItems = new Map(); |
| 126 | + } |
| 127 | +
|
| 128 | + function getErrors(importData: Payload | Models.Migration): string[] | undefined { |
| 129 | + return Array.isArray(importData.errors) ? importData.errors : undefined; |
| 130 | + } |
| 131 | +
|
| 132 | + function parseError(error: string): string | CsvImportError { |
| 133 | + try { |
| 134 | + return JSON.parse(error) as CsvImportError; |
| 135 | + } catch { |
| 136 | + return error; |
| 137 | + } |
| 138 | + } |
| 139 | +
|
| 140 | + function extractErrorMessage(errors: string[]): string { |
| 141 | + try { |
| 142 | + return JSON.parse(errors[0]).message; |
| 143 | + } catch { |
| 144 | + return 'Import failed. Check your CSV for correct fields and required values.'; |
| 145 | + } |
129 | 146 | } |
130 | 147 |
|
131 | 148 | function graphSize(status: string): number { |
|
148 | 165 | const name = collectionName ? `<b>${collectionName}</b>` : ''; |
149 | 166 | switch (status) { |
150 | 167 | case 'completed': |
| 168 | + return `CSV import completed${name ? ` to ${name}` : ''}`; |
151 | 169 | case 'failed': |
152 | | - return `Import to ${name} ${status}`; |
| 170 | + return `CSV import failed${name ? ` to ${name}` : ''}`; |
153 | 171 | case 'processing': |
154 | 172 | return `Importing CSV file${name ? ` to ${name}` : ''}`; |
155 | 173 | default: |
|
177 | 195 | }); |
178 | 196 | }); |
179 | 197 |
|
180 | | - $: isOpen = true; |
181 | | - $: showCsvImportBox = $importItems.size > 0; |
| 198 | + let isOpen = $state(true); |
| 199 | + let showCsvImportBox = $derived(importItems.size > 0); |
| 200 | +
|
| 201 | + let showDetails = $state(false); |
| 202 | + let selectedErrors = $state<string[]>([]); |
| 203 | + let parsedErrors = $state<Array<string | CsvImportError>>([]); |
| 204 | +
|
| 205 | + function openDetails(errors: string[] | undefined) { |
| 206 | + selectedErrors = errors ?? []; |
| 207 | + parsedErrors = selectedErrors.map(parseError); |
| 208 | + showDetails = true; |
| 209 | + } |
182 | 210 | </script> |
183 | 211 |
|
184 | 212 | {#if showCsvImportBox} |
|
187 | 215 | <header class="upload-box-header"> |
188 | 216 | <h4 class="upload-box-title"> |
189 | 217 | <Typography.Text variant="m-500"> |
190 | | - Importing rows ({$importItems.size}) |
| 218 | + Importing rows ({importItems.size}) |
191 | 219 | </Typography.Text> |
192 | 220 | </h4> |
193 | 221 | <button |
194 | 222 | class="upload-box-button" |
195 | 223 | class:is-open={isOpen} |
196 | 224 | aria-label="toggle upload box" |
197 | | - on:click={() => (isOpen = !isOpen)}> |
| 225 | + onclick={() => (isOpen = !isOpen)}> |
198 | 226 | <span class="icon-cheveron-up" aria-hidden="true"></span> |
199 | 227 | </button> |
200 | | - <button |
201 | | - class="upload-box-button" |
202 | | - aria-label="close backup restore box" |
203 | | - on:click={clear}> |
| 228 | + <button class="upload-box-button" aria-label="close CSV import box" onclick={clear}> |
204 | 229 | <span class="icon-x" aria-hidden="true"></span> |
205 | 230 | </button> |
206 | 231 | </header> |
207 | 232 |
|
208 | 233 | <div class="upload-box-content-list"> |
209 | | - {#each [...$importItems.entries()] as [key, value] (key)} |
| 234 | + {#each [...importItems.entries()] as [key, value] (key)} |
210 | 235 | <div class="upload-box-content" class:is-open={isOpen}> |
211 | 236 | <ul class="upload-box-list"> |
212 | 237 | <li class="upload-box-item"> |
|
222 | 247 | class:is-danger={value.status === 'failed'} |
223 | 248 | style="--graph-size:{graphSize(value.status)}%"> |
224 | 249 | </div> |
| 250 | + {#if value.status === 'failed'} |
| 251 | + <Layout.Stack |
| 252 | + direction="row" |
| 253 | + gap="xs" |
| 254 | + alignItems="center" |
| 255 | + inline> |
| 256 | + <Icon |
| 257 | + icon={IconExclamationCircle} |
| 258 | + color="--fgcolor-error" |
| 259 | + size="s" /> |
| 260 | + <Typography.Text color="--fgcolor-error"> |
| 261 | + There was an import issue. |
| 262 | + <Link |
| 263 | + style="color: inherit" |
| 264 | + onclick={() => openDetails(value.errors)} |
| 265 | + >View details</Link> |
| 266 | + </Typography.Text> |
| 267 | + </Layout.Stack> |
| 268 | + {/if} |
225 | 269 | </section> |
226 | 270 | </li> |
227 | 271 | </ul> |
|
232 | 276 | </Layout.Stack> |
233 | 277 | {/if} |
234 | 278 |
|
| 279 | +<Modal title="Import error" bind:show={showDetails} hideFooter> |
| 280 | + <Layout.Stack gap="m"> |
| 281 | + <Layout.Stack> |
| 282 | + <Code |
| 283 | + language="json" |
| 284 | + code={JSON.stringify(parsedErrors, null, 2)} |
| 285 | + withCopy |
| 286 | + allowScroll /> |
| 287 | + </Layout.Stack> |
| 288 | + </Layout.Stack> |
| 289 | +</Modal> |
| 290 | + |
235 | 291 | <style lang="scss"> |
236 | 292 | .upload-box { |
237 | 293 | display: flex; |
|
252 | 308 | } |
253 | 309 |
|
254 | 310 | .upload-box-content { |
255 | | - width: 304px; |
| 311 | + width: 324px; |
256 | 312 | } |
257 | 313 |
|
258 | 314 | .upload-box-button { |
|
0 commit comments