feat: implement pre-selection for nationality and nation fields by repurposing the placeholder field to store default values

This commit is contained in:
estifanos
2026-09-08 07:01:43 +00:00
parent 0f81f1f199
commit f89ab0fb39
7 changed files with 87 additions and 39 deletions

View File

@@ -13,7 +13,13 @@ import {
} from '@mantine/core';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BilingualInput, ModalFooter } from '@ema-platform/ui';
import {
BilingualInput,
CountrySelect,
countryCodeToValue,
countryValueToCode,
ModalFooter,
} from '@ema-platform/ui';
import type { FormFieldConfig, FormSchemaPalette } from '@ema-platform/api';
import { ConditionBuilder, type ConditionValue } from './ConditionBuilder';
import type { ConditionTarget } from '../config/schema-paths';
@@ -74,6 +80,8 @@ export function FieldEditorDrawer({
const typeInfo = fieldTypes.find((f) => f.type === draft.type);
const isNew = !field;
const isCountryType = draft.type === 'NATIONALITY' || draft.type === 'NATION';
const isDemonym = draft.type === 'NATIONALITY';
function save() {
if (!draft.key.trim() || !KEY_PATTERN.test(draft.key.trim())) {
@@ -170,11 +178,28 @@ export function FieldEditorDrawer({
}}
/>
<BilingualInput
label={t('certReq.field.placeholder', 'Placeholder')}
value={{ en: draft.placeholder?.en ?? '', am: draft.placeholder?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, placeholder: v }))}
/>
{isCountryType ? (
// The picker has its own placeholder, so this slot chooses what is
// pre-selected instead. It travels in `placeholder.en` — the one
// optional text slot the schema already has — as the English name,
// the same form the applicant's answer takes (countryCodeToValue).
<CountrySelect
label={t('certReq.field.defaultValue', 'Default value')}
description={t('certReq.field.defaultValueHelp', 'Pre-selected for the applicant, who can still change it')}
demonym={isDemonym}
value={countryValueToCode(draft.placeholder?.en, isDemonym)}
onChange={(code) => {
const name = countryCodeToValue(code, isDemonym);
setDraft((d) => ({ ...d, placeholder: name ? { en: name, am: '' } : undefined }));
}}
/>
) : (
<BilingualInput
label={t('certReq.field.placeholder', 'Placeholder')}
value={{ en: draft.placeholder?.en ?? '', am: draft.placeholder?.am ?? '' }}
onChange={(v) => setDraft((d) => ({ ...d, placeholder: v }))}
/>
)}
<BilingualInput
label={t('certReq.field.helpText', 'Help text')}

View File

@@ -1956,6 +1956,8 @@ export const am: Translations = {
typeNation: "አመልካቹ ከሙሉ የአገር ዝርዝር ይመርጣል (ኢትዮጵያ፣ ኬንያ፣ …)",
required: "የግድ ያስፈልጋል",
placeholder: "ምሳሌ ጽሑፍ",
defaultValue: "ነባሪ ዋጋ",
defaultValueHelp: "ለአመልካቹ አስቀድሞ ይመረጣል፤ አሁንም መቀየር ይችላል",
helpText: "የእገዛ ጽሑፍ",
min: "ዝቅተኛ",
max: "ከፍተኛ",

View File

@@ -1971,6 +1971,8 @@ export const en = {
typeNation: 'Applicant picks from the full country list (Ethiopia, Kenya, …)',
required: 'Required',
placeholder: 'Placeholder',
defaultValue: 'Default value',
defaultValueHelp: 'Pre-selected for the applicant, who can still change it',
helpText: 'Help text',
min: 'Minimum',
max: 'Maximum',

View File

@@ -22,10 +22,8 @@ import {
import {
AmharicDatePicker,
CountrySelect,
getCountryCode,
getCountryName,
getNationalityCode,
getNationalityName,
countryCodeToValue,
countryValueToCode,
PhoneInput,
} from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
@@ -95,30 +93,6 @@ export function fillFromVessel(
}
}
/**
* NATIONALITY/NATION fields are stored the way the profile's Address tab and
* seafarer registration store them — the English demonym ("Ethiopian") or
* country name ("Ethiopia"), so a certificate prints the word rather than
* "ET" — while CountrySelect works in alpha-2 codes. These two convert
* between the two forms, and tolerate a value already saved as a bare code.
*/
function storedToCountryCode(stored: unknown, demonym: boolean): string | null {
const raw = typeof stored === 'string' ? stored.trim() : '';
if (!raw) return null;
// Both lookups run either way: a field switched between the two types in the
// builder still resolves an answer stored in the other form.
const code = demonym
? getNationalityCode(raw) ?? getCountryCode(raw)
: getCountryCode(raw) ?? getNationalityCode(raw);
return code ?? raw;
}
/** English-pinned deliberately: the stored answer must not change with the UI language. */
function countryCodeToStored(code: string | null, demonym: boolean): string | null {
if (!code) return null;
return (demonym ? getNationalityName(code) : getCountryName(code)) || code;
}
/**
* Options for a SELECT field.
*
@@ -255,10 +229,10 @@ export function ConfigDrivenSection({
<CountrySelect
{...common}
demonym={field.type === 'NATIONALITY'}
placeholder={localized(field.placeholder) || undefined}
value={storedToCountryCode(value, field.type === 'NATIONALITY')}
// `placeholder` holds the default the page seeds, not hint text.
value={countryValueToCode(value, field.type === 'NATIONALITY')}
onChange={(v) =>
onChange(field.key, countryCodeToStored(v, field.type === 'NATIONALITY'))
onChange(field.key, countryCodeToValue(v, field.type === 'NATIONALITY'))
}
/>
) : isLegacyNationality ? (

View File

@@ -319,11 +319,28 @@ export function LicenseApplicationPage() {
const next = { ...prev };
for (const section of config.licenseType.formSchema.sections) {
for (const field of section.fields) {
const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
if (!source) continue;
const current = next[section.key]?.[field.key];
const untouched =
current === undefined || current === null || current === "";
const source = field.source ?? LEGACY_PROFILE_SOURCES[field.key];
if (!source) {
// A NATIONALITY/NATION field's `placeholder.en` is the default the
// builder chose ("Ethiopian"). It only ever fills a blank — the
// profile prefills above ran first, and an answer the applicant
// cleared on purpose is not re-filled until the draft is next
// seeded.
const isCountryPicker =
field.type === "NATIONALITY" || field.type === "NATION";
const preset = field.placeholder?.en?.trim();
if (untouched && isCountryPicker && preset) {
next[section.key] = {
...next[section.key],
[field.key]: preset,
};
changed = true;
}
continue;
}
if (!field.readOnly && !untouched) continue;
const raw = readSourcePath(context, source);
// Profile dates arrive as ISO datetimes; a DATE field's picker wants

View File

@@ -104,6 +104,12 @@ export interface FormFieldConfig {
label: Bilingual;
type: FormFieldType;
required?: boolean;
/**
* Hint text — except on NATIONALITY/NATION fields, where the builder puts
* the pre-selected answer here (`en`, as the English demonym or country
* name) and the wizard seeds it into the draft. The server stores it
* unchanged either way, so this stays frontend-only.
*/
placeholder?: Bilingual;
helpText?: Bilingual;
options?: { value: string; label: Bilingual }[];

View File

@@ -56,6 +56,28 @@ export function getNationalityCode(name: string | null | undefined, lang: Countr
return demonymCode || getCountryCode(name, lang);
}
/**
* Stored form of a NATIONALITY/NATION answer — the English demonym or country
* name, the way the profile's Address tab and seafarer registration store it,
* so a certificate prints the word rather than "ET" — resolved back to the
* alpha-2 code the picker works in. Both lookups run either way so an answer
* saved in the other form (or as a bare code) still resolves.
*/
export function countryValueToCode(stored: unknown, demonym: boolean): string | null {
const raw = typeof stored === 'string' ? stored.trim() : '';
if (!raw) return null;
const code = demonym
? getNationalityCode(raw) ?? getCountryCode(raw)
: getCountryCode(raw) ?? getNationalityCode(raw);
return code ?? raw;
}
/** The reverse: English-pinned deliberately, so the stored answer never depends on the UI language. */
export function countryCodeToValue(code: string | null, demonym: boolean): string | null {
if (!code) return null;
return (demonym ? getNationalityName(code) : getCountryName(code)) || code;
}
export function CountryFlag({ code }: { code: string }) {
const Flag = Flags[code as keyof typeof Flags];
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;