@kahitsan/ksui 0.29.1 → 0.31.0
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 +1 -1
- package/src/components/base/Button.tsx +29 -4
- package/src/components/base/DataTable.tsx +27 -9
- package/src/components/base/StatusPill.tsx +14 -29
- package/src/components/composite/resource/cells.tsx +1 -1
- package/src/index.ts +2 -1
- package/src/utils/pending-file.test.ts +59 -0
- package/src/utils/pending-file.ts +40 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -73,6 +73,10 @@ const styles: Record<string, string> = {
|
|
|
73
73
|
|
|
74
74
|
export type ButtonIntent = "primary" | "danger" | "secondary";
|
|
75
75
|
export type ButtonVariant = "clip1" | "clip2" | "ghost" | "link";
|
|
76
|
+
/** "md" (default) preserves the original px-4/py-2/text-sm footprint everywhere
|
|
77
|
+
* it's already used. "sm" is for dense contexts — e.g. a DataTable row action
|
|
78
|
+
* — where the default footprint reads as oversized against compact rows. */
|
|
79
|
+
export type ButtonSize = "sm" | "md";
|
|
76
80
|
|
|
77
81
|
export interface ButtonProps {
|
|
78
82
|
/** Element / component to render as (defaults to "button"). Intentionally
|
|
@@ -83,6 +87,7 @@ export interface ButtonProps {
|
|
|
83
87
|
as?: any;
|
|
84
88
|
intent?: ButtonIntent;
|
|
85
89
|
variant?: ButtonVariant;
|
|
90
|
+
size?: ButtonSize;
|
|
86
91
|
noRipple?: boolean;
|
|
87
92
|
noScanline?: boolean;
|
|
88
93
|
noGlow?: boolean;
|
|
@@ -164,17 +169,17 @@ interface VariantConfig {
|
|
|
164
169
|
const buttonVariantConfig: Record<ButtonVariant, VariantConfig> = {
|
|
165
170
|
clip1: {
|
|
166
171
|
effects: ["clip-top-left-bottom-right"],
|
|
167
|
-
baseClasses: "
|
|
172
|
+
baseClasses: "border",
|
|
168
173
|
overrideType: false,
|
|
169
174
|
},
|
|
170
175
|
clip2: {
|
|
171
176
|
effects: ["clip-top-right-bottom-left"],
|
|
172
|
-
baseClasses: "
|
|
177
|
+
baseClasses: "border",
|
|
173
178
|
overrideType: false,
|
|
174
179
|
},
|
|
175
180
|
ghost: {
|
|
176
181
|
effects: [],
|
|
177
|
-
baseClasses: "
|
|
182
|
+
baseClasses: "bg-transparent border-transparent hover:bg-current/10 hover:border-transparent",
|
|
178
183
|
overrideType: true,
|
|
179
184
|
},
|
|
180
185
|
link: {
|
|
@@ -185,6 +190,16 @@ const buttonVariantConfig: Record<ButtonVariant, VariantConfig> = {
|
|
|
185
190
|
},
|
|
186
191
|
};
|
|
187
192
|
|
|
193
|
+
// Padding/text/gap live here (not in variantConfig) so a variant's padding is
|
|
194
|
+
// never in the same class-string as size's — `cn()` is a plain join with no
|
|
195
|
+
// tailwind-merge dedup, so two conflicting padding utilities would silently
|
|
196
|
+
// race on Tailwind's generated CSS order instead of on usage order. `link` is
|
|
197
|
+
// always unpadded regardless of size (see buttonVariantConfig.link).
|
|
198
|
+
const buttonSizeConfig: Record<ButtonSize, { padding: string; text: string; gap: string }> = {
|
|
199
|
+
md: { padding: "px-4 py-2", text: "text-sm", gap: "gap-2" },
|
|
200
|
+
sm: { padding: "px-2.5 py-1", text: "text-xs", gap: "gap-1.5" },
|
|
201
|
+
};
|
|
202
|
+
|
|
188
203
|
interface Ripple {
|
|
189
204
|
id: number;
|
|
190
205
|
x: number;
|
|
@@ -201,6 +216,7 @@ const Button = (props: ButtonProps): JSX.Element => {
|
|
|
201
216
|
as: "button" as ButtonProps["as"],
|
|
202
217
|
intent: "primary" as ButtonIntent,
|
|
203
218
|
variant: "clip1" as ButtonVariant,
|
|
219
|
+
size: "md" as ButtonSize,
|
|
204
220
|
noRipple: false,
|
|
205
221
|
noScanline: false,
|
|
206
222
|
noGlow: false,
|
|
@@ -214,6 +230,7 @@ const Button = (props: ButtonProps): JSX.Element => {
|
|
|
214
230
|
"as",
|
|
215
231
|
"intent",
|
|
216
232
|
"variant",
|
|
233
|
+
"size",
|
|
217
234
|
"noRipple",
|
|
218
235
|
"noScanline",
|
|
219
236
|
"noGlow",
|
|
@@ -238,6 +255,7 @@ const Button = (props: ButtonProps): JSX.Element => {
|
|
|
238
255
|
const variantConfig = createMemo(
|
|
239
256
|
() => buttonVariantConfig[local.variant as ButtonVariant] || buttonVariantConfig.clip1,
|
|
240
257
|
);
|
|
258
|
+
const sizeConfig = createMemo(() => buttonSizeConfig[local.size as ButtonSize] || buttonSizeConfig.md);
|
|
241
259
|
|
|
242
260
|
const hasRipple = createMemo(() => {
|
|
243
261
|
if (local.noRipple) return false;
|
|
@@ -288,15 +306,22 @@ const Button = (props: ButtonProps): JSX.Element => {
|
|
|
288
306
|
|
|
289
307
|
const classes = createMemo(() => {
|
|
290
308
|
const coreClasses =
|
|
291
|
-
"select-none inline-flex items-center justify-center
|
|
309
|
+
"select-none inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none rounded";
|
|
292
310
|
const textColor = intentConfig().textColor;
|
|
293
311
|
const backgroundClasses = variantConfig().overrideType
|
|
294
312
|
? ""
|
|
295
313
|
: `${intentConfig().background} ${intentConfig().hover}`;
|
|
296
314
|
const variantClasses = variantConfig().baseClasses;
|
|
315
|
+
// link is always unpadded (baseClasses carries its own "px-0 py-0") but
|
|
316
|
+
// still takes text/gap sizing; every other variant takes padding too.
|
|
317
|
+
const sizeClasses =
|
|
318
|
+
local.variant === "link"
|
|
319
|
+
? `${sizeConfig().text} ${sizeConfig().gap}`
|
|
320
|
+
: `${sizeConfig().padding} ${sizeConfig().text} ${sizeConfig().gap}`;
|
|
297
321
|
|
|
298
322
|
return cn(
|
|
299
323
|
coreClasses,
|
|
324
|
+
sizeClasses,
|
|
300
325
|
textColor,
|
|
301
326
|
backgroundClasses,
|
|
302
327
|
variantClasses,
|
|
@@ -66,7 +66,7 @@ const STYLE_ID = "ksui-datatable-style";
|
|
|
66
66
|
// Full list of overridable vars (default = host value):
|
|
67
67
|
// --ksui-dt-card-bg outer card background
|
|
68
68
|
// (host `.card-bg`: linear-gradient(135deg,#0f0f0f,#1a1a1a))
|
|
69
|
-
// --ksui-dt-radius outer card / control corner radius (0.
|
|
69
|
+
// --ksui-dt-radius outer card / control corner radius (0.625rem)
|
|
70
70
|
// --ksui-dt-border card / header / footer / control border (zinc-800/50, rgba(39,39,42,0.5))
|
|
71
71
|
// --ksui-dt-row-border row divider + expansion-row top border (zinc-800/30, rgba(39,39,42,0.3))
|
|
72
72
|
// --ksui-dt-control-bg filter button / menu / select / search / show-more bg (zinc-900, #18181b)
|
|
@@ -84,10 +84,21 @@ const STYLE_ID = "ksui-datatable-style";
|
|
|
84
84
|
// --ksui-dt-accent active accent text: active pager num / show-more hover (amber-400, #fbbf24)
|
|
85
85
|
// --ksui-dt-accent-bg active pager num bg (amber-600/20, rgba(217,119,6,0.2))
|
|
86
86
|
// --ksui-dt-accent-border focus/hover accent border: search focus, show-more hover (amber-500/40, rgba(245,158,11,0.4))
|
|
87
|
+
// --ksui-dt-good positive-status badge text, e.g. "Active" (emerald-400, #34d399)
|
|
88
|
+
// --ksui-dt-good-bg positive-status badge bg (emerald-400/14, rgba(52,211,153,0.14))
|
|
89
|
+
// --ksui-dt-danger negative-status badge text, e.g. "Overdue"/"Expired" (red-400, #f87171)
|
|
90
|
+
// --ksui-dt-danger-bg negative-status badge bg (red-400/14, rgba(248,113,113,0.14))
|
|
91
|
+
// --ksui-dt-thead-bg header-row tint, layered over card-bg to set it apart from data rows (white/3%, rgba(255,255,255,0.03))
|
|
92
|
+
//
|
|
93
|
+
// `.ksui-datatable-td-num` / `.ksui-datatable-th-num` (mono, tabular-nums,
|
|
94
|
+
// right-aligned, nowrap) and `.ksui-datatable-badge` (+ `-ok` / `-warn` /
|
|
95
|
+
// `-danger` modifiers, using the vars above) are opt-in utility classes a
|
|
96
|
+
// consumer passes via a column's `className` — DataTable itself never
|
|
97
|
+
// infers column semantics from data.
|
|
87
98
|
//
|
|
88
99
|
const DATATABLE_CSS = `
|
|
89
|
-
.ksui-datatable{background:var(--ksui-dt-card-bg,linear-gradient(135deg,#0f0f0f 0%,#1a1a1a 100%));border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));border-radius:var(--ksui-dt-radius,0.
|
|
90
|
-
.ksui-datatable-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:
|
|
100
|
+
.ksui-datatable{background:var(--ksui-dt-card-bg,linear-gradient(135deg,#0f0f0f 0%,#1a1a1a 100%));border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));border-radius:var(--ksui-dt-radius,0.625rem);color:var(--ksui-dt-fg,#e4e4e7);box-shadow:0 1px 2px rgba(0,0,0,0.3),0 8px 20px -10px rgba(0,0,0,0.5);}
|
|
101
|
+
.ksui-datatable-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.75rem 0.875rem;}
|
|
91
102
|
.ksui-datatable-filters-inline{display:none;flex:1 1 0%;}
|
|
92
103
|
.ksui-datatable-filters-mobile{display:block;flex:1 1 0%;}
|
|
93
104
|
@media (min-width:768px){.ksui-datatable-filters-inline{display:block;}.ksui-datatable-filters-mobile{display:none;}}
|
|
@@ -105,9 +116,10 @@ const DATATABLE_CSS = `
|
|
|
105
116
|
.ksui-datatable-search-input:focus{border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.4));}
|
|
106
117
|
@media (min-width:640px){.ksui-datatable-search-input{width:18rem;}}
|
|
107
118
|
.ksui-datatable-scroll{overflow-x:auto;transition:opacity 0.15s ease;}
|
|
108
|
-
.ksui-datatable-table{width:100%;text-align:left;font-size:0.
|
|
109
|
-
.ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));font-size:0.
|
|
110
|
-
.ksui-datatable-th{padding:0.
|
|
119
|
+
.ksui-datatable-table{width:100%;text-align:left;font-size:0.78125rem;line-height:1.3;border-collapse:collapse;}
|
|
120
|
+
.ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-thead-bg,rgba(255,255,255,0.03));font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.65625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--ksui-dt-muted,#71717a);}
|
|
121
|
+
.ksui-datatable-th{padding:0.5625rem 0.875rem;background:var(--ksui-dt-thead-bg,rgba(255,255,255,0.03));white-space:nowrap;}
|
|
122
|
+
.ksui-datatable-th-num{text-align:right;}
|
|
111
123
|
.ksui-datatable-th-sortable{cursor:pointer;transition:color 0.15s ease;}
|
|
112
124
|
.ksui-datatable-th-sortable:hover{color:var(--ksui-dt-text-strong,#d4d4d8);}
|
|
113
125
|
.ksui-datatable-th-inner{display:inline-flex;align-items:center;}
|
|
@@ -116,13 +128,19 @@ const DATATABLE_CSS = `
|
|
|
116
128
|
.ksui-datatable-row:hover{background-color:var(--ksui-dt-row-hover,rgba(39,39,42,0.5));}
|
|
117
129
|
.ksui-datatable-row-clickable{cursor:pointer;}
|
|
118
130
|
.ksui-datatable-row-clickable:active{background-color:var(--ksui-dt-row-active,rgba(39,39,42,0.7));}
|
|
119
|
-
.ksui-datatable-td{padding:0.
|
|
131
|
+
.ksui-datatable-td{padding:0.5rem 0.875rem;vertical-align:middle;white-space:nowrap;}
|
|
132
|
+
.ksui-datatable-td-num{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;color:var(--ksui-dt-text-strong,#d4d4d8);}
|
|
133
|
+
.ksui-datatable-code{display:block;font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.71875rem;color:var(--ksui-dt-fg,#e4e4e7);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34ch;}
|
|
134
|
+
.ksui-datatable-badge{display:inline-block;font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.625rem;padding:0.125rem 0.4375rem;border-radius:0.3125rem;letter-spacing:0.02em;background:var(--ksui-dt-control-bg,#18181b);color:var(--ksui-dt-text,#a1a1aa);}
|
|
135
|
+
.ksui-datatable-badge-ok{background:var(--ksui-dt-good-bg,rgba(52,211,153,0.14));color:var(--ksui-dt-good,#34d399);}
|
|
136
|
+
.ksui-datatable-badge-warn{background:var(--ksui-dt-accent-bg,rgba(217,119,6,0.2));color:var(--ksui-dt-accent,#fbbf24);}
|
|
137
|
+
.ksui-datatable-badge-danger{background:var(--ksui-dt-danger-bg,rgba(248,113,113,0.14));color:var(--ksui-dt-danger,#f87171);}
|
|
120
138
|
.ksui-datatable-expansion-row{border-top:1px solid var(--ksui-dt-row-border,rgba(39,39,42,0.3));background-color:var(--ksui-dt-expansion-bg,rgba(9,9,11,0.4));}
|
|
121
139
|
.ksui-datatable-expansion-td{padding:0;}
|
|
122
|
-
.ksui-datatable-skeleton{height:
|
|
140
|
+
.ksui-datatable-skeleton{height:1rem;width:100%;border-radius:0.25rem;background:var(--ksui-dt-skeleton,rgba(39,39,42,0.5));animation:ksuiDatatablePulse 1.5s cubic-bezier(0.4,0,0.6,1) infinite;}
|
|
123
141
|
@keyframes ksuiDatatablePulse{0%,100%{opacity:1;}50%{opacity:0.5;}}
|
|
124
142
|
.ksui-datatable-empty{padding:3rem 1rem;text-align:center;color:var(--ksui-dt-muted,#71717a);}
|
|
125
|
-
.ksui-datatable-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:
|
|
143
|
+
.ksui-datatable-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.625rem 0.875rem;}
|
|
126
144
|
.ksui-datatable-info{font-size:0.75rem;color:var(--ksui-dt-muted,#71717a);}
|
|
127
145
|
.ksui-datatable-showmore{border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem 1rem;font-size:0.75rem;font-weight:500;color:var(--ksui-dt-text-strong,#d4d4d8);transition:border-color 0.15s ease,color 0.15s ease;cursor:pointer;}
|
|
128
146
|
.ksui-datatable-showmore:hover:not(:disabled){border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.4));color:var(--ksui-dt-accent,#fbbf24);}
|
|
@@ -1,89 +1,74 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type JSX } from "solid-js";
|
|
2
2
|
|
|
3
3
|
export type StatusTone = "success" | "neutral" | "warning" | "danger" | "info";
|
|
4
4
|
|
|
5
5
|
interface ToneClass {
|
|
6
6
|
text: string;
|
|
7
|
-
/** Lighter background (default
|
|
7
|
+
/** Lighter background (default). */
|
|
8
8
|
bg: string;
|
|
9
|
-
/** Heavier background
|
|
9
|
+
/** Heavier background, used when `solid` is set. */
|
|
10
10
|
bgSolid: string;
|
|
11
|
-
border: string;
|
|
12
|
-
/** Fill color for the optional leading dot. */
|
|
13
|
-
dot: string;
|
|
14
11
|
}
|
|
15
12
|
|
|
16
13
|
// Module-private tone palette. The caller maps its own domain enum
|
|
17
14
|
// (status / is_active / voucher state) to one of these tones and passes a
|
|
18
15
|
// plain label; nothing domain-specific leaks into this atom.
|
|
16
|
+
//
|
|
17
|
+
// Flat tinted bg + text, no border — a borderless chip reads cleaner at the
|
|
18
|
+
// compact sizes tables now use than the previous bordered pill did.
|
|
19
19
|
const TONE_CLASS: Record<StatusTone, ToneClass> = {
|
|
20
20
|
success: {
|
|
21
21
|
text: "text-emerald-400",
|
|
22
22
|
bg: "bg-emerald-500/10",
|
|
23
23
|
bgSolid: "bg-emerald-500/20",
|
|
24
|
-
border: "border-emerald-400/40",
|
|
25
|
-
dot: "bg-emerald-400",
|
|
26
24
|
},
|
|
27
25
|
neutral: {
|
|
28
26
|
text: "text-zinc-400",
|
|
29
27
|
bg: "bg-zinc-800/60",
|
|
30
28
|
bgSolid: "bg-zinc-700/40",
|
|
31
|
-
border: "border-zinc-600",
|
|
32
|
-
dot: "bg-zinc-400",
|
|
33
29
|
},
|
|
34
30
|
warning: {
|
|
35
31
|
text: "text-amber-400",
|
|
36
32
|
bg: "bg-amber-500/10",
|
|
37
33
|
bgSolid: "bg-amber-500/20",
|
|
38
|
-
border: "border-amber-400/40",
|
|
39
|
-
dot: "bg-amber-400",
|
|
40
34
|
},
|
|
41
35
|
danger: {
|
|
42
36
|
text: "text-red-400",
|
|
43
37
|
bg: "bg-red-500/10",
|
|
44
38
|
bgSolid: "bg-red-500/20",
|
|
45
|
-
border: "border-red-400/40",
|
|
46
|
-
dot: "bg-red-400",
|
|
47
39
|
},
|
|
48
40
|
info: {
|
|
49
41
|
text: "text-blue-400",
|
|
50
42
|
bg: "bg-blue-500/10",
|
|
51
43
|
bgSolid: "bg-blue-500/20",
|
|
52
|
-
border: "border-blue-400/40",
|
|
53
|
-
dot: "bg-blue-400",
|
|
54
44
|
},
|
|
55
45
|
};
|
|
56
46
|
|
|
57
47
|
interface StatusPillProps {
|
|
58
|
-
/** Text shown inside the pill (the caller's own label)
|
|
48
|
+
/** Text shown inside the pill (the caller's own label), rendered as-is —
|
|
49
|
+
* the caller decides case. */
|
|
59
50
|
label: string;
|
|
60
51
|
/** Domain-free tone selector. The caller maps its enum to one of these. */
|
|
61
52
|
tone: StatusTone;
|
|
62
|
-
/**
|
|
63
|
-
dot?: boolean;
|
|
64
|
-
/** Heavier /20 background (clients/vouchers style); default is the
|
|
65
|
-
* lighter /10 background (packages style). */
|
|
53
|
+
/** Heavier /20 background; default is the lighter /10 background. */
|
|
66
54
|
solid?: boolean;
|
|
67
55
|
/** Extra classes on the pill wrapper. */
|
|
68
56
|
class?: string;
|
|
69
57
|
testId?: string;
|
|
70
58
|
}
|
|
71
59
|
|
|
72
|
-
// Single status-pill atom
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
60
|
+
// Single status-pill atom: a flat tinted-bg text chip, no border, no leading
|
|
61
|
+
// dot, no forced case — the caller's label renders exactly as given. `solid`
|
|
62
|
+
// switches to the heavier background. Domain enum -> tone + label mapping
|
|
63
|
+
// stays with the caller.
|
|
76
64
|
export default function StatusPill(props: StatusPillProps): JSX.Element {
|
|
77
65
|
const tc = () => TONE_CLASS[props.tone];
|
|
78
66
|
const bg = () => (props.solid ? tc().bgSolid : tc().bg);
|
|
79
67
|
return (
|
|
80
68
|
<span
|
|
81
69
|
data-testid={props.testId}
|
|
82
|
-
class={`inline-flex items-center
|
|
70
|
+
class={`inline-flex items-center text-xs px-1.5 py-0.5 rounded ${tc().text} ${bg()} ${props.class ?? ""}`}
|
|
83
71
|
>
|
|
84
|
-
<Show when={props.dot}>
|
|
85
|
-
<span class={`h-1.5 w-1.5 rounded-full ${tc().dot}`} aria-hidden="true" />
|
|
86
|
-
</Show>
|
|
87
72
|
{props.label}
|
|
88
73
|
</span>
|
|
89
74
|
);
|
|
@@ -31,7 +31,7 @@ export function renderCell(
|
|
|
31
31
|
case "status": {
|
|
32
32
|
const active = Boolean(raw);
|
|
33
33
|
const arm = active ? r.active : r.inactive;
|
|
34
|
-
return <StatusPill label={arm.label} tone={arm.tone}
|
|
34
|
+
return <StatusPill label={arm.label} tone={arm.tone} solid />;
|
|
35
35
|
}
|
|
36
36
|
case "text":
|
|
37
37
|
default: {
|
package/src/index.ts
CHANGED
|
@@ -200,8 +200,9 @@ export type { ObjectUrlOptions } from "./utils/object-url-resource";
|
|
|
200
200
|
export {
|
|
201
201
|
createPendingFile,
|
|
202
202
|
revokePendingFile,
|
|
203
|
+
uploadPendingFiles,
|
|
203
204
|
} from "./utils/pending-file";
|
|
204
|
-
export type { PendingFile } from "./utils/pending-file";
|
|
205
|
+
export type { PendingFile, UploadPendingFilesOptions } from "./utils/pending-file";
|
|
205
206
|
|
|
206
207
|
export { useAccountsIndex, resolveAccount, resolveAccountName } from "./utils/accounts-index";
|
|
207
208
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// uploadPendingFiles: per-file best-effort POST to the transactions plugin's
|
|
2
|
+
// standard attachment route, returning the names that failed.
|
|
3
|
+
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
4
|
+
import { uploadPendingFiles, type PendingFile } from "./pending-file";
|
|
5
|
+
|
|
6
|
+
function pf(name: string): PendingFile {
|
|
7
|
+
return { id: name, file: new File(["x"], name), previewUrl: null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("uploadPendingFiles", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("POSTs each file to the transaction's attachments route with credentials included", async () => {
|
|
16
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
17
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
18
|
+
|
|
19
|
+
const failed = await uploadPendingFiles(42, [pf("receipt.jpg")]);
|
|
20
|
+
|
|
21
|
+
expect(failed).toEqual([]);
|
|
22
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
23
|
+
"/api/transactions/42/attachments",
|
|
24
|
+
expect.objectContaining({ method: "POST", credentials: "include" })
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("layers caller-supplied headers onto the request", async () => {
|
|
29
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
30
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
31
|
+
|
|
32
|
+
await uploadPendingFiles(42, [pf("receipt.jpg")], {
|
|
33
|
+
headers: { "X-Workspace-Id": "7" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
37
|
+
"/api/transactions/42/attachments",
|
|
38
|
+
expect.objectContaining({ headers: { "X-Workspace-Id": "7" } })
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("collects failed file names without aborting the remaining uploads", async () => {
|
|
43
|
+
const fetchMock = vi
|
|
44
|
+
.fn()
|
|
45
|
+
.mockResolvedValueOnce({ ok: false })
|
|
46
|
+
.mockRejectedValueOnce(new Error("network error"))
|
|
47
|
+
.mockResolvedValueOnce({ ok: true });
|
|
48
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
49
|
+
|
|
50
|
+
const failed = await uploadPendingFiles(42, [
|
|
51
|
+
pf("a.jpg"),
|
|
52
|
+
pf("b.jpg"),
|
|
53
|
+
pf("c.jpg"),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
expect(failed).toEqual(["a.jpg", "b.jpg"]);
|
|
57
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pre-upload pending file state — a file the user has picked or pasted but not
|
|
2
|
-
// yet uploaded. The
|
|
3
|
-
// timesheets/payroll)
|
|
4
|
-
// one canonical type + helpers and avoid drift.
|
|
2
|
+
// yet uploaded. The plugins that support attachment upload (transactions,
|
|
3
|
+
// counter, timesheets/payroll) each defined this locally; extracted here so
|
|
4
|
+
// they share one canonical type + helpers and avoid drift.
|
|
5
5
|
|
|
6
6
|
/** A file the user picked or pasted but hasn't been uploaded yet. */
|
|
7
7
|
export interface PendingFile {
|
|
@@ -29,3 +29,40 @@ export function createPendingFile(file: File): PendingFile {
|
|
|
29
29
|
export function revokePendingFile(pf: PendingFile): void {
|
|
30
30
|
if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl);
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Extra fetch() init the caller needs layered onto every upload request —
|
|
34
|
+
* e.g. timesheets/payroll's X-Workspace-Id header for the fresh-login case
|
|
35
|
+
* where the host's fetch monkey-patch has no localStorage fallback yet. */
|
|
36
|
+
export interface UploadPendingFilesOptions {
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Upload each pending file to the transactions plugin's standard multipart
|
|
41
|
+
* POST /:id/attachments S3 route — the one place attachment bytes are ever
|
|
42
|
+
* written, whichever plugin's UI collected them. Best-effort per file: a
|
|
43
|
+
* failed upload doesn't stop the rest. Returns the file names that failed,
|
|
44
|
+
* in the file's own `name` (not a caller-relabeled `file_name`), for the
|
|
45
|
+
* caller to surface as a soft error. */
|
|
46
|
+
export async function uploadPendingFiles(
|
|
47
|
+
transactionId: number,
|
|
48
|
+
files: PendingFile[],
|
|
49
|
+
opts: UploadPendingFilesOptions = {}
|
|
50
|
+
): Promise<string[]> {
|
|
51
|
+
const failed: string[] = [];
|
|
52
|
+
for (const pf of files) {
|
|
53
|
+
try {
|
|
54
|
+
const fd = new FormData();
|
|
55
|
+
fd.append("file", pf.file, pf.file.name);
|
|
56
|
+
const res = await fetch(`/api/transactions/${transactionId}/attachments`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
credentials: "include",
|
|
59
|
+
headers: opts.headers,
|
|
60
|
+
body: fd,
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) failed.push(pf.file.name);
|
|
63
|
+
} catch {
|
|
64
|
+
failed.push(pf.file.name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return failed;
|
|
68
|
+
}
|