|
| 1 | +import React from 'react'; |
| 2 | +import { Check, ChevronsUpDown } from 'lucide-react'; |
| 3 | +import { |
| 4 | + Button, |
| 5 | + Command, |
| 6 | + CommandEmpty, |
| 7 | + CommandGroup, |
| 8 | + CommandInput, |
| 9 | + CommandItem, |
| 10 | + CommandList, |
| 11 | + Popover, |
| 12 | + PopoverContent, |
| 13 | + PopoverTrigger, |
| 14 | + EmptyValue, |
| 15 | + cn, |
| 16 | +} from '@object-ui/components'; |
| 17 | +import { isValueDomainMember } from '@objectstack/spec/shared'; |
| 18 | +import { useLocalization, useDisplayLocale } from '@object-ui/i18n'; |
| 19 | +import { FieldWidgetComponentProps } from './types.js'; |
| 20 | +import { toDomProps } from './toDomProps.js'; |
| 21 | +import { useFieldTranslation } from './useFieldTranslation.js'; |
| 22 | + |
| 23 | +/** |
| 24 | + * The `valueDomain` a record-currency code field declares (ADR-0144 D2): a |
| 25 | + * `text` field whose value is an ISO 4217 code, checked on write against the |
| 26 | + * spec's `iso_4217_currency` membership table. |
| 27 | + */ |
| 28 | +export const CURRENCY_CODE_VALUE_DOMAIN = 'iso_4217_currency'; |
| 29 | + |
| 30 | +/** Whether `field` is a text field the `iso_4217_currency` domain governs. */ |
| 31 | +export function isCurrencyCodeField(field: unknown): boolean { |
| 32 | + return (field as { valueDomain?: unknown } | null | undefined)?.valueDomain === CURRENCY_CODE_VALUE_DOMAIN; |
| 33 | +} |
| 34 | + |
| 35 | +let isoCodesCache: readonly string[] | undefined; |
| 36 | + |
| 37 | +/** |
| 38 | + * Every code the `iso_4217_currency` value domain accepts, sorted. The |
| 39 | + * candidates are ICU's (`Intl.supportedValuesOf('currency')`), kept only where |
| 40 | + * the spec's own membership test agrees, so the list offers exactly what the |
| 41 | + * write path accepts and never a code it would refuse. A runtime without |
| 42 | + * `Intl.supportedValuesOf` offers no "all" group; the field keeps its |
| 43 | + * "available" group and its current value. |
| 44 | + */ |
| 45 | +export function isoCurrencyCodes(): readonly string[] { |
| 46 | + if (isoCodesCache) return isoCodesCache; |
| 47 | + const supported = (Intl as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf; |
| 48 | + const candidates = typeof supported === 'function' ? supported('currency') : []; |
| 49 | + isoCodesCache = candidates.filter((c) => isValueDomainMember(CURRENCY_CODE_VALUE_DOMAIN, c)).sort(); |
| 50 | + return isoCodesCache; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * The organization's rated currencies: every code with a row in the |
| 55 | + * organization's exchange-rate table. ⛔ Not served yet — the rate table |
| 56 | + * (`sys_exchange_rate`, ADR-0144 D3) ships in the next multi-currency release, |
| 57 | + * and this is the seam its reader plugs into. Until then the "available" |
| 58 | + * group is the home currency plus the used codes. |
| 59 | + */ |
| 60 | +export function ratedCurrencyCodes(): readonly string[] { |
| 61 | + return []; |
| 62 | +} |
| 63 | + |
| 64 | +export interface CurrencyCodeGroups { |
| 65 | + /** ADR-0144 D8 rule 3 — home + rated + used, in that order, deduplicated. */ |
| 66 | + available: string[]; |
| 67 | + /** Every other ISO 4217 code. */ |
| 68 | + all: string[]; |
| 69 | +} |
| 70 | + |
| 71 | +/** |
| 72 | + * Split the ISO 4217 codes into the "available" group — the derived enabled |
| 73 | + * currencies of ADR-0144 D8 rule 3: the home currency, then every rated |
| 74 | + * currency, then every code already used — and "all" the rest. The current |
| 75 | + * value counts as used: a record that holds a code has used it. A code the |
| 76 | + * domain does not accept is never offered. |
| 77 | + */ |
| 78 | +export function currencyCodeGroups(input: { |
| 79 | + home?: string | null; |
| 80 | + rated?: readonly string[]; |
| 81 | + used?: readonly string[]; |
| 82 | + current?: string | null; |
| 83 | + iso?: readonly string[]; |
| 84 | +}): CurrencyCodeGroups { |
| 85 | + const iso = input.iso ?? isoCurrencyCodes(); |
| 86 | + const available: string[] = []; |
| 87 | + const seen = new Set<string>(); |
| 88 | + for (const code of [input.home, ...(input.rated ?? []), ...(input.used ?? []), input.current]) { |
| 89 | + if (typeof code !== 'string' || seen.has(code) || !isValueDomainMember(CURRENCY_CODE_VALUE_DOMAIN, code)) continue; |
| 90 | + seen.add(code); |
| 91 | + available.push(code); |
| 92 | + } |
| 93 | + return { available, all: iso.filter((c) => !seen.has(c)) }; |
| 94 | +} |
| 95 | + |
| 96 | +/** `USD — US Dollar` in the display locale, or the bare code when ICU has no name. */ |
| 97 | +function currencyLabel(code: string, locale: string): string { |
| 98 | + try { |
| 99 | + const name = new Intl.DisplayNames([locale], { type: 'currency' }).of(code); |
| 100 | + return name && name !== code ? `${code} — ${name}` : code; |
| 101 | + } catch { |
| 102 | + return code; |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +/** |
| 107 | + * CurrencyCodeField — the control a record-currency code field renders |
| 108 | + * (ADR-0144 D2): a searchable select of ISO 4217 codes, grouped "available" |
| 109 | + * (home + rated + used, D8 rule 3) and "all". |
| 110 | + * |
| 111 | + * The value it emits is always the explicit code the user sees selected |
| 112 | + * (D8 rule 4): picking the selected code again keeps it — there is no |
| 113 | + * "click again to clear" here, because a blank code is what the server |
| 114 | + * refuses. A create form opens it on the organization's currency through the |
| 115 | + * form's seeding (`schemaDefaultValues`), never through this widget, so an |
| 116 | + * edit form never rewrites a stored code just by being opened. |
| 117 | + */ |
| 118 | +export function CurrencyCodeField({ value, onChange, field, readonly, error, className, ...props }: FieldWidgetComponentProps<string>) { |
| 119 | + const { t } = useFieldTranslation(); |
| 120 | + const { currency: home } = useLocalization(); |
| 121 | + const locale = useDisplayLocale(); |
| 122 | + const [open, setOpen] = React.useState(false); |
| 123 | + const current = typeof value === 'string' && value !== '' ? value : undefined; |
| 124 | + |
| 125 | + if (readonly) { |
| 126 | + return current ? <span className="text-sm">{currencyLabel(current, locale)}</span> : <EmptyValue />; |
| 127 | + } |
| 128 | + |
| 129 | + const groups = currencyCodeGroups({ home, rated: ratedCurrencyCodes(), current }); |
| 130 | + const domProps = toDomProps(props); |
| 131 | + const item = (code: string) => ( |
| 132 | + <CommandItem |
| 133 | + key={code} |
| 134 | + value={code} |
| 135 | + keywords={[currencyLabel(code, locale)]} |
| 136 | + onSelect={() => { |
| 137 | + onChange(code); |
| 138 | + setOpen(false); |
| 139 | + }} |
| 140 | + > |
| 141 | + <Check className={cn('mr-2 h-4 w-4', current === code ? 'opacity-100' : 'opacity-0')} /> |
| 142 | + {currencyLabel(code, locale)} |
| 143 | + </CommandItem> |
| 144 | + ); |
| 145 | + |
| 146 | + return ( |
| 147 | + <Popover open={open} onOpenChange={setOpen}> |
| 148 | + <PopoverTrigger asChild> |
| 149 | + <Button |
| 150 | + type="button" |
| 151 | + {...domProps} |
| 152 | + variant="outline" |
| 153 | + role="combobox" |
| 154 | + aria-expanded={open} |
| 155 | + aria-invalid={!!error} |
| 156 | + className={cn('w-full min-w-0 justify-between font-normal', className)} |
| 157 | + disabled={readonly || domProps.disabled} |
| 158 | + > |
| 159 | + <span className="truncate"> |
| 160 | + {current ? currencyLabel(current, locale) : (field?.placeholder || t('fields.currencyCode.placeholder'))} |
| 161 | + </span> |
| 162 | + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
| 163 | + </Button> |
| 164 | + </PopoverTrigger> |
| 165 | + <PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-[240px] p-0"> |
| 166 | + <Command> |
| 167 | + <CommandInput placeholder={t('fields.currencyCode.search')} /> |
| 168 | + <CommandList> |
| 169 | + <CommandEmpty>{t('fields.currencyCode.noResults')}</CommandEmpty> |
| 170 | + {groups.available.length > 0 && ( |
| 171 | + <CommandGroup heading={t('fields.currencyCode.available')}>{groups.available.map(item)}</CommandGroup> |
| 172 | + )} |
| 173 | + {groups.all.length > 0 && ( |
| 174 | + <CommandGroup heading={t('fields.currencyCode.all')}>{groups.all.map(item)}</CommandGroup> |
| 175 | + )} |
| 176 | + </CommandList> |
| 177 | + </Command> |
| 178 | + </PopoverContent> |
| 179 | + </Popover> |
| 180 | + ); |
| 181 | +} |
0 commit comments