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>
  );
}