feat: add NATIONALITY and NATION field types with dedicated country-picker logic and storage conversion

This commit is contained in:
estifanos
2026-09-08 06:45:34 +00:00
parent d5d77cbc30
commit e1150cab63
7 changed files with 126 additions and 15 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
Button,
Checkbox,
@@ -20,6 +20,18 @@ import type { ConditionTarget } from '../config/schema-paths';
const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
/**
* Field types the builder offers on top of whatever the server palette lists.
* Both render the shared country picker in the portal — the full nationality
* list ("Ethiopian") and the full country list ("Ethiopia") — so neither takes
* options, a range or a max length. Merged in rather than assumed present so a
* palette that predates them still offers them.
*/
const EXTRA_FIELD_TYPES: FormSchemaPalette['fieldTypes'] = [
{ type: 'NATIONALITY', supportsOptions: false, supportsRange: false, supportsMaxLength: false },
{ type: 'NATION', supportsOptions: false, supportsRange: false, supportsMaxLength: false },
];
function emptyField(): FormFieldConfig {
return { key: '', label: { en: '', am: '' }, type: 'TEXT' };
}
@@ -52,7 +64,15 @@ export function FieldEditorDrawer({
}
}, [opened, field]);
const typeInfo = palette?.fieldTypes.find((f) => f.type === draft.type);
const fieldTypes = useMemo(() => {
const fromPalette = palette?.fieldTypes ?? [];
return [
...fromPalette,
...EXTRA_FIELD_TYPES.filter((extra) => !fromPalette.some((f) => f.type === extra.type)),
];
}, [palette]);
const typeInfo = fieldTypes.find((f) => f.type === draft.type);
const isNew = !field;
function save() {
@@ -128,7 +148,14 @@ export function FieldEditorDrawer({
<Select
label={t('certReq.field.type', 'Field type')}
data={(palette?.fieldTypes ?? []).map((f) => ({ value: f.type, label: f.type }))}
data={fieldTypes.map((f) => ({ value: f.type, label: f.type }))}
description={
draft.type === 'NATIONALITY'
? t('certReq.field.typeNationality', 'Applicant picks from the full nationality list (Ethiopian, Kenyan, …)')
: draft.type === 'NATION'
? t('certReq.field.typeNation', 'Applicant picks from the full country list (Ethiopia, Kenya, …)')
: undefined
}
value={draft.type}
onChange={(v) => v && setDraft((d) => ({ ...d, type: v as FormFieldConfig['type'] }))}
allowDeselect={false}

View File

@@ -1944,6 +1944,8 @@ export const am: Translations = {
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
label: "መለያ",
type: "የመስክ ዓይነት",
typeNationality: "አመልካቹ ከሙሉ የዜግነት ዝርዝር ይመርጣል (ኢትዮጵያዊ፣ ኬንያዊ፣ …)",
typeNation: "አመልካቹ ከሙሉ የአገር ዝርዝር ይመርጣል (ኢትዮጵያ፣ ኬንያ፣ …)",
required: "የግድ ያስፈልጋል",
placeholder: "ምሳሌ ጽሑፍ",
helpText: "የእገዛ ጽሑፍ",

View File

@@ -1959,6 +1959,8 @@ export const en = {
keyInvalid: 'Key must start with a letter and contain only letters, numbers, underscores',
label: 'Label',
type: 'Field type',
typeNationality: 'Applicant picks from the full nationality list (Ethiopian, Kenyan, …)',
typeNation: 'Applicant picks from the full country list (Ethiopia, Kenya, …)',
required: 'Required',
placeholder: 'Placeholder',
helpText: 'Help text',

View File

@@ -19,7 +19,15 @@ import {
type Rank,
type Vessel,
} from '@ema-platform/api';
import { AmharicDatePicker, CountrySelect, PhoneInput } from '@ema-platform/ui';
import {
AmharicDatePicker,
CountrySelect,
getCountryCode,
getCountryName,
getNationalityCode,
getNationalityName,
PhoneInput,
} from '@ema-platform/ui';
import { useTranslation } from 'react-i18next';
import { LocationPicker } from '../../location/components/LocationPicker';
@@ -87,6 +95,30 @@ 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.
*
@@ -179,9 +211,17 @@ export function ConfigDrivenSection({
disabled: disabled || field.readOnly,
};
const span = field.type === 'TEXTAREA' ? 12 : 6;
// Explicit country pickers: the full nationality list ("Ethiopian") or
// the full country list ("Ethiopia"), chosen per field in the
// backoffice form-schema builder.
const isCountryPicker = field.type === 'NATIONALITY' || field.type === 'NATION';
// Same field the profile Address tab collects — give it the same
// searchable, flag-labeled picker instead of a plain option list.
const isNationality = field.key === 'nationality' || labelEn.includes('nationality');
// searchable, flag-labeled picker instead of a plain option list. Kept
// for schemas configured before the NATIONALITY type existed; those
// still round-trip as alpha-2 codes (LicenseApplicationPage.saveSection
// turns them into the country name on the way out).
const isLegacyNationality =
!isCountryPicker && (field.key === 'nationality' || labelEn.includes('nationality'));
// Options here can't be seeded statically — they're the applicant's
// own vessel register, so this overrides whatever type the backend
// configured, the same way nationality overrides SELECT above.
@@ -211,7 +251,17 @@ export function ConfigDrivenSection({
disabled={common.disabled}
/>
</Input.Wrapper>
) : isNationality ? (
) : isCountryPicker ? (
<CountrySelect
{...common}
demonym={field.type === 'NATIONALITY'}
placeholder={localized(field.placeholder) || undefined}
value={storedToCountryCode(value, field.type === 'NATIONALITY')}
onChange={(v) =>
onChange(field.key, countryCodeToStored(v, field.type === 'NATIONALITY'))
}
/>
) : isLegacyNationality ? (
<CountrySelect
{...common}
demonym

View File

@@ -61,6 +61,7 @@ import {
import {
getCountryCode,
getCountryName,
getNationalityName,
ModalFooter,
splitPersonName,
} from "@ema-platform/ui";
@@ -235,13 +236,22 @@ export function LicenseApplicationPage() {
};
if (address.nationality) {
// Profile stores the full country name; the field always renders as
// a CountrySelect (see ConfigDrivenSection), which takes alpha-2
// codes regardless of the backend's configured field type.
// Profile stores the full country name. A NATIONALITY/NATION field
// keeps that human-readable form (ConfigDrivenSection converts to and
// from the picker's alpha-2 codes); every other field renders as a
// CountrySelect through the legacy label match, which holds the code.
fill(
(label, key) =>
key === "nationality" || label.includes("nationality"),
() => getCountryCode(address.nationality) ?? address.nationality,
(field) => {
if (field.type === "NATIONALITY")
return (
getNationalityName(getCountryCode(address.nationality) ?? "") ||
address.nationality
);
if (field.type === "NATION") return address.nationality;
return getCountryCode(address.nationality) ?? address.nationality;
},
);
}
if (address.idType === "NID" && address.idNumber) {
@@ -546,11 +556,15 @@ export function LicenseApplicationPage() {
// the profile Address endpoint, stores the full country name.
const nationalityField = config?.licenseType.formSchema.sections
.find((s) => s.key === sectionKey)
// English-pinned: same reasoning as the fill() matcher above.
// English-pinned: same reasoning as the fill() matcher above. Typed
// NATIONALITY/NATION fields already hold the readable name, so they are
// left out of this legacy code-to-name conversion.
?.fields.find(
(f) =>
f.key === "nationality" ||
(f.label.en ?? "").toLowerCase().includes("nationality"),
f.type !== "NATIONALITY" &&
f.type !== "NATION" &&
(f.key === "nationality" ||
(f.label.en ?? "").toLowerCase().includes("nationality")),
);
if (nationalityField && values[nationalityField.key]) {
values[nationalityField.key] =

View File

@@ -78,7 +78,11 @@ export type FormFieldType =
| "BOOLEAN"
| "EMAIL"
| "PHONE"
| "TIN";
| "TIN"
/** Full nationality list, stored as the English demonym ("Ethiopian"). */
| "NATIONALITY"
/** Full country list, stored as the English country name ("Ethiopia"). */
| "NATION";
export interface FieldCondition {
/** Omitted when `anyOf` is used instead — see below. */

View File

@@ -5,6 +5,7 @@ import { registerLocale, getNames, getName, getAlpha2Code } from 'i18n-iso-count
import {
registerLocale as registerNationalityLocale,
getName as getNationalityNameRaw,
getAlpha2Code as getNationalityAlpha2Code,
} from 'i18n-nationality';
import * as Flags from 'country-flag-icons/react/3x2';
import en from 'i18n-iso-countries/langs/en.json';
@@ -44,6 +45,17 @@ export function getNationalityName(code: string | null | undefined, lang: Countr
return getCountryName(code, lang);
}
/**
* Alpha-2 code for a stored demonym (e.g. "Ethiopian" -> "ET"), the reverse of
* `getNationalityName`. Falls back to the country-name lookup so a field that
* happens to hold "Ethiopia" still resolves.
*/
export function getNationalityCode(name: string | null | undefined, lang: CountryLang = 'en'): string | undefined {
if (!name) return undefined;
const demonymCode = lang === 'en' ? getNationalityAlpha2Code(name, 'en') : undefined;
return demonymCode || getCountryCode(name, lang);
}
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;