ListFieldChips.tsx3.9 KBView on GitHub /**
* Chips for `list` custom fields that carry enum options — i.e. multi-selects synced from
* an external CRM.
*
* Such a value is stored as one semicolon-delimited string (`"Civil;Transportation"`)
* because that is what the CRM sync writes, so every surface showing one has to split it
* back apart and map each token onto the field's options for a label and colour. That
* decode lives here once, shared by the conversation overview card, the conversation
* badges row and the CRM table cell — the three places that rendered the raw joined string.
*
* Only pass fields that actually have options. `list` is also the type Cedar gives its
* agent-written taxonomy fields (`_ii_pricing`, `_ii_objections`, …), whose value is a
* markdown blob with no options at all — splitting one of those on `;` would shred a
* sentence into nonsense chips.
*/
import type { CrmFieldEnumOption } from '@/modules/crm/types';
import { getTextColorClass } from '@/modules/crm/utils';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
/** Split a stored list value into its individual option values. */
export function parseListValue(value: string | null | undefined): string[] {
if (!value) return [];
return value
.split(';')
.map((token) => token.trim())
.filter(Boolean);
}
/**
* Stored tokens don't reliably match option values character-for-character — the same
* option arrives as `Architectural` from one sync path and `architectural` or
* `3_-_vet_and_standardize_"typical"_details` from another. Compare on a loosened form so
* those still resolve to a real label instead of showing the raw token.
*/
const normalize = (value: string) => value.toLowerCase().replace(/_/g, ' ').trim();
function buildOptionLookup(options?: CrmFieldEnumOption[]): Map<string, CrmFieldEnumOption> {
const lookup = new Map<string, CrmFieldEnumOption>();
for (const option of options ?? []) {
if (option.value == null) continue;
lookup.set(option.value, option);
const loose = normalize(option.value);
if (!lookup.has(loose)) lookup.set(loose, option);
}
return lookup;
}
interface ListFieldChipsProps {
/** Raw stored value — semicolon-delimited option values. */
value: string | null | undefined;
/** The field's options, used to resolve each value to its label and colour. */
options?: CrmFieldEnumOption[];
/** Cap on chips rendered; the remainder collapses into a `+N` chip. Unset renders all. */
maxVisible?: number;
/** Shown when the field holds no values. */
emptyLabel?: string;
className?: string;
}
export function ListFieldChips({
value,
options,
maxVisible,
emptyLabel = '—',
className,
}: ListFieldChipsProps) {
const values = parseListValue(value);
if (values.length === 0) {
return (
<Badge variant="secondary" className="max-w-full rounded-full bg-sunken font-normal">
<span className="truncate">{emptyLabel}</span>
</Badge>
);
}
const lookup = buildOptionLookup(options);
const visible = maxVisible === undefined ? values : values.slice(0, maxVisible);
const hiddenCount = values.length - visible.length;
return (
<div className={cn('flex min-w-0 flex-wrap items-center gap-1', className)}>
{visible.map((token, index) => {
const option = lookup.get(token) ?? lookup.get(normalize(token));
return (
<Badge
key=[redacted]
variant="secondary"
className={cn(
'max-w-full shrink-0 rounded-full bg-sunken font-normal',
getTextColorClass(option?.color || 'purple'),
)}
>
<span className="truncate">{option?.label || option?.value || token}</span>
</Badge>
);
})}
{hiddenCount > 0 && (
<Badge
variant="secondary"
className="shrink-0 rounded-full bg-sunken font-normal text-muted-foreground"
>
+{hiddenCount}
</Badge>
)}
</div>
);
}