@kahitsan/ksui 0.38.0 → 0.38.2
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.2",
|
|
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",
|
|
@@ -14,7 +14,7 @@ function mockPagedFetch(rows: VoucherOption[]) {
|
|
|
14
14
|
const limit = Number(parsed.searchParams.get("limit") ?? "25");
|
|
15
15
|
const search = (parsed.searchParams.get("search") ?? "").toLowerCase();
|
|
16
16
|
const matched = search
|
|
17
|
-
? rows.filter((r) => r.code.toLowerCase().includes(search))
|
|
17
|
+
? rows.filter((r) => `${r.code} ${r.notes ?? ""}`.toLowerCase().includes(search))
|
|
18
18
|
: rows;
|
|
19
19
|
const start = (page - 1) * limit;
|
|
20
20
|
return {
|
|
@@ -112,10 +112,11 @@ describe("VoucherPicker dialog", () => {
|
|
|
112
112
|
await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
|
|
113
113
|
expect(getByTestId("voucher-picker-popup").closest("dialog")).not.toBeNull();
|
|
114
114
|
|
|
115
|
-
// Staging a row must not reach the consumer yet
|
|
115
|
+
// Staging a row must not reach the consumer yet; the footer no longer
|
|
116
|
+
// repeats the code (it's in the listing), so the confirm enabling is the signal.
|
|
116
117
|
fireEvent.click(getByTestId("voucher-picker-result-1"));
|
|
117
118
|
expect(onChange).not.toHaveBeenCalled();
|
|
118
|
-
expect(getByTestId("voucher-picker-
|
|
119
|
+
expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(false);
|
|
119
120
|
|
|
120
121
|
fireEvent.click(getByTestId("voucher-picker-confirm"));
|
|
121
122
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ id: 1, code: "SAVE20" }));
|
|
@@ -147,9 +148,10 @@ describe("VoucherPicker dialog", () => {
|
|
|
147
148
|
await waitFor(() => expect(getByTestId("voucher-picker-result-1")).toBeTruthy());
|
|
148
149
|
|
|
149
150
|
fireEvent.click(getByTestId("voucher-picker-result-1"));
|
|
150
|
-
expect(getByTestId("voucher-picker-
|
|
151
|
+
expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(false);
|
|
151
152
|
|
|
152
153
|
fireEvent.click(getByTestId("voucher-picker-result-1"));
|
|
154
|
+
expect(getByTestId("voucher-picker-confirm").hasAttribute("disabled")).toBe(true);
|
|
153
155
|
expect(getByTestId("voucher-picker-draft-summary").textContent).toContain("No voucher selected");
|
|
154
156
|
});
|
|
155
157
|
|
|
@@ -175,6 +177,68 @@ describe("VoucherPicker dialog", () => {
|
|
|
175
177
|
expect(mark!.textContent?.toLowerCase()).toBe("partner");
|
|
176
178
|
});
|
|
177
179
|
|
|
180
|
+
it("shows the voucher description in the row and the footer draft", async () => {
|
|
181
|
+
mockPagedFetch([
|
|
182
|
+
voucher({ id: 50, code: "PARTNER_ACES", notes: "BISCAST ACES outreach discount" }),
|
|
183
|
+
]);
|
|
184
|
+
const { getByTestId } = render(() => (
|
|
185
|
+
<VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
|
|
186
|
+
));
|
|
187
|
+
fireEvent.click(getByTestId("voucher-picker-trigger"));
|
|
188
|
+
|
|
189
|
+
await waitFor(() => expect(getByTestId("voucher-picker-result-50")).toBeTruthy());
|
|
190
|
+
expect(getByTestId("voucher-picker-result-50").textContent).toContain(
|
|
191
|
+
"BISCAST ACES outreach discount",
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
fireEvent.click(getByTestId("voucher-picker-result-50"));
|
|
195
|
+
// The footer shows the description under the summary, beside the buttons.
|
|
196
|
+
expect(getByTestId("voucher-picker-draft-description").textContent).toContain(
|
|
197
|
+
"BISCAST ACES outreach discount",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("finds a voucher by its description and highlights the match in it", async () => {
|
|
202
|
+
const { calls } = mockPagedFetch([
|
|
203
|
+
voucher({ id: 51, code: "PARTNER_UAPGA", notes: "UAPGA Camarines Chapter discount" }),
|
|
204
|
+
voucher({ id: 52, code: "GENERIC" }),
|
|
205
|
+
]);
|
|
206
|
+
const { getByTestId, queryByTestId } = render(() => (
|
|
207
|
+
<VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
|
|
208
|
+
));
|
|
209
|
+
fireEvent.click(getByTestId("voucher-picker-trigger"));
|
|
210
|
+
await waitFor(() => expect(getByTestId("voucher-picker-result-51")).toBeTruthy());
|
|
211
|
+
|
|
212
|
+
fireEvent.input(getByTestId("voucher-picker-search"), {
|
|
213
|
+
target: { value: "camarines" },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await waitFor(() => expect(calls.some((u) => u.includes("search=camarines"))).toBe(true));
|
|
217
|
+
await waitFor(() => expect(queryByTestId("voucher-picker-result-52")).toBeNull());
|
|
218
|
+
|
|
219
|
+
const row = getByTestId("voucher-picker-result-51");
|
|
220
|
+
const mark = row.querySelector("mark");
|
|
221
|
+
expect(mark).not.toBeNull();
|
|
222
|
+
expect(mark!.textContent?.toLowerCase()).toBe("camarines");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("skips the description line for a blank description", async () => {
|
|
226
|
+
mockPagedFetch([
|
|
227
|
+
voucher({ id: 53, code: "PLAIN", notes: " " }),
|
|
228
|
+
voucher({ id: 54, code: "PLAIN" }),
|
|
229
|
+
]);
|
|
230
|
+
const { getByTestId } = render(() => (
|
|
231
|
+
<VoucherPicker selected={null} onChange={vi.fn()} subtotal={1000} packageIds={[]} />
|
|
232
|
+
));
|
|
233
|
+
fireEvent.click(getByTestId("voucher-picker-trigger"));
|
|
234
|
+
|
|
235
|
+
await waitFor(() => expect(getByTestId("voucher-picker-result-53")).toBeTruthy());
|
|
236
|
+
// Identical rows — a whitespace-only description must render nothing extra.
|
|
237
|
+
expect(getByTestId("voucher-picker-result-53").textContent).toBe(
|
|
238
|
+
getByTestId("voucher-picker-result-54").textContent,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
178
242
|
it("shows when a voucher expires", async () => {
|
|
179
243
|
const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
|
|
180
244
|
mockPagedFetch([voucher({ id: 9, code: "ENDINGSOON", valid_until: soon })]);
|
|
@@ -31,6 +31,8 @@ export interface VoucherOption {
|
|
|
31
31
|
usage_count?: number | null;
|
|
32
32
|
/** Total redemptions allowed; null/absent means unlimited. */
|
|
33
33
|
usage_limit_total?: number | null;
|
|
34
|
+
/** Free-text description; absent on endpoints that don't expose it. */
|
|
35
|
+
notes?: string | null;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const DEFAULT_FETCH_URL = "/api/vouchers?status=active&limit=200";
|
|
@@ -183,6 +185,10 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
183
185
|
const [total, setTotal] = createSignal(0);
|
|
184
186
|
// Staged choice. `props.selected` only changes on Confirm.
|
|
185
187
|
const [draft, setDraft] = createSignal<VoucherOption | null>(null);
|
|
188
|
+
// Footer description popup: clamped to two lines, "More" only when it overflows.
|
|
189
|
+
const [descOpen, setDescOpen] = createSignal(false);
|
|
190
|
+
const [descOverflowing, setDescOverflowing] = createSignal(false);
|
|
191
|
+
let descEl: HTMLSpanElement | undefined;
|
|
186
192
|
|
|
187
193
|
// Signal, not a plain ref: the sentinel mounts only once the first page
|
|
188
194
|
// reveals there are more, which is after the observer effect first runs.
|
|
@@ -368,10 +374,29 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
368
374
|
});
|
|
369
375
|
|
|
370
376
|
const metaLine = (v: VoucherOption): string =>
|
|
371
|
-
[
|
|
377
|
+
[formatExpiry(v.valid_until, today()), formatUsage(v)]
|
|
372
378
|
.filter(Boolean)
|
|
373
379
|
.join(" · ");
|
|
374
380
|
|
|
381
|
+
// Staged description, trimmed — the footer read it twice, and a signal call
|
|
382
|
+
// in JSX doesn't narrow across re-evaluations.
|
|
383
|
+
const draftNotes = createMemo(() => {
|
|
384
|
+
const n = draft()?.notes?.trim();
|
|
385
|
+
return n ? n : null;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// The footer clamps the description to two lines; offer "More" only when the
|
|
389
|
+
// text actually overflowed. Re-measured whenever the staged description changes.
|
|
390
|
+
createEffect(() => {
|
|
391
|
+
const notes = draftNotes();
|
|
392
|
+
const el = descEl;
|
|
393
|
+
if (!notes || !el) {
|
|
394
|
+
setDescOverflowing(false);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
setDescOverflowing(el.scrollHeight > el.clientHeight);
|
|
398
|
+
});
|
|
399
|
+
|
|
375
400
|
const expiresSoon = (v: VoucherOption): boolean => {
|
|
376
401
|
if (!v.valid_until) return false;
|
|
377
402
|
const days = daysUntil(toDay(v.valid_until), today());
|
|
@@ -505,7 +530,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
505
530
|
selected(),
|
|
506
531
|
}}
|
|
507
532
|
>
|
|
508
|
-
<
|
|
533
|
+
<span class="flex flex-col items-center gap-1 shrink-0">
|
|
534
|
+
<Ticket size={18} class="text-[var(--ks-success-fg,#34d399)]" aria-hidden="true" />
|
|
535
|
+
<span class="text-[11px] leading-tight text-center text-[var(--ks-success-fg,#34d399)]">
|
|
536
|
+
{formatVoucherDescription(v)}
|
|
537
|
+
</span>
|
|
538
|
+
</span>
|
|
509
539
|
<span class="flex-1 min-w-0">
|
|
510
540
|
<span class="block text-sm font-medium text-[var(--ks-fg,#ffffff)] truncate">
|
|
511
541
|
{highlightMatch(v.code, debouncedQuery())}
|
|
@@ -522,6 +552,11 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
522
552
|
{metaLine(v)}
|
|
523
553
|
</span>
|
|
524
554
|
</span>
|
|
555
|
+
<Show when={v.notes && v.notes.trim()}>
|
|
556
|
+
<span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
|
|
557
|
+
{highlightMatch(v.notes!, debouncedQuery())}
|
|
558
|
+
</span>
|
|
559
|
+
</Show>
|
|
525
560
|
</span>
|
|
526
561
|
<span class="text-sm text-[var(--ks-success-fg,#34d399)] shrink-0 font-mono">
|
|
527
562
|
{discountLabel(v)}
|
|
@@ -548,7 +583,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
548
583
|
class="w-full text-left px-3 py-3 mb-1 rounded-lg border border-transparent flex items-center gap-3 opacity-60 cursor-not-allowed"
|
|
549
584
|
aria-disabled="true"
|
|
550
585
|
>
|
|
551
|
-
<
|
|
586
|
+
<span class="flex flex-col items-center gap-1 shrink-0">
|
|
587
|
+
<Ticket size={18} class="text-[var(--ks-fg-subtle,#71717a)]" aria-hidden="true" />
|
|
588
|
+
<span class="text-[11px] leading-tight text-center text-[var(--ks-fg-subtle,#71717a)]">
|
|
589
|
+
{formatVoucherDescription(entry.voucher)}
|
|
590
|
+
</span>
|
|
591
|
+
</span>
|
|
552
592
|
<span class="flex-1 min-w-0">
|
|
553
593
|
<span class="block text-sm text-[var(--ks-fg,#ffffff)] truncate">
|
|
554
594
|
{highlightMatch(entry.voucher.code, debouncedQuery())}
|
|
@@ -556,6 +596,11 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
556
596
|
<span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
|
|
557
597
|
{metaLine(entry.voucher)}
|
|
558
598
|
</span>
|
|
599
|
+
<Show when={entry.voucher.notes && entry.voucher.notes.trim()}>
|
|
600
|
+
<span class="block text-xs text-[var(--ks-fg-subtle,#71717a)] truncate">
|
|
601
|
+
{highlightMatch(entry.voucher.notes!, debouncedQuery())}
|
|
602
|
+
</span>
|
|
603
|
+
</Show>
|
|
559
604
|
</span>
|
|
560
605
|
<span class="text-xs text-[var(--ks-warning-fg,#fbbf24)] shrink-0 text-right max-w-[45%] truncate">
|
|
561
606
|
{entry.reason}
|
|
@@ -586,16 +631,42 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
586
631
|
{/* Bled to the card edges so the rule spans the full width, matching
|
|
587
632
|
the header. */}
|
|
588
633
|
<div class="-mx-6 -mb-6 mt-4 px-5 sm:px-6 py-3 border-t border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] flex items-center justify-between gap-3 shrink-0">
|
|
589
|
-
<
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
634
|
+
<div class="min-w-0 flex flex-col gap-1">
|
|
635
|
+
<Show
|
|
636
|
+
when={draft()}
|
|
637
|
+
fallback={
|
|
638
|
+
<span
|
|
639
|
+
data-testid="voucher-picker-draft-summary"
|
|
640
|
+
class="text-xs text-[var(--ks-fg-subtle,#71717a)]"
|
|
641
|
+
>
|
|
642
|
+
No voucher selected
|
|
643
|
+
</span>
|
|
644
|
+
}
|
|
645
|
+
>
|
|
646
|
+
<Show when={draftNotes()}>
|
|
647
|
+
<span class="flex flex-col items-start gap-0.5 min-w-0">
|
|
648
|
+
<span
|
|
649
|
+
ref={descEl}
|
|
650
|
+
data-testid="voucher-picker-draft-description"
|
|
651
|
+
class="min-w-0 text-xs text-[var(--ks-fg-muted,#a1a1aa)] whitespace-pre-line line-clamp-2"
|
|
652
|
+
>
|
|
653
|
+
{draftNotes()}
|
|
654
|
+
</span>
|
|
655
|
+
<Show when={descOverflowing()}>
|
|
656
|
+
<button
|
|
657
|
+
type="button"
|
|
658
|
+
data-testid="voucher-picker-expand-description"
|
|
659
|
+
onClick={() => setDescOpen(true)}
|
|
660
|
+
class="text-xs leading-tight text-[var(--ks-primary,#c9a961)] hover:underline transition-colors cursor-pointer"
|
|
661
|
+
aria-label="Show full description"
|
|
662
|
+
>
|
|
663
|
+
See more
|
|
664
|
+
</button>
|
|
665
|
+
</Show>
|
|
666
|
+
</span>
|
|
667
|
+
</Show>
|
|
597
668
|
</Show>
|
|
598
|
-
</
|
|
669
|
+
</div>
|
|
599
670
|
<div class="flex items-center gap-2 shrink-0">
|
|
600
671
|
<Show when={props.selected && draft()}>
|
|
601
672
|
<button
|
|
@@ -633,6 +704,43 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
|
|
|
633
704
|
</div>
|
|
634
705
|
</Modal>
|
|
635
706
|
</Show>
|
|
707
|
+
|
|
708
|
+
{/* Full-description popup: "More" in the footer only appears when the
|
|
709
|
+
clamped text overflowed, but the popup itself is reachable whenever a
|
|
710
|
+
staged description exists. */}
|
|
711
|
+
<Show when={descOpen() && draftNotes()}>
|
|
712
|
+
<Modal onClose={() => setDescOpen(false)} size="md" ariaLabel="Voucher description">
|
|
713
|
+
<div data-testid="voucher-picker-description-popup" class="flex flex-col gap-4">
|
|
714
|
+
<div class="flex items-center justify-between gap-3">
|
|
715
|
+
<h2 class="m-0 text-base font-semibold text-[var(--ks-fg,#ffffff)]">
|
|
716
|
+
Voucher description
|
|
717
|
+
</h2>
|
|
718
|
+
<button
|
|
719
|
+
type="button"
|
|
720
|
+
data-testid="voucher-picker-close-description"
|
|
721
|
+
onClick={() => setDescOpen(false)}
|
|
722
|
+
class="w-8 h-8 flex items-center justify-center rounded text-[var(--ks-fg-muted,#a1a1aa)] hover:text-[var(--ks-fg,#ffffff)] hover:bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_50%,transparent)] transition-colors cursor-pointer shrink-0"
|
|
723
|
+
aria-label="Close"
|
|
724
|
+
>
|
|
725
|
+
<X size={16} />
|
|
726
|
+
</button>
|
|
727
|
+
</div>
|
|
728
|
+
<p class="m-0 text-sm text-[var(--ks-fg,#ffffff)] whitespace-pre-line">
|
|
729
|
+
{draftNotes()}
|
|
730
|
+
</p>
|
|
731
|
+
<div class="flex justify-end">
|
|
732
|
+
<button
|
|
733
|
+
type="button"
|
|
734
|
+
data-testid="voucher-picker-description-close"
|
|
735
|
+
onClick={() => setDescOpen(false)}
|
|
736
|
+
class="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--ks-primary,#c9a961)] text-[var(--ks-fg-on-accent,#0a0a0a)] hover:opacity-90 transition-opacity cursor-pointer"
|
|
737
|
+
>
|
|
738
|
+
Close
|
|
739
|
+
</button>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
</Modal>
|
|
743
|
+
</Show>
|
|
636
744
|
</>
|
|
637
745
|
);
|
|
638
746
|
}
|