@ham2k/extension-sdk 0.1.0 → 0.2.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/docs/forms.md ADDED
@@ -0,0 +1,271 @@
1
+ # Form description schema
2
+
3
+ Extensions can use the Form description schema to request user input through complex forms rendered in either a modal dialog or a dedicated view screen.
4
+
5
+ Forms are structured as a list of fields, headers, section dividers, and markdown text blocks. They also support initial state values and asynchronous validation/transformation callbacks executed back in the JavaScript runtime.
6
+
7
+ ---
8
+
9
+ ## Form Definition Schema
10
+
11
+ All types are defined in `extensions/sdk/src/types.ts`.
12
+
13
+ ```ts
14
+ export type FormFieldType =
15
+ | 'text'
16
+ | 'multiline'
17
+ | 'email'
18
+ | 'callsign'
19
+ | 'number'
20
+ | 'select'
21
+ | 'radio'
22
+ | 'checkbox'
23
+ | 'secret' // obscured text, e.g. passwords/API keys
24
+ | 'list' // a repeatable list of items, each shaped like itemFields
25
+ | 'account'; // a read-only reference into the separate account hook system
26
+
27
+ export interface FormFieldOption {
28
+ label: string;
29
+ value: any;
30
+ }
31
+
32
+ export interface FormField {
33
+ type: 'field';
34
+ fieldType: FormFieldType;
35
+ key: string;
36
+ label: string;
37
+ value?: any; // initial state value if not provided in host call state
38
+ placeholder?: string;
39
+ uppercase?: boolean; // force typed input to uppercase (callsign fields always are)
40
+ options?: FormFieldOption[]; // required for select/radio
41
+ validate?: (value: any, state: Record<string, any>) => string | null | undefined | Promise<string | null | undefined>;
42
+ transform?: (value: any, state: Record<string, any>) => any | Promise<any>;
43
+ // Only used when fieldType === 'list'. `value` is then a
44
+ // Record<string, any>[]; each item is shaped like itemFields. By default
45
+ // the field renders as one summary row (from itemTitle, templated
46
+ // against the item's values, e.g. "${name}" — NOT "{{name}}") that opens
47
+ // an editor dialog with per-item rows and add/edit/delete; `inline: true`
48
+ // instead renders those rows (with reorder handle/delete per row, and an
49
+ // Add row) directly in the form, skipping that outer dialog — for a list
50
+ // that's the main content of its own section. Either way, tapping a row
51
+ // (or Add) opens the same itemFields editor dialog (the form renderer
52
+ // recursively, scoped to itemFields).
53
+ itemFields?: FormField[];
54
+ itemTitle?: string;
55
+ orderable?: boolean;
56
+ inline?: boolean;
57
+ // Singular noun for one item (e.g. "custom note file"), used in the
58
+ // editor dialog's Add/Edit titles and the Add button instead of `label`
59
+ // — which names the list as a whole and is typically plural. Defaults to
60
+ // `label` when omitted.
61
+ itemLabel?: string;
62
+ // Only used when fieldType === 'account'. See settings.md's "account
63
+ // field type" section — this never defines or stores account data
64
+ // itself, it's a pointer into the separate `account` hook system.
65
+ account?: { key: string; subkey?: string };
66
+ // Hidden entirely unless the app's developer mode is on; when shown,
67
+ // rendered in the orange "dev mode" accent (label/icon/subtitle tinted,
68
+ // no badge or border — matches app-polo's `styles.colors.devMode`
69
+ // convention). Every FormElement variant below supports this, not just
70
+ // fields — a whole section header can be dev-mode-only too.
71
+ devMode?: boolean;
72
+ // Settings-panel-only (ignored in ad hoc forms) — see settings.md's
73
+ // "Common Preferences and environment gating". Also shows this field on
74
+ // the app's Common Preferences quick-access panel.
75
+ common?: boolean;
76
+ // Settings-panel-only. Restricts which platform(s) show this field at
77
+ // all: one or more platform tokens (`ios`, `android`, `macos`,
78
+ // `windows`, `linux`, `web`), comma-separated or as an array. Unprefixed
79
+ // tokens are a whitelist; `-`-prefixed tokens are a blacklist. See
80
+ // settings.md.
81
+ environment?: string | string[];
82
+ // Settings-panel-only. Opts this field into a target-scoped settings
83
+ // modal elsewhere in the app — see settings.md's "Settings targets".
84
+ target?: string | string[];
85
+ // Only meaningful within a `kind: 'dynamic'` settingsPanel. After this
86
+ // field commits, the app restarts the extension runtime — for a value
87
+ // only consulted at onActivation (e.g. a list of URLs to register as
88
+ // dataFile hooks). See settings.md's Tier 2 section.
89
+ restartOnChange?: boolean;
90
+ }
91
+
92
+ export interface FormHeader {
93
+ type: 'header';
94
+ title: string;
95
+ subtitle?: string;
96
+ devMode?: boolean;
97
+ environment?: string | string[]; // settings-panel-only; see FormField.environment (not `common` — see settings.md)
98
+ }
99
+
100
+ export interface FormSectionDivider {
101
+ type: 'divider';
102
+ title?: string;
103
+ devMode?: boolean;
104
+ environment?: string | string[]; // settings-panel-only; see FormField.environment
105
+ }
106
+
107
+ export interface FormMarkdownBlock {
108
+ type: 'markdown';
109
+ text: string;
110
+ devMode?: boolean;
111
+ environment?: string | string[]; // settings-panel-only; see FormField.environment
112
+ collapsible?: boolean; // starts collapsed behind a tappable `title` header; requires `title`
113
+ title?: string; // the tappable header shown when `collapsible` is set; ignored otherwise
114
+ }
115
+
116
+ // A button that invokes a named hook method (e.g. 'clearCache') against the
117
+ // current form/panel state and shows the returned string as a result
118
+ // banner. `method` isn't statically declared anywhere — it's resolved
119
+ // against an extra, undeclared property on the registered hook object at
120
+ // dispatch time, the same "extra property beyond the typed interface" the
121
+ // SDK already permits (see settings.md's settingsPanel note). Not used for
122
+ // account credentials — those have their own testCredentials on AccountHook.
123
+ export interface FormActionElement {
124
+ type: 'action';
125
+ key: string;
126
+ label: string;
127
+ method: string;
128
+ devMode?: boolean;
129
+ common?: boolean; // settings-panel-only; see FormField.common
130
+ environment?: string | string[]; // settings-panel-only; see FormField.environment
131
+ }
132
+
133
+ // A row that opens an external URL (icon-row-styled tile with an
134
+ // open-in-new icon button); how it opens is app policy. Links carry no
135
+ // value and never appear in form state.
136
+ export interface FormLinkElement {
137
+ type: 'link';
138
+ key?: string;
139
+ label: string;
140
+ url: string;
141
+ icon?: string;
142
+ description?: string;
143
+ devMode?: boolean;
144
+ common?: boolean; // settings-panel-only; see FormField.common
145
+ environment?: string | string[]; // settings-panel-only; see FormField.environment
146
+ }
147
+
148
+ export type FormElement = FormField | FormHeader | FormSectionDivider | FormMarkdownBlock | FormActionElement | FormLinkElement;
149
+
150
+ export interface FormDefinition {
151
+ title?: string;
152
+ subtitle?: string;
153
+ elements: FormElement[];
154
+ }
155
+ ```
156
+
157
+ The same `FormDefinition`/`FormField` schema is also the basis for the settings panel system — see [settings.md](./settings.md).
158
+
159
+ ## `devMode` elements
160
+
161
+ Any element — a field, a header, a divider, a markdown block, an action
162
+ button — can be marked `devMode: true`. It's then hidden entirely (not
163
+ rendered at all, not part of the submitted values) unless the app's developer
164
+ mode is on; when it is, the element renders normally but tinted in the same
165
+ orange accent app-polo uses for dev-mode-only settings (`styles.colors.devMode`
166
+ — label/icon/subtitle text color, no badge or border). This is host-driven —
167
+ the renderer is told whether developer mode is on, it doesn't check any
168
+ setting itself — so it works the same whether the form comes from an
169
+ extension's ad hoc `host.showForm` call or a `settingsPanel`.
170
+
171
+ ---
172
+
173
+ ## Usage
174
+
175
+ ### 1. Dialog Flow (`host.showForm`)
176
+
177
+ An extension can prompt the user at any time using a modal dialog:
178
+
179
+ ```ts
180
+ import { host } from "@ham2k/extension-sdk";
181
+
182
+ const result = await host.showForm({
183
+ title: "Setup Contest",
184
+ subtitle: "Configure exchange and operator options",
185
+ elements: [
186
+ {
187
+ type: "header",
188
+ title: "Station Parameters"
189
+ },
190
+ {
191
+ type: "field",
192
+ fieldType: "callsign",
193
+ key: "operatorCall",
194
+ label: "Operator Callsign",
195
+ validate: (val) => val.length < 3 ? "Invalid callsign" : null,
196
+ transform: (val) => val.toUpperCase()
197
+ },
198
+ {
199
+ type: "divider"
200
+ },
201
+ {
202
+ type: "field",
203
+ fieldType: "select",
204
+ key: "category",
205
+ label: "Contest Category",
206
+ options: [
207
+ { label: "Single Operator Low Power", value: "SOLP" },
208
+ { label: "Single Operator High Power", value: "SOHP" },
209
+ { label: "Multi Operator", value: "MULTI" }
210
+ ]
211
+ }
212
+ ]
213
+ }, {
214
+ operatorCall: "KI2D" // pre-filled state
215
+ });
216
+
217
+ if (result) {
218
+ console.log("Form values submitted:", result);
219
+ } else {
220
+ console.log("Form cancelled");
221
+ }
222
+ ```
223
+
224
+ ### 2. View/Hook Flow (`form` category)
225
+
226
+ Extensions can also contribute static/predefined forms by registering a hook in the `form` category. The host can query the form by key, validate fields, and transform them:
227
+
228
+ ```ts
229
+ // Static registration in extension code
230
+ api.registerHook('form', {
231
+ key: 'my-contest-settings',
232
+ hook: {
233
+ async getDefinition(args, ctx) {
234
+ return {
235
+ title: "Contest Setup",
236
+ elements: [
237
+ {
238
+ type: "field",
239
+ fieldType: "text",
240
+ key: "exchange",
241
+ label: "State/Section Exchange"
242
+ }
243
+ ]
244
+ };
245
+ },
246
+ async validateField({ fieldKey, value, state }, ctx) {
247
+ if (fieldKey === 'exchange' && value.length !== 2) {
248
+ return "Exchange must be a 2-character abbreviation";
249
+ }
250
+ return null;
251
+ },
252
+ async transformField({ fieldKey, value, state }, ctx) {
253
+ if (fieldKey === 'exchange') {
254
+ return value.toUpperCase();
255
+ }
256
+ return value;
257
+ }
258
+ }
259
+ });
260
+ ```
261
+
262
+ ---
263
+
264
+ ## Validation & Transformation Lifecycle
265
+
266
+ 1. **User input**: When a user fills out text or makes selections, the Dart UI clears any previous error for that field.
267
+ 2. **Submit validation**: When the user clicks **Save / Submit**:
268
+ - The Flutter form renderer triggers validation for each field asynchronously.
269
+ - Any validation error returns back to Dart and is displayed underneath the field. If any fields have errors, submission is aborted.
270
+ 3. **Submit transformation**: When all fields are successfully validated, Dart invokes any JS transformation callbacks for each field.
271
+ 4. **Completion**: The final transformed state is resolved and returned back to the caller (or saved in the view context).