mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 07:08:20 +00:00
feat: add NATIONALITY and NATION field types with dedicated country-picker logic and storage conversion
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -1944,6 +1944,8 @@ export const am: Translations = {
|
||||
keyInvalid: "ቁልፍ በፊደል መጀመር እና ፊደላት፣ ቁጥሮች፣ underscore ብቻ መያዝ አለበት",
|
||||
label: "መለያ",
|
||||
type: "የመስክ ዓይነት",
|
||||
typeNationality: "አመልካቹ ከሙሉ የዜግነት ዝርዝር ይመርጣል (ኢትዮጵያዊ፣ ኬንያዊ፣ …)",
|
||||
typeNation: "አመልካቹ ከሙሉ የአገር ዝርዝር ይመርጣል (ኢትዮጵያ፣ ኬንያ፣ …)",
|
||||
required: "የግድ ያስፈልጋል",
|
||||
placeholder: "ምሳሌ ጽሑፍ",
|
||||
helpText: "የእገዛ ጽሑፍ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user