Skip to content

Commit eab623b

Browse files
committed
chore: tweaks to bulk uploads
1 parent 2a4e62e commit eab623b

4 files changed

Lines changed: 216 additions & 56 deletions

File tree

src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@placeos/ts-client';
1717
import { Observable } from 'rxjs';
1818
import { unique } from '../../common/general';
19+
import { notifyError, notifyWarn } from '../../common/notifications';
1920
import { HashMap, Identity } from '../../common/types';
2021
import { IconComponent } from '../../ui/icon.component';
2122
import { TranslatePipe } from '../../ui/translate.pipe';
@@ -150,13 +151,16 @@ export class BulkItemModalComponent<
150151
/** List of raw data to use for bulk add */
151152
public data_list: HashMap<unknown>[] = [];
152153
/** Whether requests are being processed */
153-
public loading: boolean;
154+
public loading = false;
154155
/** Template data for use */
155156
public template: HashMap<unknown>[] = [];
156157
public mappings: Record<string, string> = {};
157158

158159
public available_fields: Identity[] = [];
159160

161+
/** Required fields for validation depending on resource type */
162+
private _required_fields: string[] = [];
163+
160164
public get type(): string {
161165
return this._data.name;
162166
}
@@ -168,6 +172,7 @@ export class BulkItemModalComponent<
168172
constructor() {
169173
this.available_fields = this.getAvailableFields();
170174
this.template = this.generateTemplate();
175+
this._required_fields = this.getRequiredFields();
171176
}
172177

173178
/**
@@ -179,15 +184,9 @@ export class BulkItemModalComponent<
179184
if (is_mapped) {
180185
const Resource = this._data.constr;
181186
this.item_list = data.map((item) => {
182-
const new_item: HashMap<unknown> = {};
183-
Object.keys(item).forEach((key) => {
184-
try {
185-
new_item[key] = JSON.parse(item[key] as string);
186-
} catch {
187-
new_item[key] = item[key];
188-
}
189-
});
190-
return new Resource(new_item);
187+
// Values are already parsed by csvToJson/parseCSV,
188+
// so no need for additional JSON.parse here
189+
return new Resource(item);
191190
});
192191
this.flow_step = 'list';
193192
} else {
@@ -198,11 +197,35 @@ export class BulkItemModalComponent<
198197
}
199198

200199
public showStatus() {
200+
const validation_errors = this.validateItems();
201+
if (validation_errors.length > 0) {
202+
for (const error of validation_errors.slice(0, 5)) {
203+
notifyError(error);
204+
}
205+
if (validation_errors.length > 5) {
206+
notifyWarn(
207+
`...and ${validation_errors.length - 5} more validation errors.`,
208+
);
209+
}
210+
return;
211+
}
212+
const duplicates = this.findDuplicates();
213+
if (duplicates.length > 0) {
214+
for (const warning of duplicates.slice(0, 5)) {
215+
notifyWarn(warning);
216+
}
217+
if (duplicates.length > 5) {
218+
notifyWarn(
219+
`...and ${duplicates.length - 5} more duplicate warnings.`,
220+
);
221+
}
222+
}
201223
this.flow_step = 'status';
202224
}
203225

204226
public done() {
205-
setTimeout(() => this._dialog_ref.close(), 3000);
227+
this.loading = false;
228+
setTimeout(() => this._dialog_ref.close(), 5000);
206229
}
207230

208231
private getAvailableFields(): Identity[] {
@@ -233,4 +256,57 @@ export class BulkItemModalComponent<
233256
return [new PlaceZone(ZONE_TEMPLATE).toJSON()];
234257
}
235258
}
259+
260+
private getRequiredFields(): string[] {
261+
switch (this._data.constr as unknown) {
262+
case PlaceUser:
263+
return ['email', 'authority_id'];
264+
case PlaceSystem:
265+
return ['name'];
266+
case PlaceModule:
267+
return ['driver_id'];
268+
case PlaceDriver:
269+
return ['name', 'module_name', 'role'];
270+
case PlaceZone:
271+
return ['name'];
272+
default:
273+
return [];
274+
}
275+
}
276+
277+
private validateItems(): string[] {
278+
const errors: string[] = [];
279+
this.item_list.forEach((item, index) => {
280+
for (const field of this._required_fields) {
281+
const value = item[field];
282+
if (value === undefined || value === null || value === '') {
283+
errors.push(
284+
`Row ${index + 1}: Missing required field "${field}"`,
285+
);
286+
}
287+
}
288+
});
289+
return errors;
290+
}
291+
292+
private findDuplicates(): string[] {
293+
const warnings: string[] = [];
294+
const seen_keys = new Map<string, number>();
295+
const dedup_field =
296+
this._data.constr === (PlaceUser as unknown) ? 'email' : 'name';
297+
298+
this.item_list.forEach((item, index) => {
299+
const key = String(item[dedup_field] || '')
300+
.toLowerCase()
301+
.trim();
302+
if (key && seen_keys.has(key)) {
303+
warnings.push(
304+
`Row ${index + 1}: Duplicate "${dedup_field}" value "${key}" (same as row ${seen_keys.get(key) + 1})`,
305+
);
306+
} else if (key) {
307+
seen_keys.set(key, index);
308+
}
309+
});
310+
return warnings;
311+
}
236312
}

src/app/overlays/bulk-item-modal/csv-upload.component.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@ import { TranslatePipe } from '../../ui/translate.pipe';
1919
(dragenter)="dragging = true"
2020
(dragleave)="dragging = false"
2121
(dragend)="dragging = false"
22+
(dragover)="onDragOver($event)"
23+
(drop)="onDrop($event)"
2224
>
2325
<icon class="text-6xl">cloud_upload</icon>
2426
<div class="text">{{ 'COMMON.BULK_DROP_MSG' | translate }}</div>
2527
<input
2628
class="absolute inset-0 opacity-0"
2729
type="file"
30+
accept=".csv,.tsv"
2831
(change)="loadCSVData($event)"
2932
/>
3033
</button>
@@ -69,31 +72,50 @@ export class CsvUploadComponent {
6972
/** Whether CSV data is being processed */
7073
public loading: boolean;
7174

75+
public onDragOver(event: DragEvent) {
76+
event.preventDefault();
77+
event.stopPropagation();
78+
}
79+
80+
public onDrop(event: DragEvent) {
81+
event.preventDefault();
82+
event.stopPropagation();
83+
this.dragging = false;
84+
const file = event.dataTransfer?.files?.[0];
85+
if (file) {
86+
this.loadFile(file);
87+
}
88+
}
89+
7290
public loadCSVData(event) {
73-
this.loading = true;
7491
/* istanbul ignore else */
7592
if (event.target) {
7693
const element = event.target as HTMLInputElement;
7794
const file = element.files[0];
7895
/* istanbul ignore else */
7996
if (file) {
80-
const reader = new FileReader();
81-
reader.readAsText(file, 'UTF-8');
82-
reader.addEventListener('load', (evt) => {
83-
this.processCSVData(
84-
(evt.target as FileReader).result as string,
85-
file.name.endsWith('.csv') ? ',' : '\t',
86-
);
87-
element.value = '';
88-
});
89-
reader.addEventListener('error', (_) => {
90-
this.loading = false;
91-
notifyError('Error reading file.');
92-
});
97+
this.loadFile(file);
98+
element.value = '';
9399
}
94100
}
95101
}
96102

103+
private loadFile(file: File) {
104+
this.loading = true;
105+
const reader = new FileReader();
106+
reader.readAsText(file, 'UTF-8');
107+
reader.addEventListener('load', (evt) => {
108+
this.processCSVData(
109+
(evt.target as FileReader).result as string,
110+
file.name.endsWith('.csv') ? ',' : '\t',
111+
);
112+
});
113+
reader.addEventListener('error', (_) => {
114+
this.loading = false;
115+
notifyError('Error reading file.');
116+
});
117+
}
118+
97119
public downloadTemplateCSV() {
98120
const ignore_keys = ['module_list', 'settings', '_type', 'version'];
99121
const csv_data = jsonToCsv(
@@ -112,7 +134,11 @@ export class CsvUploadComponent {
112134
this.loading = false;
113135
this.list.emit(list);
114136
} catch (e) {
137+
this.loading = false;
115138
console.error(e);
139+
notifyError(
140+
'Error parsing CSV data. Please check the file format.',
141+
);
116142
}
117143
}
118144
}

src/app/overlays/bulk-item-modal/status-list.component.ts

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,37 @@ import {
44
SimpleChanges,
55
input,
66
output,
7+
signal,
78
} from '@angular/core';
89
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
910
import { MatTooltipModule } from '@angular/material/tooltip';
1011
import { PlaceResource } from '@placeos/ts-client';
11-
import { Observable } from 'rxjs';
12+
import { Observable, firstValueFrom } from 'rxjs';
13+
import { notifyError } from '../../common/notifications';
1214
import { IconComponent } from '../../ui/icon.component';
1315
import { TranslatePipe } from '../../ui/translate.pipe';
1416

17+
const BATCH_SIZE = 5;
18+
1519
@Component({
1620
selector: 'bulk-item-status-list',
1721
template: `
1822
<div class="flex flex-col items-center px-4 pb-4">
19-
@if (!done) {
23+
@if (!is_done()) {
2024
<div class="info">
2125
{{ 'COMMON.BULK_UPLOADING' | translate }}
2226
</div>
27+
<div class="text-base-content/60 mb-2 text-sm">
28+
{{ completed_count() }} / {{ list().length }} completed
29+
@if (error_count() > 0) {
30+
({{ error_count() }} failed)
31+
}
32+
</div>
33+
} @else {
34+
<div class="mb-2 text-sm">
35+
{{ completed_count() }} succeeded, {{ error_count() }}
36+
failed
37+
</div>
2338
}
2439
@for (item of list(); track item.id; let i = $index) {
2540
<div
@@ -34,7 +49,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
3449
}
3550
</div>
3651
<div class="status">
37-
@if (status[i] !== 'loading') {
52+
@if (status[i] && status[i] !== 'loading') {
3853
<div
3954
class="flex h-8 w-8 items-center justify-center rounded-full text-2xl shadow-sm"
4055
[class.bg-error]="status[i] !== 'done'"
@@ -82,6 +97,12 @@ export class StatusListComponent implements OnChanges {
8297
public readonly done = output<Record<string, unknown>[]>();
8398
/** Status of each of the items to be created */
8499
public status: Record<string, string> = {};
100+
/** Whether all items have been processed */
101+
public readonly is_done = signal(false);
102+
/** Count of successfully completed items */
103+
public readonly completed_count = signal(0);
104+
/** Count of failed items */
105+
public readonly error_count = signal(0);
85106

86107
public ngOnChanges(changes: SimpleChanges) {
87108
if (changes.list && this.list()) {
@@ -91,32 +112,69 @@ export class StatusListComponent implements OnChanges {
91112

92113
public async saveItems() {
93114
try {
94-
const list = [];
95-
let index = 0;
96-
for (const item of this.list()) {
97-
this.status[index] = 'loading';
98-
const saved_item = await this.save()({ ...item, id: '' })
99-
.toPromise()
100-
.catch((err) => {
101-
console.log('Error:', err);
102-
this.status[index] = `Error: ${err.status || err} ${
103-
err.statusText || err
104-
}`;
105-
console.error(this.status[index]);
106-
// notifyError(this.status[index]);
107-
});
108-
list.push(saved_item);
109-
if (this.status[index] === 'loading') {
110-
this.status[index] = 'done';
111-
}
112-
index++;
115+
const items = this.list();
116+
const results: (PlaceResource | undefined)[] = new Array(
117+
items.length,
118+
);
119+
let success_count = 0;
120+
let fail_count = 0;
121+
122+
for (let i = 0; i < items.length; i += BATCH_SIZE) {
123+
const batch = items.slice(i, i + BATCH_SIZE);
124+
const batch_promises = batch.map(async (item, batch_index) => {
125+
const index = i + batch_index;
126+
this.status[index] = 'loading';
127+
try {
128+
const saved_item = await firstValueFrom(
129+
this.save()({ ...item, id: '' }),
130+
);
131+
this.status[index] = 'done';
132+
success_count++;
133+
this.completed_count.set(success_count);
134+
results[index] = saved_item;
135+
} catch (err) {
136+
const message = this.formatError(err);
137+
this.status[index] = message;
138+
console.error(`Failed to save item ${index}:`, err);
139+
notifyError(message);
140+
fail_count++;
141+
this.error_count.set(fail_count);
142+
}
143+
});
144+
await Promise.all(batch_promises);
113145
}
114-
const clean_list = list.filter((item) => !!item);
146+
147+
this.is_done.set(true);
148+
const clean_list = results.filter((item) => !!item);
115149
if (clean_list.length > 0) {
116-
this.done.emit(clean_list);
150+
this.done.emit(
151+
clean_list as unknown as Record<string, unknown>[],
152+
);
117153
}
118154
} catch (e) {
119155
console.error(e);
120156
}
121157
}
158+
159+
private formatError(err: unknown): string {
160+
if (err && typeof err === 'object') {
161+
const http_err = err as Record<string, unknown>;
162+
const status = http_err.status || '';
163+
const status_text = http_err.statusText || '';
164+
const message =
165+
http_err.message ||
166+
(http_err.error &&
167+
typeof http_err.error === 'object' &&
168+
(http_err.error as Record<string, unknown>).message
169+
? (http_err.error as Record<string, unknown>).message
170+
: '');
171+
if (status || status_text) {
172+
return `Error: ${status} ${status_text}`.trim();
173+
}
174+
if (message) {
175+
return `Error: ${message}`;
176+
}
177+
}
178+
return `Error: ${String(err)}`;
179+
}
122180
}

0 commit comments

Comments
 (0)