From b5e8c51ce06d44c836f270de4bfaa3c0c28ef80a Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 11 Nov 2024 17:18:25 -0700 Subject: [PATCH 01/11] sdk input spec improvements --- sdk/base/lib/actions/input/builder/value.ts | 240 +++++++++--------- .../lib/actions/input/builder/variants.ts | 57 +++-- sdk/base/lib/actions/setupActions.ts | 22 +- sdk/package/lib/util/fileHelper.ts | 2 +- 4 files changed, 167 insertions(+), 154 deletions(-) diff --git a/sdk/base/lib/actions/input/builder/value.ts b/sdk/base/lib/actions/input/builder/value.ts index 78318f868a..85b3693c33 100644 --- a/sdk/base/lib/actions/input/builder/value.ts +++ b/sdk/base/lib/actions/input/builder/value.ts @@ -27,36 +27,12 @@ import { unknown, } from "ts-matches" -export type RequiredDefault = - | false - | { - default: A | null - } - -function requiredLikeToAbove, A>( - requiredLike: Input, -) { - // prettier-ignore - return { - required: (typeof requiredLike === 'object' ? true : requiredLike) as ( - Input extends { default: unknown} ? true: - Input extends true ? true : - false - ), - default:(typeof requiredLike === 'object' ? requiredLike.default : null) as ( - Input extends { default: infer Default } ? Default : - null - ) - }; -} -type AsRequired = MaybeRequiredType extends - | { default: unknown } - | never - ? Type - : Type | null | undefined +type AsRequired = Required extends true + ? T + : T | null | undefined const testForAsRequiredParser = once( - () => object({ required: object({ default: unknown }) }).test, + () => object({ required: literal(true) }).test, ) function asRequiredParser< Type, @@ -122,19 +98,19 @@ export class Value { boolean, ) } - static text>(a: { + static text(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: string | RandomString | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'World' } - * @example required: { default: { charset: 'abcdefg', len: 16 } } + * provide a default value. + * @type { string | RandomString | null } + * @example default: null + * @example default: 'World' + * @example default: { charset: 'abcdefg', len: 16 } */ + default: string | RandomString | null required: Required /** * @description Mask (aka camouflage) text input with dots: ● ● ● @@ -188,7 +164,6 @@ export class Value { immutable: a.immutable ?? false, generate: a.generate ?? null, ...a, - ...requiredLikeToAbove(a.required), }), asRequiredParser(string, a), ) @@ -200,7 +175,8 @@ export class Value { name: string description?: string | null warning?: string | null - required: RequiredDefault + default: DefaultString | null + required: boolean masked?: boolean placeholder?: string | null minLength?: number | null @@ -228,19 +204,16 @@ export class Value { immutable: false, generate: a.generate ?? null, ...a, - ...requiredLikeToAbove(a.required), } }, string.optional()) } - static textarea(a: { + static textarea(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null - /** - * @description Unlike other "required" fields, for textarea this is a simple boolean. - */ - required: boolean + default: string | null + required: Required minLength?: number | null maxLength?: number | null placeholder?: string | null @@ -250,20 +223,23 @@ export class Value { */ immutable?: boolean }) { - return new Value(async () => { - const built: ValueSpecTextarea = { - description: null, - warning: null, - minLength: null, - maxLength: null, - placeholder: null, - type: "textarea" as const, - disabled: false, - immutable: a.immutable ?? false, - ...a, - } - return built - }, string) + return new Value, never>( + async () => { + const built: ValueSpecTextarea = { + description: null, + warning: null, + minLength: null, + maxLength: null, + placeholder: null, + type: "textarea" as const, + disabled: false, + immutable: a.immutable ?? false, + ...a, + } + return built + }, + asRequiredParser(string, a), + ) } static dynamicTextarea( getA: LazyBuild< @@ -272,6 +248,7 @@ export class Value { name: string description?: string | null warning?: string | null + default: string | null required: boolean minLength?: number | null maxLength?: number | null @@ -280,7 +257,7 @@ export class Value { } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { description: null, @@ -293,20 +270,20 @@ export class Value { immutable: false, ...a, } - }, string) + }, string.optional()) } - static number>(a: { + static number(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: number | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 7 } + * @description optionally provide a default value. + * @type { default: number | null } + * @example default: null + * @example default: 7 */ + default: number | null required: Required min?: number | null max?: number | null @@ -343,7 +320,6 @@ export class Value { disabled: false, immutable: a.immutable ?? false, ...a, - ...requiredLikeToAbove(a.required), }), asRequiredParser(number, a), ) @@ -355,7 +331,8 @@ export class Value { name: string description?: string | null warning?: string | null - required: RequiredDefault + default: number | null + required: boolean min?: number | null max?: number | null step?: number | null @@ -380,22 +357,21 @@ export class Value { disabled: false, immutable: false, ...a, - ...requiredLikeToAbove(a.required), } }, number.optional()) } - static color>(a: { + static color(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'ffffff' } + * @description optionally provide a default value. + * @type { default: string | null } + * @example default: null + * @example default: 'ffffff' */ + default: string | null required: Required /** * @description Once set, the value can never be changed. @@ -411,9 +387,7 @@ export class Value { disabled: false, immutable: a.immutable ?? false, ...a, - ...requiredLikeToAbove(a.required), }), - asRequiredParser(string, a), ) } @@ -425,7 +399,8 @@ export class Value { name: string description?: string | null warning?: string | null - required: RequiredDefault + default: string | null + required: boolean disabled?: false | string } >, @@ -439,22 +414,21 @@ export class Value { disabled: false, immutable: false, ...a, - ...requiredLikeToAbove(a.required), } }, string.optional()) } - static datetime>(a: { + static datetime(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: '1985-12-16 18:00:00.000' } + * @description optionally provide a default value. + * @type { default: string | null } + * @example default: null + * @example default: '1985-12-16 18:00:00.000' */ + default: string | null required: Required /** * @description Informs the browser how to behave and which date/time component to display. @@ -481,7 +455,6 @@ export class Value { disabled: false, immutable: a.immutable ?? false, ...a, - ...requiredLikeToAbove(a.required), }), asRequiredParser(string, a), ) @@ -493,7 +466,8 @@ export class Value { name: string description?: string | null warning?: string | null - required: RequiredDefault + default: string | null + required: boolean inputmode?: ValueSpecDatetime["inputmode"] min?: string | null max?: string | null @@ -513,13 +487,12 @@ export class Value { disabled: false, immutable: false, ...a, - ...requiredLikeToAbove(a.required), } }, string.optional()) } static select< - Required extends RequiredDefault, Values extends Record, + Required extends boolean, >(a: { name: string description?: string | null @@ -527,11 +500,11 @@ export class Value { warning?: string | null /** * @description Determines if the field is required. If so, optionally provide a default value from the list of values. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'radio1' } + * @type { (keyof Values & string) | null } + * @example default: null + * @example default: 'radio1' */ + default: (keyof Values & string) | null required: Required /** * @description A mapping of unique radio options to their human readable display format. @@ -551,7 +524,7 @@ export class Value { */ immutable?: boolean }) { - return new Value, never>( + return new Value, never>( () => ({ description: null, warning: null, @@ -559,7 +532,6 @@ export class Value { disabled: false, immutable: a.immutable ?? false, ...a, - ...requiredLikeToAbove(a.required), }), asRequiredParser( anyOf( @@ -578,7 +550,8 @@ export class Value { name: string description?: string | null warning?: string | null - required: RequiredDefault + default: string | null + required: boolean values: Record disabled?: false | string | string[] } @@ -593,7 +566,6 @@ export class Value { disabled: false, immutable: false, ...a, - ...requiredLikeToAbove(a.required), } }, string.optional()) } @@ -605,7 +577,7 @@ export class Value { /** * @description A simple list of which options should be checked by default. */ - default: string[] + default: (keyof Values & string)[] /** * @description A mapping of checkbox options to their human readable display format. * @example @@ -689,11 +661,11 @@ export class Value { } }, spec.validator) } - // static file(a: { + // static file(a: { // name: string // description?: string | null // extensions: string[] - // required: boolean + // required: Required // }) { // const buildValue = { // type: "file" as const, @@ -701,14 +673,14 @@ export class Value { // warning: null, // ...a, // } - // return new Value( + // return new Value, Store>( // () => ({ // ...buildValue, // }), // asRequiredParser(object({ filePath: string }), a), // ) // } - // static dynamicFile( + // static dynamicFile( // a: LazyBuild< // Store, // { @@ -716,21 +688,30 @@ export class Value { // description?: string | null // warning?: string | null // extensions: string[] - // required: Required + // required: boolean // } // >, // ) { - // return new Value( + // return new Value( // async (options) => ({ // type: "file" as const, // description: null, // warning: null, // ...(await a(options)), // }), - // string.optional(), + // object({ filePath: string }).optional(), // ) // } - static union, Type, Store>( + static union< + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, + Store, + Required extends boolean, + >( a: { name: string description?: string | null @@ -743,6 +724,7 @@ export class Value { * @example required: { default: null } * @example required: { default: 'variant1' } */ + default: (keyof VariantValues & string) | null required: Required /** * @description Once set, the value can never be changed. @@ -750,9 +732,12 @@ export class Value { */ immutable?: boolean }, - aVariants: Variants, + aVariants: Variants, ) { - return new Value, Store>( + return new Value< + AsRequired, + Store + >( async (options) => ({ type: "union" as const, description: null, @@ -760,34 +745,41 @@ export class Value { disabled: false, ...a, variants: await aVariants.build(options as any), - ...requiredLikeToAbove(a.required), immutable: a.immutable ?? false, }), asRequiredParser(aVariants.validator, a), ) } static filteredUnion< - Required extends RequiredDefault, - Type extends Record, - Store = never, + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, + Store, + Required extends boolean, >( getDisabledFn: LazyBuild, a: { name: string description?: string | null warning?: string | null + default: (keyof VariantValues & string) | null required: Required }, - aVariants: Variants | Variants, + aVariants: Variants | Variants, ) { - return new Value, Store>( + return new Value< + AsRequired, + Store + >( async (options) => ({ type: "union" as const, description: null, warning: null, ...a, variants: await aVariants.build(options as any), - ...requiredLikeToAbove(a.required), disabled: (await getDisabledFn(options)) || false, immutable: false, }), @@ -795,9 +787,14 @@ export class Value { ) } static dynamicUnion< - Required extends RequiredDefault, - Type extends Record, - Store = never, + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, + Store, + Required extends boolean, >( getA: LazyBuild< Store, @@ -805,13 +802,17 @@ export class Value { name: string description?: string | null warning?: string | null + default: (keyof VariantValues & string) | null required: Required disabled: string[] | false | string } >, - aVariants: Variants | Variants, + aVariants: Variants | Variants, ) { - return new Value(async (options) => { + return new Value< + typeof aVariants.validator._TYPE | null | undefined, + Store + >(async (options) => { const newValues = await getA(options) return { type: "union" as const, @@ -819,7 +820,6 @@ export class Value { warning: null, ...newValues, variants: await aVariants.build(options as any), - ...requiredLikeToAbove(newValues.required), immutable: false, } }, aVariants.validator.optional()) diff --git a/sdk/base/lib/actions/input/builder/variants.ts b/sdk/base/lib/actions/input/builder/variants.ts index 6c0f839054..a77a41ce9d 100644 --- a/sdk/base/lib/actions/input/builder/variants.ts +++ b/sdk/base/lib/actions/input/builder/variants.ts @@ -1,6 +1,27 @@ +import { DeepPartial } from "../../../types" import { ValueSpec, ValueSpecUnion } from "../inputSpecTypes" -import { LazyBuild, InputSpec } from "./inputSpec" -import { Parser, anyOf, literals, object } from "ts-matches" +import { LazyBuild, InputSpec, ExtractInputSpecType } from "./inputSpec" +import { Parser, anyOf, literal, literals, object } from "ts-matches" + +export type UnionRes< + Store, + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, + K extends keyof VariantValues & string = keyof VariantValues & string, +> = { + selection: K + value: { + [key in K]: ExtractInputSpecType + } & { + [key in keyof VariantValues & string]: DeepPartial< + ExtractInputSpecType + > + } +} /** * Used in the the Value.select { @link './value.ts' } @@ -51,11 +72,18 @@ export const pruning = Value.union( ); ``` */ -export class Variants { - static text: any +export class Variants< + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, + Store, +> { private constructor( public build: LazyBuild, - public validator: Parser, + public validator: Parser>, ) {} static of< VariantValues extends { @@ -67,26 +95,15 @@ export class Variants { Store = never, >(a: VariantValues) { const validator = anyOf( - ...Object.entries(a).map(([name, { spec }]) => + ...Object.entries(a).map(([id, { spec }]) => object({ - selection: literals(name), + selection: literal(id), value: spec.validator, }), ), ) as Parser - return new Variants< - { - [K in keyof VariantValues]: { - selection: K - // prettier-ignore - value: - VariantValues[K]["spec"] extends (InputSpec | InputSpec) ? B : - never - } - }[keyof VariantValues], - Store - >(async (options) => { + return new Variants(async (options) => { const variants = {} as { [K in keyof VariantValues]: { name: string @@ -118,6 +135,6 @@ export class Variants { ``` */ withStore() { - return this as any as Variants + return this as any as Variants } } diff --git a/sdk/base/lib/actions/setupActions.ts b/sdk/base/lib/actions/setupActions.ts index 62d6cedd96..c16ff521ce 100644 --- a/sdk/base/lib/actions/setupActions.ts +++ b/sdk/base/lib/actions/setupActions.ts @@ -52,15 +52,13 @@ export class Action< | Record | InputSpec | InputSpec, - Type extends - ExtractInputSpecType = ExtractInputSpecType, > { private constructor( readonly id: Id, private readonly metadataFn: MaybeFn, private readonly inputSpec: InputSpecType, - private readonly getInputFn: GetInput, - private readonly runFn: Run, + private readonly getInputFn: GetInput>, + private readonly runFn: Run>, ) {} static withInput< Id extends T.ActionId, @@ -69,15 +67,13 @@ export class Action< | Record | InputSpec | InputSpec, - Type extends - ExtractInputSpecType = ExtractInputSpecType, >( id: Id, metadata: MaybeFn>, inputSpec: InputSpecType, - getInput: GetInput, - run: Run, - ): Action { + getInput: GetInput>, + run: Run>, + ): Action { return new Action( id, mapMaybeFn(metadata, (m) => ({ ...m, hasInput: true })), @@ -90,7 +86,7 @@ export class Action< id: Id, metadata: MaybeFn>, run: Run<{}>, - ): Action { + ): Action { return new Action( id, mapMaybeFn(metadata, (m) => ({ ...m, hasInput: false })), @@ -114,7 +110,7 @@ export class Action< } async run(options: { effects: T.Effects - input: Type + input: ExtractInputSpecType }): Promise { return (await this.runFn(options)) || null } @@ -122,13 +118,13 @@ export class Action< export class Actions< Store, - AllActions extends Record>, + AllActions extends Record>, > { private constructor(private readonly actions: AllActions) {} static of(): Actions { return new Actions({}) } - addAction>( + addAction>( action: A, ): Actions { return new Actions({ ...this.actions, [action.id]: action }) diff --git a/sdk/package/lib/util/fileHelper.ts b/sdk/package/lib/util/fileHelper.ts index 615bcfdc91..07dfc35977 100644 --- a/sdk/package/lib/util/fileHelper.ts +++ b/sdk/package/lib/util/fileHelper.ts @@ -164,7 +164,7 @@ export class FileHelper { /** * Accepts partial structured data and performs a merge with the existing file on disk. */ - async merge(data: Partial) { + async merge(data: T.DeepPartial) { const fileData = (await this.readOnce()) || (() => { From ffd6281c36b67928a1e5cf56403fa471915b18fc Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Tue, 12 Nov 2024 16:22:20 -0700 Subject: [PATCH 02/11] more sdk changes --- .../SystemForEmbassy/transformConfigSpec.ts | 2 - sdk/base/lib/actions/index.ts | 45 +++---- sdk/base/lib/actions/input/builder/value.ts | 90 +++++-------- .../lib/actions/input/builder/variants.ts | 8 +- .../lib/actions/input/inputSpecConstants.ts | 22 ++-- sdk/base/lib/actions/input/inputSpecTypes.ts | 2 - sdk/base/lib/types.ts | 2 +- sdk/package/lib/StartSdk.ts | 124 +++++++++--------- sdk/package/lib/test/inputSpecBuilder.test.ts | 124 +++++++++++++----- sdk/package/lib/test/output.test.ts | 2 +- sdk/package/scripts/oldSpecToBuilder.ts | 41 ++---- 11 files changed, 229 insertions(+), 233 deletions(-) diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/transformConfigSpec.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/transformConfigSpec.ts index 4c074a1bdb..1eb2ea508d 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/transformConfigSpec.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/transformConfigSpec.ts @@ -43,7 +43,6 @@ export function transformConfigSpec(oldSpec: OldConfigSpec): IST.InputSpec { }), {}, ), - required: false, disabled: false, immutable: false, } @@ -127,7 +126,6 @@ export function transformConfigSpec(oldSpec: OldConfigSpec): IST.InputSpec { {} as Record, ), disabled: false, - required: true, default: oldVal.default, immutable: false, } diff --git a/sdk/base/lib/actions/index.ts b/sdk/base/lib/actions/index.ts index 2d052e0721..4bcbec8b19 100644 --- a/sdk/base/lib/actions/index.ts +++ b/sdk/base/lib/actions/index.ts @@ -1,6 +1,7 @@ import * as T from "../types" import * as IST from "../actions/input/inputSpecTypes" import { Action } from "./setupActions" +import { ExtractInputSpecType } from "./input/builder/inputSpec" export type RunActionInput = | Input @@ -44,36 +45,32 @@ export const runAction = async < }) } } -type GetActionInputType< - A extends Action>, -> = A extends Action ? I : never +type GetActionInputType> = + A extends Action ? ExtractInputSpecType : never type ActionRequestBase = { reason?: string replayId?: string } -type ActionRequestInput< - T extends Action>, -> = { +type ActionRequestInput> = { kind: "partial" value: Partial> } -export type ActionRequestOptions< - T extends Action>, -> = ActionRequestBase & - ( - | { - when?: Exclude< - T.ActionRequestTrigger, - { condition: "input-not-matches" } - > - input?: ActionRequestInput - } - | { - when: T.ActionRequestTrigger & { condition: "input-not-matches" } - input: ActionRequestInput - } - ) +export type ActionRequestOptions> = + ActionRequestBase & + ( + | { + when?: Exclude< + T.ActionRequestTrigger, + { condition: "input-not-matches" } + > + input?: ActionRequestInput + } + | { + when: T.ActionRequestTrigger & { condition: "input-not-matches" } + input: ActionRequestInput + } + ) const _validate: T.ActionRequest = {} as ActionRequestOptions & { actionId: string @@ -81,9 +78,7 @@ const _validate: T.ActionRequest = {} as ActionRequestOptions & { severity: T.ActionSeverity } -export const requestAction = < - T extends Action>, ->(options: { +export const requestAction = >(options: { effects: T.Effects packageId: T.PackageId action: T diff --git a/sdk/base/lib/actions/input/builder/value.ts b/sdk/base/lib/actions/input/builder/value.ts index 85b3693c33..0243b516b4 100644 --- a/sdk/base/lib/actions/input/builder/value.ts +++ b/sdk/base/lib/actions/input/builder/value.ts @@ -490,10 +490,7 @@ export class Value { } }, string.optional()) } - static select< - Values extends Record, - Required extends boolean, - >(a: { + static select>(a: { name: string description?: string | null /** Presents a warning prompt before permitting the value to change. */ @@ -504,8 +501,7 @@ export class Value { * @example default: null * @example default: 'radio1' */ - default: (keyof Values & string) | null - required: Required + default: keyof Values & string /** * @description A mapping of unique radio options to their human readable display format. * @example @@ -524,7 +520,7 @@ export class Value { */ immutable?: boolean }) { - return new Value, never>( + return new Value( () => ({ description: null, warning: null, @@ -533,14 +529,9 @@ export class Value { immutable: a.immutable ?? false, ...a, }), - asRequiredParser( - anyOf( - ...Object.keys(a.values).map((x: keyof Values & string) => - literal(x), - ), - ), - a, - ) as any, + anyOf( + ...Object.keys(a.values).map((x: keyof Values & string) => literal(x)), + ), ) } static dynamicSelect( @@ -550,14 +541,13 @@ export class Value { name: string description?: string | null warning?: string | null - default: string | null - required: boolean + default: string values: Record disabled?: false | string | string[] } >, ) { - return new Value(async (options) => { + return new Value(async (options) => { const a = await getA(options) return { description: null, @@ -567,7 +557,7 @@ export class Value { immutable: false, ...a, } - }, string.optional()) + }, string) } static multiselect>(a: { name: string @@ -710,7 +700,6 @@ export class Value { } }, Store, - Required extends boolean, >( a: { name: string @@ -718,14 +707,11 @@ export class Value { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value from the list of variants. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'variant1' } + * @description Provide a default value from the list of variants. + * @type { string } + * @example default: 'variant1' */ - default: (keyof VariantValues & string) | null - required: Required + default: keyof VariantValues & string /** * @description Once set, the value can never be changed. * @default false @@ -734,10 +720,7 @@ export class Value { }, aVariants: Variants, ) { - return new Value< - AsRequired, - Store - >( + return new Value( async (options) => ({ type: "union" as const, description: null, @@ -747,7 +730,7 @@ export class Value { variants: await aVariants.build(options as any), immutable: a.immutable ?? false, }), - asRequiredParser(aVariants.validator, a), + aVariants.validator, ) } static filteredUnion< @@ -758,22 +741,17 @@ export class Value { } }, Store, - Required extends boolean, >( getDisabledFn: LazyBuild, a: { name: string description?: string | null warning?: string | null - default: (keyof VariantValues & string) | null - required: Required + default: keyof VariantValues & string }, aVariants: Variants | Variants, ) { - return new Value< - AsRequired, - Store - >( + return new Value( async (options) => ({ type: "union" as const, description: null, @@ -783,7 +761,7 @@ export class Value { disabled: (await getDisabledFn(options)) || false, immutable: false, }), - asRequiredParser(aVariants.validator, a), + aVariants.validator, ) } static dynamicUnion< @@ -794,7 +772,6 @@ export class Value { } }, Store, - Required extends boolean, >( getA: LazyBuild< Store, @@ -802,27 +779,26 @@ export class Value { name: string description?: string | null warning?: string | null - default: (keyof VariantValues & string) | null - required: Required + default: keyof VariantValues & string disabled: string[] | false | string } >, aVariants: Variants | Variants, ) { - return new Value< - typeof aVariants.validator._TYPE | null | undefined, - Store - >(async (options) => { - const newValues = await getA(options) - return { - type: "union" as const, - description: null, - warning: null, - ...newValues, - variants: await aVariants.build(options as any), - immutable: false, - } - }, aVariants.validator.optional()) + return new Value( + async (options) => { + const newValues = await getA(options) + return { + type: "union" as const, + description: null, + warning: null, + ...newValues, + variants: await aVariants.build(options as any), + immutable: false, + } + }, + aVariants.validator, + ) } static list(a: List) { diff --git a/sdk/base/lib/actions/input/builder/variants.ts b/sdk/base/lib/actions/input/builder/variants.ts index a77a41ce9d..1b71d5e019 100644 --- a/sdk/base/lib/actions/input/builder/variants.ts +++ b/sdk/base/lib/actions/input/builder/variants.ts @@ -14,10 +14,9 @@ export type UnionRes< K extends keyof VariantValues & string = keyof VariantValues & string, > = { selection: K - value: { - [key in K]: ExtractInputSpecType - } & { - [key in keyof VariantValues & string]: DeepPartial< + value: ExtractInputSpecType + other?: { + [key in keyof VariantValues & string]?: DeepPartial< ExtractInputSpecType > } @@ -65,7 +64,6 @@ export const pruning = Value.union( description: '- Disabled: Disable pruning\n- Automatic: Limit blockchain size on disk to a certain number of megabytes\n- Manual: Prune blockchain with the "pruneblockchain" RPC\n', warning: null, - required: true, default: "disabled", }, pruningSettingsVariants diff --git a/sdk/base/lib/actions/input/inputSpecConstants.ts b/sdk/base/lib/actions/input/inputSpecConstants.ts index 3beaefd512..57bf8a79bb 100644 --- a/sdk/base/lib/actions/input/inputSpecConstants.ts +++ b/sdk/base/lib/actions/input/inputSpecConstants.ts @@ -10,35 +10,34 @@ import { Variants } from "./builder/variants" export const customSmtp = InputSpec.of, never>({ server: Value.text({ name: "SMTP Server", - required: { - default: null, - }, + required: true, + default: null, }), port: Value.number({ name: "Port", - required: { default: 587 }, + required: true, + default: 587, min: 1, max: 65535, integer: true, }), from: Value.text({ name: "From Address", - required: { - default: null, - }, + required: true, + default: null, placeholder: "test@example.com", inputmode: "email", patterns: [Patterns.email], }), login: Value.text({ name: "Login", - required: { - default: null, - }, + required: true, + default: null, }), password: Value.text({ name: "Password", required: false, + default: null, masked: true, }), }) @@ -54,7 +53,7 @@ export const smtpInputSpec = Value.filteredUnion( { name: "SMTP", description: "Optionally provide an SMTP server for sending emails", - required: { default: "disabled" }, + default: "disabled", }, Variants.of({ disabled: { name: "Disabled", spec: InputSpec.of({}) }, @@ -66,6 +65,7 @@ export const smtpInputSpec = Value.filteredUnion( description: "A custom from address for this service. If not provided, the system from address will be used.", required: false, + default: null, placeholder: "test@example.com", inputmode: "email", patterns: [Patterns.email], diff --git a/sdk/base/lib/actions/input/inputSpecTypes.ts b/sdk/base/lib/actions/input/inputSpecTypes.ts index ee9189ae36..362a56ea10 100644 --- a/sdk/base/lib/actions/input/inputSpecTypes.ts +++ b/sdk/base/lib/actions/input/inputSpecTypes.ts @@ -115,7 +115,6 @@ export type ValueSpecSelect = { description: string | null warning: string | null type: "select" - required: boolean default: string | null disabled: false | string | string[] immutable: boolean @@ -158,7 +157,6 @@ export type ValueSpecUnion = { } > disabled: false | string | string[] - required: boolean default: string | null immutable: boolean } diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index d7ab1e51b0..ab1acaa877 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -86,7 +86,7 @@ export namespace ExpectedExports { export type actions = Actions< any, - Record> + Record> > } export type ABI = { diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index f41961d166..28a14e1e55 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -1,7 +1,4 @@ -import { - RequiredDefault, - Value, -} from "../../base/lib/actions/input/builder/value" +import { Value } from "../../base/lib/actions/input/builder/value" import { InputSpec, ExtractInputSpecType, @@ -141,9 +138,7 @@ export class StartSdk { ...startSdkEffectWrapper, action: { run: actions.runAction, - request: < - T extends Action>, - >( + request: >( effects: T.Effects, packageId: T.PackageId, action: T, @@ -157,9 +152,7 @@ export class StartSdk { severity, options: options, }), - requestOwn: < - T extends Action>, - >( + requestOwn: >( effects: T.Effects, action: T, severity: T.ActionSeverity, @@ -1060,14 +1053,14 @@ export class StartSdk { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: string | RandomString | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'World' } - * @example required: { default: { charset: 'abcdefg', len: 16 } } + * @description optionally provide a default value. + * @type { string | RandomString | null } + * @example default: null + * @example default: 'World' + * @example default: { charset: 'abcdefg', len: 16 } */ - required: RequiredDefault + default: DefaultString | null + required: boolean /** * @description Mask (aka camouflage) text input with dots: ● ● ● * @default false @@ -1110,15 +1103,12 @@ export class StartSdk { description?: string | null /** Presents a warning prompt before permitting the value to change. */ warning?: string | null - /** - * @description Unlike other "required" fields, for textarea this is a simple boolean. - */ + default: string | null required: boolean minLength?: number | null maxLength?: number | null placeholder?: string | null disabled?: false | string - generate?: null | RandomString } >, ) => Value.dynamicTextarea(getA), @@ -1131,13 +1121,13 @@ export class StartSdk { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: number | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 7 } + * @description optionally provide a default value. + * @type { number | null } + * @example default: null + * @example default: 7 */ - required: RequiredDefault + default: number | null + required: boolean min?: number | null max?: number | null /** @@ -1167,13 +1157,13 @@ export class StartSdk { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'ffffff' } + * @description optionally provide a default value. + * @type { string | null } + * @example default: null + * @example default: 'ffffff' */ - required: RequiredDefault + default: string | null + required: boolean disabled?: false | string } >, @@ -1187,13 +1177,13 @@ export class StartSdk { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: '1985-12-16 18:00:00.000' } + * @description optionally provide a default value. + * @type { string | null } + * @example default: null + * @example default: '1985-12-16 18:00:00.000' */ - required: RequiredDefault + default: string + required: boolean /** * @description Informs the browser how to behave and which date/time component to display. * @default "datetime-local" @@ -1205,7 +1195,7 @@ export class StartSdk { } >, ) => Value.dynamicDatetime(getA), - dynamicSelect: ( + dynamicSelect: >( getA: LazyBuild< Store, { @@ -1214,13 +1204,12 @@ export class StartSdk { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value from the list of values. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'radio1' } + * @description provide a default value from the list of values. + * @type { default: string } + * @example default: 'radio1' */ - required: RequiredDefault + default: keyof Variants & string + required: boolean /** * @description A mapping of unique radio options to their human readable display format. * @example @@ -1232,7 +1221,7 @@ export class StartSdk { } * ``` */ - values: Record + values: Variants /** * @options * - false - The field can be modified. @@ -1282,27 +1271,37 @@ export class StartSdk { >, ) => Value.dynamicMultiselect(getA), filteredUnion: < - Required extends RequiredDefault, - Type extends Record, + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, >( getDisabledFn: LazyBuild, a: { name: string description?: string | null warning?: string | null - required: Required + default: keyof VariantValues & string }, - aVariants: Variants | Variants, + aVariants: + | Variants + | Variants, ) => - Value.filteredUnion( + Value.filteredUnion( getDisabledFn, a, aVariants, ), dynamicUnion: < - Required extends RequiredDefault, - Type extends Record, + VariantValues extends { + [K in string]: { + name: string + spec: InputSpec | InputSpec + } + }, >( getA: LazyBuild< Store, @@ -1312,13 +1311,12 @@ export class StartSdk { /** Presents a warning prompt before permitting the value to change. */ warning?: string | null /** - * @description Determines if the field is required. If so, optionally provide a default value from the list of variants. - * @type { false | { default: string | null } } - * @example required: false - * @example required: { default: null } - * @example required: { default: 'variant1' } + * @description provide a default value from the list of variants. + * @type { string } + * @example default: 'variant1' */ - required: Required + default: keyof VariantValues & string + required: boolean /** * @options * - false - The field can be modified. @@ -1329,8 +1327,10 @@ export class StartSdk { disabled: false | string | string[] } >, - aVariants: Variants | Variants, - ) => Value.dynamicUnion(getA, aVariants), + aVariants: + | Variants + | Variants, + ) => Value.dynamicUnion(getA, aVariants), }, Variants: { of: < diff --git a/sdk/package/lib/test/inputSpecBuilder.test.ts b/sdk/package/lib/test/inputSpecBuilder.test.ts index f9d4321a63..3eb0588c91 100644 --- a/sdk/package/lib/test/inputSpecBuilder.test.ts +++ b/sdk/package/lib/test/inputSpecBuilder.test.ts @@ -17,7 +17,8 @@ describe("builder tests", () => { "peer-tor-address": Value.text({ name: "Peer tor address", description: "The Tor address of the peer interface", - required: { default: null }, + required: true, + default: null, }), }).build({} as any) expect(bitcoinPropertiesBuilt).toMatchObject({ @@ -55,7 +56,8 @@ describe("values", () => { test("text", async () => { const value = Value.text({ name: "Testing", - required: { default: null }, + required: true, + default: null, }) const validator = value.validator const rawIs = await value.build({} as any) @@ -66,7 +68,8 @@ describe("values", () => { test("text with default", async () => { const value = Value.text({ name: "Testing", - required: { default: "this is a default value" }, + required: true, + default: "this is a default value", }) const validator = value.validator const rawIs = await value.build({} as any) @@ -78,6 +81,7 @@ describe("values", () => { const value = Value.text({ name: "Testing", required: false, + default: null, }) const validator = value.validator const rawIs = await value.build({} as any) @@ -89,6 +93,7 @@ describe("values", () => { const value = Value.color({ name: "Testing", required: false, + default: null, description: null, warning: null, }) @@ -99,7 +104,8 @@ describe("values", () => { test("datetime", async () => { const value = Value.datetime({ name: "Testing", - required: { default: null }, + required: true, + default: null, description: null, warning: null, inputmode: "date", @@ -114,6 +120,7 @@ describe("values", () => { const value = Value.datetime({ name: "Testing", required: false, + default: null, description: null, warning: null, inputmode: "date", @@ -128,6 +135,7 @@ describe("values", () => { const value = Value.textarea({ name: "Testing", required: false, + default: null, description: null, warning: null, minLength: null, @@ -136,12 +144,13 @@ describe("values", () => { }) const validator = value.validator validator.unsafeCast("test text") - testOutput()(null) + testOutput()(null) }) test("number", async () => { const value = Value.number({ name: "Testing", - required: { default: null }, + required: true, + default: null, integer: false, description: null, warning: null, @@ -159,6 +168,7 @@ describe("values", () => { const value = Value.number({ name: "Testing", required: false, + default: null, integer: false, description: null, warning: null, @@ -175,7 +185,7 @@ describe("values", () => { test("select", async () => { const value = Value.select({ name: "Testing", - required: { default: null }, + default: "a", values: { a: "A", b: "B", @@ -192,7 +202,7 @@ describe("values", () => { test("nullable select", async () => { const value = Value.select({ name: "Testing", - required: false, + default: "a", values: { a: "A", b: "B", @@ -203,8 +213,7 @@ describe("values", () => { const validator = value.validator validator.unsafeCast("a") validator.unsafeCast("b") - validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) }) test("multiselect", async () => { const value = Value.multiselect({ @@ -250,7 +259,7 @@ describe("values", () => { const value = Value.union( { name: "Testing", - required: { default: null }, + default: "a", description: null, warning: null, }, @@ -271,7 +280,18 @@ describe("values", () => { const validator = value.validator validator.unsafeCast({ selection: "a", value: { b: false } }) type Test = typeof validator._TYPE - testOutput()(null) + testOutput< + Test, + { + selection: "a" + value: { + b: boolean + } + other?: { + a?: { b?: boolean } + } + } + >()(null) }) describe("dynamic", () => { @@ -301,7 +321,8 @@ describe("values", () => { test("text", async () => { const value = Value.dynamicText(async () => ({ name: "Testing", - required: { default: null }, + required: true, + default: null, })) const validator = value.validator const rawIs = await value.build({} as any) @@ -317,7 +338,8 @@ describe("values", () => { test("text with default", async () => { const value = Value.dynamicText(async () => ({ name: "Testing", - required: { default: "this is a default value" }, + required: true, + default: "this is a default value", })) const validator = value.validator validator.unsafeCast("test text") @@ -333,6 +355,7 @@ describe("values", () => { const value = Value.dynamicText(async () => ({ name: "Testing", required: false, + default: null, })) const validator = value.validator const rawIs = await value.build({} as any) @@ -349,6 +372,7 @@ describe("values", () => { const value = Value.dynamicColor(async () => ({ name: "Testing", required: false, + default: null, description: null, warning: null, })) @@ -414,7 +438,8 @@ describe("values", () => { return { name: "Testing", - required: { default: null }, + required: true, + default: null, inputmode: "date", } }, @@ -436,6 +461,7 @@ describe("values", () => { const value = Value.dynamicTextarea(async () => ({ name: "Testing", required: false, + default: null, description: null, warning: null, minLength: null, @@ -444,8 +470,7 @@ describe("values", () => { })) const validator = value.validator validator.unsafeCast("test text") - expect(() => validator.unsafeCast(null)).toThrowError() - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", required: false, @@ -454,7 +479,8 @@ describe("values", () => { test("number", async () => { const value = Value.dynamicNumber(() => ({ name: "Testing", - required: { default: null }, + required: true, + default: null, integer: false, description: null, warning: null, @@ -477,7 +503,7 @@ describe("values", () => { test("select", async () => { const value = Value.dynamicSelect(() => ({ name: "Testing", - required: { default: null }, + default: "a", values: { a: "A", b: "B", @@ -489,11 +515,9 @@ describe("values", () => { validator.unsafeCast("a") validator.unsafeCast("b") validator.unsafeCast("c") - validator.unsafeCast(null) - testOutput()(null) + testOutput()(null) expect(await value.build(fakeOptions)).toMatchObject({ name: "Testing", - required: true, }) }) test("multiselect", async () => { @@ -529,7 +553,7 @@ describe("values", () => { () => ["a", "c"], { name: "Testing", - required: { default: null }, + default: "a", description: null, warning: null, }, @@ -563,8 +587,24 @@ describe("values", () => { type Test = typeof validator._TYPE testOutput< Test, - | { selection: "a"; value: { b: boolean } } - | { selection: "b"; value: { b: boolean } } + { + selection: "a" | "b" + value: + | { + b: boolean + } + | { + b: boolean + } + other?: { + a?: { + b?: boolean + } + b?: { + b?: boolean + } + } + } >()(null) const built = await value.build({} as any) @@ -596,7 +636,7 @@ describe("values", () => { () => ({ disabled: ["a", "c"], name: "Testing", - required: { default: null }, + default: "b", description: null, warning: null, }), @@ -630,10 +670,24 @@ describe("values", () => { type Test = typeof validator._TYPE testOutput< Test, - | { selection: "a"; value: { b: boolean } } - | { selection: "b"; value: { b: boolean } } - | null - | undefined + { + selection: "a" | "b" + value: + | { + b: boolean + } + | { + b: boolean + } + other?: { + a?: { + b?: boolean + } + b?: { + b?: boolean + } + } + } >()(null) const built = await value.build({} as any) @@ -728,6 +782,7 @@ describe("Nested nullable values", () => { description: "If no name is provided, the name from inputSpec will be used", required: false, + default: null, }), }) const validator = value.validator @@ -743,6 +798,7 @@ describe("Nested nullable values", () => { description: "If no name is provided, the name from inputSpec will be used", required: false, + default: null, warning: null, placeholder: null, integer: false, @@ -765,6 +821,7 @@ describe("Nested nullable values", () => { description: "If no name is provided, the name from inputSpec will be used", required: false, + default: null, warning: null, }), }) @@ -780,7 +837,7 @@ describe("Nested nullable values", () => { name: "Temp Name", description: "If no name is provided, the name from inputSpec will be used", - required: false, + default: "a", warning: null, values: { a: "A", @@ -791,7 +848,7 @@ describe("Nested nullable values", () => { name: "Temp Name", description: "If no name is provided, the name from inputSpec will be used", - required: false, + default: "a", warning: null, values: { a: "A", @@ -799,10 +856,9 @@ describe("Nested nullable values", () => { }).build({} as any) const validator = value.validator - validator.unsafeCast({ a: null }) validator.unsafeCast({ a: "a" }) expect(() => validator.unsafeCast({ a: "4" })).toThrowError() - testOutput()(null) + testOutput()(null) }) test("Testing multiselect", async () => { const value = InputSpec.of({ diff --git a/sdk/package/lib/test/output.test.ts b/sdk/package/lib/test/output.test.ts index 37636e52f1..53d006274a 100644 --- a/sdk/package/lib/test/output.test.ts +++ b/sdk/package/lib/test/output.test.ts @@ -87,7 +87,7 @@ describe("Inputs", () => { dbcache: 5, pruning: { selection: "disabled", - value: {}, + value: { disabled: {} }, }, blockfilters: { blockfilterindex: false, diff --git a/sdk/package/scripts/oldSpecToBuilder.ts b/sdk/package/scripts/oldSpecToBuilder.ts index 04128f2bd6..11ef303407 100644 --- a/sdk/package/scripts/oldSpecToBuilder.ts +++ b/sdk/package/scripts/oldSpecToBuilder.ts @@ -85,6 +85,7 @@ const {InputSpec, List, Value, Variants} = sdk description: value.description || null, warning: value.warning || null, required: !(value.nullable || false), + default: value.default, placeholder: value.placeholder || null, maxLength: null, minLength: null, @@ -96,12 +97,8 @@ const {InputSpec, List, Value, Variants} = sdk return `${rangeToTodoComment(value?.range)}Value.text(${JSON.stringify( { name: value.name || null, - // prettier-ignore - required: ( - value.default != null ? {default: value.default} : - value.nullable === false ? {default: null} : - !value.nullable - ), + default: value.default || null, + required: !value.nullable, description: value.description || null, warning: value.warning || null, masked: value.masked || false, @@ -130,12 +127,8 @@ const {InputSpec, List, Value, Variants} = sdk name: value.name || null, description: value.description || null, warning: value.warning || null, - // prettier-ignore - required: ( - value.default != null ? {default: value.default} : - value.nullable === false ? {default: null} : - !value.nullable - ), + default: value.default || null, + required: !value.nullable, min: null, max: null, step: null, @@ -174,13 +167,7 @@ const {InputSpec, List, Value, Variants} = sdk name: value.name || null, description: value.description || null, warning: value.warning || null, - - // prettier-ignore - required:( - value.default != null ? {default: value.default} : - value.nullable === false ? {default: null} : - !value.nullable - ), + default: value.default, values, }, null, @@ -207,14 +194,7 @@ const {InputSpec, List, Value, Variants} = sdk name: ${JSON.stringify(value.name || null)}, description: ${JSON.stringify(value.tag.description || null)}, warning: ${JSON.stringify(value.tag.warning || null)}, - - // prettier-ignore - required: ${JSON.stringify( - // prettier-ignore - value.default != null ? {default: value.default} : - value.nullable === false ? {default: null} : - !value.nullable, - )}, + default: ${JSON.stringify(value.default)}, }, ${variants})` } case "list": { @@ -341,12 +321,7 @@ const {InputSpec, List, Value, Variants} = sdk value?.spec?.tag?.description || null, )}, warning: ${JSON.stringify(value?.spec?.tag?.warning || null)}, - required: ${JSON.stringify( - // prettier-ignore - 'default' in value?.spec ? {default: value?.spec?.default} : - !!value?.spec?.tag?.nullable || false ? {default: null} : - false, - )}, + default: ${JSON.stringify(value?.spec?.default || null)}, }, ${variants}) `, ) From e7eb17c6978bd7d319169e379287ffa7f52c188a Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 12 Nov 2024 19:58:33 -0700 Subject: [PATCH 03/11] fe changes --- .../backup-drives/backup-drives.component.ts | 10 +- .../form-select/form-select.component.html | 5 +- .../server-show/server-show.page.ts | 15 +- .../ui/src/app/services/api/api.fixures.ts | 136 ++++++++---------- .../ui/src/app/services/form.service.ts | 12 +- 5 files changed, 74 insertions(+), 104 deletions(-) diff --git a/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts b/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts index 743d3546b8..8594330772 100644 --- a/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts +++ b/web/projects/ui/src/app/components/backup-drives/backup-drives.component.ts @@ -268,25 +268,29 @@ const cifsSpec = ISB.InputSpec.of({ 'The hostname of your target device on the Local Area Network.', warning: null, placeholder: `e.g. 'My Computer' OR 'my-computer.local'`, - required: { default: null }, + required: true, + default: null, patterns: [], }), path: ISB.Value.text({ name: 'Path', description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`, placeholder: 'e.g. my-shared-folder or /Desktop/my-folder', - required: { default: null }, + required: true, + default: null, }), username: ISB.Value.text({ name: 'Username', description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`, - required: { default: null }, + required: true, + default: null, placeholder: 'My Network Folder', }), password: ISB.Value.text({ name: 'Password', description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`, required: false, + default: null, masked: true, placeholder: 'My Network Folder', }), diff --git a/web/projects/ui/src/app/components/form/form-select/form-select.component.html b/web/projects/ui/src/app/components/form/form-select/form-select.component.html index fe2b561c7b..9149e8844c 100644 --- a/web/projects/ui/src/app/components/form/form-select/form-select.component.html +++ b/web/projects/ui/src/app/components/form/form-select/form-select.component.html @@ -2,13 +2,12 @@ [tuiHintContent]="spec | hint" [disabled]="disabled" [readOnly]="readOnly" - [tuiTextfieldCleaner]="!spec.required" + [tuiTextfieldCleaner]="false" [pseudoInvalid]="invalid" [(ngModel)]="selected" (focusedChange)="onFocus($event)" > - {{ spec.name }} - * + {{ spec.name }}*