diff --git a/lib/config/builder/config.ts b/lib/config/builder/config.ts index 110903f..905c2c7 100644 --- a/lib/config/builder/config.ts +++ b/lib/config/builder/config.ts @@ -5,13 +5,13 @@ import { _ } from "../../util" import { Effects } from "../../types" import { Parser, object } from "ts-matches" -export type LazyBuildOptions = { +export type LazyBuildOptions = { effects: Effects - utils: Utils + utils: Utils config: ConfigType | null } -export type LazyBuild = ( - options: LazyBuildOptions, +export type LazyBuild = ( + options: LazyBuildOptions, ) => Promise | ExpectedOut export type MaybeLazyValues = LazyBuild | A @@ -88,8 +88,8 @@ export class Config, WD, ConfigType> { return answer } - static of, Manifest, ConfigType>(spec: { - [K in keyof Type]: Value + static of, WrapperData, ConfigType>(spec: { + [K in keyof Type]: Value }) { const validatorObj = {} as { [K in keyof Type]: Parser @@ -98,6 +98,16 @@ export class Config, WD, ConfigType> { validatorObj[key] = spec[key].validator } const validator = object(validatorObj) - return new Config(spec, validator) + return new Config(spec, validator) + } + + static withWrapperData() { + return { + of>(spec: { + [K in keyof Type]: Value + }) { + return Config.of(spec) + }, + } } } diff --git a/lib/config/builder/value.ts b/lib/config/builder/value.ts index a7f9e82..f84e236 100644 --- a/lib/config/builder/value.ts +++ b/lib/config/builder/value.ts @@ -1,4 +1,4 @@ -import { Config, LazyBuild } from "./config" +import { Config, LazyBuild, LazyBuildOptions } from "./config" import { List } from "./list" import { Variants } from "./variants" import { @@ -70,6 +70,7 @@ function asRequiredParser< if (testForAsRequiredParser()(input)) return parser as any return parser.optional() as any } + /** * A value is going to be part of the form in the FE of the OS. * Something like a boolean, a string, a number, etc. @@ -114,6 +115,29 @@ export class Value { boolean, ) } + static dynamicToggle( + a: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + default?: boolean | null + } + >, + ) { + return new Value( + async (options) => ({ + description: null, + warning: null, + default: null, + type: "toggle" as const, + ...(await a(options)), + }), + boolean, + ) + } static text, WD, CT>(a: { name: string description?: string | null @@ -146,6 +170,44 @@ export class Value { asRequiredParser(string, a), ) } + static dynamicText( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + required: RequiredDefault + + /** Default = false */ + masked?: boolean + placeholder?: string | null + minLength?: number | null + maxLength?: number | null + patterns?: Pattern[] + /** Default = 'text' */ + inputmode?: ValueSpecText["inputmode"] + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + type: "text" as const, + description: null, + warning: null, + masked: false, + placeholder: null, + minLength: null, + maxLength: null, + patterns: [], + inputmode: "text", + ...a, + ...requiredLikeToAbove(a.required), + } + }, string.optional()) + } static textarea(a: { name: string description?: string | null @@ -169,6 +231,34 @@ export class Value { string, ) } + static dynamicTextarea( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + required: boolean + minLength?: number | null + maxLength?: number | null + placeholder?: string | null + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + description: null, + warning: null, + minLength: null, + maxLength: null, + placeholder: null, + type: "textarea" as const, + ...a, + } + }, string) + } static number, WD, CT>(a: { name: string description?: string | null @@ -198,6 +288,41 @@ export class Value { asRequiredParser(number, a), ) } + static dynamicNumber( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + required: RequiredDefault + min?: number | null + max?: number | null + /** Default = '1' */ + step?: string | null + integer: boolean + units?: string | null + placeholder?: string | null + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + type: "number" as const, + description: null, + warning: null, + min: null, + max: null, + step: null, + units: null, + placeholder: null, + ...a, + ...requiredLikeToAbove(a.required), + } + }, number.optional()) + } static color, WD, CT>(a: { name: string description?: string | null @@ -216,6 +341,30 @@ export class Value { asRequiredParser(string, a), ) } + + static dynamicColor( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + required: RequiredDefault + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + type: "color" as const, + description: null, + warning: null, + ...a, + ...requiredLikeToAbove(a.required), + } + }, string.optional()) + } static datetime, WD, CT>(a: { name: string description?: string | null @@ -242,6 +391,38 @@ export class Value { asRequiredParser(string, a), ) } + static dynamicDatetime( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + required: RequiredDefault + /** Default = 'datetime-local' */ + inputmode?: ValueSpecDatetime["inputmode"] + min?: string | null + max?: string | null + step?: string | null + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + type: "datetime" as const, + description: null, + warning: null, + inputmode: "datetime-local", + min: null, + max: null, + step: null, + ...a, + ...requiredLikeToAbove(a.required), + } + }, string.optional()) + } static select< Required extends RequiredDefault, B extends Record, @@ -270,6 +451,30 @@ export class Value { ) as any, ) } + static dynamicSelect( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + required: RequiredDefault + values: Record + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + description: null, + warning: null, + type: "select" as const, + ...a, + ...requiredLikeToAbove(a.required), + } + }, string.optional()) + } static multiselect, WD, CT>(a: { name: string description?: string | null @@ -293,6 +498,33 @@ export class Value { ), ) } + static dynamicMultiselect( + getA: LazyBuild< + WD, + CT, + { + name: string + description?: string | null + warning?: string | null + default: string[] + values: Record + minLength?: number | null + maxLength?: number | null + } + >, + ) { + return new Value(async (options) => { + const a = await getA(options) + return { + type: "multiselect" as const, + minLength: null, + maxLength: null, + warning: null, + description: null, + ...a, + } + }, arrayOf(string)) + } static object, WrapperData, ConfigType>( a: { name: string @@ -339,6 +571,33 @@ export class Value { asRequiredParser(aVariants.validator, a), ) } + static filteredUnion< + Required extends RequiredDefault, + Type, + WrapperData, + ConfigType, + >( + a: { + name: string + description?: string | null + warning?: string | null + required: Required + default?: string | null + }, + aVariants: Variants, + ) { + return new Value, WrapperData, ConfigType>( + async (options) => ({ + type: "union" as const, + description: null, + warning: null, + ...a, + variants: await aVariants.build(options as any), + ...requiredLikeToAbove(a.required), + }), + asRequiredParser(aVariants.validator, a), + ) + } static list( a: List, diff --git a/lib/config/builder/variants.ts b/lib/config/builder/variants.ts index 0aa39a5..5e5c112 100644 --- a/lib/config/builder/variants.ts +++ b/lib/config/builder/variants.ts @@ -103,4 +103,23 @@ export class Variants { return variants }, validator) } + + /** Danger, don't filter everything!! */ + disableVariants( + fn: LazyBuild< + WD, + ConfigType, + Array + >, + ) { + const previousMe = this + return new Variants(async (options) => { + const answer = { ...(await previousMe.build(options)) } + const filterValues = await fn(options) + for (const key of filterValues) { + delete answer[key as any] + } + return answer + }, this.validator) + } } diff --git a/lib/test/configBuilder.test.ts b/lib/test/configBuilder.test.ts index 39daf4a..d16c3d3 100644 --- a/lib/test/configBuilder.test.ts +++ b/lib/test/configBuilder.test.ts @@ -189,7 +189,7 @@ describe("values", () => { const validator = value.validator validator.unsafeCast("a") validator.unsafeCast("b") - expect(() => validator.unsafeCast(null)).toThrowError() + expect(() => validator.unsafeCast("c")).toThrowError() testOutput()(null) }) test("nullable select", async () => { @@ -295,6 +295,280 @@ describe("values", () => { validator.unsafeCast([1, 2, 3]) testOutput()(null) }) + + describe("dynamic", () => { + const fakeOptions = { + config: "config", + effects: "effects", + utils: "utils", + } as any + test("toggle", async () => { + const value = Value.dynamicToggle<{}, {}>(async () => ({ + name: "Testing", + description: null, + warning: null, + default: null, + })) + const validator = value.validator + validator.unsafeCast(false) + expect(() => validator.unsafeCast(null)).toThrowError() + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + description: null, + warning: null, + default: null, + }) + }) + test("text", async () => { + const value = Value.dynamicText(async () => ({ + name: "Testing", + required: { default: null }, + })) + const validator = value.validator + const rawIs = await value.build({} as any) + validator.unsafeCast("test text") + validator.unsafeCast(null) + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: true, + default: null, + }) + }) + test("text", async () => { + const value = Value.dynamicText(async () => ({ + name: "Testing", + required: { default: "null" }, + })) + const validator = value.validator + validator.unsafeCast("test text") + validator.unsafeCast(null) + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: true, + default: "null", + }) + }) + test("optional text", async () => { + const value = Value.dynamicText(async () => ({ + name: "Testing", + required: false, + })) + const validator = value.validator + const rawIs = await value.build({} as any) + validator.unsafeCast("test text") + validator.unsafeCast(null) + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: false, + default: null, + }) + }) + test("color", async () => { + const value = Value.dynamicColor(async () => ({ + name: "Testing", + required: false, + description: null, + warning: null, + })) + const validator = value.validator + validator.unsafeCast("#000000") + validator.unsafeCast(null) + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: false, + default: null, + description: null, + warning: null, + }) + }) + test("datetime", async () => { + const value = Value.dynamicDatetime<{ test: "a" }, { test2: 6 }>( + async ({ effects, utils, config }) => { + ;async () => { + ;(await utils.getOwnWrapperData("/test").once()) satisfies "a" + config satisfies { test2: 6 } | null + } + + return { + name: "Testing", + required: { default: null }, + inputmode: "date", + } + }, + ) + const validator = value.validator + validator.unsafeCast("2021-01-01") + validator.unsafeCast(null) + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: true, + default: null, + description: null, + warning: null, + inputmode: "date", + }) + }) + test("textarea", async () => { + const value = Value.dynamicTextarea(async () => ({ + name: "Testing", + required: false, + description: null, + warning: null, + minLength: null, + maxLength: null, + placeholder: null, + })) + const validator = value.validator + validator.unsafeCast("test text") + expect(() => validator.unsafeCast(null)).toThrowError() + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: false, + }) + }) + test("number", async () => { + const value = Value.dynamicNumber(() => ({ + name: "Testing", + required: { default: null }, + integer: false, + description: null, + warning: null, + min: null, + max: null, + step: null, + units: null, + placeholder: null, + })) + const validator = value.validator + validator.unsafeCast(2) + validator.unsafeCast(null) + expect(() => validator.unsafeCast("null")).toThrowError() + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: true, + }) + }) + test("select", async () => { + const value = Value.dynamicSelect(() => ({ + name: "Testing", + required: { default: null }, + values: { + a: "A", + b: "B", + }, + description: null, + warning: null, + })) + const validator = value.validator + validator.unsafeCast("a") + validator.unsafeCast("b") + validator.unsafeCast("c") + validator.unsafeCast(null) + testOutput()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + required: true, + }) + }) + test("multiselect", async () => { + const value = Value.dynamicMultiselect(() => ({ + name: "Testing", + values: { + a: "A", + b: "B", + }, + default: [], + description: null, + warning: null, + minLength: null, + maxLength: null, + })) + const validator = value.validator + validator.unsafeCast([]) + validator.unsafeCast(["a", "b"]) + validator.unsafeCast(["c"]) + + expect(() => validator.unsafeCast([4])).toThrowError() + expect(() => validator.unsafeCast(null)).toThrowError() + testOutput>()(null) + expect(await value.build(fakeOptions)).toMatchObject({ + name: "Testing", + default: [], + }) + }) + }) + describe("filtering", () => { + test("union", async () => { + const value = Value.union( + { + name: "Testing", + required: { default: null }, + description: null, + warning: null, + default: null, + }, + Variants.of({ + a: { + name: "a", + spec: Config.of({ + b: Value.toggle({ + name: "b", + description: null, + warning: null, + default: null, + }), + }), + }, + b: { + name: "b", + spec: Config.of({ + b: Value.toggle({ + name: "b", + description: null, + warning: null, + default: null, + }), + }), + }, + }).disableVariants(() => [ + "a", + // @ts-expect-error + "c", + ]), + ) + const validator = value.validator + validator.unsafeCast({ unionSelectKey: "a", unionValueKey: { b: false } }) + type Test = typeof validator._TYPE + testOutput< + Test, + | { unionSelectKey: "a"; unionValueKey: { b: boolean } } + | { unionSelectKey: "b"; unionValueKey: { b: boolean } } + >()(null) + + const built = await value.build({} as any) + expect(built).toMatchObject({ + name: "Testing", + variants: { + b: {}, + }, + }) + expect(built).not.toMatchObject({ + name: "Testing", + variants: { + a: {}, + b: {}, + }, + }) + }) + }) }) describe("Builder List", () => { diff --git a/lib/types.ts b/lib/types.ts index 916bae6..e8960e7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -163,6 +163,30 @@ export type Effects = { toWrite: string }): Promise readFile(input: { volumeId: string; path: string }): Promise + /** Usable when not sandboxed */ + appendFile(input: { + path: string + volumeId: string + toWrite: string + }): Promise + /** + * Move file from src to dst + * Usable when not sandboxed */ + moveFile(input: { + srcVolume: string + dstVolume: string + srcPath: string + dstPath: string + }): Promise + /** + * copy from src to dst + * Usable when not sandboxed */ + copyFile(input: { + srcVolume: string + dstVolume: string + srcPath: string + dstPath: string + }): Promise metadata(input: { volumeId: string; path: string }): Promise /** Create a directory. Usable when not sandboxed */ createDir(input: { volumeId: string; path: string }): Promise diff --git a/scripts/oldSpecToBuilder.ts b/scripts/oldSpecToBuilder.ts index 0a8e1be..cdaf3a9 100644 --- a/scripts/oldSpecToBuilder.ts +++ b/scripts/oldSpecToBuilder.ts @@ -43,18 +43,17 @@ export default async function makeFileContentFromOld( const data = await inputData const namedConsts = new Set(["Config", "Value", "List"]) - const configNameRaw = newConst("configSpecRaw", convertInputSpec(data)) + const configName = newConst( + "configSpec", + `Config.withWrapperData().of(${convertInputSpecInner(data)})`, + ) const configMatcherName = newConst( "matchConfigSpec", - `${configNameRaw}.validator`, + `${configName}.validator`, ) outputLines.push( `export type ConfigSpec = typeof ${configMatcherName}._TYPE;`, ) - const configName = newConst( - "ConfigSpec", - `${configNameRaw} as Config`, - ) return outputLines.join("\n") @@ -69,14 +68,18 @@ export default async function makeFileContentFromOld( if (nested) return data return newConst(key, data) } - function convertInputSpec(data: any) { - let answer = "Config.of({" + function convertInputSpecInner(data: any) { + let answer = "{" for (const [key, value] of Object.entries(data)) { const variableName = maybeNewConst(key, convertValueSpec(value)) answer += `${JSON.stringify(key)}: ${variableName},` } - return `${answer}})` + return `${answer}}` + } + + function convertInputSpec(data: any) { + return `Config.of(${convertInputSpecInner(data)})` } function convertValueSpec(value: any): string { switch (value.type) {