diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 4eedd8b15b..334c4b1f18 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -550,9 +550,7 @@ export type Endpoint14_2Input = { readonly id?: Endpoint14_2Request["payload"]["id"] readonly title: Endpoint14_2Request["payload"]["title"] readonly metadata?: Endpoint14_2Request["payload"]["metadata"] - readonly mode: Endpoint14_2Request["payload"]["mode"] - readonly fields?: Endpoint14_2Request["payload"]["fields"] - readonly url?: Endpoint14_2Request["payload"]["url"] + readonly fields: Endpoint14_2Request["payload"]["fields"] } export type Endpoint14_2Output = EffectValue>["data"] export type FormCreateOperation = (input: Endpoint14_2Input) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index f11982abc3..7e14e5151f 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -655,21 +655,12 @@ type Endpoint14_2Input = { readonly id?: Endpoint14_2Request["payload"]["id"] readonly title: Endpoint14_2Request["payload"]["title"] readonly metadata?: Endpoint14_2Request["payload"]["metadata"] - readonly mode: Endpoint14_2Request["payload"]["mode"] - readonly fields?: Endpoint14_2Request["payload"]["fields"] - readonly url?: Endpoint14_2Request["payload"]["url"] + readonly fields: Endpoint14_2Request["payload"]["fields"] } const Endpoint14_2 = (raw: RawClient["server.form"]) => (input: Endpoint14_2Input) => raw["session.form.create"]({ params: { sessionID: input["sessionID"] }, - payload: { - id: input["id"], - title: input["title"], - metadata: input["metadata"], - mode: input["mode"], - fields: input["fields"], - url: input["url"], - }, + payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] }, }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 3982a99566..12c8aefd32 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -1056,14 +1056,7 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/form`, - body: { - id: input["id"], - title: input["title"], - metadata: input["metadata"], - mode: input["mode"], - fields: input["fields"], - url: input["url"], - }, + body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] }, successStatus: 200, declaredStatuses: [404, 409, 400, 401], empty: false, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3277dcdccf..17d21221a7 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2423,195 +2423,359 @@ export type FormRequestListInput = { export type FormRequestListOutput = { location: { directory: string; workspaceID?: string; project: { id: string; directory: string } } - data: Array< - | { - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - mode: "form" - fields: Array< - | { + data: Array<{ + id: string + sessionID: string + title: string + metadata?: { [x: string]: JsonValue } + fields: [ + ( + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - > - } - | { id: string; sessionID: string; title: string; metadata?: { [x: string]: JsonValue }; mode: "url"; url: string } - > + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + ), + ...Array< + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + >, + ] + }> } export type FormListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type FormListOutput = { - data: Array< - | { - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - mode: "form" - fields: Array< - | { + data: Array<{ + id: string + sessionID: string + title: string + metadata?: { [x: string]: JsonValue } + fields: [ + ( + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - > - } - | { id: string; sessionID: string; title: string; metadata?: { [x: string]: JsonValue }; mode: "url"; url: string } - > + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + ), + ...Array< + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + >, + ] + }> }["data"] export type FormCreateInput = { @@ -2620,678 +2784,983 @@ export type FormCreateInput = { readonly id?: string | null readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } - readonly mode: "form" | "url" - readonly fields?: ReadonlyArray< - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly fields: readonly [ + ( + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "string" - readonly format?: "email" | "uri" | "date" | "date-time" - readonly minLength?: number - readonly maxLength?: number - readonly pattern?: string - readonly placeholder?: string - readonly default?: string - readonly options?: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly custom?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "number" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "integer" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "boolean" - readonly default?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "multiselect" - readonly options: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly minItems?: number - readonly maxItems?: number - readonly custom?: boolean - readonly default?: ReadonlyArray - } - > | null - readonly url?: string | null + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + ), + ...Array< + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + >, + ] }["id"] readonly title: { readonly id?: string | null readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } - readonly mode: "form" | "url" - readonly fields?: ReadonlyArray< - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly fields: readonly [ + ( + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "string" - readonly format?: "email" | "uri" | "date" | "date-time" - readonly minLength?: number - readonly maxLength?: number - readonly pattern?: string - readonly placeholder?: string - readonly default?: string - readonly options?: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly custom?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "number" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "integer" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "boolean" - readonly default?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "multiselect" - readonly options: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly minItems?: number - readonly maxItems?: number - readonly custom?: boolean - readonly default?: ReadonlyArray - } - > | null - readonly url?: string | null + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + ), + ...Array< + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + >, + ] }["title"] readonly metadata?: { readonly id?: string | null readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } - readonly mode: "form" | "url" - readonly fields?: ReadonlyArray< - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly fields: readonly [ + ( + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "string" - readonly format?: "email" | "uri" | "date" | "date-time" - readonly minLength?: number - readonly maxLength?: number - readonly pattern?: string - readonly placeholder?: string - readonly default?: string - readonly options?: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly custom?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "number" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "integer" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "boolean" - readonly default?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "multiselect" - readonly options: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly minItems?: number - readonly maxItems?: number - readonly custom?: boolean - readonly default?: ReadonlyArray - } - > | null - readonly url?: string | null + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + ), + ...Array< + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + >, + ] }["metadata"] - readonly mode: { + readonly fields: { readonly id?: string | null readonly title: string readonly metadata?: { readonly [x: string]: JsonValue } - readonly mode: "form" | "url" - readonly fields?: ReadonlyArray< - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly fields: readonly [ + ( + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "string" - readonly format?: "email" | "uri" | "date" | "date-time" - readonly minLength?: number - readonly maxLength?: number - readonly pattern?: string - readonly placeholder?: string - readonly default?: string - readonly options?: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly custom?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "number" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "integer" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "boolean" - readonly default?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "multiselect" - readonly options: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly minItems?: number - readonly maxItems?: number - readonly custom?: boolean - readonly default?: ReadonlyArray - } - > | null - readonly url?: string | null - }["mode"] - readonly fields?: { - readonly id?: string | null - readonly title: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly mode: "form" | "url" - readonly fields?: ReadonlyArray< - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "string" - readonly format?: "email" | "uri" | "date" | "date-time" - readonly minLength?: number - readonly maxLength?: number - readonly pattern?: string - readonly placeholder?: string - readonly default?: string - readonly options?: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly custom?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "number" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "integer" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "boolean" - readonly default?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "multiselect" - readonly options: ReadonlyArray<{ - readonly value: string - readonly label: string + readonly title?: string readonly description?: string - }> - readonly minItems?: number - readonly maxItems?: number - readonly custom?: boolean - readonly default?: ReadonlyArray - } - > | null - readonly url?: string | null + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + ), + ...Array< + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "string" + readonly format?: "email" | "uri" | "date" | "date-time" + readonly minLength?: number + readonly maxLength?: number + readonly pattern?: string + readonly placeholder?: string + readonly default?: string + readonly options?: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly custom?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "number" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "integer" + readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" + readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" + readonly default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "boolean" + readonly default?: boolean + } + | { + readonly key: string + readonly title?: string + readonly description?: string + readonly required?: boolean + readonly when?: ReadonlyArray<{ + readonly key: string + readonly op: "eq" | "neq" + readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + readonly type: "multiselect" + readonly options: ReadonlyArray<{ + readonly value: string + readonly label: string + readonly description?: string + }> + readonly minItems?: number + readonly maxItems?: number + readonly custom?: boolean + readonly default?: ReadonlyArray + } + | { + readonly key: string + readonly type: "external" + readonly url: string + readonly title?: string + readonly description?: string + } + >, + ] }["fields"] - readonly url?: { - readonly id?: string | null - readonly title: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly mode: "form" | "url" - readonly fields?: ReadonlyArray< - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "string" - readonly format?: "email" | "uri" | "date" | "date-time" - readonly minLength?: number - readonly maxLength?: number - readonly pattern?: string - readonly placeholder?: string - readonly default?: string - readonly options?: ReadonlyArray<{ - readonly value: string - readonly label: string - readonly description?: string - }> - readonly custom?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "number" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "integer" - readonly minimum?: number | "Infinity" | "-Infinity" | "NaN" - readonly maximum?: number | "Infinity" | "-Infinity" | "NaN" - readonly default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "boolean" - readonly default?: boolean - } - | { - readonly key: string - readonly title?: string - readonly description?: string - readonly required?: boolean - readonly when?: ReadonlyArray<{ - readonly key: string - readonly op: "eq" | "neq" - readonly value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - readonly type: "multiselect" - readonly options: ReadonlyArray<{ - readonly value: string - readonly label: string - readonly description?: string - }> - readonly minItems?: number - readonly maxItems?: number - readonly custom?: boolean - readonly default?: ReadonlyArray - } - > | null - readonly url?: string | null - }["url"] } export type FormCreateOutput = { - data: - | { - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - mode: "form" - fields: Array< - | { + data: { + id: string + sessionID: string + title: string + metadata?: { [x: string]: JsonValue } + fields: [ + ( + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - > - } - | { id: string; sessionID: string; title: string; metadata?: { [x: string]: JsonValue }; mode: "url"; url: string } + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + ), + ...Array< + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + >, + ] + } }["data"] export type FormGetInput = { @@ -3300,97 +3769,180 @@ export type FormGetInput = { } export type FormGetOutput = { - data: - | { - id: string - sessionID: string - title: string - metadata?: { [x: string]: JsonValue } - mode: "form" - fields: Array< - | { + data: { + id: string + sessionID: string + title: string + metadata?: { [x: string]: JsonValue } + fields: [ + ( + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "number" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "integer" - minimum?: number | "Infinity" | "-Infinity" | "NaN" - maximum?: number | "Infinity" | "-Infinity" | "NaN" - default?: number | "Infinity" | "-Infinity" | "NaN" - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "boolean" - default?: boolean - } - | { + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string - title?: string - description?: string - required?: boolean - when?: Array<{ - key: string - op: "eq" | "neq" - value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean - }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - > - } - | { id: string; sessionID: string; title: string; metadata?: { [x: string]: JsonValue }; mode: "url"; url: string } + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + ), + ...Array< + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "number" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "integer" + minimum?: number | "Infinity" | "-Infinity" | "NaN" + maximum?: number | "Infinity" | "-Infinity" | "NaN" + default?: number | "Infinity" | "-Infinity" | "NaN" + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ + key: string + op: "eq" | "neq" + value: string | number | "Infinity" | "-Infinity" | "NaN" | boolean + }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + >, + ] + } }["data"] export type FormStateInput = { @@ -4858,84 +5410,140 @@ export type EventSubscribeOutput = type: "form.created" location?: { directory: string; workspaceID?: string } data: { - form: - | { - id: string - sessionID: string - title: string - metadata?: { [x: string]: unknown } - mode: "form" - fields: Array< - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "string" - format?: "email" | "uri" | "date" | "date-time" - minLength?: number - maxLength?: number - pattern?: string - placeholder?: string - default?: string - options?: Array<{ value: string; label: string; description?: string }> - custom?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "number" - minimum?: number - maximum?: number - default?: number - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "integer" - minimum?: number - maximum?: number - default?: number - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "boolean" - default?: boolean - } - | { - key: string - title?: string - description?: string - required?: boolean - when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> - type: "multiselect" - options: Array<{ value: string; label: string; description?: string }> - minItems?: number - maxItems?: number - custom?: boolean - default?: Array - } - > - } - | { - id: string - sessionID: string - title: string - metadata?: { [x: string]: unknown } - mode: "url" - url: string - } + form: { + id: string + sessionID: string + title: string + metadata?: { [x: string]: unknown } + fields: [ + ( + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "number" + minimum?: number + maximum?: number + default?: number + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "integer" + minimum?: number + maximum?: number + default?: number + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + ), + ...Array< + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "string" + format?: "email" | "uri" | "date" | "date-time" + minLength?: number + maxLength?: number + pattern?: string + placeholder?: string + default?: string + options?: Array<{ value: string; label: string; description?: string }> + custom?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "number" + minimum?: number + maximum?: number + default?: number + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "integer" + minimum?: number + maximum?: number + default?: number + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "boolean" + default?: boolean + } + | { + key: string + title?: string + description?: string + required?: boolean + when?: Array<{ key: string; op: "eq" | "neq"; value: string | number | boolean }> + type: "multiselect" + options: Array<{ value: string; label: string; description?: string }> + minItems?: number + maxItems?: number + custom?: boolean + default?: Array + } + | { key: string; type: "external"; url: string; title?: string; description?: string } + >, + ] + } } } | { diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 7cdda87b4c..30ae70e16c 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -16,6 +16,9 @@ export type Info = typeof Info.Type export const Field = Form.Field export type Field = Form.Field +export const Fields = Form.Fields +export type Fields = Form.Fields + export const When = Form.When export type When = Form.When @@ -64,9 +67,7 @@ export class InvalidFormError extends Schema.TaggedErrorClass( message: Schema.String, }) {} -export type CreateInput = - | (Omit & { readonly id?: ID }) - | (Omit & { readonly id?: ID }) +export type CreateInput = Omit & { readonly id?: ID } export interface ReplyInput { readonly id: ID @@ -74,7 +75,7 @@ export interface ReplyInput { } export interface ListInput { - readonly sessionID?: Form.FormInfo["sessionID"] + readonly sessionID?: Form.Info["sessionID"] } export interface Interface { @@ -125,20 +126,15 @@ export const layer = Layer.effect( const id = input.id ?? ID.create() const existing = yield* Cache.getSuccess(forms, id) if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id }) - if (input.mode === "form") { - const invalid = validateFields(input.fields) - if (invalid) return yield* new InvalidFormError({ message: invalid }) - } - const base = { + const invalid = validateFields(input.fields) + if (invalid) return yield* new InvalidFormError({ message: invalid }) + const form: Info = { id, sessionID: input.sessionID, title: input.title, ...(input.metadata === undefined ? {} : { metadata: input.metadata }), + fields: input.fields, } - const form: Info = - input.mode === "form" - ? { ...base, mode: "form", fields: input.fields } - : { ...base, mode: "url", url: input.url } const entry: Entry = { form, state: { status: "pending" }, @@ -228,16 +224,16 @@ export const locationLayer = layer export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) function validateAnswer(form: Info, answer: Answer) { - if (form.mode === "url") { - if (Object.keys(answer).length === 0) return - return "URL forms must be answered with an empty answer" - } - const fields = new Map(form.fields.map((field) => [field.key, field])) + const fields = new Map(form.fields.map((field) => [field.key, field] as const)) for (const key of Object.keys(answer)) { if (!fields.has(key)) return `Unknown form field: ${key}` } for (const field of form.fields) { const value = answer[field.key] + if (field.type === "external") { + if (value !== true) return `External form field must be acknowledged: ${field.key}` + continue + } const active = isActive(field, answer) if (value === undefined) { if (field.required && active) return `Missing required form field: ${field.key}` @@ -249,7 +245,9 @@ function validateAnswer(form: Info, answer: Answer) { } } -function isActive(field: Form.Field, answer: Answer) { +type InputField = Exclude + +function isActive(field: InputField, answer: Answer) { if (!field.when) return true return field.when.every((when) => matches(when, answer[when.key])) } @@ -267,9 +265,13 @@ function matches(when: Form.When, value: Form.Value | undefined) { // are closed. Rejecting these at creation surfaces authoring mistakes to the caller instead of // silently never matching. function validateFields(fields: ReadonlyArray) { - const earlier = new Map() + if (fields.length === 0) return "Form must have at least one field" + const earlier = new Map() + const keys = new Set() for (const field of fields) { - if (earlier.has(field.key)) return `Duplicate form field key: ${field.key}` + if (keys.has(field.key)) return `Duplicate form field key: ${field.key}` + keys.add(field.key) + if (field.type === "external") continue for (const when of field.when ?? []) { const target = earlier.get(when.key) if (!target) return `Form field condition must reference an earlier field: ${field.key} -> ${when.key}` @@ -280,7 +282,7 @@ function validateFields(fields: ReadonlyArray) { } } -function validateWhen(when: Form.When, target: Form.Field) { +function validateWhen(when: Form.When, target: InputField) { if (target.type === "boolean") { if (typeof when.value !== "boolean") return "Form field condition value must be a boolean" return @@ -297,7 +299,7 @@ function validateWhen(when: Form.When, target: Form.Field) { } } -function validateField(field: Form.Field, value: Form.Value): string | undefined { +function validateField(field: InputField, value: Form.Value): string | undefined { if (field.type === "string") { if (typeof value !== "string") return `Expected string for form field: ${field.key}` if (field.required && value.length === 0) return `Missing required form field: ${field.key}` diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 7a99c15982..c9d9a83f82 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -123,6 +123,7 @@ type ServerEntry = { // MCP elicitations are Location-scoped, not Session-scoped: the server cannot attribute them to a // persisted session row, so their forms are owned by this opaque sentinel session identifier. const GLOBAL_ELICITATION_SESSION_ID = "global" +const URL_ELICITATION_FIELD_KEY = "elicitation" export interface Interface { readonly servers: () => Effect.Effect @@ -311,8 +312,7 @@ export const layer = Layer.effect( elicitationID: input.params.elicitationId, message: input.params.message, }, - mode: "url", - url: input.params.url, + fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }], }) .pipe( Effect.raceFirst(waitForAbort(input.signal)), @@ -325,15 +325,16 @@ export const layer = Layer.effect( ) } const params = input.params + const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) => + toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true), + ) + if (!field) return { action: "accept", content: {} } return yield* forms .ask({ sessionID: GLOBAL_ELICITATION_SESSION_ID, title: `${input.server} is requesting input`, metadata: { kind: "mcp-elicitation", server: input.server, message: params.message }, - mode: "form", - fields: Object.entries(params.requestedSchema.properties).map(([key, property]) => - toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true), - ), + fields: [field, ...fields], }) .pipe( Effect.raceFirst(waitForAbort(input.signal)), @@ -355,7 +356,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID) if (!formID) return - yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore) + yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore) }), } satisfies MCPClient.ElicitationHandler diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 646efd4b3b..ec2b1938a0 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -22,7 +22,7 @@ Usage notes: - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label` export const Input = Schema.Struct({ - questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }), + questions: Schema.NonEmptyArray(QuestionV2.Prompt).annotate({ description: "Questions to ask" }), }) export const Output = Schema.Struct({ @@ -86,21 +86,10 @@ export const Plugin = { kind: "question", tool: { messageID: context.assistantMessageID, callID: context.toolCallID }, }, - mode: "form", - fields: input.questions.map( - (question, index): Form.Field => ({ - key: `q${index}`, - title: question.header, - description: question.question, - type: question.multiple === true ? "multiselect" : "string", - options: question.options.map((option) => ({ - value: option.label, - label: option.label, - description: option.description, - })), - custom: true, - }), - ), + fields: [ + toField(input.questions[0], 0), + ...input.questions.slice(1).map((question, index) => toField(question, index + 1)), + ], }) .pipe(Effect.orDie), ), @@ -122,3 +111,18 @@ export const Plugin = { .pipe(Effect.orDie) }), } + +function toField(question: QuestionV2.Prompt, index: number): Form.Field { + return { + key: `q${index}`, + title: question.header, + description: question.question, + type: question.multiple === true ? "multiselect" : "string", + options: question.options.map((option) => ({ + value: option.label, + label: option.label, + description: option.description, + })), + custom: true, + } +} diff --git a/packages/core/test/form.test.ts b/packages/core/test/form.test.ts index f5cf16e159..3328372203 100644 --- a/packages/core/test/form.test.ts +++ b/packages/core/test/form.test.ts @@ -15,7 +15,6 @@ const input = { id: formID, sessionID: SessionSchema.ID.make("ses_test"), title: "Test form", - mode: "form", fields: [{ key: "name", type: "string", required: true }], } satisfies Form.CreateInput @@ -47,7 +46,6 @@ describe("Form", () => { const created = yield* service.create({ sessionID: "global", title: "MCP input", - mode: "form", fields: [{ key: "name", type: "string", required: true }], }) expect(created.sessionID).toBe("global") @@ -59,6 +57,14 @@ describe("Form", () => { yield* service.reply({ id: created.id, answer: { name: "Ava" } }) expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } }) + + const externalOnly = yield* service.create({ + sessionID: "global", + title: "External setup", + fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }], + }) + yield* service.reply({ id: externalOnly.id, answer: { setup: true } }) + expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } }) }), ) @@ -68,15 +74,18 @@ describe("Form", () => { const created = yield* service.create({ sessionID: "global", title: "Conditional form", - mode: "form", fields: [ { key: "confirm", type: "boolean", required: true }, { key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] }, ], }) - const inactive = yield* service.reply({ id: created.id, answer: { confirm: true, reason: "x" } }).pipe(Effect.flip) - expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" })) + const inactive = yield* service + .reply({ id: created.id, answer: { confirm: true, reason: "x" } }) + .pipe(Effect.flip) + expect(inactive).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }), + ) const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip) expect(missing).toEqual( @@ -101,7 +110,6 @@ describe("Form", () => { const created = yield* service.create({ sessionID: "global", title: "Multiselect form", - mode: "form", fields: [ { key: "langs", type: "multiselect", options }, { key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] }, @@ -124,7 +132,6 @@ describe("Form", () => { const created = yield* service.create({ sessionID: "global", title: "Dependent form", - mode: "form", fields: [ { key: "a", type: "boolean" }, { key: "b", type: "boolean" }, @@ -142,7 +149,9 @@ describe("Form", () => { }) const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip) - expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" })) + expect(missingX).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }), + ) const inactiveX = yield* service .reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } }) @@ -150,7 +159,9 @@ describe("Form", () => { expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" })) const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip) - expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" })) + expect(missingZ).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }), + ) yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } }) expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } }) @@ -167,7 +178,6 @@ describe("Form", () => { const created = yield* service.create({ sessionID: "global", title: "Selection form", - mode: "form", fields: [ { key: "langs", type: "multiselect", options }, { key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] }, @@ -186,7 +196,9 @@ describe("Form", () => { ) const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip) - expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" })) + expect(inactive).toEqual( + new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }), + ) yield* service.reply({ id: created.id, answer: { langs: ["go"] } }) expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } }) @@ -199,7 +211,6 @@ describe("Form", () => { const created = yield* service.create({ sessionID: "global", title: "Cascading form", - mode: "form", fields: [ { key: "a", type: "boolean" }, { key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] }, @@ -220,14 +231,14 @@ describe("Form", () => { it.effect("rejects invalid when definitions at creation", () => Effect.gen(function* () { const service = yield* Form.Service - const flipCreate = (fields: ReadonlyArray) => - service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip) + const flipCreate = (fields: Form.CreateInput["fields"]) => + service.create({ sessionID: "global", title: "Invalid form", fields }).pipe(Effect.flip) expect( - yield* flipCreate([ - { key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }, - ]), - ).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" })) + yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]), + ).toEqual( + new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }), + ) expect( yield* flipCreate([ @@ -236,14 +247,19 @@ describe("Form", () => { ]), ).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" })) + expect( + yield* flipCreate([ + { key: "a", type: "external", url: "https://example.com" }, + { key: "a", type: "string" }, + ]), + ).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" })) + expect( yield* flipCreate([ { key: "a", type: "boolean" }, { key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] }, ]), - ).toEqual( - new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }), - ) + ).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" })) expect( yield* flipCreate([ @@ -258,6 +274,40 @@ describe("Form", () => { }), ) + it.effect("requires external field acknowledgements", () => + Effect.gen(function* () { + const service = yield* Form.Service + const created = yield* service.create({ + sessionID: "global", + title: "External setup", + fields: [ + { key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" }, + { key: "name", type: "string", required: true }, + ], + }) + + const invalidAnswers: ReadonlyArray = [ + { name: "Ava" }, + { authorization: false, name: "Ava" }, + { authorization: "yes", name: "Ava" }, + ] + for (const answer of invalidAnswers) { + expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual( + new Form.InvalidAnswerError({ + id: created.id, + message: "External form field must be acknowledged: authorization", + }), + ) + } + + yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } }) + expect(yield* service.state(created.id)).toEqual({ + status: "answered", + answer: { authorization: true, name: "Ava" }, + }) + }), + ) + it.effect("cleans up created forms when event publication fails", () => Effect.gen(function* () { const service = yield* Form.Service diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 32a0f94d88..2fca0e0b95 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -28,7 +28,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { McpTool } from "@opencode-ai/core/tool/mcp" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" import { testEffect } from "./lib/effect" import { location } from "./fixture/location" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" @@ -47,7 +47,9 @@ type ResourceTemplatePage = { nextCursor?: string } -function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) { +function resourceServer( + input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {}, +) { return Effect.acquireRelease( Effect.promise(async () => { const state = { @@ -71,7 +73,42 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } = }, }, ) - protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => + Promise.resolve({ + tools: input.emptyElicitation + ? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }] + : input.urlElicitation + ? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }] + : [], + }), + ) + if (input.emptyElicitation) { + protocol.setRequestHandler(CallToolRequestSchema, async () => { + const result = await protocol.elicitInput({ + mode: "form", + message: "Confirm", + requestedSchema: { type: "object", properties: {} }, + }) + return { + content: [{ type: "text", text: JSON.stringify(result) }], + structuredContent: result, + } + }) + } + if (input.urlElicitation) { + protocol.setRequestHandler(CallToolRequestSchema, async () => { + const result = await protocol.elicitInput({ + mode: "url", + message: "Authorize access", + url: "https://example.com/authorize", + elicitationId: "elicitation-test", + }) + return { + content: [{ type: "text", text: JSON.stringify(result) }], + structuredContent: result, + } + }) + } if (input.resources !== false) { protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { state.resourceLists += 1 @@ -98,6 +135,7 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } = state, url: http.url.toString(), sendResourceListChanged: () => protocol.sendResourceListChanged(), + completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(), close: async () => { await protocol.close().catch(() => {}) await http.stop(true) @@ -108,10 +146,11 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } = ) } -function resourceMcpLayer(url: string) { +function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect) { const directory = AbsolutePath.make(import.meta.dir) const unusedIntegration = () => Effect.die("unused integration service") return MCP.layer.pipe( + Layer.provideMerge(Form.layer), Layer.provide( Layer.mergeAll( Layer.succeed( @@ -133,14 +172,16 @@ function resourceMcpLayer(url: string) { Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), Layer.mock(EventV2.Service, { subscribe: () => Stream.never, - publish: (definition, data) => - Effect.succeed({ + publish: (definition, data) => { + const event = { id: EventV2.ID.create(), type: definition.type, data, - } as EventV2.Payload), + } as EventV2.Payload + if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event) + return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event)) + }, }), - Layer.mock(Form.Service, {}), Layer.mock(Integration.Service, { connection: { active: unusedIntegration, @@ -490,6 +531,53 @@ test("skips MCP resource requests when the capability is absent", async () => { ) }) +test("accepts empty MCP elicitations without creating forms", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ resources: false, emptyElicitation: true }) + const result = yield* Effect.gen(function* () { + const service = yield* MCP.Service + const forms = yield* Form.Service + const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" }) + expect(yield* forms.list()).toEqual([]) + return result + }).pipe(Effect.provide(resourceMcpLayer(server.url))) + + expect(result.structured).toEqual({ action: "accept", content: {} }) + }), + ), + ) +}) + +test("acknowledges completed MCP URL elicitations without returning internal content", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ resources: false, urlElicitation: true }) + const created = yield* Deferred.make() + const result = yield* Effect.gen(function* () { + const service = yield* MCP.Service + const forms = yield* Form.Service + const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped) + + const form = yield* Deferred.await(created) + expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }]) + + yield* Effect.promise(server.completeElicitation) + const result = yield* Fiber.join(call) + expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } }) + return result + }).pipe( + Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))), + ) + + expect(result.structured).toEqual({ action: "accept" }) + }), + ), + ) +}) + test("loads and reads MCP resources", async () => { await Effect.runPromise( Effect.scoped( diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index ef6bfdc54a..5d20d01622 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -18,6 +18,15 @@ let captured: Form.CreateInput | undefined let reject = false let deny = false const capturedInput = () => captured +const questionInput = { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Continue" }], + }, + ], +} const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -90,7 +99,7 @@ describe("QuestionTool", () => { yield* settleTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, + call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput }, }), ).toEqual({ result: { type: "error", value: "Permission denied: question" }, @@ -158,7 +167,6 @@ describe("QuestionTool", () => { sessionID, title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, - mode: "form", fields: [ { key: "q0", @@ -199,14 +207,22 @@ describe("QuestionTool", () => { yield* executeTool(registryService, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, + call: { type: "tool-call", id: "call-question", name: "question", input: questionInput }, }) expect(capturedInput()).toEqual({ sessionID, title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } }, - mode: "form", - fields: [], + fields: [ + { + key: "q0", + title: "Continue", + description: "Continue?", + options: [{ value: "Yes", label: "Yes", description: "Continue" }], + custom: true, + type: "string", + }, + ], }) }), ) @@ -220,7 +236,7 @@ describe("QuestionTool", () => { const fiber = yield* executeTool(registryService, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } }, + call: { type: "tool-call", id: "call-question", name: "question", input: questionInput }, }).pipe(Effect.forkScoped) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index 599d9daced..2b13155403 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -10,7 +10,12 @@ function ok(data: T) { } function form(id: string, sessionID: string): FormInfo { - return { id, sessionID, title: "Input requested", mode: "form", fields: [] } + return { + id, + sessionID, + title: "Input requested", + fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }], + } } function formCreated(info: FormInfo): V2Event { diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 6e955cfcf4..af534b51ea 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -838,13 +838,18 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: route("/api/session/{sessionID}/form", { sessionID: ctx.state.id }), headers: ctx.headers(), - body: { mode: "url", url: "https://example.com/form" }, + body: { + title: "External form", + fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }], + }, })) .json(200, (body) => { object(body) object(body.data) check(typeof body.data.id === "string", "form create should return an ID") - check(body.data.mode === "url", "form create should preserve URL mode") + array(body.data.fields) + object(body.data.fields[0]) + check(body.data.fields[0].type === "external", "form create should preserve the external field") }), http.protected .get("/api/session/{sessionID}/form/{formID}", "v2.session.form.get") diff --git a/packages/protocol/src/groups/form.ts b/packages/protocol/src/groups/form.ts index b3fe2f12e8..f9160e5dc9 100644 --- a/packages/protocol/src/groups/form.ts +++ b/packages/protocol/src/groups/form.ts @@ -14,11 +14,9 @@ import { LocationQuery, locationQueryOpenApi } from "./location.js" const CreatePayload = Schema.Struct({ id: Form.ID.pipe(Schema.optional), - title: Form.FormInfo.fields.title, - metadata: Form.FormInfo.fields.metadata, - mode: Schema.Literals(["form", "url"]), - fields: Form.FormInfo.fields.fields.pipe(Schema.optional), - url: Form.UrlInfo.fields.url.pipe(Schema.optional), + title: Form.Info.fields.title, + metadata: Form.Info.fields.metadata, + fields: Form.Info.fields.fields, }).annotate({ identifier: "Form.CreatePayload" }) export type CreatePayload = typeof CreatePayload.Type diff --git a/packages/schema/src/form.ts b/packages/schema/src/form.ts index 98f863f7fa..6c2fdc29e8 100644 --- a/packages/schema/src/form.ts +++ b/packages/schema/src/form.ts @@ -94,10 +94,27 @@ export const MultiselectField = Schema.Struct({ }).annotate({ identifier: "Form.MultiselectField" }) export interface MultiselectField extends Schema.Schema.Type {} -export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe( - Schema.toTaggedUnion("type"), -) -export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField +export const ExternalField = Schema.Struct({ + key: Schema.String, + type: Schema.Literal("external"), + url: Schema.String, + title: Schema.String.pipe(optional), + description: Schema.String.pipe(optional), +}).annotate({ identifier: "Form.ExternalField" }) +export interface ExternalField extends Schema.Schema.Type {} + +export const Field = Schema.Union([ + StringField, + NumberField, + IntegerField, + BooleanField, + MultiselectField, + ExternalField, +]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Form.Field" })) +export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField | ExternalField + +export const Fields = Schema.NonEmptyArray(Field).annotate({ identifier: "Form.Fields" }) +export type Fields = typeof Fields.Type const InfoBase = { id: ID, @@ -110,22 +127,11 @@ const InfoBase = { metadata: Metadata.pipe(optional), } -export const FormInfo = Schema.Struct({ +export const Info = Schema.Struct({ ...InfoBase, - mode: Schema.Literal("form"), - fields: Schema.Array(Field), -}).annotate({ identifier: "Form.FormInfo" }) -export interface FormInfo extends Schema.Schema.Type {} - -export const UrlInfo = Schema.Struct({ - ...InfoBase, - mode: Schema.Literal("url"), - url: Schema.String, -}).annotate({ identifier: "Form.UrlInfo" }) -export interface UrlInfo extends Schema.Schema.Type {} - -export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode")) -export type Info = FormInfo | UrlInfo + fields: Fields, +}).annotate({ identifier: "Form.Info" }) +export interface Info extends Schema.Schema.Type {} export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate( { diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index e34b3b6f6e..c441ad88c3 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { DateTime, Schema } from "effect" import { Agent } from "../src/agent.js" import { FileSystem } from "../src/filesystem.js" +import { Form } from "../src/form.js" import { Mcp } from "../src/mcp.js" import { Model } from "../src/model.js" import { Project } from "../src/project.js" @@ -47,6 +48,33 @@ describe("contract hygiene", () => { ).toEqual({ text: "completed" }) }) + test("forms require at least one field", () => { + expect(() => + Schema.decodeUnknownSync(Form.Info)({ + id: Form.ID.create(), + sessionID: "global", + title: "Empty form", + fields: [], + }), + ).toThrow() + expect( + Schema.decodeUnknownSync(Form.Info)({ + id: Form.ID.create(), + sessionID: "global", + title: "External form", + fields: [{ key: "authorization", type: "external", url: "https://example.com" }], + }).fields, + ).toHaveLength(1) + expect(() => + Schema.decodeUnknownSync(Form.Info)({ + id: Form.ID.create(), + sessionID: "global", + title: "External form", + fields: [{ type: "external", url: "https://example.com" }], + }), + ).toThrow() + }) + test("model defaults and provider overlays preserve public invariants", () => { const id = Model.ID.make("model") expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) @@ -69,6 +97,10 @@ describe("contract hygiene", () => { const identifiers = [ Agent.Color, FileSystem.Submatch, + Form.Field, + Form.Fields, + Form.Info, + Form.ExternalField, Mcp.Resource, Mcp.ResourceTemplate, Mcp.ResourceCatalog, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9baf54e1b7..56a9bff6b6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1408,7 +1408,7 @@ export type GlobalEvent = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo + form: FormInfo } } | { @@ -3545,22 +3545,30 @@ export type FormMultiselectField = { default?: Array } -export type FormFormInfo = { - id: string - sessionID: string - title: string - metadata?: FormMetadata - mode: "form" - fields: Array +export type FormExternalField = { + key: string + type: "external" + url: string + title?: string + description?: string } -export type FormUrlInfo = { +export type FormField = + | FormStringField + | FormNumberField + | FormIntegerField + | FormBooleanField + | FormMultiselectField + | FormExternalField + +export type FormFields = Array + +export type FormInfo = { id: string sessionID: string title: string metadata?: FormMetadata - mode: "url" - url: string + fields: FormFields } export type FormValue = @@ -5835,9 +5843,7 @@ export type FormCreatePayload = { id?: string title: string metadata?: FormMetadata - mode: "form" | "url" - fields?: Array - url?: string + fields: FormFields } export type FormState = @@ -6558,7 +6564,7 @@ export type FormCreated = { type: "form.created" location?: LocationRef data: { - form: FormFormInfo | FormUrlInfo + form: FormInfo } } @@ -7830,7 +7836,7 @@ export type EventFormCreated = { id: string type: "form.created" properties: { - form: FormFormInfo | FormUrlInfo + form: FormInfo } } @@ -9888,33 +9894,21 @@ export type FormMultiselectFieldV2 = { default?: Array } -export type FormFormInfoV2 = { - id: string - sessionID: string - title: string - metadata?: FormMetadata - mode: "form" - fields: Array -} +export type FormFieldsV2 = [FormField, FormField] -export type FormUrlInfoV2 = { +export type FormInfoV2 = { id: string sessionID: string title: string metadata?: FormMetadata - mode: "url" - url: string + fields: FormFieldsV2 } export type FormCreatePayloadV2 = { id?: string | null title: string metadata?: FormMetadata - mode: "form" | "url" - fields?: Array< - FormStringFieldV2 | FormNumberField | FormIntegerField | FormBooleanField | FormMultiselectFieldV2 - > | null - url?: string | null + fields: FormFieldsV2 } export type FormValueV2 = @@ -10623,22 +10617,22 @@ export type FormMultiselectField1 = { default?: Array } -export type FormFormInfo1 = { - id: string - sessionID: string - title: string - metadata?: FormMetadata1 - mode: "form" - fields: Array -} +export type FormField1 = + | FormStringField1 + | FormNumberField1 + | FormIntegerField1 + | FormBooleanField1 + | FormMultiselectField1 + | FormExternalField -export type FormUrlInfo1 = { +export type FormFields1 = [FormField1, FormField1] + +export type FormInfo1 = { id: string sessionID: string title: string metadata?: FormMetadata1 - mode: "url" - url: string + fields: FormFields1 } export type FormCreatedV2 = { @@ -10650,7 +10644,7 @@ export type FormCreatedV2 = { type: "form.created" location?: LocationRefV2 data: { - form: FormFormInfo1 | FormUrlInfo1 + form: FormInfo1 } } @@ -17240,7 +17234,7 @@ export type V2FormRequestListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -17277,7 +17271,7 @@ export type V2SessionFormListResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -17318,7 +17312,7 @@ export type V2SessionFormCreateResponses = { * Success */ 200: { - data: FormFormInfoV2 | FormUrlInfoV2 + data: FormInfoV2 } } @@ -17356,7 +17350,7 @@ export type V2SessionFormGetResponses = { * Success */ 200: { - data: FormFormInfoV2 | FormUrlInfoV2 + data: FormInfoV2 } } diff --git a/packages/server/src/handlers/form.ts b/packages/server/src/handlers/form.ts index db910464af..e561153bb0 100644 --- a/packages/server/src/handlers/form.ts +++ b/packages/server/src/handlers/form.ts @@ -44,29 +44,21 @@ export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) = "session.form.create", Effect.fn(function* (ctx) { const form = yield* Form.Service - const common = { - id: ctx.payload.id, - sessionID: ctx.params.sessionID, - title: ctx.payload.title, - metadata: ctx.payload.metadata, - } - const input = yield* (() => { - if (ctx.payload.mode === "form") { - if (!ctx.payload.fields) { - return new InvalidRequestError({ message: "Form fields are required", field: "fields" }) - } - return Effect.succeed({ ...common, mode: "form" as const, fields: ctx.payload.fields }) - } - if (!ctx.payload.url) return new InvalidRequestError({ message: "Form URL is required", field: "url" }) - return Effect.succeed({ ...common, mode: "url" as const, url: ctx.payload.url }) - })() - - const created = yield* form.create(input).pipe( - Effect.catchTags({ - "Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }), - "Form.InvalidFormError": (error) => new InvalidRequestError({ message: error.message, field: "fields" }), - }), - ) + const created = yield* form + .create({ + id: ctx.payload.id, + sessionID: ctx.params.sessionID, + title: ctx.payload.title, + metadata: ctx.payload.metadata, + fields: ctx.payload.fields, + }) + .pipe( + Effect.catchTags({ + "Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }), + "Form.InvalidFormError": (error) => + new InvalidRequestError({ message: error.message, field: "fields" }), + }), + ) return { data: created } }), ) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 40303f30bb..fd6e4ea4de 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -196,9 +196,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { const api = OpenCode.make(options) const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( Effect.map((response) => response.location.directory), - Effect.catch(() => - Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), - ), + Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))), ) const reconnectEndpoint = input.server.reconnect const reconnect = reconnectEndpoint @@ -411,11 +409,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { }) }) -function App(props: { - onSnapshot?: () => Promise - pluginHost: TuiPluginHost - pair?: DialogPairCredentials -}) { +function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPluginHost; pair?: DialogPairCredentials }) { const log = useLog({ component: "app" }) const startup = useTuiStartup() const tuiConfig = useTuiConfig() diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 26ee2f0481..b3a4c60eca 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -6,8 +6,7 @@ import type { AgentInfo, CommandInfo, - FormFormInfo, - FormUrlInfo, + FormInfo, IntegrationInfo, LocationRef, McpServer, @@ -38,7 +37,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") // Global MCP elicitations temporarily use "global" instead of a real session ID, so the // server cannot recover their Location when settling them. Preserve the event Location // until MCP elicitations carry session ownership. -export type FormInfo = (FormFormInfo | FormUrlInfo) & { readonly location?: LocationRef } +export type FormWithLocation = FormInfo & { readonly location?: LocationRef } type LocationData = { agent?: AgentInfo[] @@ -66,7 +65,7 @@ type Data = { input: Record permission: Record // Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel. - form: Record + form: Record } project: { permission: Record @@ -1033,7 +1032,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: response.location.directory, workspaceID: response.location.workspaceID, } - const forms = response.data.reduce>( + const forms = response.data.reduce>( (result, form) => ({ ...result, [form.sessionID]: [ diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 5f143370ce..8c7aec59c7 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -4,19 +4,25 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid" import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" import open from "open" import { selectedForeground, tint, useTheme } from "../../context/theme" -import type { FormFormInfo, FormValue } from "@opencode-ai/sdk/v2" -import type { FormInfo } from "../../context/data" +import type { FormField, FormValue } from "@opencode-ai/sdk/v2" +import type { FormWithLocation } from "../../context/data" import { useSDK } from "../../context/sdk" +import { useClipboard } from "../../context/clipboard" import { SplitBorder } from "../../ui/border" +import { useToast } from "../../ui/toast" import { useTuiConfig } from "../../config" import { useBindings, useOpencodeModeStack } from "../../keymap" const FORM_MODE = "form" -type Field = FormFormInfo["fields"][number] +type Field = Exclude -function fieldLabel(field: Field) { - return field.title ?? field.key +function isField(field: FormField): field is Field { + return field.type !== "external" +} + +function fieldLabel(field: FormField) { + return field.title ?? (field.type === "external" ? field.url : field.key) } function truncate(label: string, max: number) { @@ -24,7 +30,7 @@ function truncate(label: string, max: number) { } function validateText(field: Field, text: string): string | undefined { - if (field.type !== "string") return + if (field.type !== "string") return undefined if (field.minLength !== undefined && text.length < field.minLength) return `Must be at least ${field.minLength} characters` if (field.maxLength !== undefined && text.length > field.maxLength) @@ -50,17 +56,19 @@ function validateText(field: Field, text: string): string | undefined { return "Expected a date (YYYY-MM-DD)" } if (field.format === "date-time" && Number.isNaN(new Date(text).getTime())) return "Expected a date and time" + return undefined } -function validateSelection(field: Field, value: FormValue | undefined) { - if (field.type !== "multiselect" || value === undefined) return +function validateSelection(field: Field, value: FormValue | undefined): string | undefined { + if (field.type !== "multiselect" || value === undefined) return undefined if (!Array.isArray(value)) return "Expected selections" if (field.required && value.length === 0) return "Select at least one option" if (field.minItems !== undefined && value.length < field.minItems) return `Select at least ${field.minItems}` if (field.maxItems !== undefined && value.length > field.maxItems) return `Select at most ${field.maxItems}` + return undefined } -function validateValue(field: Field, value: FormValue | undefined) { +function validateValue(field: Field, value: FormValue | undefined): string | undefined { if (value === undefined) return field.required ? "Answer required" : undefined if (field.required && (value === "" || (Array.isArray(value) && value.length === 0))) { return field.type === "multiselect" ? "Select at least one option" : "Answer required" @@ -72,14 +80,14 @@ function validateValue(field: Field, value: FormValue | undefined) { if (field.options && !field.custom && !field.options.some((option) => option.value === value)) { return "Select an available option" } - return + return undefined } if (field.type === "number" || field.type === "integer") { if (typeof value !== "number" || !Number.isFinite(value)) return "Expected a number" if (field.type === "integer" && !Number.isInteger(value)) return "Expected an integer" if (typeof field.minimum === "number" && value < field.minimum) return `Must be at least ${field.minimum}` if (typeof field.maximum === "number" && value > field.maximum) return `Must be at most ${field.maximum}` - return + return undefined } if (field.type === "boolean") return typeof value === "boolean" ? undefined : "Expected yes or no" const invalid = validateSelection(field, value) @@ -91,6 +99,7 @@ function validateValue(field: Field, value: FormValue | undefined) { ) { return "Select only available options" } + return undefined } function fieldRows(field: Field): { value: FormValue; label: string; description?: string }[] { @@ -117,11 +126,6 @@ function selectedRow(field: Field | undefined, value: FormValue | undefined) { return 0 } -function customDefault(field: Field) { - if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return - if (!field.options.some((option) => option.value === field.default)) return field.default -} - function display(field: Field, value: FormValue | undefined) { if (value === undefined) return "" const label = (item: string | number | boolean) => @@ -130,7 +134,7 @@ function display(field: Field, value: FormValue | undefined) { return label(value) } -function requestOptions(form: FormInfo) { +function requestOptions(form: FormWithLocation) { if (form.sessionID !== "global" || !form.location) return undefined return { headers: { @@ -140,107 +144,32 @@ function requestOptions(form: FormInfo) { } } -export function FormPrompt(props: { form: FormInfo }) { - return props.form.mode === "url" ? : -} - -function UrlPrompt(props: { form: FormInfo & { mode: "url" } }) { - const sdk = useSDK() - const { theme } = useTheme() - const modeStack = useOpencodeModeStack() - const message = createMemo(() => { - const value = props.form.metadata?.["message"] - return typeof value === "string" ? value : undefined - }) - - onMount(() => onCleanup(modeStack.push(FORM_MODE))) - - useBindings(() => ({ - mode: FORM_MODE, - enabled: true, - commands: [ - { - name: "app.exit", - title: "Dismiss form", - category: "Form", - run() { - void sdk.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) - }, - }, - ], - bindings: [ - { - key: "return", - desc: "Open link", - group: "Form", - cmd: () => { - void open(props.form.url) - }, - }, - { - key: "escape", - desc: "Dismiss form", - group: "Form", - cmd: () => { - void sdk.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) - }, - }, - ], - })) - - return ( - - - {props.form.title} - - {message()} - - {props.form.url} - - - - enter open link - - - esc dismiss - - - - ) -} - -function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { +export function FormPrompt(props: { form: FormWithLocation }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() const dimensions = useTerminalDimensions() const tuiConfig = useTuiConfig() const modeStack = useOpencodeModeStack() + const clipboard = useClipboard() + const toast = useToast() + const configuredFields = props.form.fields.filter(isField) const [tabHover, setTabHover] = createSignal(null) const [store, setStore] = createStore({ tab: 0, answers: Object.fromEntries( - props.form.fields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])), + configuredFields.flatMap((field) => (field.default === undefined ? [] : [[field.key, field.default]])), ) as Record, custom: Object.fromEntries( - props.form.fields.flatMap((field) => { - const value = customDefault(field) - return value === undefined ? [] : [[field.key, value]] + configuredFields.flatMap((field) => { + if (field.type !== "string" || !field.options || !field.custom || typeof field.default !== "string") return [] + if (field.options.some((option) => option.value === field.default)) return [] + return [[field.key, field.default]] }), ) as Record, - selected: selectedRow(props.form.fields[0], props.form.fields[0]?.default), + externalReady: {} as Record, + selected: selectedRow(configuredFields[0], configuredFields[0]?.default), editing: false, error: "", }) @@ -248,9 +177,14 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { let textarea: TextareaRenderable | undefined let review: ScrollBoxRenderable | undefined + const message = createMemo(() => { + const value = props.form.metadata?.["message"] + return typeof value === "string" ? value : undefined + }) const fields = createMemo(() => { const answers: Record = {} return props.form.fields.filter((field) => { + if (field.type === "external") return true const active = (field.when ?? []).every((when) => { const value = answers[when.key] if (value === undefined) return false @@ -263,9 +197,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { }) const single = createMemo(() => { const list = fields() - if (props.form.fields.length !== 1) return false if (list.length !== 1) return false - const field = list[0]! + const field = list[0] + if (field.type === "external") return false return field.type === "boolean" || (field.type === "string" && field.options !== undefined) }) const tabs = createMemo(() => (single() ? 1 : fields().length + 1)) @@ -280,10 +214,18 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { return value !== undefined }).length, ) - const field = createMemo(() => fields()[Math.min(store.tab, fields().length - 1)]) + const field = createMemo(() => fields()[store.tab]) + const answerField = createMemo(() => { + const current = field() + return current && isField(current) ? current : undefined + }) + const externalField = createMemo(() => { + const current = field() + return current?.type === "external" ? current : undefined + }) const confirm = createMemo(() => !single() && store.tab >= fields().length) const rows = createMemo(() => { - const current = field() + const current = answerField() if (!current) return [] const configured = fieldRows(current) const value = store.answers[current.key] @@ -296,21 +238,32 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { }) const textual = createMemo(() => { if (confirm()) return false - const current = field() + const current = answerField() if (!current) return false if (current.type === "number" || current.type === "integer") return true return current.type === "string" && current.options === undefined }) const custom = createMemo(() => { - const current = field() + const current = answerField() if (!current) return false if (current.type === "string" && current.options !== undefined) return current.custom === true if (current.type === "multiselect") return current.custom === true return false }) - const multi = createMemo(() => field()?.type === "multiselect") + const multi = createMemo(() => answerField()?.type === "multiselect") + const actionLabel = createMemo(() => { + if (confirm()) return "submit" + const external = externalField() + if (external) { + if (store.answers[external.key] === true) return "continue" + return store.externalReady[external.key] ? "I finished" : "open link" + } + if (multi()) return "toggle" + if (single()) return "submit" + return "confirm" + }) const placeholder = createMemo(() => { - const current = field() + const current = answerField() if (current?.type === "string") { if (current.placeholder) return current.placeholder if (current.format === "email") return "name@example.com" @@ -328,11 +281,11 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { return "Type your answer" }) const other = createMemo(() => custom() && store.selected === rows().length) - const input = createMemo(() => store.custom[field()?.key ?? ""] ?? "") + const input = createMemo(() => store.custom[answerField()?.key ?? ""] ?? "") const customPicked = createMemo(() => { const value = input() if (!value) return false - const answer = store.answers[field()?.key ?? ""] + const answer = store.answers[answerField()?.key ?? ""] if (Array.isArray(answer)) return answer.includes(value) return answer === value }) @@ -363,7 +316,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { } function pick(value: FormValue, customValue?: string) { - const current = field() + const current = answerField() if (!current) return const invalid = validateValue(current, value) if (invalid) { @@ -380,7 +333,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { } function toggle(value: string) { - const current = field() + const current = answerField() if (!current) return const existing = store.answers[current.key] const list = Array.isArray(existing) ? [...existing] : [] @@ -392,7 +345,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { function validateCurrent() { if (confirm()) return true - const current = field() + const current = answerField() if (!current) return true const invalid = validateValue(current, store.answers[current.key]) if (!invalid) return true @@ -404,7 +357,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { if (!confirm() && index > store.tab && !validateCurrent()) return const next = fields()[index] setStore("tab", index) - setStore("selected", selectedRow(next, next ? store.answers[next.key] : undefined)) + setStore("selected", next && isField(next) ? selectedRow(next, store.answers[next.key]) : 0) setStore("editing", false) setStore("error", "") } @@ -433,7 +386,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { } function commitInput(text: string) { - const current = field() + const current = answerField() if (!current) return false const isTextual = textual() const isMulti = multi() @@ -507,9 +460,9 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { if (!single()) selectTab((store.tab + direction + tabs()) % tabs()) } - function selectTabFromMouse(target?: Field) { + function selectTabFromMouse(target?: FormField) { const targetIndex = () => { - const index = target ? fields().findIndex((field) => field.key === target.key) : fields().length + const index = target ? fields().findIndex((field) => field === target) : fields().length return index === -1 ? fields().length : index } const move = () => selectTab(targetIndex()) @@ -524,6 +477,83 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { move() } + function cancel() { + void sdk.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) + } + + function openExternal() { + const current = externalField() + if (!current) return + setStore("error", "") + void open(current.url) + .then(() => setStore("externalReady", { ...store.externalReady, [current.key]: true })) + .catch(() => setStore("error", "Could not open the browser. Copy the URL and continue manually.")) + } + + function copyExternal() { + const current = externalField() + if (!current || !clipboard.write) return + void clipboard + .write(current.url) + .then(() => { + setStore("externalReady", { ...store.externalReady, [current.key]: true }) + toast.show({ message: "Copied URL to clipboard", variant: "info" }) + }) + .catch(toast.error) + } + + function acknowledgeExternal() { + const current = externalField() + if (!current) return + if (store.answers[current.key] === true) { + selectTab(store.tab + 1) + return + } + if (!store.externalReady[current.key]) { + openExternal() + return + } + answer(current.key, true) + selectTab(store.tab + 1) + } + + function submit() { + const unacknowledged = fields().find((field) => field.type === "external" && store.answers[field.key] !== true) + if (unacknowledged) { + setStore("error", `External action must be acknowledged: ${fieldLabel(unacknowledged)}`) + return + } + const invalid = fields() + .filter(isField) + .find((field) => validateValue(field, store.answers[field.key])) + if (invalid) { + setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") + return + } + sdk.api.form + .reply( + { + sessionID: props.form.sessionID, + formID: props.form.id, + answer: Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + }, + requestOptions(props.form), + ) + .catch((error: unknown) => { + setStore( + "error", + typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" + ? error.message + : "Invalid answer", + ) + }) + } + onMount(() => onCleanup(modeStack.push(FORM_MODE))) useBindings(() => ({ @@ -585,7 +615,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { group: "Form", cmd: () => { const text = textarea?.plainText?.trim() ?? "" - const current = field() + const current = answerField() if (!current) return if (textual()) { submitInput(text) @@ -606,6 +636,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { useBindings(() => { const total = rows().length + (custom() ? 1 : 0) const max = Math.min(total, 9) + const external = externalField() return { mode: FORM_MODE, @@ -615,12 +646,7 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { name: "app.exit", title: "Dismiss form", category: "Form", - run() { - void sdk.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) - }, + run: cancel, }, ], bindings: [ @@ -650,110 +676,86 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()), }, - ...(confirm() + ...(external ? [ { key: "return", - desc: "Submit form", + desc: + store.answers[external.key] === true + ? "Continue" + : store.externalReady[external.key] + ? "Confirm completion" + : "Open link", group: "Form", - cmd: () => { - const invalid = fields().find((field) => validateValue(field, store.answers[field.key])) - if (invalid) { - setStore("error", validateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") - return - } - sdk.api.form - .reply( - { - sessionID: props.form.sessionID, - formID: props.form.id, - answer: Object.fromEntries( - fields().flatMap((field) => { - const value = store.answers[field.key] - return value === undefined ? [] : [[field.key, value] as const] - }), - ), - }, - requestOptions(props.form), - ) - .catch((error: unknown) => { - setStore( - "error", - typeof error === "object" && - error !== null && - "message" in error && - typeof error.message === "string" - ? error.message - : "Invalid answer", - ) - }) - }, + cmd: acknowledgeExternal, }, - { - key: "escape", - desc: "Dismiss form", - group: "Form", - cmd: () => { - void sdk.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) - }, - }, - { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, - { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, - { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, - { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + { key: "c", desc: "Copy link", group: "Form", cmd: copyExternal }, + { key: "escape", desc: "Dismiss form", group: "Form", cmd: cancel }, ...tuiConfig.keybinds.get("app.exit"), ] - : [ - ...Array.from({ length: max }, (_, index) => ({ - key: String(index + 1), - desc: `Select answer ${index + 1}`, - group: "Form", - cmd: () => { - setStore("selected", index) - selectOption() + : confirm() + ? [ + { + key: "return", + desc: "Submit form", + group: "Form", + cmd: submit, }, - })), - { - key: "up", - desc: "Previous answer", - group: "Form", - cmd: () => setStore("selected", (store.selected - 1 + total) % total), - }, - { - key: "k", - desc: "Previous answer", - group: "Form", - cmd: () => setStore("selected", (store.selected - 1 + total) % total), - }, - { - key: "down", - desc: "Next answer", - group: "Form", - cmd: () => setStore("selected", (store.selected + 1) % total), - }, - { - key: "j", - desc: "Next answer", - group: "Form", - cmd: () => setStore("selected", (store.selected + 1) % total), - }, - { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() }, - { - key: "escape", - desc: "Dismiss form", - group: "Form", - cmd: () => { - void sdk.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: cancel, }, - }, - ...tuiConfig.keybinds.get("app.exit"), - ]), + { key: "up", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, + { key: "k", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(-1) }, + { key: "down", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + { key: "j", desc: "Scroll review", group: "Form", cmd: () => review?.scrollBy(1) }, + ...tuiConfig.keybinds.get("app.exit"), + ] + : [ + ...Array.from({ length: max }, (_, index) => ({ + key: String(index + 1), + desc: `Select answer ${index + 1}`, + group: "Form", + cmd: () => { + setStore("selected", index) + selectOption() + }, + })), + { + key: "up", + desc: "Previous answer", + group: "Form", + cmd: () => setStore("selected", (store.selected - 1 + total) % total), + }, + { + key: "k", + desc: "Previous answer", + group: "Form", + cmd: () => setStore("selected", (store.selected - 1 + total) % total), + }, + { + key: "down", + desc: "Next answer", + group: "Form", + cmd: () => setStore("selected", (store.selected + 1) % total), + }, + { + key: "j", + desc: "Next answer", + group: "Form", + cmd: () => setStore("selected", (store.selected + 1) % total), + }, + { key: "return", desc: "Select answer", group: "Form", cmd: () => selectOption() }, + { + key: "escape", + desc: "Dismiss form", + group: "Form", + cmd: cancel, + }, + ...tuiConfig.keybinds.get("app.exit"), + ]), ], } }) @@ -769,14 +771,21 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { {props.form.title} + + + {message()} + + {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`} - - · {answered()}/{fields().length} answered - + 0}> + + · {answered()}/{fields().length} completed + + @@ -828,16 +837,45 @@ function FieldsPrompt(props: { form: FormInfo & { mode: "form" } }) { - + + {(external) => ( + + + {external().title} + + + {external().description} + + { + if (renderer.getSelection()?.getSelectedText()) return + openExternal() + }} + > + {external().url} + + + {store.answers[external().key] === true + ? "✓ Acknowledged" + : store.externalReady[external().key] + ? "Complete the external action, then press enter to confirm." + : "Open or copy the URL, complete the external action, then confirm."} + + + )} + + + - {field()!.description ?? fieldLabel(field()!)} - {field()!.required ? " (required)" : ""} + {answerField()!.description ?? fieldLabel(answerField()!)} + {answerField()!.required ? " (required)" : ""} {multi() ? " (select all that apply)" : ""} - +