@mshuja288/aksuicore 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@mshuja288/aksuicore",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Shared UI components and templates for Ikssam applications",
5
5
  "license": "UNLICENSED",
6
- "private": false,
7
6
  "type": "module",
8
7
  "main": "./dist/index.cjs",
9
8
  "module": "./dist/index.js",
@@ -65,12 +65,34 @@ export type ExportColumnRef = {
65
65
  header: string;
66
66
  };
67
67
 
68
+ export type BulkImportMode = "insert" | "update" | "upsert";
69
+
70
+ export type BulkImportError = {
71
+ row: number;
72
+ field?: string | null;
73
+ code: string;
74
+ message: string;
75
+ };
76
+
77
+ export type BulkImportResult = {
78
+ totalRows: number;
79
+ failed: number;
80
+ applied: boolean;
81
+ errors: BulkImportError[];
82
+ };
83
+
68
84
  const EXPORT_FORMAT_OPTIONS: { format: ExportDownloadFormat; label: string }[] = [
69
85
  { format: "csv", label: "CSV" },
70
86
  { format: "xlsx", label: "Excel" },
71
87
  { format: "json", label: "JSON" },
72
88
  ];
73
89
 
90
+ const BULK_IMPORT_MODE_OPTIONS: { mode: BulkImportMode; label: string }[] = [
91
+ { mode: "insert", label: "Insert" },
92
+ { mode: "update", label: "Update" },
93
+ { mode: "upsert", label: "Upsert" },
94
+ ];
95
+
74
96
  type SortDirection = "asc" | "desc";
75
97
  type SortState = { key: string; direction: SortDirection };
76
98
  type DetailLine = { label: string; value: string };
@@ -80,6 +102,48 @@ function defaultVisibleKeys<T>(columns: TableColumn<T>[]) {
80
102
  return columns.filter((column) => column.defaultVisible !== false).map((column) => column.key);
81
103
  }
82
104
 
105
+ /** Shared across all RecordWorkspace tables (preview + side rail). */
106
+ const WORKSPACE_CHROME_KEY = "aksui.workspace.chrome";
107
+
108
+ type WorkspaceChrome = {
109
+ previewEnabled: boolean;
110
+ sideOpen: boolean;
111
+ };
112
+
113
+ function readWorkspaceChrome(): WorkspaceChrome {
114
+ if (typeof window === "undefined") {
115
+ return { previewEnabled: false, sideOpen: false };
116
+ }
117
+ try {
118
+ const raw = window.localStorage.getItem(WORKSPACE_CHROME_KEY);
119
+ if (!raw) {
120
+ return { previewEnabled: false, sideOpen: false };
121
+ }
122
+ const parsed = JSON.parse(raw) as Partial<WorkspaceChrome>;
123
+ return {
124
+ previewEnabled: Boolean(parsed.previewEnabled),
125
+ sideOpen: Boolean(parsed.sideOpen),
126
+ };
127
+ } catch {
128
+ return { previewEnabled: false, sideOpen: false };
129
+ }
130
+ }
131
+
132
+ function writeWorkspaceChrome(chrome: WorkspaceChrome) {
133
+ if (typeof window === "undefined") {
134
+ return;
135
+ }
136
+ try {
137
+ window.localStorage.setItem(WORKSPACE_CHROME_KEY, JSON.stringify(chrome));
138
+ } catch {
139
+ /* ignore quota / private mode */
140
+ }
141
+ }
142
+
143
+ function filtersPrefsKey(columnPrefsKey: string | undefined) {
144
+ return columnPrefsKey ? `${columnPrefsKey}.filters` : undefined;
145
+ }
146
+
83
147
  function readStoredColumnKeys(prefsKey: string | undefined, fallback: string[], allowed: Set<string>) {
84
148
  if (!prefsKey || typeof window === "undefined") {
85
149
  return fallback;
@@ -111,6 +175,48 @@ function writeStoredColumnKeys(prefsKey: string | undefined, keys: string[]) {
111
175
  }
112
176
  }
113
177
 
178
+ function readStoredFilters(prefsKey: string | undefined, allowed: Set<string>): Record<string, string> {
179
+ if (!prefsKey || typeof window === "undefined") {
180
+ return {};
181
+ }
182
+ try {
183
+ const raw = window.localStorage.getItem(prefsKey);
184
+ if (!raw) {
185
+ return {};
186
+ }
187
+ const parsed = JSON.parse(raw) as unknown;
188
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
189
+ return {};
190
+ }
191
+ const next: Record<string, string> = {};
192
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
193
+ if (allowed.has(key) && typeof value === "string" && value.trim()) {
194
+ next[key] = value;
195
+ }
196
+ }
197
+ return next;
198
+ } catch {
199
+ return {};
200
+ }
201
+ }
202
+
203
+ function writeStoredFilters(prefsKey: string | undefined, filters: Record<string, string>) {
204
+ if (!prefsKey || typeof window === "undefined") {
205
+ return;
206
+ }
207
+ try {
208
+ const trimmed: Record<string, string> = {};
209
+ for (const [key, value] of Object.entries(filters)) {
210
+ if (value.trim()) {
211
+ trimmed[key] = value;
212
+ }
213
+ }
214
+ window.localStorage.setItem(prefsKey, JSON.stringify(trimmed));
215
+ } catch {
216
+ /* ignore quota / private mode */
217
+ }
218
+ }
219
+
114
220
  function compareColumnValues<T>(column: TableColumn<T>, left: T, right: T) {
115
221
  const leftRaw = column.display ? column.display(left) : column.value(left);
116
222
  const rightRaw = column.display ? column.display(right) : column.value(right);
@@ -194,6 +300,16 @@ function PreviewIcon() {
194
300
  );
195
301
  }
196
302
 
303
+ function UploadIcon() {
304
+ return (
305
+ <svg viewBox="0 0 20 20" aria-hidden="true">
306
+ <path d="M10 16.5V7.3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
307
+ <path d="M6.5 10.8 10 7.2l3.5 3.6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
308
+ <path d="M4 4.5h12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
309
+ </svg>
310
+ );
311
+ }
312
+
197
313
  function DownloadIcon() {
198
314
  return (
199
315
  <svg viewBox="0 0 20 20" aria-hidden="true">
@@ -258,7 +374,11 @@ export function RecordWorkspace<T>({
258
374
  canDelete,
259
375
  onDownload,
260
376
  canDownload = true,
377
+ canExport,
261
378
  downloadBusy = false,
379
+ onBulkImport,
380
+ canImport = true,
381
+ importBusy = false,
262
382
  showColumnPicker = true,
263
383
  columnPrefsKey,
264
384
  visibleColumnKeys,
@@ -285,11 +405,20 @@ export function RecordWorkspace<T>({
285
405
  * Second argument lists currently visible columns (exportKey ?? key).
286
406
  */
287
407
  onDownload?: (format: ExportDownloadFormat, columns: ExportColumnRef[]) => void;
408
+ /** Alias for `canDownload` (RBAC). When set, export requires this and `canDownload` to be true. */
409
+ canExport?: boolean;
288
410
  canDownload?: boolean;
289
411
  downloadBusy?: boolean;
412
+ onBulkImport?: (mode: BulkImportMode, file: File) => Promise<BulkImportResult>;
413
+ canImport?: boolean;
414
+ importBusy?: boolean;
290
415
  /** Column checklist in the side rail. Default true. */
291
416
  showColumnPicker?: boolean;
292
- /** Persist visible column keys in localStorage under this key. */
417
+ /**
418
+ * Persist visible column keys in localStorage under this key.
419
+ * Also persists filter values at `${columnPrefsKey}.filters`.
420
+ * Preview + side-rail open/collapsed use a shared key across all tables (`aksui.workspace.chrome`).
421
+ */
293
422
  columnPrefsKey?: string;
294
423
  /** Controlled visible column keys. */
295
424
  visibleColumnKeys?: string[];
@@ -309,13 +438,21 @@ export function RecordWorkspace<T>({
309
438
  const [internalVisibleKeys, setInternalVisibleKeys] = useState<string[]>(() =>
310
439
  readStoredColumnKeys(columnPrefsKey, fallbackKeys, allowedKeys),
311
440
  );
312
- const [filters, setFilters] = useState<Record<string, string>>({});
441
+ const filterStorageKey = filtersPrefsKey(columnPrefsKey);
442
+ const [filters, setFilters] = useState<Record<string, string>>(() =>
443
+ readStoredFilters(filterStorageKey, allowedKeys),
444
+ );
313
445
  const [sort, setSort] = useState<SortState | null>(null);
314
- const [sideOpen, setSideOpen] = useState(false);
446
+ const [sideOpen, setSideOpen] = useState(() => readWorkspaceChrome().sideOpen);
315
447
  const [toolsDrawerOpen, setToolsDrawerOpen] = useState(false);
316
448
  const [downloadOpen, setDownloadOpen] = useState(false);
449
+ const [bulkOpen, setBulkOpen] = useState(false);
450
+ const [bulkMode, setBulkMode] = useState<BulkImportMode>("upsert");
451
+ const [bulkFile, setBulkFile] = useState<File | null>(null);
452
+ const [bulkReport, setBulkReport] = useState<BulkImportResult | null>(null);
453
+ const bulkFileInputRef = useRef<HTMLInputElement | null>(null);
317
454
  const [columnsOpen, setColumnsOpen] = useState(false);
318
- const [previewEnabled, setPreviewEnabled] = useState(false);
455
+ const [previewEnabled, setPreviewEnabled] = useState(() => readWorkspaceChrome().previewEnabled);
319
456
  const [previewKey, setPreviewKey] = useState<string | number | null>(null);
320
457
  const [tooltip, setTooltip] = useState<TooltipState | null>(null);
321
458
  const [pageSize, setPageSize] = useState("15");
@@ -337,6 +474,10 @@ export function RecordWorkspace<T>({
337
474
  setInternalVisibleKeys(readStoredColumnKeys(columnPrefsKey, fallbackKeys, allowedKeys));
338
475
  }, [allowedKeys, columnPrefsKey, fallbackKeys, isControlled]);
339
476
 
477
+ useEffect(() => {
478
+ setFilters(readStoredFilters(filtersPrefsKey(columnPrefsKey), allowedKeys));
479
+ }, [allowedKeys, columnPrefsKey]);
480
+
340
481
  const resolvedVisibleKeys = useMemo(() => {
341
482
  const source = isControlled ? visibleColumnKeys! : internalVisibleKeys;
342
483
  const filtered = source.filter((key) => allowedKeys.has(key));
@@ -425,7 +566,9 @@ export function RecordWorkspace<T>({
425
566
  const start = showAll ? 0 : (currentPage - 1) * numericPageSize;
426
567
  const pageItems = showAll ? sorted : sorted.slice(start, start + numericPageSize);
427
568
  const showAdd = canAdd && Boolean(onAdd);
428
- const showDownload = Boolean(onDownload) && canDownload;
569
+ const exportAllowed = (canExport ?? true) && canDownload;
570
+ const showDownload = Boolean(onDownload) && exportAllowed;
571
+ const showBulkImport = Boolean(onBulkImport) && canImport;
429
572
  const showFilters = filterableColumns.length > 0;
430
573
  const showColumns = showColumnPicker && columns.length > 0;
431
574
  const previewItem = previewKey === null ? null : sorted.find((item) => rowKey(item) === previewKey) ?? null;
@@ -478,6 +621,30 @@ export function RecordWorkspace<T>({
478
621
  setDownloadOpen(false);
479
622
  }
480
623
 
624
+ async function submitBulkImport() {
625
+ if (!onBulkImport || importBusy || !bulkFile) {
626
+ return;
627
+ }
628
+ setBulkReport(null);
629
+ try {
630
+ const report = await onBulkImport(bulkMode, bulkFile);
631
+ setBulkReport(report);
632
+ if (report.applied) {
633
+ setBulkFile(null);
634
+ if (bulkFileInputRef.current) {
635
+ bulkFileInputRef.current.value = "";
636
+ }
637
+ }
638
+ } catch {
639
+ setBulkReport({
640
+ totalRows: 0,
641
+ failed: 1,
642
+ applied: false,
643
+ errors: [{ row: 0, code: "ImportFailed", message: "Import request failed." }],
644
+ });
645
+ }
646
+ }
647
+
481
648
  function cellText(column: TableColumn<T>, item: T) {
482
649
  const raw = column.display ? column.display(item) : column.value(item);
483
650
  return raw === null || raw === undefined || raw === "" ? "—" : String(raw);
@@ -493,15 +660,25 @@ export function RecordWorkspace<T>({
493
660
  const previewTitle = previewItem && visibleColumns[0] ? cellText(visibleColumns[0], previewItem) : null;
494
661
 
495
662
  function changeFilter(key: string, value: string) {
496
- setFilters((current) => ({ ...current, [key]: value }));
663
+ setFilters((current) => {
664
+ const next = { ...current, [key]: value };
665
+ writeStoredFilters(filterStorageKey, next);
666
+ return next;
667
+ });
497
668
  setPage(1);
498
669
  }
499
670
 
500
671
  function clearFilters() {
501
672
  setFilters({});
673
+ writeStoredFilters(filterStorageKey, {});
502
674
  setPage(1);
503
675
  }
504
676
 
677
+ function setSideOpenValue(open: boolean) {
678
+ setSideOpen(open);
679
+ writeWorkspaceChrome({ previewEnabled, sideOpen: open });
680
+ }
681
+
505
682
  function toggleSort(column: TableColumn<T>) {
506
683
  if (column.sortable === false) {
507
684
  return;
@@ -520,6 +697,7 @@ export function RecordWorkspace<T>({
520
697
 
521
698
  function setPreviewEnabledValue(enabled: boolean) {
522
699
  setPreviewEnabled(enabled);
700
+ writeWorkspaceChrome({ previewEnabled: enabled, sideOpen });
523
701
  setTooltip(null);
524
702
  if (!enabled) {
525
703
  setPreviewKey(null);
@@ -591,7 +769,7 @@ export function RecordWorkspace<T>({
591
769
  title="Columns"
592
770
  aria-label="Columns"
593
771
  onClick={() => {
594
- setSideOpen(true);
772
+ setSideOpenValue(true);
595
773
  setColumnsOpen(true);
596
774
  }}
597
775
  >
@@ -607,20 +785,35 @@ export function RecordWorkspace<T>({
607
785
  aria-label="Download"
608
786
  disabled={downloadBusy}
609
787
  onClick={() => {
610
- setSideOpen(true);
788
+ setSideOpenValue(true);
611
789
  setDownloadOpen(true);
612
790
  }}
613
791
  >
614
792
  <DownloadIcon />
615
793
  </button>
616
794
  ) : null}
795
+ {showBulkImport ? (
796
+ <button
797
+ type="button"
798
+ className="ghost side-rail-icon"
799
+ title="Bulk entry"
800
+ aria-label="Bulk entry"
801
+ disabled={importBusy}
802
+ onClick={() => {
803
+ setSideOpenValue(true);
804
+ setBulkOpen(true);
805
+ }}
806
+ >
807
+ <UploadIcon />
808
+ </button>
809
+ ) : null}
617
810
  {showFilters ? (
618
811
  <button
619
812
  type="button"
620
813
  className="ghost side-rail-icon"
621
814
  title="Show filters"
622
815
  aria-label="Show filters"
623
- onClick={() => setSideOpen(true)}
816
+ onClick={() => setSideOpenValue(true)}
624
817
  >
625
818
  <FilterIcon />
626
819
  {activeFilterCount > 0 ? <span className="filter-badge">{activeFilterCount}</span> : null}
@@ -742,6 +935,75 @@ export function RecordWorkspace<T>({
742
935
  </div>
743
936
  ) : null}
744
937
 
938
+ {showBulkImport ? (
939
+ <div className="side-rail-bulk">
940
+ <button
941
+ type="button"
942
+ className="ghost side-rail-bulk-toggle"
943
+ aria-expanded={bulkOpen}
944
+ disabled={importBusy}
945
+ onClick={() => setBulkOpen((open) => !open)}
946
+ >
947
+ <UploadIcon />
948
+ <span>{importBusy ? "Importing…" : "Bulk entry"}</span>
949
+ </button>
950
+ {bulkOpen ? (
951
+ <div className="side-rail-bulk-panel" role="group" aria-label="Bulk entry">
952
+ <label className="side-rail-bulk-mode">
953
+ Mode
954
+ <Select
955
+ value={bulkMode}
956
+ options={BULK_IMPORT_MODE_OPTIONS.map((option) => ({ value: option.mode, label: option.label }))}
957
+ onChange={(value) => setBulkMode(value as BulkImportMode)}
958
+ />
959
+ </label>
960
+ <label className="side-rail-bulk-file">
961
+ File
962
+ <input
963
+ ref={bulkFileInputRef}
964
+ type="file"
965
+ accept=".csv,.xlsx,.json"
966
+ disabled={importBusy}
967
+ onChange={(event) => {
968
+ setBulkFile(event.target.files?.[0] ?? null);
969
+ setBulkReport(null);
970
+ }}
971
+ />
972
+ </label>
973
+ <button
974
+ type="button"
975
+ className="side-rail-bulk-submit"
976
+ disabled={importBusy || !bulkFile}
977
+ onClick={() => void submitBulkImport()}
978
+ >
979
+ {importBusy ? "Importing…" : "Import"}
980
+ </button>
981
+ {bulkReport ? (
982
+ <div className="side-rail-bulk-report" role="status">
983
+ <p>
984
+ {bulkReport.applied
985
+ ? `Applied ${bulkReport.totalRows} row(s).`
986
+ : `${bulkReport.failed} error(s) in ${bulkReport.totalRows} row(s).`}
987
+ </p>
988
+ {bulkReport.errors.length > 0 ? (
989
+ <ul className="side-rail-bulk-errors">
990
+ {bulkReport.errors.slice(0, 8).map((error, index) => (
991
+ <li key={`${error.row}-${error.code}-${index}`}>
992
+ Row {error.row}: {error.message}
993
+ </li>
994
+ ))}
995
+ {bulkReport.errors.length > 8 ? (
996
+ <li>…and {bulkReport.errors.length - 8} more</li>
997
+ ) : null}
998
+ </ul>
999
+ ) : null}
1000
+ </div>
1001
+ ) : null}
1002
+ </div>
1003
+ ) : null}
1004
+ </div>
1005
+ ) : null}
1006
+
745
1007
  {showFilters ? (
746
1008
  <div className="side-rail-filters">
747
1009
  <div className="filter-panel-head">
@@ -1002,7 +1264,7 @@ export function RecordWorkspace<T>({
1002
1264
  className="ghost side-rail-toggle"
1003
1265
  aria-expanded={sideOpen}
1004
1266
  title={sideOpen ? "Collapse" : "Expand"}
1005
- onClick={() => setSideOpen((open) => !open)}
1267
+ onClick={() => setSideOpenValue(!sideOpen)}
1006
1268
  >
1007
1269
  <ChevronIcon open={sideOpen} />
1008
1270
  {sideOpen ? <span>Collapse</span> : null}
@@ -5,4 +5,7 @@ export {
5
5
  type TableColumn,
6
6
  type ExportDownloadFormat,
7
7
  type ExportColumnRef,
8
+ type BulkImportMode,
9
+ type BulkImportError,
10
+ type BulkImportResult,
8
11
  } from "./RecordWorkspace";
@@ -405,7 +405,8 @@ button.side-rail-add:hover {
405
405
  }
406
406
 
407
407
  .side-rail-columns,
408
- .side-rail-download {
408
+ .side-rail-download,
409
+ .side-rail-bulk {
409
410
  display: grid;
410
411
  gap: 8px;
411
412
  }
@@ -533,6 +534,79 @@ button.side-rail-download-format:disabled {
533
534
  cursor: default;
534
535
  }
535
536
 
537
+ button.side-rail-bulk-toggle {
538
+ display: inline-flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ gap: 8px;
542
+ width: 100%;
543
+ padding: 10px 12px;
544
+ border: 1px solid var(--aks-border, #d7dde5);
545
+ border-radius: var(--aks-radius);
546
+ background: #fff;
547
+ color: var(--aks-brand);
548
+ box-shadow: none;
549
+ }
550
+
551
+ button.side-rail-bulk-toggle svg {
552
+ width: 18px;
553
+ height: 18px;
554
+ }
555
+
556
+ button.side-rail-bulk-toggle:hover:not(:disabled) {
557
+ background: #f5f7fa;
558
+ }
559
+
560
+ button.side-rail-bulk-toggle:disabled {
561
+ opacity: 0.55;
562
+ cursor: default;
563
+ }
564
+
565
+ .side-rail-bulk-panel {
566
+ display: grid;
567
+ gap: 10px;
568
+ padding: 8px;
569
+ border: 1px solid var(--aks-line);
570
+ border-radius: var(--aks-radius);
571
+ background: #faf9f7;
572
+ }
573
+
574
+ label.side-rail-bulk-mode,
575
+ label.side-rail-bulk-file {
576
+ display: grid;
577
+ gap: 6px;
578
+ margin: 0;
579
+ font-size: 13px;
580
+ font-weight: 600;
581
+ color: var(--aks-brand);
582
+ }
583
+
584
+ button.side-rail-bulk-submit {
585
+ width: 100%;
586
+ padding: 10px 12px;
587
+ border: none;
588
+ border-radius: var(--aks-radius);
589
+ background: var(--aks-accent, #c45c26);
590
+ color: #fff;
591
+ font-weight: 600;
592
+ box-shadow: none;
593
+ }
594
+
595
+ button.side-rail-bulk-submit:disabled {
596
+ opacity: 0.5;
597
+ cursor: default;
598
+ }
599
+
600
+ .side-rail-bulk-report {
601
+ font-size: 12px;
602
+ color: var(--aks-brand);
603
+ }
604
+
605
+ .side-rail-bulk-errors {
606
+ margin: 6px 0 0;
607
+ padding-left: 18px;
608
+ }
609
+
536
610
  .side-rail-filters {
537
611
  display: grid;
538
612
  gap: 12px;