Skip to content

Commit ae2bebc

Browse files
committed
feat(badge): support array values in DescriptionCard and unify badge interface
- DescriptionCard badge field now accepts array values, rendering multiple badges - Extract shared BadgeVariant type and BadgeOptions interface into badge-utils.ts - DataTable's BadgeCellOptions now extends shared BadgeOptions - Add badgeLabelMap and defaultBadgeVariant to DescriptionCard FieldMeta - Deprecate BadgeVariantType in favor of shared BadgeVariant - Add resolveBadgeVariant/resolveBadgeLabel shared utilities - Add example of array badge usage in vite-app
1 parent 1c01670 commit ae2bebc

10 files changed

Lines changed: 183 additions & 52 deletions

File tree

examples/vite-app/src/pages/dashboard/orders/[id]/page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const OrderDetailPage = () => {
6868
const orderData = {
6969
orderId: id,
7070
status: "processing",
71+
tags: ["wholesale", "priority", "fragile"],
7172
customer: "Acme Corporation",
7273
totalAmount: 3450.0,
7374
currency: "USD",
@@ -109,6 +110,18 @@ const OrderDetailPage = () => {
109110
},
110111
},
111112
},
113+
{
114+
key: "tags",
115+
label: "Tags",
116+
type: "badge",
117+
meta: {
118+
badgeVariantMap: {
119+
wholesale: "outline-info",
120+
priority: "error",
121+
fragile: "warning",
122+
},
123+
},
124+
},
112125
{ key: "customer", label: "Customer" },
113126
{
114127
key: "totalAmount",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { BadgeProps } from "./badge";
2+
3+
/**
4+
* Variant union accepted by the app-shell `<Badge>` component.
5+
* Shared across DataTable, DescriptionCard, and any other badge consumers.
6+
*/
7+
export type BadgeVariant = NonNullable<BadgeProps["variant"]>;
8+
9+
/**
10+
* Common options for badge rendering.
11+
*/
12+
export interface BadgeOptions {
13+
/**
14+
* Maps each value (stringified) to a Badge variant. Values not in the
15+
* map fall back to `defaultBadgeVariant`.
16+
*/
17+
badgeVariantMap?: Record<string, BadgeVariant>;
18+
/**
19+
* Maps each value (stringified) to a display label. Values not in the
20+
* map render the raw value (or sentence-cased value if enabled).
21+
*/
22+
badgeLabelMap?: Record<string, string>;
23+
/** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */
24+
defaultBadgeVariant?: BadgeVariant;
25+
}
26+
27+
/**
28+
* Resolve badge variant for a given value string.
29+
* Supports case-insensitive lookup as a fallback.
30+
*/
31+
export function resolveBadgeVariant(
32+
value: string,
33+
options: BadgeOptions | undefined,
34+
): BadgeVariant {
35+
const map = options?.badgeVariantMap;
36+
if (map) {
37+
const direct = map[value];
38+
if (direct) return direct;
39+
const lower = map[value.toLowerCase()];
40+
if (lower) return lower;
41+
}
42+
return options?.defaultBadgeVariant ?? "neutral";
43+
}
44+
45+
/**
46+
* Resolve badge display label for a given value string.
47+
*/
48+
export function resolveBadgeLabel(
49+
value: string,
50+
options: BadgeOptions | undefined,
51+
): string | undefined {
52+
return options?.badgeLabelMap?.[value];
53+
}

packages/core/src/components/data-table/cell-renderers.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { ReactNode } from "react";
22
import { Link } from "react-router";
33
import { Badge } from "@/components/badge";
4+
import { resolveBadgeVariant, resolveBadgeLabel } from "@/components/badge-utils";
45
import type {
56
BadgeCellOptions,
6-
BadgeVariant,
77
Column,
88
DateCellOptions,
99
LinkCellOptions,
@@ -79,7 +79,10 @@ function renderMoney<TRow extends Record<string, unknown>>(
7979
// `maxDecimals` raises the cap above the currency default while keeping the
8080
// minimum at the currency default (e.g. 2 for USD). Lets a JPY column stay
8181
// at 0 decimals while a USD price-detail column shows up to 4.
82-
const formatOptions: Intl.NumberFormatOptions = { style: "currency", currency };
82+
const formatOptions: Intl.NumberFormatOptions = {
83+
style: "currency",
84+
currency,
85+
};
8386
if (options?.maxDecimals != null) {
8487
formatOptions.maximumFractionDigits = options.maxDecimals;
8588
}
@@ -119,9 +122,8 @@ function renderDate(value: unknown, options: DateCellOptions | undefined): React
119122
function renderBadge(value: unknown, options: BadgeCellOptions | undefined): ReactNode {
120123
if (isEmpty(value)) return PLACEHOLDER;
121124
const key = String(value);
122-
const variant: BadgeVariant =
123-
options?.badgeVariantMap?.[key] ?? options?.defaultBadgeVariant ?? "neutral";
124-
const label = options?.badgeLabelMap?.[key] ?? key;
125+
const variant = resolveBadgeVariant(key, options);
126+
const label = resolveBadgeLabel(key, options) ?? key;
125127
return <Badge variant={variant}>{label}</Badge>;
126128
}
127129

packages/core/src/components/data-table/types.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ReactNode } from "react";
2-
import type { BadgeProps } from "@/components/badge";
2+
import type { BadgeOptions } from "@/components/badge-utils";
33
import type {
44
CollectionControl,
55
Filter,
@@ -28,8 +28,7 @@ import type {
2828
*/
2929
export type ColumnCellType = "text" | "number" | "money" | "date" | "badge" | "link";
3030

31-
/** Variant union accepted by the app-shell `<Badge>` component. */
32-
export type BadgeVariant = NonNullable<BadgeProps["variant"]>;
31+
export type { BadgeVariant } from "@/components/badge-utils";
3332

3433
/** Options for `type: "number"` cells. */
3534
export interface NumberCellOptions {
@@ -70,20 +69,7 @@ export interface DateCellOptions {
7069
}
7170

7271
/** Options for `type: "badge"` cells. */
73-
export interface BadgeCellOptions {
74-
/**
75-
* Maps each cell value (stringified) to a Badge variant. Values not in the
76-
* map fall back to `defaultBadgeVariant`.
77-
*/
78-
badgeVariantMap?: Record<string, BadgeVariant>;
79-
/**
80-
* Maps each cell value (stringified) to a display label. Values not in the
81-
* map render the raw cell value.
82-
*/
83-
badgeLabelMap?: Record<string, string>;
84-
/** Variant used when the value is not in `badgeVariantMap`. Default: `"neutral"`. */
85-
defaultBadgeVariant?: BadgeVariant;
86-
}
72+
export interface BadgeCellOptions extends BadgeOptions {}
8773

8874
/** Options for `type: "link"` cells. `href` is required. */
8975
export interface LinkCellOptions<TRow extends Record<string, unknown>> {

packages/core/src/components/description-card/DescriptionCard.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,32 @@ describe("DescriptionCard", () => {
4242

4343
expect(screen.getByText("NOT_RECEIVED")).toBeDefined();
4444
});
45+
46+
it("renders multiple badges from array value", () => {
47+
render(
48+
<DescriptionCard
49+
title="Order"
50+
data={{ tags: ["urgent", "fragile", "international"] }}
51+
fields={[
52+
{
53+
key: "tags",
54+
label: "Tags",
55+
type: "badge",
56+
meta: {
57+
badgeVariantMap: {
58+
urgent: "error",
59+
fragile: "warning",
60+
international: "outline-info",
61+
},
62+
},
63+
},
64+
]}
65+
/>,
66+
{ wrapper },
67+
);
68+
69+
expect(screen.getByText("Urgent")).toBeDefined();
70+
expect(screen.getByText("Fragile")).toBeDefined();
71+
expect(screen.getByText("International")).toBeDefined();
72+
});
4573
});

packages/core/src/components/description-card/field-renderers.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,42 @@ describe("renderField", () => {
5353
const { container } = render(renderField(makeField({ type: "badge", value: null })));
5454
expect(container.textContent).toBe("–");
5555
});
56+
57+
it("renders multiple badges from array value", () => {
58+
const { container } = render(
59+
renderField(makeField({ type: "badge", value: ["urgent", "fragile"] })),
60+
);
61+
expect(container.textContent).toContain("Urgent");
62+
expect(container.textContent).toContain("Fragile");
63+
});
64+
65+
it("renders multiple badges with variant map", () => {
66+
const { container } = render(
67+
renderField(
68+
makeField({
69+
type: "badge",
70+
value: ["urgent", "low"],
71+
meta: { badgeVariantMap: { urgent: "error", low: "neutral" } },
72+
}),
73+
),
74+
);
75+
expect(container.textContent).toContain("Urgent");
76+
expect(container.textContent).toContain("Low");
77+
});
78+
79+
it("renders dash for empty array", () => {
80+
const { container } = render(renderField(makeField({ type: "badge", value: [] })));
81+
expect(container.textContent).toBe("–");
82+
});
83+
84+
it("skips null values in array", () => {
85+
const { container } = render(
86+
renderField(makeField({ type: "badge", value: ["active", null, "featured"] })),
87+
);
88+
expect(container.textContent).toContain("Active");
89+
expect(container.textContent).toContain("Featured");
90+
expect(container.textContent).not.toContain("–");
91+
});
5692
});
5793

5894
describe("date", () => {

packages/core/src/components/description-card/field-renderers.tsx

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { Link } from "react-router";
55
import { Badge } from "../badge";
66
import { Tooltip } from "../tooltip";
77
import { Copy, Check, ExternalLink } from "lucide-react";
8-
import type { ResolvedField, DateFormat, BadgeVariantType } from "./types";
8+
import type { ResolvedField, DateFormat } from "./types";
9+
import type { BadgeVariant } from "../badge-utils";
10+
import { resolveBadgeVariant, resolveBadgeLabel } from "../badge-utils";
911
import { useDescriptionCardT } from "./i18n";
1012

1113
// ============================================================================
@@ -335,25 +337,38 @@ function TextFieldRenderer({ field }: { field: ResolvedField }) {
335337
* Render a badge field
336338
*/
337339
function BadgeFieldRenderer({ field }: { field: ResolvedField }) {
338-
if (isEmpty(field.value)) {
340+
const values = Array.isArray(field.value)
341+
? (field.value as unknown[])
342+
: field.value != null
343+
? [field.value]
344+
: [];
345+
346+
if (values.every((v) => isEmpty(v))) {
339347
return <span className="astw:text-sm astw:font-medium astw:text-foreground">{EMPTY_DASH}</span>;
340348
}
341349

342-
const value = String(field.value);
343-
const variantMap = field.meta?.badgeVariantMap || {};
350+
const badgeOptions = {
351+
badgeVariantMap: field.meta?.badgeVariantMap,
352+
badgeLabelMap: field.meta?.badgeLabelMap,
353+
defaultBadgeVariant: field.meta?.defaultBadgeVariant ?? ("outline-neutral" as BadgeVariant),
354+
};
344355
const sentenceCaseBadges = field.meta?.sentenceCaseBadges ?? true;
345356

346-
// Try to find a matching variant (case-insensitive)
347-
const lowerValue = value.toLowerCase();
348-
const variant: BadgeVariantType =
349-
variantMap[value] || variantMap[lowerValue] || "outline-neutral";
350-
const displayValue = sentenceCaseBadges ? toSentenceCase(value) : value;
357+
const badges = values
358+
.filter((v) => !isEmpty(v))
359+
.map((raw, i) => {
360+
const value = String(raw);
361+
const variant = resolveBadgeVariant(value, badgeOptions);
362+
const label = resolveBadgeLabel(value, badgeOptions);
363+
const displayValue = label ?? (sentenceCaseBadges ? toSentenceCase(value) : value);
364+
return (
365+
<Badge key={i} variant={variant} className="astw:w-fit">
366+
{displayValue}
367+
</Badge>
368+
);
369+
});
351370

352-
return (
353-
<Badge variant={variant} className="astw:w-fit">
354-
{displayValue}
355-
</Badge>
356-
);
371+
return <div className="astw:flex astw:flex-wrap astw:gap-1">{badges}</div>;
357372
}
358373

359374
/**

packages/core/src/components/description-card/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ export type {
1717
DateFormat,
1818
BadgeVariantType,
1919
} from "./types";
20+
21+
export type { BadgeVariant } from "../badge-utils";

packages/core/src/components/description-card/types.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CSSProperties, ReactNode } from "react";
2+
import type { BadgeVariant } from "../badge-utils";
23

34
// ============================================================================
45
// FIELD TYPES
@@ -10,22 +11,9 @@ import type { CSSProperties, ReactNode } from "react";
1011
export type FieldType = "text" | "badge" | "money" | "date" | "link" | "address" | "reference";
1112

1213
/**
13-
* Badge variant mapping for automatic status coloring
14+
* @deprecated Use `BadgeVariant` from `@tailor-platform/app-shell` instead.
1415
*/
15-
export type BadgeVariantType =
16-
| "default"
17-
| "success"
18-
| "warning"
19-
| "error"
20-
| "neutral"
21-
| "subtle-success"
22-
| "subtle-warning"
23-
| "subtle-error"
24-
| "outline-success"
25-
| "outline-warning"
26-
| "outline-error"
27-
| "outline-info"
28-
| "outline-neutral";
16+
export type BadgeVariantType = BadgeVariant;
2917

3018
/**
3119
* Behavior when a field value is empty/null/undefined
@@ -48,7 +36,14 @@ export interface FieldMeta {
4836
/** Show copy button for this field */
4937
copyable?: boolean;
5038
/** Map field values to badge variants */
51-
badgeVariantMap?: Record<string, BadgeVariantType>;
39+
badgeVariantMap?: Record<string, BadgeVariant>;
40+
/**
41+
* Maps each value (stringified) to a display label. Values not in the
42+
* map render the raw value (or sentence-cased value if enabled).
43+
*/
44+
badgeLabelMap?: Record<string, string>;
45+
/** Variant used when the value is not in `badgeVariantMap`. Default: `"outline-neutral"`. */
46+
defaultBadgeVariant?: BadgeVariant;
5247
/** Render badge labels in sentence case by default; set false to keep the original value */
5348
sentenceCaseBadges?: boolean;
5449
/** Key path to currency code in data object (for money fields) */

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export { useOverrideBreadcrumb } from "./hooks/use-override-breadcrumb";
8484

8585
// Components
8686
export { Badge, badgeVariants, type BadgeProps } from "./components/badge";
87+
export type { BadgeVariant, BadgeOptions } from "./components/badge-utils";
8788
export { DescriptionCard, type DescriptionCardProps } from "./components/description-card";
8889
export {
8990
ActivityCard,

0 commit comments

Comments
 (0)