@comms-id/sanctions 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/controller.d.ts +15 -0
- package/dist/controller.js +11 -0
- package/dist/local/profile.d.ts +13 -0
- package/dist/local/profile.js +145 -0
- package/dist/runtime/screen-aria.d.ts +20 -0
- package/dist/runtime/screen-aria.js +43 -0
- package/dist/runtime/screen-controller.d.ts +5 -0
- package/dist/runtime/screen-controller.js +188 -0
- package/dist/runtime/screen-types.d.ts +146 -0
- package/dist/runtime/screen-types.js +4 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -31,3 +31,18 @@ Sanctions screening is not open to a browser holding no credential: call it from
|
|
|
31
31
|
Entries: `.` (types and errors, safe in a browser), `./server` (signs each call), `./browser` (your relay route; never signs), `./fixtures` (LOCAL FIXTURES, no network), `./descriptor` (what `@comms-id/relay` may forward).
|
|
32
32
|
|
|
33
33
|
The package is private until the publishing path exists (#515). `LICENSE` is the consumer client licence.
|
|
34
|
+
|
|
35
|
+
## The form
|
|
36
|
+
|
|
37
|
+
`@comms-id/sanctions/controller` is the headless controller of a sanctions screening form: values, checks, one explicit submit, the answer, errors and retry, with the accessibility attributes of a labelled form. It has no framework and no DOM. `@comms-id/sanctions-react` (a hook and a component) and `<comms-id-sanctions>` (a web element) run on it, and so can your own UI.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createSanctionsController } from "@comms-id/sanctions/controller";
|
|
41
|
+
|
|
42
|
+
const controller = createSanctionsController({ client, onResult: (record, timing) => console.log(record, timing.ms) });
|
|
43
|
+
controller.subscribe(() => render(controller.getState()));
|
|
44
|
+
controller.setValue("name", "…");
|
|
45
|
+
controller.submit();
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`src/local/profile.ts` is the only product-specific part: it lists the fields, checks the values, prepares the call and says the reply in plain words. Everything else is generated. Like the client itself, the form is for your server or your relay: screening is never open to a browser that holds no credential.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SanctionsClient } from "./generated/client.js";
|
|
2
|
+
import { type ProductRecord, profile } from "./local/profile.js";
|
|
3
|
+
import type { ScreenController, ScreenControllerOptions, ScreenState } from "./runtime/screen-types.js";
|
|
4
|
+
export type { Attributes, ScreenIds } from "./runtime/screen-aria.js";
|
|
5
|
+
export { fieldId, formAttributes, hintId, inputAttributes, messageId, resultAttributes, statusAttributes, } from "./runtime/screen-aria.js";
|
|
6
|
+
export { initialScreenState } from "./runtime/screen-controller.js";
|
|
7
|
+
export type { Prepared, ResultItem, Run, ScreenError, ScreenField, ScreenProfile, ScreenStatus, Screened, ScreenTiming, ScreenValues, ScreenView, } from "./runtime/screen-types.js";
|
|
8
|
+
export type { SanctionsClient } from "./generated/client.js";
|
|
9
|
+
export { profile };
|
|
10
|
+
export type { ProductRecord as SanctionsRecord };
|
|
11
|
+
export type SanctionsController = ScreenController<ProductRecord>;
|
|
12
|
+
export type SanctionsControllerOptions = Omit<ScreenControllerOptions<SanctionsClient, ProductRecord>, "profile">;
|
|
13
|
+
export type SanctionsState = ScreenState<ProductRecord>;
|
|
14
|
+
/** Creates the controller. Nothing is called until the first `submit`. */
|
|
15
|
+
export declare const createSanctionsController: (options: SanctionsControllerOptions) => SanctionsController;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Generated by @comms-id/forge-transformers from the service's OpenAPI contract. Do not edit: change the contract and regenerate.
|
|
2
|
+
// Controller entry: the headless controller of the Comms.ID Sanctions screening form, its accessibility
|
|
3
|
+
// attributes and the product's profile. No framework, no DOM. The React package and the web element
|
|
4
|
+
// run on it.
|
|
5
|
+
import { profile } from "./local/profile.js";
|
|
6
|
+
import { createScreenController } from "./runtime/screen-controller.js";
|
|
7
|
+
export { fieldId, formAttributes, hintId, inputAttributes, messageId, resultAttributes, statusAttributes, } from "./runtime/screen-aria.js";
|
|
8
|
+
export { initialScreenState } from "./runtime/screen-controller.js";
|
|
9
|
+
export { profile };
|
|
10
|
+
/** Creates the controller. Nothing is called until the first `submit`. */
|
|
11
|
+
export const createSanctionsController = (options) => createScreenController({ ...options, profile });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SanctionsClient } from "../generated/client.js";
|
|
2
|
+
import type { ScreenResponse } from "../generated/types.js";
|
|
3
|
+
import type { ScreenProfile, ScreenView } from "../runtime/screen-types.js";
|
|
4
|
+
/** The reply of the screening: every possible match with its evidence, the list generation and its freshness. */
|
|
5
|
+
export type ProductRecord = ScreenResponse;
|
|
6
|
+
export declare const ATTRIBUTION = "Source: Australian Sanctions Consolidated List, Department of Foreign Affairs and Trade (DFAT).";
|
|
7
|
+
/** True when the text is a real calendar date written YYYY-MM-DD. */
|
|
8
|
+
export declare const isCalendarDate: (text: string) => boolean;
|
|
9
|
+
/** "2026-10-03T00:00:00.000Z" as "2026-10-03 00:00 UTC". */
|
|
10
|
+
export declare const stamp: (iso: string) => string;
|
|
11
|
+
/** The reply in plain words. */
|
|
12
|
+
export declare const describeScreening: (reply: ScreenResponse) => ScreenView;
|
|
13
|
+
export declare const profile: ScreenProfile<SanctionsClient, ProductRecord>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The Sanctions screening form's profile: the only sanctions-specific code of the form. It checks the
|
|
2
|
+
// values (a name, its kind, and an optional date and place of birth), prepares the call, and reduces
|
|
3
|
+
// the reply to the plain words every UI shows and the record the consumer receives. The controller,
|
|
4
|
+
// the React package and the web element are generated and know nothing else.
|
|
5
|
+
//
|
|
6
|
+
// A result is a list of possible matches with their evidence. "No possible match" is a statement about
|
|
7
|
+
// the exact names and aliases of one generation of the list, and the notes say so; it is never a
|
|
8
|
+
// clearance, and a screening that could not run is an error, never "no possible match".
|
|
9
|
+
export const ATTRIBUTION = "Source: Australian Sanctions Consolidated List, Department of Foreign Affairs and Trade (DFAT).";
|
|
10
|
+
const MAX_TEXT = 1024;
|
|
11
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
12
|
+
const GENERATION_SHOWN = 8;
|
|
13
|
+
const KINDS = ["Individual", "Entity", "Vessel"];
|
|
14
|
+
const isKind = (value) => KINDS.includes(value);
|
|
15
|
+
/** True when the text is a real calendar date written YYYY-MM-DD. */
|
|
16
|
+
export const isCalendarDate = (text) => {
|
|
17
|
+
const match = DATE.exec(text);
|
|
18
|
+
if (match === null) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
22
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
23
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
24
|
+
};
|
|
25
|
+
/** "2026-10-03T00:00:00.000Z" as "2026-10-03 00:00 UTC". */
|
|
26
|
+
export const stamp = (iso) => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
27
|
+
const range = ([from, to]) => (from === to ? from : `${from} to ${to}`);
|
|
28
|
+
const flagsOf = (candidate) => {
|
|
29
|
+
const flags = [candidate.nameMatch ? "Name matches" : "Name does not match"];
|
|
30
|
+
if (candidate.dobMatch !== null) {
|
|
31
|
+
flags.push(candidate.dobMatch ? "Date of birth matches" : "Date of birth does not match");
|
|
32
|
+
}
|
|
33
|
+
if (candidate.pobMatch !== null) {
|
|
34
|
+
flags.push(candidate.pobMatch ? "Place of birth matches" : "Place of birth does not match");
|
|
35
|
+
}
|
|
36
|
+
return flags;
|
|
37
|
+
};
|
|
38
|
+
const itemOf = (candidate, index) => {
|
|
39
|
+
const { record } = candidate;
|
|
40
|
+
const lines = [`${record.type} · ${record.nameType} · Reference ${record.reference}`];
|
|
41
|
+
if (record.dobRanges.length > 0) {
|
|
42
|
+
lines.push(`Born ${record.dobRanges.map(range).join("; ")}`);
|
|
43
|
+
}
|
|
44
|
+
if (record.placeOfBirth.length > 0) {
|
|
45
|
+
lines.push(`Place of birth: ${record.placeOfBirth.join("; ")}`);
|
|
46
|
+
}
|
|
47
|
+
return { key: `${record.reference}-${index}`, title: record.name, lines, flags: flagsOf(candidate) };
|
|
48
|
+
};
|
|
49
|
+
/** The reply in plain words. */
|
|
50
|
+
export const describeScreening = (reply) => {
|
|
51
|
+
const count = reply.candidates.length;
|
|
52
|
+
const notes = [
|
|
53
|
+
`Checked ${stamp(reply.checkedAt)} against list generation ${reply.generation.slice(0, GENERATION_SHOWN)}.`,
|
|
54
|
+
"Only exact names and aliases are compared: a different spelling is not found.",
|
|
55
|
+
];
|
|
56
|
+
if (reply.freshness.status === "degraded") {
|
|
57
|
+
notes.push(`The list could not be refreshed on its last attempt. This answer uses the last good list (updated ${stamp(reply.freshness.lastSuccess)}).`);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
tone: count === 0 ? "clear" : "attention",
|
|
61
|
+
headline: count === 0
|
|
62
|
+
? "No possible match found on the Consolidated List."
|
|
63
|
+
: `${count} possible match${count === 1 ? "" : "es"} found on the Consolidated List. Review each one.`,
|
|
64
|
+
items: reply.candidates.map(itemOf),
|
|
65
|
+
notes,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const invalid = (field, message) => ({
|
|
69
|
+
kind: "invalid",
|
|
70
|
+
field,
|
|
71
|
+
message,
|
|
72
|
+
});
|
|
73
|
+
const checked = (values) => {
|
|
74
|
+
const type = values.type ?? "";
|
|
75
|
+
const name = (values.name ?? "").trim();
|
|
76
|
+
const dateOfBirth = (values.dateOfBirth ?? "").trim();
|
|
77
|
+
const placeOfBirth = (values.placeOfBirth ?? "").trim();
|
|
78
|
+
if (!isKind(type)) {
|
|
79
|
+
return invalid("type", "Choose individual, entity or vessel.");
|
|
80
|
+
}
|
|
81
|
+
if (name === "") {
|
|
82
|
+
return invalid("name", "Enter the name to screen.");
|
|
83
|
+
}
|
|
84
|
+
if (name.length > MAX_TEXT) {
|
|
85
|
+
return invalid("name", `The name is too long: at most ${MAX_TEXT} characters.`);
|
|
86
|
+
}
|
|
87
|
+
if (dateOfBirth !== "" && !isCalendarDate(dateOfBirth)) {
|
|
88
|
+
return invalid("dateOfBirth", "Enter the date of birth as YYYY-MM-DD, for example 1970-01-31.");
|
|
89
|
+
}
|
|
90
|
+
if (placeOfBirth.length > MAX_TEXT) {
|
|
91
|
+
return invalid("placeOfBirth", `The place of birth is too long: at most ${MAX_TEXT} characters.`);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
type,
|
|
95
|
+
name,
|
|
96
|
+
...(dateOfBirth === "" ? {} : { dateOfBirth }),
|
|
97
|
+
...(placeOfBirth === "" ? {} : { placeOfBirth }),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
export const profile = {
|
|
101
|
+
attribution: ATTRIBUTION,
|
|
102
|
+
legend: "Sanctions screening",
|
|
103
|
+
service: "Sanctions screening",
|
|
104
|
+
submitLabel: "Screen",
|
|
105
|
+
working: "Screening against the Consolidated List",
|
|
106
|
+
initial: { type: "Individual", name: "", dateOfBirth: "", placeOfBirth: "" },
|
|
107
|
+
fields: [
|
|
108
|
+
{
|
|
109
|
+
name: "type",
|
|
110
|
+
label: "Kind",
|
|
111
|
+
kind: "select",
|
|
112
|
+
required: true,
|
|
113
|
+
options: KINDS.map((kind) => ({ value: kind, label: kind })),
|
|
114
|
+
},
|
|
115
|
+
{ name: "name", label: "Name", kind: "text", required: true, hint: "The full name or an alias." },
|
|
116
|
+
{
|
|
117
|
+
name: "dateOfBirth",
|
|
118
|
+
label: "Date of birth",
|
|
119
|
+
kind: "date",
|
|
120
|
+
required: false,
|
|
121
|
+
placeholder: "YYYY-MM-DD",
|
|
122
|
+
hint: "For an individual: narrows the possible matches.",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "placeOfBirth",
|
|
126
|
+
label: "Place of birth",
|
|
127
|
+
kind: "text",
|
|
128
|
+
required: false,
|
|
129
|
+
hint: "For an individual: narrows the possible matches.",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
prepare: (values) => {
|
|
133
|
+
const request = checked(values);
|
|
134
|
+
if ("kind" in request) {
|
|
135
|
+
return request;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: "call",
|
|
139
|
+
run: async (client, options) => {
|
|
140
|
+
const record = await client.screen(request, options);
|
|
141
|
+
return { record, view: describeScreening(record), attribution: undefined };
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ScreenField, ScreenState } from "./screen-types.js";
|
|
2
|
+
export interface ScreenIds {
|
|
3
|
+
readonly form: string;
|
|
4
|
+
/** Prefix of the field ids; the id of a field is `<prefix>-<name>`. */
|
|
5
|
+
readonly prefix: string;
|
|
6
|
+
readonly result: string;
|
|
7
|
+
readonly status: string;
|
|
8
|
+
}
|
|
9
|
+
/** Attribute name to value; an undefined value means the attribute is left off. */
|
|
10
|
+
export type Attributes = Readonly<Record<string, string | undefined>>;
|
|
11
|
+
export declare const fieldId: (ids: ScreenIds, name: string) => string;
|
|
12
|
+
export declare const hintId: (ids: ScreenIds, name: string) => string;
|
|
13
|
+
export declare const messageId: (ids: ScreenIds, name: string) => string;
|
|
14
|
+
export declare const formAttributes: (state: ScreenState<unknown>, ids: ScreenIds) => Attributes;
|
|
15
|
+
/** The attributes of the input (or select) of one field. */
|
|
16
|
+
export declare const inputAttributes: (state: ScreenState<unknown>, ids: ScreenIds, field: ScreenField) => Attributes;
|
|
17
|
+
/** The polite live region that says what just happened. */
|
|
18
|
+
export declare const statusAttributes: (ids: ScreenIds) => Attributes;
|
|
19
|
+
/** The region that holds the answer: named, so assistive technology can find it. */
|
|
20
|
+
export declare const resultAttributes: (ids: ScreenIds, label: string) => Attributes;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Runtime template: copied into a generated client package as src/runtime/screen-aria.ts.
|
|
2
|
+
// The accessibility contract of the screening form, as plain attributes: a labelled form with a
|
|
3
|
+
// fieldset, every input named by its label and tied to its hint and its message, the invalid field
|
|
4
|
+
// marked, and a polite live region for what just happened. The React package and the web element
|
|
5
|
+
// render these attributes unchanged.
|
|
6
|
+
export const fieldId = (ids, name) => `${ids.prefix}-${name}`;
|
|
7
|
+
export const hintId = (ids, name) => `${ids.prefix}-${name}-hint`;
|
|
8
|
+
export const messageId = (ids, name) => `${ids.prefix}-${name}-message`;
|
|
9
|
+
const isInvalid = (state, name) => state.status === "invalid" && state.invalid?.field === name;
|
|
10
|
+
export const formAttributes = (state, ids) => ({
|
|
11
|
+
id: ids.form,
|
|
12
|
+
"aria-busy": state.status === "submitting" ? "true" : undefined,
|
|
13
|
+
novalidate: "",
|
|
14
|
+
});
|
|
15
|
+
/** The attributes of the input (or select) of one field. */
|
|
16
|
+
export const inputAttributes = (state, ids, field) => ({
|
|
17
|
+
id: fieldId(ids, field.name),
|
|
18
|
+
name: field.name,
|
|
19
|
+
"aria-describedby": [
|
|
20
|
+
field.hint === undefined ? undefined : hintId(ids, field.name),
|
|
21
|
+
isInvalid(state, field.name) ? messageId(ids, field.name) : undefined,
|
|
22
|
+
]
|
|
23
|
+
.filter((part) => part !== undefined)
|
|
24
|
+
.join(" ") || undefined,
|
|
25
|
+
"aria-invalid": isInvalid(state, field.name) ? "true" : undefined,
|
|
26
|
+
"aria-required": field.required ? "true" : undefined,
|
|
27
|
+
autocomplete: "off",
|
|
28
|
+
autocapitalize: "off",
|
|
29
|
+
spellcheck: "false",
|
|
30
|
+
});
|
|
31
|
+
/** The polite live region that says what just happened. */
|
|
32
|
+
export const statusAttributes = (ids) => ({
|
|
33
|
+
id: ids.status,
|
|
34
|
+
role: "status",
|
|
35
|
+
"aria-live": "polite",
|
|
36
|
+
"aria-atomic": "true",
|
|
37
|
+
});
|
|
38
|
+
/** The region that holds the answer: named, so assistive technology can find it. */
|
|
39
|
+
export const resultAttributes = (ids, label) => ({
|
|
40
|
+
id: ids.result,
|
|
41
|
+
role: "region",
|
|
42
|
+
"aria-label": label,
|
|
43
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ScreenController, ScreenControllerOptions, ScreenProfile, ScreenState } from "./screen-types.js";
|
|
2
|
+
/** The state of a form nobody has touched. */
|
|
3
|
+
export declare const initialScreenState: <R>(profile: ScreenProfile<never, R>, values?: Readonly<Record<string, string>>) => ScreenState<R>;
|
|
4
|
+
/** Creates the controller. Nothing is called until the first `submit`. */
|
|
5
|
+
export declare const createScreenController: <C, R>(options: ScreenControllerOptions<C, R>) => ScreenController<R>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Runtime template: copied into a generated client package as src/runtime/screen-controller.ts.
|
|
2
|
+
// The headless controller of a screening form: values, checks, one explicit submit, the answer, errors
|
|
3
|
+
// and retry, with no framework and no markup. The React package and the web element both run on it.
|
|
4
|
+
//
|
|
5
|
+
// A screening is never sent while the user types: it is sent when the form is submitted. A change of a
|
|
6
|
+
// value drops the answer that belonged to the old values, so an answer is never shown beside values it
|
|
7
|
+
// is not for. The product-specific part is the profile (src/local/profile.ts).
|
|
8
|
+
import { isCommsIdError, outcomeOf } from "./errors.js";
|
|
9
|
+
const errorMessage = (service, code, outcome) => {
|
|
10
|
+
if (code === "FAIR_USE_CEILING") {
|
|
11
|
+
return `The ${service.toLowerCase()} limit is reached. Try again later.`;
|
|
12
|
+
}
|
|
13
|
+
if (outcome === "source-unavailable") {
|
|
14
|
+
return `${service} is unavailable. Try again later.`;
|
|
15
|
+
}
|
|
16
|
+
return `${service} failed. Try again.`;
|
|
17
|
+
};
|
|
18
|
+
const toError = (service, cause) => {
|
|
19
|
+
const outcome = outcomeOf(cause);
|
|
20
|
+
const base = isCommsIdError(cause)
|
|
21
|
+
? { code: cause.code, detail: cause.message, outcome, retryable: cause.retryable, requestId: cause.requestId }
|
|
22
|
+
: {
|
|
23
|
+
code: "UNKNOWN",
|
|
24
|
+
detail: cause instanceof Error ? cause.message : String(cause),
|
|
25
|
+
outcome,
|
|
26
|
+
retryable: false,
|
|
27
|
+
requestId: undefined,
|
|
28
|
+
};
|
|
29
|
+
return { ...base, message: errorMessage(service, base.code, outcome) };
|
|
30
|
+
};
|
|
31
|
+
const isOurAbort = (cause) => isCommsIdError(cause) && cause.code === "ABORTED";
|
|
32
|
+
/** The state of a form nobody has touched. */
|
|
33
|
+
export const initialScreenState = (profile, values = {}) => ({
|
|
34
|
+
values: { ...profile.initial, ...values },
|
|
35
|
+
status: "idle",
|
|
36
|
+
invalid: undefined,
|
|
37
|
+
error: undefined,
|
|
38
|
+
result: undefined,
|
|
39
|
+
announcement: "",
|
|
40
|
+
attribution: profile.attribution,
|
|
41
|
+
});
|
|
42
|
+
/** Creates the controller. Nothing is called until the first `submit`. */
|
|
43
|
+
export const createScreenController = (options) => {
|
|
44
|
+
const { client, profile } = options;
|
|
45
|
+
const now = options.now ?? (() => performance.now());
|
|
46
|
+
const start = options.values ?? {};
|
|
47
|
+
let state = initialScreenState(profile, start);
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
let sequence = 0;
|
|
50
|
+
let inFlight;
|
|
51
|
+
let retryAction;
|
|
52
|
+
let disposed = false;
|
|
53
|
+
const notify = () => {
|
|
54
|
+
for (const listener of [...listeners]) {
|
|
55
|
+
listener();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const set = (patch) => {
|
|
59
|
+
state = { ...state, ...patch };
|
|
60
|
+
notify();
|
|
61
|
+
};
|
|
62
|
+
/** Starts a new generation: a call still in flight becomes stale. */
|
|
63
|
+
const supersede = () => {
|
|
64
|
+
sequence += 1;
|
|
65
|
+
inFlight?.abort();
|
|
66
|
+
inFlight = undefined;
|
|
67
|
+
return sequence;
|
|
68
|
+
};
|
|
69
|
+
const finish = (screened, started) => {
|
|
70
|
+
const timing = { ms: Math.round(now() - started) };
|
|
71
|
+
set({
|
|
72
|
+
status: "done",
|
|
73
|
+
error: undefined,
|
|
74
|
+
invalid: undefined,
|
|
75
|
+
result: { record: screened.record, view: screened.view, timing },
|
|
76
|
+
attribution: screened.attribution ?? profile.attribution,
|
|
77
|
+
announcement: screened.view.headline,
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
options.onResult?.(screened.record, timing);
|
|
81
|
+
}
|
|
82
|
+
catch (thrown) {
|
|
83
|
+
// The consumer's callback must not corrupt the state; surface its error to the page.
|
|
84
|
+
queueMicrotask(() => {
|
|
85
|
+
throw thrown;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const call = async (generation, run) => {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
inFlight = controller;
|
|
92
|
+
retryAction = () => {
|
|
93
|
+
const next = supersede();
|
|
94
|
+
set({ status: "submitting", error: undefined, announcement: `${profile.working}.` });
|
|
95
|
+
call(next, run).catch(() => undefined);
|
|
96
|
+
};
|
|
97
|
+
const started = now();
|
|
98
|
+
let screened;
|
|
99
|
+
try {
|
|
100
|
+
screened = await run(client, { signal: controller.signal });
|
|
101
|
+
}
|
|
102
|
+
catch (cause) {
|
|
103
|
+
if (generation !== sequence || disposed || isOurAbort(cause)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const error = toError(profile.service, cause);
|
|
107
|
+
set({ status: "error", error, result: undefined, announcement: error.message });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (generation !== sequence || disposed) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
finish(screened, started);
|
|
114
|
+
};
|
|
115
|
+
const setValue = (name, value) => {
|
|
116
|
+
if (disposed || state.values[name] === value) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
supersede();
|
|
120
|
+
set({
|
|
121
|
+
values: { ...state.values, [name]: value },
|
|
122
|
+
status: "idle",
|
|
123
|
+
invalid: undefined,
|
|
124
|
+
error: undefined,
|
|
125
|
+
result: undefined,
|
|
126
|
+
announcement: "",
|
|
127
|
+
attribution: profile.attribution,
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
const submit = () => {
|
|
131
|
+
if (disposed || state.status === "submitting") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const prepared = profile.prepare(state.values);
|
|
135
|
+
const generation = supersede();
|
|
136
|
+
if (prepared.kind === "invalid") {
|
|
137
|
+
set({
|
|
138
|
+
status: "invalid",
|
|
139
|
+
invalid: { field: prepared.field, message: prepared.message },
|
|
140
|
+
error: undefined,
|
|
141
|
+
result: undefined,
|
|
142
|
+
announcement: prepared.message,
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
set({
|
|
147
|
+
status: "submitting",
|
|
148
|
+
invalid: undefined,
|
|
149
|
+
error: undefined,
|
|
150
|
+
result: undefined,
|
|
151
|
+
announcement: `${profile.working}.`,
|
|
152
|
+
});
|
|
153
|
+
call(generation, prepared.run).catch(() => undefined);
|
|
154
|
+
};
|
|
155
|
+
const reset = () => {
|
|
156
|
+
if (disposed) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
supersede();
|
|
160
|
+
state = initialScreenState(profile, start);
|
|
161
|
+
notify();
|
|
162
|
+
};
|
|
163
|
+
const retry = () => {
|
|
164
|
+
if (disposed || state.status !== "error") {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
retryAction?.();
|
|
168
|
+
};
|
|
169
|
+
const dispose = () => {
|
|
170
|
+
disposed = true;
|
|
171
|
+
supersede();
|
|
172
|
+
listeners.clear();
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
getState: () => state,
|
|
176
|
+
subscribe: (listener) => {
|
|
177
|
+
listeners.add(listener);
|
|
178
|
+
return () => {
|
|
179
|
+
listeners.delete(listener);
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
setValue,
|
|
183
|
+
submit,
|
|
184
|
+
reset,
|
|
185
|
+
retry,
|
|
186
|
+
dispose,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Outcome } from "./errors.js";
|
|
2
|
+
import type { CallOptions } from "./http.js";
|
|
3
|
+
/** A call the profile prepared: it runs against the client when the form is submitted. */
|
|
4
|
+
export type Run<C, T> = (client: C, options: CallOptions) => Promise<T>;
|
|
5
|
+
/** One box of the form. The UIs render the fields in the order the profile lists them. */
|
|
6
|
+
export interface ScreenField {
|
|
7
|
+
/** Shown under the field, for example the format of a date. */
|
|
8
|
+
readonly hint?: string;
|
|
9
|
+
readonly kind: "text" | "select" | "date";
|
|
10
|
+
readonly label: string;
|
|
11
|
+
/** The key in the values, and the `name` of the input. */
|
|
12
|
+
readonly name: string;
|
|
13
|
+
/** The choices of a select. */
|
|
14
|
+
readonly options?: readonly {
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly value: string;
|
|
17
|
+
}[];
|
|
18
|
+
readonly placeholder?: string;
|
|
19
|
+
readonly required: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** What the user typed: one string for each field, empty when nothing was typed. */
|
|
22
|
+
export type ScreenValues = Readonly<Record<string, string>>;
|
|
23
|
+
/** One thing the screening found, as the UIs show it. */
|
|
24
|
+
export interface ResultItem {
|
|
25
|
+
/** Short statements about how it matched. */
|
|
26
|
+
readonly flags: readonly string[];
|
|
27
|
+
/** Unique in one result. */
|
|
28
|
+
readonly key: string;
|
|
29
|
+
/** Detail lines under the title. */
|
|
30
|
+
readonly lines: readonly string[];
|
|
31
|
+
readonly title: string;
|
|
32
|
+
}
|
|
33
|
+
/** The answer in plain words: what every UI shows, and what assistive technology hears. */
|
|
34
|
+
export interface ScreenView {
|
|
35
|
+
/** A sentence that says what the screening found. */
|
|
36
|
+
readonly headline: string;
|
|
37
|
+
readonly items: readonly ResultItem[];
|
|
38
|
+
/** Statements that go with the answer, for example how fresh the data is. */
|
|
39
|
+
readonly notes: readonly string[];
|
|
40
|
+
/** "attention" when something was found and must be looked at, "clear" when nothing was. */
|
|
41
|
+
readonly tone: "attention" | "clear";
|
|
42
|
+
}
|
|
43
|
+
export interface Screened<R> {
|
|
44
|
+
/** The attribution this reply asks for, when it carries one. */
|
|
45
|
+
readonly attribution: string | undefined;
|
|
46
|
+
/** What the consumer receives. */
|
|
47
|
+
readonly record: R;
|
|
48
|
+
readonly view: ScreenView;
|
|
49
|
+
}
|
|
50
|
+
/** What the profile makes of the values: refuse them, or a call to make. */
|
|
51
|
+
export type Prepared<C, R> = {
|
|
52
|
+
readonly field: string;
|
|
53
|
+
readonly kind: "invalid";
|
|
54
|
+
readonly message: string;
|
|
55
|
+
} | {
|
|
56
|
+
readonly kind: "call";
|
|
57
|
+
readonly run: Run<C, Screened<R>>;
|
|
58
|
+
};
|
|
59
|
+
/** The only product-specific part of a screening form: written by hand in the package (src/local/profile.ts). */
|
|
60
|
+
export interface ScreenProfile<C, R> {
|
|
61
|
+
/** The standard attribution, always shown. */
|
|
62
|
+
readonly attribution: string;
|
|
63
|
+
readonly fields: readonly ScreenField[];
|
|
64
|
+
/** The values of a form nobody has touched. */
|
|
65
|
+
readonly initial: ScreenValues;
|
|
66
|
+
/** The visible name of the form (a fieldset legend). */
|
|
67
|
+
readonly legend: string;
|
|
68
|
+
/** Checks the values and prepares the call. An invalid value sends nothing. */
|
|
69
|
+
readonly prepare: (values: ScreenValues) => Prepared<C, R>;
|
|
70
|
+
/** The service in a failure message: "Sanctions screening" gives "Sanctions screening is unavailable." */
|
|
71
|
+
readonly service: string;
|
|
72
|
+
readonly submitLabel: string;
|
|
73
|
+
/** A phrase for the announcement while the call runs: "Screening the name". */
|
|
74
|
+
readonly working: string;
|
|
75
|
+
}
|
|
76
|
+
export type ScreenStatus =
|
|
77
|
+
/** Nothing submitted, or the values changed after the last answer. */
|
|
78
|
+
"idle"
|
|
79
|
+
/** The call is running. */
|
|
80
|
+
| "submitting"
|
|
81
|
+
/** The answer is in `result`. */
|
|
82
|
+
| "done"
|
|
83
|
+
/** A value is not valid. Nothing was sent. */
|
|
84
|
+
| "invalid"
|
|
85
|
+
/** The call failed; `error` says how. */
|
|
86
|
+
| "error";
|
|
87
|
+
export interface ScreenError {
|
|
88
|
+
readonly code: string;
|
|
89
|
+
/** What the technical error said, for a developer: never shown to the end user. */
|
|
90
|
+
readonly detail: string;
|
|
91
|
+
readonly message: string;
|
|
92
|
+
/** A failed call is never shown as "nothing found". */
|
|
93
|
+
readonly outcome: Outcome;
|
|
94
|
+
readonly requestId: string | undefined;
|
|
95
|
+
readonly retryable: boolean;
|
|
96
|
+
}
|
|
97
|
+
export interface ScreenTiming {
|
|
98
|
+
/** Milliseconds the call took, measured in the browser. */
|
|
99
|
+
readonly ms: number;
|
|
100
|
+
}
|
|
101
|
+
export interface ScreenState<R> {
|
|
102
|
+
/** One sentence for assistive technology: what just happened. Empty when nothing needs saying. */
|
|
103
|
+
readonly announcement: string;
|
|
104
|
+
/** The attribution to show now: the one the last reply carried, or the standard one. */
|
|
105
|
+
readonly attribution: string;
|
|
106
|
+
readonly error: ScreenError | undefined;
|
|
107
|
+
/** Set with the status "invalid": which field, and why. */
|
|
108
|
+
readonly invalid: {
|
|
109
|
+
readonly field: string;
|
|
110
|
+
readonly message: string;
|
|
111
|
+
} | undefined;
|
|
112
|
+
readonly result: {
|
|
113
|
+
readonly record: R;
|
|
114
|
+
readonly timing: ScreenTiming;
|
|
115
|
+
readonly view: ScreenView;
|
|
116
|
+
} | undefined;
|
|
117
|
+
readonly status: ScreenStatus;
|
|
118
|
+
readonly values: ScreenValues;
|
|
119
|
+
}
|
|
120
|
+
export interface ScreenControllerOptions<C, R> {
|
|
121
|
+
/** Any client of the product's API: server, browser (relay) or fixtures. */
|
|
122
|
+
readonly client: C;
|
|
123
|
+
/** Milliseconds, for timing. performance.now by default. */
|
|
124
|
+
readonly now?: () => number;
|
|
125
|
+
/** Called once when an answer has arrived, after the state is updated. */
|
|
126
|
+
readonly onResult?: (record: R, timing: ScreenTiming) => void;
|
|
127
|
+
readonly profile: ScreenProfile<C, R>;
|
|
128
|
+
/** Values to start with, for fields the page knows. */
|
|
129
|
+
readonly values?: ScreenValues;
|
|
130
|
+
}
|
|
131
|
+
export interface ScreenController<R> {
|
|
132
|
+
/** Stops every call. The controller does nothing afterwards. */
|
|
133
|
+
dispose: () => void;
|
|
134
|
+
/** The current state. The same object until the state changes. */
|
|
135
|
+
getState: () => ScreenState<R>;
|
|
136
|
+
/** Back to the values the form started with, with no answer and no message. */
|
|
137
|
+
reset: () => void;
|
|
138
|
+
/** Runs again the call that failed last. */
|
|
139
|
+
retry: () => void;
|
|
140
|
+
/** Changes one value. An answer or message that belonged to the old values is dropped. */
|
|
141
|
+
setValue: (name: string, value: string) => void;
|
|
142
|
+
/** Checks the values and, when they are valid, makes the call. */
|
|
143
|
+
submit: () => void;
|
|
144
|
+
/** Calls the listener after every change. Returns the function that stops it. */
|
|
145
|
+
subscribe: (listener: () => void) => () => void;
|
|
146
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Runtime template: copied into a generated client package as src/runtime/screen-types.ts, with
|
|
2
|
+
// screen-controller.ts and screen-aria.ts, when the product has a screening form (generate.config.json,
|
|
3
|
+
// "ui", kind "screen"). The types of the headless screen controller and of the per-product profile.
|
|
4
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comms-id/sanctions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Typed client for the Comms.ID Sanctions API: screening of one individual, entity or vessel against the Australian Sanctions Consolidated List",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"types": "./dist/browser.d.ts",
|
|
20
20
|
"default": "./dist/browser.js"
|
|
21
21
|
},
|
|
22
|
+
"./controller": {
|
|
23
|
+
"types": "./dist/controller.d.ts",
|
|
24
|
+
"default": "./dist/controller.js"
|
|
25
|
+
},
|
|
22
26
|
"./fixtures": {
|
|
23
27
|
"types": "./dist/fixtures.d.ts",
|
|
24
28
|
"default": "./dist/fixtures.js"
|