@cosmicdrift/kumiko-renderer 0.38.0 → 0.40.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/package.json +3 -3
- package/src/app/kumiko-screen.tsx +34 -28
- package/src/components/render-edit.tsx +25 -10
- package/src/components/render-field.tsx +4 -2
- package/src/i18n-defaults.ts +35 -0
- package/src/i18n.tsx +15 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +20 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.38.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.38.0",
|
|
20
20
|
"react": "^19.2.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
@@ -5,13 +5,13 @@ import type {
|
|
|
5
5
|
EntityDefinition,
|
|
6
6
|
EntityEditScreenDefinition,
|
|
7
7
|
EntityListScreenDefinition,
|
|
8
|
-
FieldCondition,
|
|
9
8
|
RowAction,
|
|
10
9
|
RowActionWriteHandler,
|
|
11
10
|
RowFieldExtractor,
|
|
12
11
|
ScreenDefinition,
|
|
13
12
|
ToolbarAction,
|
|
14
13
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
|
+
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
15
15
|
import type {
|
|
16
16
|
Command,
|
|
17
17
|
FormSnapshot,
|
|
@@ -46,13 +46,6 @@ function evalRowExtractor(
|
|
|
46
46
|
return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean {
|
|
50
|
-
if (typeof cond === "boolean") return cond;
|
|
51
|
-
const val = values[cond.field];
|
|
52
|
-
if ("eq" in cond) return val === cond.eq;
|
|
53
|
-
return val !== cond.ne;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
49
|
// KumikoScreen picks up a ScreenDefinition from the schema by qn and
|
|
57
50
|
// routes it to the right renderer based on `screen.type`. Command
|
|
58
51
|
// qualification (`<feature>:write:<entity>:create` etc.) happens here
|
|
@@ -695,9 +688,16 @@ function EntityListBody({
|
|
|
695
688
|
// immer da (Provider von createKumikoApp).
|
|
696
689
|
if (action.kind === "navigate") {
|
|
697
690
|
// Default entityId für entityEdit-Targets: row["id"] wenn
|
|
698
|
-
// kein expliziter entityId-Feldname gesetzt ist.
|
|
691
|
+
// kein expliziter entityId-Feldname gesetzt ist. Nur für Targets
|
|
692
|
+
// DERSELBEN Entity — ein Cross-Entity-Edit-Screen bekäme sonst die
|
|
693
|
+
// falsche row.id injiziert, und "Duplicate → Create"-Patterns
|
|
694
|
+
// würden in den Update-Mode gezwungen. Cross-Entity-Navigation
|
|
695
|
+
// setzt action.entityId explizit.
|
|
699
696
|
const targetIsEntityEdit = schema.screens.some(
|
|
700
|
-
(s) =>
|
|
697
|
+
(s) =>
|
|
698
|
+
s.type === "entityEdit" &&
|
|
699
|
+
s.entity === screen.entity &&
|
|
700
|
+
lastSegment(s.id) === action.screen,
|
|
701
701
|
);
|
|
702
702
|
const actionVisible = action.visible;
|
|
703
703
|
return {
|
|
@@ -731,6 +731,11 @@ function EntityListBody({
|
|
|
731
731
|
// NACH navigate: pushState trägt keine Query — Params die
|
|
732
732
|
// vor dem Push gesetzt werden, kleben an der ALTEN URL und
|
|
733
733
|
// sind auf dem Ziel-Screen weg (actionForm-Prefill leer).
|
|
734
|
+
// Bekannte Kante (bewusst offen): zielt die Action auf den
|
|
735
|
+
// AKTUELLEN pathname, short-circuit't pushPath ohne die Query
|
|
736
|
+
// zu leeren — die neuen Params mergen dann auf den alten
|
|
737
|
+
// ?-String. Für Row-Actions praktisch nicht erreichbar
|
|
738
|
+
// (Pfad differiert über entityId/screen).
|
|
734
739
|
nav.setSearchParams(stringified);
|
|
735
740
|
}
|
|
736
741
|
},
|
|
@@ -777,7 +782,7 @@ function EntityListBody({
|
|
|
777
782
|
};
|
|
778
783
|
})
|
|
779
784
|
.filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
|
|
780
|
-
}, [screen.rowActions, effectiveTranslate, dispatcher, nav, schema.screens]);
|
|
785
|
+
}, [screen.rowActions, screen.entity, effectiveTranslate, dispatcher, nav, schema.screens]);
|
|
781
786
|
|
|
782
787
|
// ToolbarActions: Schema → Resolved-Form (analog rowActions).
|
|
783
788
|
// navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
|
|
@@ -1055,7 +1060,7 @@ function ConfigEditBody({
|
|
|
1055
1060
|
}, [valuesQuery.data, screen.fields, screen.configKeys]);
|
|
1056
1061
|
|
|
1057
1062
|
// Sources-Lookup: qualifiedKey → ConfigValueSource für das
|
|
1058
|
-
// ConfigSourceBadge. Wird via
|
|
1063
|
+
// ConfigSourceBadge. Wird via labelAppendix an RenderEdit
|
|
1059
1064
|
// durchgereicht.
|
|
1060
1065
|
const sources = useMemo<Record<string, ConfigValueSource>>(() => {
|
|
1061
1066
|
if (valuesQuery.data === null) return {};
|
|
@@ -1145,25 +1150,26 @@ function ConfigEditBody({
|
|
|
1145
1150
|
customSubmit={customSubmit}
|
|
1146
1151
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1147
1152
|
{...(translate !== undefined && { translate })}
|
|
1148
|
-
|
|
1153
|
+
labelAppendix={(fieldName: string) => {
|
|
1149
1154
|
const source = sources[fieldName];
|
|
1155
|
+
return source ? (
|
|
1156
|
+
<ConfigSourceBadge source={source} screenScope={screen.scope} />
|
|
1157
|
+
) : undefined;
|
|
1158
|
+
}}
|
|
1159
|
+
fieldAppendix={(fieldName: string) => {
|
|
1150
1160
|
const cascade = cascades[fieldName];
|
|
1161
|
+
if (cascade === undefined) return undefined;
|
|
1151
1162
|
return (
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
{
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
await cascadeQuery.refetch?.();
|
|
1163
|
-
}}
|
|
1164
|
-
/>
|
|
1165
|
-
) : null}
|
|
1166
|
-
</>
|
|
1163
|
+
<ConfigCascadeView
|
|
1164
|
+
cascade={cascade}
|
|
1165
|
+
screenScope={screen.scope}
|
|
1166
|
+
qualifiedKey={screen.configKeys[fieldName]}
|
|
1167
|
+
onReset={async (key, scope) => {
|
|
1168
|
+
await dispatcher.write("config:write:reset", { key, scope });
|
|
1169
|
+
await valuesQuery.refetch?.();
|
|
1170
|
+
await cascadeQuery.refetch?.();
|
|
1171
|
+
}}
|
|
1172
|
+
/>
|
|
1167
1173
|
);
|
|
1168
1174
|
}}
|
|
1169
1175
|
/>
|
|
@@ -3,7 +3,11 @@ import type {
|
|
|
3
3
|
EntityEditScreenDefinition,
|
|
4
4
|
FieldCondition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
evalFieldCondition,
|
|
8
|
+
isExtensionEditSection,
|
|
9
|
+
normalizeEditField,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
11
|
import type {
|
|
8
12
|
DispatcherError,
|
|
9
13
|
EditExtensionSectionViewModel,
|
|
@@ -70,7 +74,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
70
74
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
71
75
|
* werden kann ("Genehmigen" / "Versenden" / etc.). */
|
|
72
76
|
readonly submitLabel?: string;
|
|
73
|
-
/** Pro-Field-Zusatz-Inhalt nach dem Label (z.B. ConfigSourceBadge).
|
|
77
|
+
/** Pro-Field-Zusatz-Inhalt inline nach dem Label (z.B. ConfigSourceBadge).
|
|
78
|
+
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
79
|
+
* undefined. */
|
|
80
|
+
readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
81
|
+
/** Pro-Field-Zusatz-Inhalt unter dem Input (z.B. ConfigCascadeView).
|
|
74
82
|
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
75
83
|
* undefined. */
|
|
76
84
|
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
@@ -80,12 +88,8 @@ function toConditionValue<TValues extends FormValues, TCtx>(
|
|
|
80
88
|
cond: FieldCondition,
|
|
81
89
|
): NonNullable<FieldConditions<TValues, TCtx>["visible"]> {
|
|
82
90
|
if (typeof cond === "boolean") return cond;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return (values: TValues) => (values as Record<string, unknown>)[field] === eq;
|
|
86
|
-
}
|
|
87
|
-
const { field, ne } = cond;
|
|
88
|
-
return (values: TValues) => (values as Record<string, unknown>)[field] !== ne;
|
|
91
|
+
// @cast-boundary form-values: TValues ist strukturell ein Record.
|
|
92
|
+
return (values: TValues) => evalFieldCondition(cond, values as Record<string, unknown>);
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
function deriveFormFields<TValues extends FormValues, TCtx>(
|
|
@@ -175,6 +179,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
175
179
|
onCancel,
|
|
176
180
|
onReload,
|
|
177
181
|
submitLabel,
|
|
182
|
+
labelAppendix,
|
|
178
183
|
fieldAppendix,
|
|
179
184
|
entityId: entityIdProp,
|
|
180
185
|
extensionInitialValues,
|
|
@@ -342,8 +347,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
342
347
|
/>
|
|
343
348
|
);
|
|
344
349
|
}
|
|
350
|
+
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
351
|
+
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
352
|
+
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
353
|
+
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
345
354
|
return (
|
|
346
|
-
<Section
|
|
355
|
+
<Section
|
|
356
|
+
key={section.title}
|
|
357
|
+
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
358
|
+
testId={`section-${section.title}`}
|
|
359
|
+
>
|
|
347
360
|
<Grid columns={section.columns}>
|
|
348
361
|
{section.fields.map((field: EditFieldViewModel) => (
|
|
349
362
|
<GridCellForField
|
|
@@ -356,8 +369,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
356
369
|
}}
|
|
357
370
|
GridCell={GridCell}
|
|
358
371
|
featureName={featureName}
|
|
372
|
+
{...(labelAppendix !== undefined && {
|
|
373
|
+
labelAppendix: labelAppendix(field.field),
|
|
374
|
+
})}
|
|
359
375
|
{...(fieldAppendix !== undefined && {
|
|
360
|
-
labelAppendix: fieldAppendix(field.field),
|
|
361
376
|
fieldAppendix: fieldAppendix(field.field),
|
|
362
377
|
})}
|
|
363
378
|
/>
|
|
@@ -39,8 +39,9 @@ export function RenderField({
|
|
|
39
39
|
const { Field, Input } = usePrimitives();
|
|
40
40
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
41
41
|
// navigator.language (Browser-Sprache) zurück statt der gewählten
|
|
42
|
-
// App-Sprache.
|
|
43
|
-
//
|
|
42
|
+
// App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
|
|
43
|
+
// public exportiert und verlangt jetzt einen LocaleProvider —
|
|
44
|
+
// Standalone-Consumer/Tests müssen wrappen (createKumikoApp tut es).
|
|
44
45
|
const appLocale = useLocale().locale();
|
|
45
46
|
if (!field.visible) return null;
|
|
46
47
|
|
|
@@ -261,6 +262,7 @@ function renderInput({
|
|
|
261
262
|
{...common}
|
|
262
263
|
value={stringValue(field.value)}
|
|
263
264
|
onChange={(v) => onChange(v)}
|
|
265
|
+
{...(field.wallClock !== undefined && { wallClock: field.wallClock })}
|
|
264
266
|
/>
|
|
265
267
|
);
|
|
266
268
|
case "select": {
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -71,6 +71,25 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
71
71
|
"kumiko.validation.too-short": "Zu kurz (mindestens {min} Zeichen).",
|
|
72
72
|
"kumiko.validation.too-long": "Zu lang (höchstens {max} Zeichen).",
|
|
73
73
|
"kumiko.validation.out-of-range": "Wert außerhalb des erlaubten Bereichs.",
|
|
74
|
+
|
|
75
|
+
// errors.validation.* — der kanonische Key-Namespace den Server
|
|
76
|
+
// (ValidationError) und Client (zod-bridge) für Field-Issues
|
|
77
|
+
// erzeugen. Codes = Zod-4-Issue-Codes + Framework-eigene.
|
|
78
|
+
"errors.validation.invalid_type": "Ungültiger Wert.",
|
|
79
|
+
"errors.validation.too_small": "Zu klein oder zu kurz (Minimum: {minimum}).",
|
|
80
|
+
"errors.validation.too_big": "Zu groß oder zu lang (Maximum: {maximum}).",
|
|
81
|
+
"errors.validation.invalid_format": "Ungültiges Format.",
|
|
82
|
+
"errors.validation.not_multiple_of": "Muss ein Vielfaches von {divisor} sein.",
|
|
83
|
+
"errors.validation.unrecognized_keys": "Unbekannte Felder.",
|
|
84
|
+
"errors.validation.invalid_union": "Ungültiger Wert.",
|
|
85
|
+
"errors.validation.invalid_key": "Ungültiger Schlüssel.",
|
|
86
|
+
"errors.validation.invalid_element": "Ungültiger Eintrag.",
|
|
87
|
+
"errors.validation.invalid_value": "Ungültige Auswahl.",
|
|
88
|
+
"errors.validation.custom": "Ungültiger Wert.",
|
|
89
|
+
"errors.validation.unexpected_field": "Unbekanntes Feld.",
|
|
90
|
+
"errors.validation.out_of_bounds": "Wert außerhalb des erlaubten Bereichs.",
|
|
91
|
+
"errors.validation.invalid_option": "Ungültige Auswahl.",
|
|
92
|
+
"errors.validation.failed": "Validierung fehlgeschlagen.",
|
|
74
93
|
},
|
|
75
94
|
en: {
|
|
76
95
|
"kumiko.actions.save": "Save",
|
|
@@ -120,5 +139,21 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
120
139
|
"kumiko.validation.too-short": "Too short (at least {min} characters).",
|
|
121
140
|
"kumiko.validation.too-long": "Too long (at most {max} characters).",
|
|
122
141
|
"kumiko.validation.out-of-range": "Value out of allowed range.",
|
|
142
|
+
|
|
143
|
+
"errors.validation.invalid_type": "Invalid value.",
|
|
144
|
+
"errors.validation.too_small": "Too small or too short (minimum: {minimum}).",
|
|
145
|
+
"errors.validation.too_big": "Too big or too long (maximum: {maximum}).",
|
|
146
|
+
"errors.validation.invalid_format": "Invalid format.",
|
|
147
|
+
"errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
|
|
148
|
+
"errors.validation.unrecognized_keys": "Unknown fields.",
|
|
149
|
+
"errors.validation.invalid_union": "Invalid value.",
|
|
150
|
+
"errors.validation.invalid_key": "Invalid key.",
|
|
151
|
+
"errors.validation.invalid_element": "Invalid entry.",
|
|
152
|
+
"errors.validation.invalid_value": "Invalid choice.",
|
|
153
|
+
"errors.validation.custom": "Invalid value.",
|
|
154
|
+
"errors.validation.unexpected_field": "Unknown field.",
|
|
155
|
+
"errors.validation.out_of_bounds": "Value out of allowed range.",
|
|
156
|
+
"errors.validation.invalid_option": "Invalid choice.",
|
|
157
|
+
"errors.validation.failed": "Validation failed.",
|
|
123
158
|
},
|
|
124
159
|
};
|
package/src/i18n.tsx
CHANGED
|
@@ -25,6 +25,21 @@ export type TranslationBundle = Readonly<Record<string, string>>;
|
|
|
25
25
|
/** Map von Locale-Code (BCP-47, z.B. `"de"`, `"en-US"`) → Bundle. */
|
|
26
26
|
export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
|
|
27
27
|
|
|
28
|
+
/** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
|
|
29
|
+
* die Locales werden zusammengeführt. Standard-Baustein für Client-
|
|
30
|
+
* Plugins, die App-Overrides über ihre Default-Bundles legen. */
|
|
31
|
+
export function mergeTranslations(
|
|
32
|
+
base: TranslationsByLocale,
|
|
33
|
+
override: TranslationsByLocale,
|
|
34
|
+
): TranslationsByLocale {
|
|
35
|
+
const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
|
|
36
|
+
const merged: Record<string, Record<string, string>> = {};
|
|
37
|
+
for (const locale of locales) {
|
|
38
|
+
merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
|
|
39
|
+
}
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
type LocaleContextValue = {
|
|
29
44
|
readonly resolver: LocaleResolver;
|
|
30
45
|
readonly fallbackBundles: readonly TranslationsByLocale[];
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -264,10 +264,16 @@ export type InputProps =
|
|
|
264
264
|
readonly kind: "timestamp";
|
|
265
265
|
readonly id: string;
|
|
266
266
|
readonly name: string;
|
|
267
|
-
/** ISO-8601 Datetime-String
|
|
268
|
-
*
|
|
267
|
+
/** ISO-8601 Datetime-String. UTC-Instant mit `Z`
|
|
268
|
+
* ("2026-04-25T13:45:00Z") oder Wall-Clock ohne Offset
|
|
269
|
+
* ("2026-04-25T13:45", nur bei wallClock). Empty-State = `""`.
|
|
270
|
+
* Web nutzt `<input type="datetime-local">` und konvertiert. */
|
|
269
271
|
readonly value: string;
|
|
270
272
|
readonly onChange: (v: string | undefined) => void;
|
|
273
|
+
/** true = locatedTimestamp (Wall-Clock ohne Offset, Server
|
|
274
|
+
* validiert z.iso.datetime({local:true})). false/undefined =
|
|
275
|
+
* UTC-Instant, onChange MUSS mit `Z`-Suffix emittieren. */
|
|
276
|
+
readonly wallClock?: boolean;
|
|
271
277
|
readonly disabled?: boolean;
|
|
272
278
|
readonly required?: boolean;
|
|
273
279
|
readonly hasError?: boolean;
|
|
@@ -404,7 +410,10 @@ export type FormProps = {
|
|
|
404
410
|
* View mit Header-Text. Native-Impls können den Title als Accordion
|
|
405
411
|
* oder Collapsible rendern. */
|
|
406
412
|
export type SectionProps = {
|
|
407
|
-
|
|
413
|
+
/** Optional — ohne Titel rendert die Section nur die Gruppierung
|
|
414
|
+
* (kein Header). RenderEdit lässt ihn weg, wenn er den Screen-Titel
|
|
415
|
+
* der Action-Bar 1:1 wiederholen würde. */
|
|
416
|
+
readonly title?: string;
|
|
408
417
|
readonly children: ReactNode;
|
|
409
418
|
readonly testId?: string;
|
|
410
419
|
};
|
|
@@ -477,9 +486,16 @@ export type DialogProps = {
|
|
|
477
486
|
};
|
|
478
487
|
|
|
479
488
|
/** Source-badge for one cascade step (User / Tenant / System / …).
|
|
480
|
-
* Used inline next to a config value to indicate where it came from.
|
|
489
|
+
* Used inline next to a config value to indicate where it came from.
|
|
490
|
+
* Requires a LocaleProvider above it (labels run through useTranslation)
|
|
491
|
+
* — createKumikoApp wires one; standalone consumers must wrap. */
|
|
481
492
|
export type ConfigSourceBadgeProps = {
|
|
482
493
|
readonly source: ConfigValueSource;
|
|
494
|
+
/** Scope of the hosting screen. Non-system screens collapse sources
|
|
495
|
+
* ABOVE their scope (system-row/app-override/computed) into the neutral
|
|
496
|
+
* default badge — operator internals stay invisible to tenants, same
|
|
497
|
+
* rule as ConfigCascadeView's toDisplayLevels. */
|
|
498
|
+
readonly screenScope?: ConfigScope;
|
|
483
499
|
};
|
|
484
500
|
|
|
485
501
|
/** Collapsible cascade-view that lives under a config-edit input.
|