mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
port 040 config (#2657)
* port 040 config, WIP * update fixtures * use taiga modal for backups too * fix: update Taiga UI and refactor everything to work * chore: package-lock * fix interfaces and mocks for interfaces * better mocks * function to transform old spec to new * delete unused fns * delete unused FE config utils * fix exports from sdk * reorganize exports * functions to translate config * rename unionSelectKey and unionValueKey * Adding in the transformation of the getConfig to the new types. * chore: add Taiga UI to preloader --------- Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: J H <dragondef@gmail.com>
This commit is contained in:
@@ -42,6 +42,12 @@ import {
|
||||
} from "@start9labs/start-sdk/cjs/lib/interfaces/Host"
|
||||
import { ServiceInterfaceBuilder } from "@start9labs/start-sdk/cjs/lib/interfaces/ServiceInterfaceBuilder"
|
||||
import { Effects } from "../../../Models/Effects"
|
||||
import {
|
||||
OldConfigSpec,
|
||||
matchOldConfigSpec,
|
||||
transformConfigSpec,
|
||||
transformOldConfigToNew,
|
||||
} from "./transformConfigSpec"
|
||||
|
||||
type Optional<A> = A | undefined | null
|
||||
function todo(): never {
|
||||
@@ -533,7 +539,9 @@ export class SystemForEmbassy implements System {
|
||||
effects: Effects,
|
||||
timeoutMs: number | null,
|
||||
): Promise<T.ConfigRes> {
|
||||
return this.getConfigUncleaned(effects, timeoutMs).then(removePointers)
|
||||
return this.getConfigUncleaned(effects, timeoutMs)
|
||||
.then(removePointers)
|
||||
.then(convertToNewConfig)
|
||||
}
|
||||
private async getConfigUncleaned(
|
||||
effects: Effects,
|
||||
@@ -1054,3 +1062,10 @@ function extractServiceInterfaceId(manifest: Manifest, specInterface: string) {
|
||||
const serviceInterfaceId = `${specInterface}-${internalPort}`
|
||||
return serviceInterfaceId
|
||||
}
|
||||
async function convertToNewConfig(value: T.ConfigRes): Promise<T.ConfigRes> {
|
||||
const valueSpec: OldConfigSpec = matchOldConfigSpec.unsafeCast(value.spec)
|
||||
const spec = transformConfigSpec(valueSpec)
|
||||
if (!value.config) return { spec, config: null }
|
||||
const config = transformOldConfigToNew(valueSpec, value.config)
|
||||
return { spec, config }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { CT } from "@start9labs/start-sdk"
|
||||
import {
|
||||
dictionary,
|
||||
object,
|
||||
anyOf,
|
||||
string,
|
||||
literals,
|
||||
array,
|
||||
number,
|
||||
boolean,
|
||||
Parser,
|
||||
deferred,
|
||||
every,
|
||||
nill,
|
||||
} from "ts-matches"
|
||||
|
||||
export function transformConfigSpec(oldSpec: OldConfigSpec): CT.InputSpec {
|
||||
return Object.entries(oldSpec).reduce((inputSpec, [key, oldVal]) => {
|
||||
let newVal: CT.ValueSpec
|
||||
|
||||
if (oldVal.type === "boolean") {
|
||||
newVal = {
|
||||
type: "toggle",
|
||||
name: oldVal.name,
|
||||
default: oldVal.default,
|
||||
description: oldVal.description || null,
|
||||
warning: oldVal.warning || null,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
}
|
||||
} else if (oldVal.type === "enum") {
|
||||
newVal = {
|
||||
type: "select",
|
||||
name: oldVal.name,
|
||||
description: oldVal.description || null,
|
||||
warning: oldVal.warning || null,
|
||||
default: oldVal.default,
|
||||
values: oldVal.values.reduce(
|
||||
(obj, curr) => ({
|
||||
...obj,
|
||||
[curr]: oldVal["value-names"][curr],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
required: false,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
}
|
||||
} else if (oldVal.type === "list") {
|
||||
newVal = getListSpec(oldVal)
|
||||
} else if (oldVal.type === "number") {
|
||||
const range = Range.from(oldVal.range)
|
||||
|
||||
newVal = {
|
||||
type: "number",
|
||||
name: oldVal.name,
|
||||
default: oldVal.default || null,
|
||||
description: oldVal.description || null,
|
||||
warning: oldVal.warning || null,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
required: !oldVal.nullable,
|
||||
min: range.min
|
||||
? range.minInclusive
|
||||
? range.min
|
||||
: range.min + 1
|
||||
: null,
|
||||
max: range.max
|
||||
? range.maxInclusive
|
||||
? range.max
|
||||
: range.max - 1
|
||||
: null,
|
||||
integer: oldVal.integral,
|
||||
step: null,
|
||||
units: oldVal.units || null,
|
||||
placeholder: oldVal.placeholder || null,
|
||||
}
|
||||
} else if (oldVal.type === "object") {
|
||||
newVal = {
|
||||
type: "object",
|
||||
name: oldVal.name,
|
||||
description: oldVal.description || null,
|
||||
warning: oldVal.warning || null,
|
||||
spec: transformConfigSpec(matchOldConfigSpec.unsafeCast(oldVal.spec)),
|
||||
}
|
||||
} else if (oldVal.type === "string") {
|
||||
newVal = {
|
||||
type: "text",
|
||||
name: oldVal.name,
|
||||
default: oldVal.default || null,
|
||||
description: oldVal.description || null,
|
||||
warning: oldVal.warning || null,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
required: !oldVal.nullable,
|
||||
patterns:
|
||||
oldVal.pattern && oldVal["pattern-description"]
|
||||
? [
|
||||
{
|
||||
regex: oldVal.pattern,
|
||||
description: oldVal["pattern-description"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
masked: oldVal.masked,
|
||||
generate: null,
|
||||
inputmode: "text",
|
||||
placeholder: oldVal.placeholder || null,
|
||||
}
|
||||
} else {
|
||||
newVal = {
|
||||
type: "union",
|
||||
name: oldVal.tag.name,
|
||||
description: oldVal.tag.description || null,
|
||||
warning: oldVal.tag.warning || null,
|
||||
variants: Object.entries(oldVal.variants).reduce(
|
||||
(obj, [id, spec]) => ({
|
||||
...obj,
|
||||
[id]: {
|
||||
name: oldVal.tag["variant-names"][id],
|
||||
spec: transformConfigSpec(matchOldConfigSpec.unsafeCast(spec)),
|
||||
},
|
||||
}),
|
||||
{} as Record<string, { name: string; spec: CT.InputSpec }>,
|
||||
),
|
||||
disabled: false,
|
||||
required: true,
|
||||
default: oldVal.default,
|
||||
immutable: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...inputSpec,
|
||||
[key]: newVal,
|
||||
}
|
||||
}, {} as CT.InputSpec)
|
||||
}
|
||||
|
||||
export function transformOldConfigToNew(
|
||||
spec: OldConfigSpec,
|
||||
config: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
return Object.entries(spec).reduce((obj, [key, val]) => {
|
||||
let newVal = config[key]
|
||||
|
||||
if (isObject(val)) {
|
||||
newVal = transformOldConfigToNew(
|
||||
matchOldConfigSpec.unsafeCast(val.spec),
|
||||
config[key],
|
||||
)
|
||||
}
|
||||
|
||||
if (isUnion(val)) {
|
||||
const selection = config[key][val.tag.id]
|
||||
delete config[key][val.tag.id]
|
||||
|
||||
newVal = {
|
||||
selection,
|
||||
value: transformOldConfigToNew(
|
||||
matchOldConfigSpec.unsafeCast(val.variants[selection]),
|
||||
config[key],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (isList(val) && isObjectList(val)) {
|
||||
newVal = (config[key] as object[]).map((obj) =>
|
||||
transformOldConfigToNew(
|
||||
matchOldConfigSpec.unsafeCast(val.spec.spec),
|
||||
obj,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...obj,
|
||||
[key]: newVal,
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function transformNewConfigToOld(
|
||||
spec: OldConfigSpec,
|
||||
config: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
return Object.entries(spec).reduce((obj, [key, val]) => {
|
||||
let newVal = config[key]
|
||||
|
||||
if (isObject(val)) {
|
||||
newVal = transformNewConfigToOld(
|
||||
matchOldConfigSpec.unsafeCast(val.spec),
|
||||
config[key],
|
||||
)
|
||||
}
|
||||
|
||||
if (isUnion(val)) {
|
||||
newVal = {
|
||||
[val.tag.id]: config[key].selection,
|
||||
...transformNewConfigToOld(
|
||||
matchOldConfigSpec.unsafeCast(val.variants[config[key].selection]),
|
||||
config[key].unionSelectValue,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (isList(val) && isObjectList(val)) {
|
||||
newVal = (config[key] as object[]).map((obj) =>
|
||||
transformNewConfigToOld(
|
||||
matchOldConfigSpec.unsafeCast(val.spec.spec),
|
||||
obj,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...obj,
|
||||
[key]: newVal,
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
|
||||
function getListSpec(
|
||||
oldVal: OldValueSpecList,
|
||||
): CT.ValueSpecMultiselect | CT.ValueSpecList {
|
||||
const range = Range.from(oldVal.range)
|
||||
|
||||
let partial: Omit<CT.ValueSpecList, "type" | "spec" | "default"> = {
|
||||
name: oldVal.name,
|
||||
description: oldVal.description || null,
|
||||
warning: oldVal.warning || null,
|
||||
minLength: range.min
|
||||
? range.minInclusive
|
||||
? range.min
|
||||
: range.min + 1
|
||||
: null,
|
||||
maxLength: range.max
|
||||
? range.maxInclusive
|
||||
? range.max
|
||||
: range.max - 1
|
||||
: null,
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
if (isEnumList(oldVal)) {
|
||||
return {
|
||||
...partial,
|
||||
type: "multiselect",
|
||||
default: oldVal.default as string[],
|
||||
immutable: false,
|
||||
values: oldVal.spec.values.reduce(
|
||||
(obj, curr) => ({
|
||||
...obj,
|
||||
[curr]: oldVal.spec["value-names"][curr],
|
||||
}),
|
||||
{},
|
||||
),
|
||||
}
|
||||
} else if (isStringList(oldVal)) {
|
||||
return {
|
||||
...partial,
|
||||
type: "list",
|
||||
default: oldVal.default as string[],
|
||||
spec: {
|
||||
type: "text",
|
||||
patterns:
|
||||
oldVal.spec.pattern && oldVal.spec["pattern-description"]
|
||||
? [
|
||||
{
|
||||
regex: oldVal.spec.pattern,
|
||||
description: oldVal.spec["pattern-description"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
masked: oldVal.spec.masked,
|
||||
generate: null,
|
||||
inputmode: "text",
|
||||
placeholder: oldVal.spec.placeholder || null,
|
||||
},
|
||||
}
|
||||
} else if (isObjectList(oldVal)) {
|
||||
return {
|
||||
...partial,
|
||||
type: "list",
|
||||
default: oldVal.default as Record<string, unknown>[],
|
||||
spec: {
|
||||
type: "object",
|
||||
spec: transformConfigSpec(
|
||||
matchOldConfigSpec.unsafeCast(oldVal.spec.spec),
|
||||
),
|
||||
uniqueBy: oldVal.spec["unique-by"],
|
||||
displayAs: oldVal.spec["display-as"] || null,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
throw new Error("Invalid list subtype. enum, string, and object permitted.")
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(val: OldValueSpec): val is OldValueSpecObject {
|
||||
return val.type === "object"
|
||||
}
|
||||
|
||||
function isUnion(val: OldValueSpec): val is OldValueSpecUnion {
|
||||
return val.type === "union"
|
||||
}
|
||||
|
||||
function isList(val: OldValueSpec): val is OldValueSpecList {
|
||||
return val.type === "list"
|
||||
}
|
||||
|
||||
function isEnumList(
|
||||
val: OldValueSpecList,
|
||||
): val is OldValueSpecList & { subtype: "enum" } {
|
||||
return val.subtype === "enum"
|
||||
}
|
||||
|
||||
function isStringList(
|
||||
val: OldValueSpecList,
|
||||
): val is OldValueSpecList & { subtype: "string" } {
|
||||
return val.subtype === "string"
|
||||
}
|
||||
|
||||
function isObjectList(
|
||||
val: OldValueSpecList,
|
||||
): val is OldValueSpecList & { subtype: "object" } {
|
||||
if (["number", "union"].includes(val.subtype)) {
|
||||
throw new Error("Invalid list subtype. enum, string, and object permitted.")
|
||||
}
|
||||
return val.subtype === "object"
|
||||
}
|
||||
export type OldConfigSpec = Record<string, OldValueSpec>
|
||||
const [_matchOldConfigSpec, setMatchOldConfigSpec] = deferred<unknown>()
|
||||
export const matchOldConfigSpec = _matchOldConfigSpec as Parser<
|
||||
unknown,
|
||||
OldConfigSpec
|
||||
>
|
||||
export const matchOldDefaultString = anyOf(
|
||||
string,
|
||||
object({ charset: string, len: number }),
|
||||
)
|
||||
type OldDefaultString = typeof matchOldDefaultString._TYPE
|
||||
|
||||
export const matchOldValueSpecString = object(
|
||||
{
|
||||
masked: boolean,
|
||||
copyable: boolean,
|
||||
type: literals("string"),
|
||||
nullable: boolean,
|
||||
name: string,
|
||||
placeholder: string,
|
||||
pattern: string,
|
||||
"pattern-description": string,
|
||||
default: matchOldDefaultString,
|
||||
textarea: boolean,
|
||||
description: string,
|
||||
warning: string,
|
||||
},
|
||||
[
|
||||
"placeholder",
|
||||
"pattern",
|
||||
"pattern-description",
|
||||
"default",
|
||||
"textarea",
|
||||
"description",
|
||||
"warning",
|
||||
],
|
||||
)
|
||||
|
||||
export const matchOldValueSpecNumber = object(
|
||||
{
|
||||
type: literals("number"),
|
||||
nullable: boolean,
|
||||
name: string,
|
||||
range: string,
|
||||
integral: boolean,
|
||||
default: number,
|
||||
description: string,
|
||||
warning: string,
|
||||
units: string,
|
||||
placeholder: string,
|
||||
},
|
||||
["default", "description", "warning", "units", "placeholder"],
|
||||
)
|
||||
type OldValueSpecNumber = typeof matchOldValueSpecNumber._TYPE
|
||||
|
||||
export const matchOldValueSpecBoolean = object(
|
||||
{
|
||||
type: literals("boolean"),
|
||||
default: boolean,
|
||||
name: string,
|
||||
description: string,
|
||||
warning: string,
|
||||
},
|
||||
["description", "warning"],
|
||||
)
|
||||
type OldValueSpecBoolean = typeof matchOldValueSpecBoolean._TYPE
|
||||
|
||||
const matchOldValueSpecObject = object(
|
||||
{
|
||||
type: literals("object"),
|
||||
spec: _matchOldConfigSpec,
|
||||
name: string,
|
||||
description: string,
|
||||
warning: string,
|
||||
},
|
||||
["description", "warning"],
|
||||
)
|
||||
type OldValueSpecObject = typeof matchOldValueSpecObject._TYPE
|
||||
|
||||
const matchOldValueSpecEnum = object(
|
||||
{
|
||||
values: array(string),
|
||||
"value-names": dictionary([string, string]),
|
||||
type: literals("enum"),
|
||||
default: string,
|
||||
name: string,
|
||||
description: string,
|
||||
warning: string,
|
||||
},
|
||||
["description", "warning"],
|
||||
)
|
||||
type OldValueSpecEnum = typeof matchOldValueSpecEnum._TYPE
|
||||
|
||||
const matchOldUnionTagSpec = object(
|
||||
{
|
||||
id: string, // The name of the field containing one of the union variants
|
||||
"variant-names": dictionary([string, string]), // The name of each variant
|
||||
name: string,
|
||||
description: string,
|
||||
warning: string,
|
||||
},
|
||||
["description", "warning"],
|
||||
)
|
||||
const matchOldValueSpecUnion = object({
|
||||
type: literals("union"),
|
||||
tag: matchOldUnionTagSpec,
|
||||
variants: dictionary([string, _matchOldConfigSpec]),
|
||||
default: string,
|
||||
})
|
||||
type OldValueSpecUnion = typeof matchOldValueSpecUnion._TYPE
|
||||
|
||||
const [matchOldUniqueBy, setOldUniqueBy] = deferred<OldUniqueBy>()
|
||||
type OldUniqueBy =
|
||||
| null
|
||||
| string
|
||||
| { any: OldUniqueBy[] }
|
||||
| { all: OldUniqueBy[] }
|
||||
|
||||
setOldUniqueBy(
|
||||
anyOf(
|
||||
nill,
|
||||
string,
|
||||
object({ any: array(matchOldUniqueBy) }),
|
||||
object({ all: array(matchOldUniqueBy) }),
|
||||
),
|
||||
)
|
||||
|
||||
const matchOldListValueSpecObject = object(
|
||||
{
|
||||
spec: _matchOldConfigSpec, // this is a mapped type of the config object at this level, replacing the object's values with specs on those values
|
||||
"unique-by": matchOldUniqueBy, // indicates whether duplicates can be permitted in the list
|
||||
"display-as": string, // this should be a handlebars template which can make use of the entire config which corresponds to 'spec'
|
||||
},
|
||||
["display-as"],
|
||||
)
|
||||
const matchOldListValueSpecString = object(
|
||||
{
|
||||
masked: boolean,
|
||||
copyable: boolean,
|
||||
pattern: string,
|
||||
"pattern-description": string,
|
||||
placeholder: string,
|
||||
},
|
||||
["pattern", "pattern-description", "placeholder"],
|
||||
)
|
||||
|
||||
const matchOldListValueSpecEnum = object({
|
||||
values: array(string),
|
||||
"value-names": dictionary([string, string]),
|
||||
})
|
||||
|
||||
// represents a spec for a list
|
||||
const matchOldValueSpecList = every(
|
||||
object(
|
||||
{
|
||||
type: literals("list"),
|
||||
range: string, // '[0,1]' (inclusive) OR '[0,*)' (right unbounded), normal math rules
|
||||
default: anyOf(
|
||||
array(string),
|
||||
array(number),
|
||||
array(matchOldDefaultString),
|
||||
array(object),
|
||||
),
|
||||
name: string,
|
||||
description: string,
|
||||
warning: string,
|
||||
},
|
||||
["description", "warning"],
|
||||
),
|
||||
anyOf(
|
||||
object({
|
||||
subtype: literals("string"),
|
||||
spec: matchOldListValueSpecString,
|
||||
}),
|
||||
object({
|
||||
subtype: literals("enum"),
|
||||
spec: matchOldListValueSpecEnum,
|
||||
}),
|
||||
object({
|
||||
subtype: literals("object"),
|
||||
spec: matchOldListValueSpecObject,
|
||||
}),
|
||||
),
|
||||
)
|
||||
type OldValueSpecList = typeof matchOldValueSpecList._TYPE
|
||||
|
||||
export const matchOldValueSpec = anyOf(
|
||||
matchOldValueSpecString,
|
||||
matchOldValueSpecNumber,
|
||||
matchOldValueSpecBoolean,
|
||||
matchOldValueSpecObject,
|
||||
matchOldValueSpecEnum,
|
||||
matchOldValueSpecList,
|
||||
matchOldValueSpecUnion,
|
||||
)
|
||||
type OldValueSpec = typeof matchOldValueSpec._TYPE
|
||||
|
||||
setMatchOldConfigSpec(dictionary([string, matchOldValueSpec]))
|
||||
|
||||
export class Range {
|
||||
min?: number
|
||||
max?: number
|
||||
minInclusive!: boolean
|
||||
maxInclusive!: boolean
|
||||
|
||||
static from(s: string = "(*,*)"): Range {
|
||||
const r = new Range()
|
||||
r.minInclusive = s.startsWith("[")
|
||||
r.maxInclusive = s.endsWith("]")
|
||||
const [minStr, maxStr] = s.split(",").map((a) => a.trim())
|
||||
r.min = minStr === "(*" ? undefined : Number(minStr.slice(1))
|
||||
r.max = maxStr === "*)" ? undefined : Number(maxStr.slice(0, -1))
|
||||
return r
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,6 @@ export class StartSdk<Manifest extends SDKManifest, Store> {
|
||||
},
|
||||
List: {
|
||||
text: List.text,
|
||||
number: List.number,
|
||||
obj: <Type extends Record<string, any>>(
|
||||
a: {
|
||||
name: string
|
||||
@@ -523,29 +522,6 @@ export class StartSdk<Manifest extends SDKManifest, Store> {
|
||||
}
|
||||
>,
|
||||
) => List.dynamicText<Store>(getA),
|
||||
dynamicNumber: (
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
warning?: string | null
|
||||
/** Default = [] */
|
||||
default?: string[]
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
disabled?: false | string
|
||||
spec: {
|
||||
integer: boolean
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
step?: number | null
|
||||
units?: string | null
|
||||
placeholder?: string | null
|
||||
}
|
||||
}
|
||||
>,
|
||||
) => List.dynamicNumber<Store>(getA),
|
||||
},
|
||||
Migration: {
|
||||
of: <Version extends ManifestVersion>(options: {
|
||||
|
||||
@@ -125,96 +125,6 @@ export class List<Type, Store> {
|
||||
return built
|
||||
}, arrayOf(string))
|
||||
}
|
||||
static number(
|
||||
a: {
|
||||
name: string
|
||||
description?: string | null
|
||||
warning?: string | null
|
||||
/** Default = [] */
|
||||
default?: string[]
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
},
|
||||
aSpec: {
|
||||
integer: boolean
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
step?: number | null
|
||||
units?: string | null
|
||||
placeholder?: string | null
|
||||
},
|
||||
) {
|
||||
return new List<number[], never>(() => {
|
||||
const spec = {
|
||||
type: "number" as const,
|
||||
placeholder: null,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
units: null,
|
||||
...aSpec,
|
||||
}
|
||||
const built: ValueSpecListOf<"number"> = {
|
||||
description: null,
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
default: [],
|
||||
type: "list" as const,
|
||||
disabled: false,
|
||||
...a,
|
||||
spec,
|
||||
}
|
||||
return built
|
||||
}, arrayOf(number))
|
||||
}
|
||||
static dynamicNumber<Store = never>(
|
||||
getA: LazyBuild<
|
||||
Store,
|
||||
{
|
||||
name: string
|
||||
description?: string | null
|
||||
warning?: string | null
|
||||
/** Default = [] */
|
||||
default?: string[]
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
disabled?: false | string
|
||||
spec: {
|
||||
integer: boolean
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
step?: number | null
|
||||
units?: string | null
|
||||
placeholder?: string | null
|
||||
}
|
||||
}
|
||||
>,
|
||||
) {
|
||||
return new List<number[], Store>(async (options) => {
|
||||
const { spec: aSpec, ...a } = await getA(options)
|
||||
const spec = {
|
||||
type: "number" as const,
|
||||
placeholder: null,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
units: null,
|
||||
...aSpec,
|
||||
}
|
||||
return {
|
||||
description: null,
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
default: [],
|
||||
type: "list" as const,
|
||||
disabled: false,
|
||||
...a,
|
||||
spec,
|
||||
}
|
||||
}, arrayOf(number))
|
||||
}
|
||||
static obj<Type extends Record<string, any>, Store>(
|
||||
a: {
|
||||
name: string
|
||||
|
||||
@@ -69,8 +69,8 @@ export class Variants<Type, Store> {
|
||||
const validator = anyOf(
|
||||
...Object.entries(a).map(([name, { spec }]) =>
|
||||
object({
|
||||
unionSelectKey: literals(name),
|
||||
unionValueKey: spec.validator,
|
||||
selection: literals(name),
|
||||
value: spec.validator,
|
||||
}),
|
||||
),
|
||||
) as Parser<unknown, any>
|
||||
@@ -78,9 +78,9 @@ export class Variants<Type, Store> {
|
||||
return new Variants<
|
||||
{
|
||||
[K in keyof VariantValues]: {
|
||||
unionSelectKey: K
|
||||
selection: K
|
||||
// prettier-ignore
|
||||
unionValueKey:
|
||||
value:
|
||||
VariantValues[K]["spec"] extends (Config<infer B, Store> | Config<infer B, never>) ? B :
|
||||
never
|
||||
}
|
||||
|
||||
@@ -15,70 +15,93 @@ export type ValueType =
|
||||
export type ValueSpec = ValueSpecOf<ValueType>
|
||||
/** core spec types. These types provide the metadata for performing validations */
|
||||
// prettier-ignore
|
||||
export type ValueSpecOf<T extends ValueType> = T extends "text"
|
||||
? ValueSpecText
|
||||
: T extends "textarea"
|
||||
? ValueSpecTextarea
|
||||
: T extends "number"
|
||||
? ValueSpecNumber
|
||||
: T extends "color"
|
||||
? ValueSpecColor
|
||||
: T extends "datetime"
|
||||
? ValueSpecDatetime
|
||||
: T extends "toggle"
|
||||
? ValueSpecToggle
|
||||
: T extends "select"
|
||||
? ValueSpecSelect
|
||||
: T extends "multiselect"
|
||||
? ValueSpecMultiselect
|
||||
: T extends "list"
|
||||
? ValueSpecList
|
||||
: T extends "object"
|
||||
? ValueSpecObject
|
||||
: T extends "file"
|
||||
? ValueSpecFile
|
||||
: T extends "union"
|
||||
? ValueSpecUnion
|
||||
: never
|
||||
export type ValueSpecOf<T extends ValueType> =
|
||||
T extends "text" ? ValueSpecText :
|
||||
T extends "textarea" ? ValueSpecTextarea :
|
||||
T extends "number" ? ValueSpecNumber :
|
||||
T extends "color" ? ValueSpecColor :
|
||||
T extends "datetime" ? ValueSpecDatetime :
|
||||
T extends "toggle" ? ValueSpecToggle :
|
||||
T extends "select" ? ValueSpecSelect :
|
||||
T extends "multiselect" ? ValueSpecMultiselect :
|
||||
T extends "list" ? ValueSpecList :
|
||||
T extends "object" ? ValueSpecObject :
|
||||
T extends "file" ? ValueSpecFile :
|
||||
T extends "union" ? ValueSpecUnion :
|
||||
never
|
||||
|
||||
export type ValueSpecText = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: "text"
|
||||
patterns: Pattern[]
|
||||
minLength: number | null
|
||||
maxLength: number | null
|
||||
masked: boolean
|
||||
|
||||
inputmode: "text" | "email" | "tel" | "url"
|
||||
placeholder: string | null
|
||||
|
||||
export interface ValueSpecText extends ListValueSpecText, WithStandalone {
|
||||
required: boolean
|
||||
default: DefaultString | null
|
||||
disabled: false | string
|
||||
generate: null | RandomString
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecTextarea extends WithStandalone {
|
||||
export type ValueSpecTextarea = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: "textarea"
|
||||
placeholder: string | null
|
||||
minLength: number | null
|
||||
maxLength: number | null
|
||||
required: boolean
|
||||
disabled: false | string
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
|
||||
export type FilePath = {
|
||||
filePath: string
|
||||
}
|
||||
export interface ValueSpecNumber extends ListValueSpecNumber, WithStandalone {
|
||||
export type ValueSpecNumber = {
|
||||
type: "number"
|
||||
min: number | null
|
||||
max: number | null
|
||||
integer: boolean
|
||||
step: number | null
|
||||
units: string | null
|
||||
placeholder: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
required: boolean
|
||||
default: number | null
|
||||
disabled: false | string
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecColor extends WithStandalone {
|
||||
export type ValueSpecColor = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: "color"
|
||||
required: boolean
|
||||
default: string | null
|
||||
disabled: false | string
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecDatetime extends WithStandalone {
|
||||
export type ValueSpecDatetime = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: "datetime"
|
||||
required: boolean
|
||||
inputmode: "date" | "time" | "datetime-local"
|
||||
@@ -86,10 +109,14 @@ export interface ValueSpecDatetime extends WithStandalone {
|
||||
max: string | null
|
||||
default: string | null
|
||||
disabled: false | string
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecSelect extends SelectBase, WithStandalone {
|
||||
export type ValueSpecSelect = {
|
||||
values: Record<string, string>
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: "select"
|
||||
required: boolean
|
||||
default: string | null
|
||||
@@ -99,10 +126,16 @@ export interface ValueSpecSelect extends SelectBase, WithStandalone {
|
||||
* string[] means that the options are disabled
|
||||
*/
|
||||
disabled: false | string | string[]
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecMultiselect extends SelectBase, WithStandalone {
|
||||
export type ValueSpecMultiselect = {
|
||||
values: Record<string, string>
|
||||
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: "multiselect"
|
||||
minLength: number | null
|
||||
maxLength: number | null
|
||||
@@ -113,17 +146,25 @@ export interface ValueSpecMultiselect extends SelectBase, WithStandalone {
|
||||
*/
|
||||
disabled: false | string | string[]
|
||||
default: string[]
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecToggle extends WithStandalone {
|
||||
export type ValueSpecToggle = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: "toggle"
|
||||
default: boolean | null
|
||||
disabled: false | string
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecUnion extends WithStandalone {
|
||||
export type ValueSpecUnion = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
|
||||
type: "union"
|
||||
variants: Record<
|
||||
string,
|
||||
@@ -140,39 +181,37 @@ export interface ValueSpecUnion extends WithStandalone {
|
||||
disabled: false | string | string[]
|
||||
required: boolean
|
||||
default: string | null
|
||||
/** Immutable means it can only be configed at the first config then never again */
|
||||
/** Immutable means it can only be configured at the first config then never again */
|
||||
immutable: boolean
|
||||
}
|
||||
export interface ValueSpecFile extends WithStandalone {
|
||||
export type ValueSpecFile = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: "file"
|
||||
extensions: string[]
|
||||
required: boolean
|
||||
}
|
||||
export interface ValueSpecObject extends WithStandalone {
|
||||
type: "object"
|
||||
spec: InputSpec
|
||||
}
|
||||
export interface WithStandalone {
|
||||
export type ValueSpecObject = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: "object"
|
||||
spec: InputSpec
|
||||
}
|
||||
export interface SelectBase {
|
||||
values: Record<string, string>
|
||||
}
|
||||
export type ListValueSpecType = "text" | "number" | "object"
|
||||
export type ListValueSpecType = "text" | "object"
|
||||
/** represents a spec for the values of a list */
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> = T extends "text"
|
||||
? ListValueSpecText
|
||||
: T extends "number"
|
||||
? ListValueSpecNumber
|
||||
: T extends "object"
|
||||
? ListValueSpecObject
|
||||
: never
|
||||
// prettier-ignore
|
||||
export type ListValueSpecOf<T extends ListValueSpecType> =
|
||||
T extends "text" ? ListValueSpecText :
|
||||
T extends "object" ? ListValueSpecObject :
|
||||
never
|
||||
/** represents a spec for a list */
|
||||
export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
|
||||
export interface ValueSpecListOf<T extends ListValueSpecType>
|
||||
extends WithStandalone {
|
||||
export type ValueSpecListOf<T extends ListValueSpecType> = {
|
||||
name: string
|
||||
description: string | null
|
||||
warning: string | null
|
||||
type: "list"
|
||||
spec: ListValueSpecOf<T>
|
||||
minLength: number | null
|
||||
@@ -180,19 +219,17 @@ export interface ValueSpecListOf<T extends ListValueSpecType>
|
||||
disabled: false | string
|
||||
default:
|
||||
| string[]
|
||||
| number[]
|
||||
| DefaultString[]
|
||||
| Record<string, unknown>[]
|
||||
| readonly string[]
|
||||
| readonly number[]
|
||||
| readonly DefaultString[]
|
||||
| readonly Record<string, unknown>[]
|
||||
}
|
||||
export interface Pattern {
|
||||
export type Pattern = {
|
||||
regex: string
|
||||
description: string
|
||||
}
|
||||
export interface ListValueSpecText {
|
||||
export type ListValueSpecText = {
|
||||
type: "text"
|
||||
patterns: Pattern[]
|
||||
minLength: number | null
|
||||
@@ -203,16 +240,8 @@ export interface ListValueSpecText {
|
||||
inputmode: "text" | "email" | "tel" | "url"
|
||||
placeholder: string | null
|
||||
}
|
||||
export interface ListValueSpecNumber {
|
||||
type: "number"
|
||||
min: number | null
|
||||
max: number | null
|
||||
integer: boolean
|
||||
step: number | null
|
||||
units: string | null
|
||||
placeholder: string | null
|
||||
}
|
||||
export interface ListValueSpecObject {
|
||||
|
||||
export type ListValueSpecObject = {
|
||||
type: "object"
|
||||
/** this is a mapped type of the config object at this level, replacing the object's values with specs on those values */
|
||||
spec: InputSpec
|
||||
@@ -242,8 +271,3 @@ export function isValueSpecListOf<S extends ListValueSpecType>(
|
||||
): t is ValueSpecListOf<S> & { spec: ListValueSpecOf<S> } {
|
||||
return "spec" in t && t.spec.type === s
|
||||
}
|
||||
export const unionSelectKey = "unionSelectKey" as const
|
||||
export type UnionSelectKey = typeof unionSelectKey
|
||||
|
||||
export const unionValueKey = "unionValueKey" as const
|
||||
export type UnionValueKey = typeof unionValueKey
|
||||
|
||||
@@ -12,4 +12,4 @@ export * as T from "./types"
|
||||
export * as yaml from "yaml"
|
||||
export * as matches from "ts-matches"
|
||||
|
||||
export * as util from "./util/index.browser"
|
||||
export * as utils from "./util/index.browser"
|
||||
|
||||
4
sdk/lib/osBindings/InstalledVersionParams.ts
Normal file
4
sdk/lib/osBindings/InstalledVersionParams.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PackageId } from "./PackageId"
|
||||
|
||||
export type InstalledVersionParams = { id: PackageId }
|
||||
@@ -79,6 +79,7 @@ export { ImageMetadata } from "./ImageMetadata"
|
||||
export { ImageSource } from "./ImageSource"
|
||||
export { InitProgressRes } from "./InitProgressRes"
|
||||
export { InstalledState } from "./InstalledState"
|
||||
export { InstalledVersionParams } from "./InstalledVersionParams"
|
||||
export { InstallingInfo } from "./InstallingInfo"
|
||||
export { InstallingState } from "./InstallingState"
|
||||
export { IpHostname } from "./IpHostname"
|
||||
|
||||
@@ -268,26 +268,9 @@ describe("values", () => {
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ unionSelectKey: "a", unionValueKey: { b: false } })
|
||||
validator.unsafeCast({ selection: "a", value: { b: false } })
|
||||
type Test = typeof validator._TYPE
|
||||
testOutput<Test, { unionSelectKey: "a"; unionValueKey: { b: boolean } }>()(
|
||||
null,
|
||||
)
|
||||
})
|
||||
test("list", async () => {
|
||||
const value = Value.list(
|
||||
List.number(
|
||||
{
|
||||
name: "test",
|
||||
},
|
||||
{
|
||||
integer: false,
|
||||
},
|
||||
),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast([1, 2, 3])
|
||||
testOutput<typeof validator._TYPE, number[]>()(null)
|
||||
testOutput<Test, { selection: "a"; value: { b: boolean } }>()(null)
|
||||
})
|
||||
|
||||
describe("dynamic", () => {
|
||||
@@ -577,12 +560,12 @@ describe("values", () => {
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ unionSelectKey: "a", unionValueKey: { b: false } })
|
||||
validator.unsafeCast({ selection: "a", value: { b: false } })
|
||||
type Test = typeof validator._TYPE
|
||||
testOutput<
|
||||
Test,
|
||||
| { unionSelectKey: "a"; unionValueKey: { b: boolean } }
|
||||
| { unionSelectKey: "b"; unionValueKey: { b: boolean } }
|
||||
| { selection: "a"; value: { b: boolean } }
|
||||
| { selection: "b"; value: { b: boolean } }
|
||||
>()(null)
|
||||
|
||||
const built = await value.build({} as any)
|
||||
@@ -644,12 +627,12 @@ describe("values", () => {
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ unionSelectKey: "a", unionValueKey: { b: false } })
|
||||
validator.unsafeCast({ selection: "a", value: { b: false } })
|
||||
type Test = typeof validator._TYPE
|
||||
testOutput<
|
||||
Test,
|
||||
| { unionSelectKey: "a"; unionValueKey: { b: boolean } }
|
||||
| { unionSelectKey: "b"; unionValueKey: { b: boolean } }
|
||||
| { selection: "a"; value: { b: boolean } }
|
||||
| { selection: "b"; value: { b: boolean } }
|
||||
| null
|
||||
| undefined
|
||||
>()(null)
|
||||
@@ -736,24 +719,6 @@ describe("Builder List", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
test("number", async () => {
|
||||
const value = Value.list(
|
||||
List.dynamicNumber(() => ({
|
||||
name: "test",
|
||||
spec: { integer: true },
|
||||
})),
|
||||
)
|
||||
const validator = value.validator
|
||||
expect(() => validator.unsafeCast(["test", "text"])).toThrowError()
|
||||
validator.unsafeCast([4, 2])
|
||||
expect(() => validator.unsafeCast(null)).toThrowError()
|
||||
validator.unsafeCast([])
|
||||
testOutput<typeof validator._TYPE, number[]>()(null)
|
||||
expect(await value.build({} as any)).toMatchObject({
|
||||
name: "test",
|
||||
spec: { integer: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Nested nullable values", () => {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
ListValueSpecOf,
|
||||
ValueSpec,
|
||||
isValueSpecListOf,
|
||||
} from "../config/configTypes"
|
||||
import { ListValueSpecOf, isValueSpecListOf } from "../config/configTypes"
|
||||
import { Config } from "../config/builder/config"
|
||||
import { List } from "../config/builder/list"
|
||||
import { Value } from "../config/builder/value"
|
||||
|
||||
describe("Config Types", () => {
|
||||
test("isValueSpecListOf", async () => {
|
||||
const options = [List.obj, List.text, List.number]
|
||||
const options = [List.obj, List.text]
|
||||
for (const option of options) {
|
||||
const test = (option as any)(
|
||||
{} as any,
|
||||
@@ -18,8 +14,6 @@ describe("Config Types", () => {
|
||||
const someList = await Value.list(test).build({} as any)
|
||||
if (isValueSpecListOf(someList, "text")) {
|
||||
someList.spec satisfies ListValueSpecOf<"text">
|
||||
} else if (isValueSpecListOf(someList, "number")) {
|
||||
someList.spec satisfies ListValueSpecOf<"number">
|
||||
} else if (isValueSpecListOf(someList, "object")) {
|
||||
someList.spec satisfies ListValueSpecOf<"object">
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import {
|
||||
UnionSelectKey,
|
||||
unionSelectKey,
|
||||
UnionValueKey,
|
||||
unionValueKey,
|
||||
} from "../config/configTypes"
|
||||
import { ConfigSpec, matchConfigSpec } from "./output"
|
||||
import * as _I from "../index"
|
||||
import { camelCase } from "../../scripts/oldSpecToBuilder"
|
||||
@@ -30,13 +24,10 @@ testOutput<
|
||||
ConfigSpec["advanced"]["peers"]["addnode"][0]["hostname"],
|
||||
string | null | undefined
|
||||
>()(null)
|
||||
testOutput<
|
||||
ConfigSpec["testListUnion"][0]["union"][UnionValueKey]["name"],
|
||||
string
|
||||
>()(null)
|
||||
testOutput<ConfigSpec["testListUnion"][0]["union"][UnionSelectKey], "lnd">()(
|
||||
testOutput<ConfigSpec["testListUnion"][0]["union"]["value"]["name"], string>()(
|
||||
null,
|
||||
)
|
||||
testOutput<ConfigSpec["testListUnion"][0]["union"]["selection"], "lnd">()(null)
|
||||
testOutput<ConfigSpec["mediasources"], Array<"filebrowser" | "nextcloud">>()(
|
||||
null,
|
||||
)
|
||||
@@ -45,7 +36,7 @@ testOutput<ConfigSpec["mediasources"], Array<"filebrowser" | "nextcloud">>()(
|
||||
testOutput<ConfigSpec["rpc"]["enable"], string>()(null)
|
||||
// prettier-ignore
|
||||
// @ts-expect-error Expect that the string is the one above
|
||||
testOutput<ConfigSpec["testListUnion"][0][UnionSelectKey][UnionSelectKey], "unionSelectKey">()(null);
|
||||
testOutput<ConfigSpec["testListUnion"][0]['selection']['selection'], "selection">()(null);
|
||||
|
||||
/// Here we test the output of the matchConfigSpec function
|
||||
describe("Inputs", () => {
|
||||
@@ -53,7 +44,7 @@ describe("Inputs", () => {
|
||||
mediasources: ["filebrowser"],
|
||||
testListUnion: [
|
||||
{
|
||||
union: { [unionSelectKey]: "lnd", [unionValueKey]: { name: "string" } },
|
||||
union: { selection: "lnd", value: { name: "string" } },
|
||||
},
|
||||
],
|
||||
rpc: {
|
||||
@@ -92,8 +83,8 @@ describe("Inputs", () => {
|
||||
},
|
||||
dbcache: 5,
|
||||
pruning: {
|
||||
unionSelectKey: "disabled",
|
||||
unionValueKey: {},
|
||||
selection: "disabled",
|
||||
value: {},
|
||||
},
|
||||
blockfilters: {
|
||||
blockfilterindex: false,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// a,g,h,A-Z,,,,-
|
||||
|
||||
import * as crypto from "crypto"
|
||||
export function getRandomCharInSet(charset: string): string {
|
||||
const set = stringToCharSet(charset)
|
||||
let charIdx = crypto.randomInt(0, set.len)
|
||||
let charIdx = Math.floor(
|
||||
(crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32) * set.len,
|
||||
)
|
||||
for (let range of set.ranges) {
|
||||
if (range.len > charIdx) {
|
||||
return String.fromCharCode(range.start.charCodeAt(0) + charIdx)
|
||||
|
||||
@@ -80,7 +80,7 @@ export const addressHostToUrl = (
|
||||
): UrlString[] => {
|
||||
const res = []
|
||||
const fmt = (scheme: string | null, host: HostnameInfo, port: number) => {
|
||||
const includePort =
|
||||
const excludePort =
|
||||
scheme &&
|
||||
scheme in knownProtocols &&
|
||||
port === knownProtocols[scheme as keyof typeof knownProtocols].defaultPort
|
||||
@@ -96,7 +96,7 @@ export const addressHostToUrl = (
|
||||
}
|
||||
return `${scheme ? `${scheme}://` : ""}${
|
||||
username ? `${username}@` : ""
|
||||
}${hostname}${includePort ? `:${port}` : ""}${suffix}`
|
||||
}${hostname}${excludePort ? "" : `:${port}`}${suffix}`
|
||||
}
|
||||
if (host.hostname.sslPort !== null) {
|
||||
res.push(fmt(sslScheme, host, host.hostname.sslPort))
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import * as T from "../types"
|
||||
|
||||
/// Currently being used
|
||||
export { addressHostToUrl } from "./getServiceInterface"
|
||||
export { getDefaultString } from "./getDefaultString"
|
||||
|
||||
/// Not being used, but known to be browser compatible
|
||||
export { GetServiceInterface, getServiceInterface } from "./getServiceInterface"
|
||||
export { getServiceInterfaces } from "./getServiceInterfaces"
|
||||
// prettier-ignore
|
||||
export type FlattenIntersection<T> =
|
||||
T extends ArrayLike<any> ? T :
|
||||
T extends object ? {} & {[P in keyof T]: T[P]} :
|
||||
T;
|
||||
|
||||
export type _<T> = FlattenIntersection<T>
|
||||
|
||||
export const isKnownError = (e: unknown): e is T.KnownError =>
|
||||
e instanceof Object && ("error" in e || "error-code" in e)
|
||||
|
||||
declare const affine: unique symbol
|
||||
|
||||
export type Affine<A> = { [affine]: A }
|
||||
|
||||
type NeverPossible = { [affine]: string }
|
||||
export type NoAny<A> = NeverPossible extends A
|
||||
? keyof NeverPossible extends keyof A
|
||||
? never
|
||||
: A
|
||||
: A
|
||||
export * from "./typeHelpers"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as T from "../types"
|
||||
|
||||
import "./nullIfEmpty"
|
||||
import "./fileHelper"
|
||||
import "../store/getStore"
|
||||
@@ -12,26 +10,5 @@ export { GetServiceInterface, getServiceInterface } from "./getServiceInterface"
|
||||
export { getServiceInterfaces } from "./getServiceInterfaces"
|
||||
export { addressHostToUrl } from "./getServiceInterface"
|
||||
export { hostnameInfoToAddress } from "./Hostname"
|
||||
// prettier-ignore
|
||||
export type FlattenIntersection<T> =
|
||||
T extends ArrayLike<any> ? T :
|
||||
T extends object ? {} & {[P in keyof T]: T[P]} :
|
||||
T;
|
||||
|
||||
export type _<T> = FlattenIntersection<T>
|
||||
|
||||
export const isKnownError = (e: unknown): e is T.KnownError =>
|
||||
e instanceof Object && ("error" in e || "error-code" in e)
|
||||
|
||||
declare const affine: unique symbol
|
||||
|
||||
export type Affine<A> = { [affine]: A }
|
||||
|
||||
type NeverPossible = { [affine]: string }
|
||||
export type NoAny<A> = NeverPossible extends A
|
||||
? keyof NeverPossible extends keyof A
|
||||
? never
|
||||
: A
|
||||
: A
|
||||
|
||||
export * from "./typeHelpers"
|
||||
export { getDefaultString } from "./getDefaultString"
|
||||
|
||||
23
sdk/lib/util/typeHelpers.ts
Normal file
23
sdk/lib/util/typeHelpers.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as T from "../types"
|
||||
|
||||
// prettier-ignore
|
||||
export type FlattenIntersection<T> =
|
||||
T extends ArrayLike<any> ? T :
|
||||
T extends object ? {} & {[P in keyof T]: T[P]} :
|
||||
T;
|
||||
|
||||
export type _<T> = FlattenIntersection<T>
|
||||
|
||||
export const isKnownError = (e: unknown): e is T.KnownError =>
|
||||
e instanceof Object && ("error" in e || "error-code" in e)
|
||||
|
||||
declare const affine: unique symbol
|
||||
|
||||
export type Affine<A> = { [affine]: A }
|
||||
|
||||
type NeverPossible = { [affine]: string }
|
||||
export type NoAny<A> = NeverPossible extends A
|
||||
? keyof NeverPossible extends keyof A
|
||||
? never
|
||||
: A
|
||||
: A
|
||||
@@ -285,26 +285,26 @@ const {Config, List, Value, Variants} = sdk
|
||||
maxLength: null,
|
||||
})})`
|
||||
}
|
||||
case "number": {
|
||||
return `${rangeToTodoComment(value?.range)}List.number(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
default: value.default || null,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}, ${JSON.stringify({
|
||||
integer: value?.spec?.integral || false,
|
||||
min: null,
|
||||
max: null,
|
||||
units: value?.spec?.units || null,
|
||||
placeholder: value?.spec?.placeholder || null,
|
||||
})})`
|
||||
}
|
||||
// case "number": {
|
||||
// return `${rangeToTodoComment(value?.range)}List.number(${JSON.stringify(
|
||||
// {
|
||||
// name: value.name || null,
|
||||
// minLength: null,
|
||||
// maxLength: null,
|
||||
// default: value.default || null,
|
||||
// description: value.description || null,
|
||||
// warning: value.warning || null,
|
||||
// },
|
||||
// null,
|
||||
// 2,
|
||||
// )}, ${JSON.stringify({
|
||||
// integer: value?.spec?.integral || false,
|
||||
// min: null,
|
||||
// max: null,
|
||||
// units: value?.spec?.units || null,
|
||||
// placeholder: value?.spec?.placeholder || null,
|
||||
// })})`
|
||||
// }
|
||||
case "enum": {
|
||||
return "/* error!! list.enum */"
|
||||
}
|
||||
|
||||
9004
web/package-lock.json
generated
9004
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -43,20 +43,22 @@
|
||||
"@angular/service-worker": "^14.2.2",
|
||||
"@ionic/angular": "^6.1.15",
|
||||
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
||||
"@ng-web-apis/common": "^2.0.0",
|
||||
"@ng-web-apis/mutation-observer": "^2.0.0",
|
||||
"@ng-web-apis/resize-observer": "^2.0.0",
|
||||
"@ng-web-apis/common": "^3.0.6",
|
||||
"@ng-web-apis/mutation-observer": "^3.2.1",
|
||||
"@ng-web-apis/resize-observer": "^3.2.1",
|
||||
"@noble/curves": "^1.4.0",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@start9labs/argon2": "^0.2.2",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@start9labs/start-sdk": "file:../sdk/dist",
|
||||
"@taiga-ui/addon-charts": "3.20.0",
|
||||
"@taiga-ui/cdk": "3.20.0",
|
||||
"@taiga-ui/core": "3.20.0",
|
||||
"@taiga-ui/icons": "3.20.0",
|
||||
"@taiga-ui/kit": "3.20.0",
|
||||
"@taiga-ui/addon-charts": "3.84.0",
|
||||
"@taiga-ui/cdk": "3.84.0",
|
||||
"@taiga-ui/core": "3.84.0",
|
||||
"@taiga-ui/experimental": "3.84.0",
|
||||
"@taiga-ui/icons": "3.84.0",
|
||||
"@taiga-ui/kit": "3.84.0",
|
||||
"@tinkoff/ng-dompurify": "4.0.0",
|
||||
"@tinkoff/ng-event-plugins": "3.2.0",
|
||||
"angular-svg-round-progressbar": "^9.0.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"base64-js": "^1.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -11,7 +11,7 @@ import { ErrorToastService } from '@start9labs/shared'
|
||||
export class AppComponent {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly navCtrl: NavController,
|
||||
) {}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class AppComponent {
|
||||
|
||||
await this.navCtrl.navigateForward(route)
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { TuiRootModule } from '@taiga-ui/core'
|
||||
import { TuiAlertModule, TuiRootModule } from '@taiga-ui/core'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
@@ -41,6 +41,7 @@ const {
|
||||
RecoverPageModule,
|
||||
TransferPageModule,
|
||||
TuiRootModule,
|
||||
TuiAlertModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { DiskInfo, ErrorService } from '@start9labs/shared'
|
||||
import { PasswordPage } from 'src/app/modals/password/password.page'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-attach',
|
||||
@@ -21,7 +21,7 @@ export class AttachPage {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
@@ -41,7 +41,7 @@ export class AttachPage {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export class AttachPage {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { DiskInfo, ErrorService, GuidPipe } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService, GuidPipe } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@@ -27,7 +27,7 @@ export class EmbassyPage {
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly stateService: StateService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly guidPipe: GuidPipe,
|
||||
) {}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class EmbassyPage {
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export class EmbassyPage {
|
||||
await this.stateService.setupEmbassy(logicalname, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { IonicSlides } from '@ionic/angular'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
|
||||
SwiperCore.use([IonicSlides])
|
||||
|
||||
@@ -19,7 +19,7 @@ export class HomePage {
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class HomePage {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
catchError,
|
||||
EMPTY,
|
||||
filter,
|
||||
from,
|
||||
interval,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
@@ -16,8 +17,6 @@ import {
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading',
|
||||
@@ -69,7 +68,7 @@ export class LoadingPage {
|
||||
constructor(
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
private async getStatus(): Promise<{
|
||||
@@ -96,7 +95,7 @@ export class LoadingPage {
|
||||
return from(this.getStatus()).pipe(
|
||||
filter(Boolean),
|
||||
catchError(e => {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
return of(e)
|
||||
}),
|
||||
take(1),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@@ -20,7 +20,7 @@ export class RecoverPage {
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class RecoverPage {
|
||||
})
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { Component, ElementRef, Inject, NgZone, ViewChild } from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorToastService } from '@start9labs/shared'
|
||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@@ -12,7 +12,8 @@ import { StateService } from 'src/app/services/state.service'
|
||||
})
|
||||
export class SuccessPage {
|
||||
@ViewChild('canvas', { static: true })
|
||||
private canvas: ElementRef<HTMLCanvasElement> = {} as ElementRef<HTMLCanvasElement>
|
||||
private canvas: ElementRef<HTMLCanvasElement> =
|
||||
{} as ElementRef<HTMLCanvasElement>
|
||||
private ctx: CanvasRenderingContext2D = {} as CanvasRenderingContext2D
|
||||
|
||||
torAddress?: string
|
||||
@@ -28,7 +29,7 @@ export class SuccessPage {
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly errCtrl: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly api: ApiService,
|
||||
private readonly downloadHtml: DownloadHTMLService,
|
||||
@@ -83,7 +84,7 @@ export class SuccessPage {
|
||||
await this.api.exit()
|
||||
}
|
||||
} catch (e: any) {
|
||||
await this.errCtrl.present(e)
|
||||
await this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, NavController } from '@ionic/angular'
|
||||
import { DiskInfo, ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
@@ -17,7 +17,7 @@ export class TransferPage {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class TransferPage {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
BIN
web/projects/shared/assets/img/icon_transparent.png
Normal file
BIN
web/projects/shared/assets/img/icon_transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
web/projects/shared/assets/img/storefront-outline.png
Normal file
BIN
web/projects/shared/assets/img/storefront-outline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="1.5em" viewBox="0 0 24 24" width="1.5em">
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
clip-rule="evenodd"
|
||||
d="M7.2395 3.45152L4.01497 3.49988C3.46275 3.50816 3.00837 3.06721 3.00008 2.51499C2.9918 1.96277 3.43275 1.50839 3.98497 1.50011L9.98497 1.41012C10.0214 1.40957 10.0575 1.41098 10.093 1.41425C10.6089 1.4111 11.1258 1.60635 11.5194 2L16 6.48053C16.781 7.26158 16.781 8.52791 16 9.30895L12.5492 12.7597L10.3089 15C9.52787 15.7811 8.26154 15.781 7.48049 15L5.24023 12.7597L2.99997 10.5195C2.21892 9.73843 2.21892 8.4721 2.99997 7.69105L7.2395 3.45152ZM10.1052 3.41422L4.41418 9.10527L5.80892 10.5H11.9805L14.5858 7.89474L10.1052 3.41422ZM20.4206 13.5445L20.4223 13.5453C20.6348 10.9945 18.7416 9.37791 17.9715 8.83085C17.9293 8.80059 17.8726 8.79856 17.8281 8.82545C17.7836 8.85234 17.7676 8.8632 17.7761 8.91357C17.9704 10.0886 16.2673 11.5428 16.2673 11.5428C15.8623 11.9726 15.5886 12.3895 15.4811 12.9331C15.2176 14.2662 16.0981 15.5842 17.4524 15.8519C18.8068 16.1196 20.1167 15.2659 20.3802 13.9329C20.4067 13.7992 20.4166 13.6763 20.4206 13.5445ZM3 18C2.44772 18 2 18.4477 2 19V21C2 21.5523 2.44772 22 3 22H21C21.5523 22 22 21.5523 22 21V19C22 18.4477 21.5523 18 21 18H3Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,20 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
@include shadow(3);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
margin: auto;
|
||||
padding: 1.5rem;
|
||||
background: var(--tui-elevation-01);
|
||||
border-radius: var(--tui-radius-m);
|
||||
|
||||
--tui-primary: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
tui-loader {
|
||||
flex-shrink: 0;
|
||||
min-width: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusContent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader [textContent]="content"></tui-loader>
|
||||
`,
|
||||
styleUrls: ['./loading.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LoadingComponent {
|
||||
readonly content: PolymorpheusContent =
|
||||
inject(POLYMORPHEUS_CONTEXT)['content']
|
||||
}
|
||||
13
web/projects/shared/src/components/loading/loading.module.ts
Normal file
13
web/projects/shared/src/components/loading/loading.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { TuiLoaderModule } from '@taiga-ui/core'
|
||||
import { tuiAsDialog } from '@taiga-ui/cdk'
|
||||
import { LoadingComponent } from './loading.component'
|
||||
import { LoadingService } from './loading.service'
|
||||
|
||||
@NgModule({
|
||||
imports: [TuiLoaderModule],
|
||||
declarations: [LoadingComponent],
|
||||
exports: [LoadingComponent],
|
||||
providers: [tuiAsDialog(LoadingService)],
|
||||
})
|
||||
export class LoadingModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AbstractTuiDialogService } from '@taiga-ui/cdk'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { LoadingComponent } from './loading.component'
|
||||
|
||||
@Injectable({ providedIn: `root` })
|
||||
export class LoadingService extends AbstractTuiDialogService<unknown> {
|
||||
protected readonly component = new PolymorpheusComponent(LoadingComponent)
|
||||
protected readonly defaultOptions = {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ModalController } from '@ionic/angular'
|
||||
import { defer, isObservable, Observable, of } from 'rxjs'
|
||||
import { catchError, ignoreElements, share } from 'rxjs/operators'
|
||||
|
||||
import { getErrorMessage } from '../../services/error-toast.service'
|
||||
import { getErrorMessage } from '../../services/error.service'
|
||||
|
||||
@Component({
|
||||
selector: 'markdown',
|
||||
|
||||
@@ -9,6 +9,9 @@ export * from './components/alert/alert.component'
|
||||
export * from './components/alert/alert.module'
|
||||
export * from './components/alert/alert-button.directive'
|
||||
export * from './components/alert/alert-input.directive'
|
||||
export * from './components/loading/loading.component'
|
||||
export * from './components/loading/loading.module'
|
||||
export * from './components/loading/loading.service'
|
||||
export * from './components/markdown/markdown.component'
|
||||
export * from './components/markdown/markdown.component.module'
|
||||
export * from './components/text-spinner/text-spinner.component'
|
||||
@@ -42,7 +45,7 @@ export * from './pipes/unit-conversion/unit-conversion.pipe'
|
||||
|
||||
export * from './services/download-html.service'
|
||||
export * from './services/emver.service'
|
||||
export * from './services/error-toast.service'
|
||||
export * from './services/error.service'
|
||||
export * from './services/http.service'
|
||||
|
||||
export * from './themes/dark-theme/dark-theme.component'
|
||||
@@ -63,6 +66,7 @@ export * from './util/base-64'
|
||||
export * from './util/copy-to-clipboard'
|
||||
export * from './util/get-new-entries'
|
||||
export * from './util/get-pkg-id'
|
||||
export * from './util/invert'
|
||||
export * from './util/misc.util'
|
||||
export * from './util/rpc.util'
|
||||
export * from './util/to-local-iso-string'
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { IonicSafeString, ToastController } from '@ionic/angular'
|
||||
import { HttpError } from '../classes/http-error'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorToastService {
|
||||
private toast?: HTMLIonToastElement
|
||||
|
||||
constructor(private readonly toastCtrl: ToastController) {}
|
||||
|
||||
async present(e: HttpError | string, link?: string): Promise<void> {
|
||||
console.error(e)
|
||||
|
||||
if (this.toast) return
|
||||
|
||||
this.toast = await this.toastCtrl.create({
|
||||
header: 'Error',
|
||||
message: getErrorMessage(e, link),
|
||||
duration: 0,
|
||||
position: 'top',
|
||||
cssClass: 'error-toast',
|
||||
buttons: [
|
||||
{
|
||||
side: 'end',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
this.dismiss()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.toast.present()
|
||||
}
|
||||
|
||||
async dismiss(): Promise<void> {
|
||||
if (this.toast) {
|
||||
await this.toast.dismiss()
|
||||
this.toast = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(
|
||||
e: HttpError | string,
|
||||
link?: string,
|
||||
): string | IonicSafeString {
|
||||
let message = ''
|
||||
|
||||
if (typeof e === 'string') {
|
||||
message = e
|
||||
} else if (e.code === 0) {
|
||||
message =
|
||||
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
|
||||
link = 'https://docs.start9.com/0.3.5.x/support/common-issues#request-error'
|
||||
} else if (!e.message) {
|
||||
message = 'Unknown Error'
|
||||
} else {
|
||||
message = e.message
|
||||
}
|
||||
|
||||
if (link) {
|
||||
return new IonicSafeString(
|
||||
`${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer" style="color: white;">Get Help</a>`,
|
||||
)
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
43
web/projects/shared/src/services/error.service.ts
Normal file
43
web/projects/shared/src/services/error.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ErrorHandler, inject, Injectable } from '@angular/core'
|
||||
import { TuiAlertService, TuiNotification } from '@taiga-ui/core'
|
||||
import { HttpError } from '../classes/http-error'
|
||||
|
||||
// TODO: Enable this as ErrorHandler
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorService extends ErrorHandler {
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
|
||||
override handleError(error: HttpError | string, link?: string) {
|
||||
console.error(error)
|
||||
|
||||
this.alerts
|
||||
.open(getErrorMessage(error, link), {
|
||||
label: 'Error',
|
||||
autoClose: false,
|
||||
status: TuiNotification.Error,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(e: HttpError | string, link?: string): string {
|
||||
let message = ''
|
||||
|
||||
if (typeof e === 'string') {
|
||||
message = e
|
||||
} else if (e.code === 0) {
|
||||
message =
|
||||
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
|
||||
} else if (!e.message) {
|
||||
message = 'Unknown Error'
|
||||
link = 'https://docs.start9.com/latest/support/faq'
|
||||
} else {
|
||||
message = e.message
|
||||
}
|
||||
|
||||
return link
|
||||
? `${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer">Get Help</a>`
|
||||
: message
|
||||
}
|
||||
12
web/projects/shared/src/util/invert.ts
Normal file
12
web/projects/shared/src/util/invert.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function invert<
|
||||
T extends string | number | symbol,
|
||||
D extends string | number | symbol,
|
||||
>(obj: Record<T, D>): Record<D, T> {
|
||||
const result = {} as Record<D, T>
|
||||
|
||||
for (const key in obj) {
|
||||
result[obj[key]] = key
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DarkThemeModule,
|
||||
EnterModule,
|
||||
LightThemeModule,
|
||||
LoadingModule,
|
||||
MarkdownModule,
|
||||
ResponsiveColModule,
|
||||
SharedPipesModule,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
|
||||
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
|
||||
import { MarketplaceModule } from './marketplace.module'
|
||||
import { PreloaderModule } from './app/preloader/preloader.module'
|
||||
import { FooterModule } from './app/footer/footer.module'
|
||||
@@ -49,7 +49,7 @@ import { environment } from '../environments/environment'
|
||||
EnterModule,
|
||||
OSWelcomePageModule,
|
||||
MarkdownModule,
|
||||
GenericInputComponentModule,
|
||||
LoadingModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
MarketplaceModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { Router, RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
|
||||
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
||||
import { TUI_ICONS_PATH } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
PATCH_CACHE,
|
||||
@@ -53,6 +54,10 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
provide: THEME,
|
||||
useExisting: ThemeSwitcherService,
|
||||
},
|
||||
{
|
||||
provide: TUI_ICONS_PATH,
|
||||
useValue: (name: string) => `/assets/taiga-ui/icons/${name}.svg#${name}`,
|
||||
},
|
||||
]
|
||||
|
||||
export function appInitializer(
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<!-- Ionic components -->
|
||||
<ion-accordion></ion-accordion>
|
||||
<ion-accordion-group></ion-accordion-group>
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-avatar></ion-avatar>
|
||||
<ion-back-button></ion-back-button>
|
||||
<ion-badge></ion-badge>
|
||||
@@ -19,14 +17,11 @@
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
@@ -39,24 +34,16 @@
|
||||
<ion-label></ion-label>
|
||||
<ion-label style="font-weight: bold"></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
<ion-note></ion-note>
|
||||
<ion-progress-bar></ion-progress-bar>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-searchbar></ion-searchbar>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text><strong>load bold font</strong></ion-text>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
|
||||
<!-- images -->
|
||||
@@ -66,10 +53,37 @@
|
||||
<img src="assets/img/icons/wifi-1.png" />
|
||||
<img src="assets/img/icons/wifi-2.png" />
|
||||
<img src="assets/img/icons/wifi-3.png" />
|
||||
|
||||
<!-- Taiga UI icons -->
|
||||
<img *ngFor="let icon of taiga" src="assets/taiga-ui/icons/{{ icon }}.svg" />
|
||||
|
||||
<!-- Taiga UI components -->
|
||||
<tui-input></tui-input>
|
||||
<tui-input-time></tui-input-time>
|
||||
<tui-input-date></tui-input-date>
|
||||
<tui-input-date-time></tui-input-date-time>
|
||||
<tui-input-files></tui-input-files>
|
||||
<tui-input-number></tui-input-number>
|
||||
<tui-text-area></tui-text-area>
|
||||
<tui-select></tui-select>
|
||||
<tui-multi-select></tui-multi-select>
|
||||
<tui-tooltip></tui-tooltip>
|
||||
<tui-toggle></tui-toggle>
|
||||
<tui-radio-list></tui-radio-list>
|
||||
<tui-error></tui-error>
|
||||
<tui-svg></tui-svg>
|
||||
<tui-icon></tui-icon>
|
||||
<tui-expand></tui-expand>
|
||||
<tui-elastic-container></tui-elastic-container>
|
||||
<tui-scrollbar></tui-scrollbar>
|
||||
<button tuiButton></button>
|
||||
<button tuiLink></button>
|
||||
<button tuiCell></button>
|
||||
<progress tuiProgressBar></progress>
|
||||
</div>
|
||||
|
||||
<!-- fonts -->
|
||||
<div style="visibility: hidden; height: 0">
|
||||
<!-- fonts -->
|
||||
<p style="font-family: Montserrat">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: bold">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: 100">a</p>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import {
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
ModalController,
|
||||
ToastController,
|
||||
} from '@ionic/angular'
|
||||
|
||||
// TODO: Turn into DI token if this is needed someplace else too
|
||||
const ICONS = [
|
||||
@@ -88,6 +94,26 @@ const ICONS = [
|
||||
'wifi',
|
||||
]
|
||||
|
||||
const TAIGA = [
|
||||
'tuiIconPaintOutline',
|
||||
'tuiIconTrash',
|
||||
'tuiIconTrashOutline',
|
||||
'tuiIconChevronDown',
|
||||
'tuiIconChevronDownOutline',
|
||||
'tuiIconRefreshCcw',
|
||||
'tuiIconRefreshCcwOutline',
|
||||
'tuiIconEye',
|
||||
'tuiIconEyeOutline',
|
||||
'tuiIconEyeOff',
|
||||
'tuiIconEyeOffOutline',
|
||||
'tuiIconPlus',
|
||||
'tuiIconMinus',
|
||||
'tuiIconCheck',
|
||||
'tuiIconClose',
|
||||
'tuiIconCalendarLarge',
|
||||
'tuiIconHelpCircle',
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'section[appPreloader]',
|
||||
templateUrl: 'preloader.component.html',
|
||||
@@ -95,4 +121,12 @@ const ICONS = [
|
||||
})
|
||||
export class PreloaderComponent {
|
||||
readonly icons = ICONS
|
||||
readonly taiga = TAIGA
|
||||
|
||||
constructor(
|
||||
_modals: ModalController,
|
||||
_alerts: AlertController,
|
||||
_toasts: ToastController,
|
||||
_actions: ActionSheetController,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,65 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
TuiErrorModule,
|
||||
TuiExpandModule,
|
||||
TuiLinkModule,
|
||||
TuiScrollbarModule,
|
||||
TuiSvgModule,
|
||||
TuiTooltipModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiElasticContainerModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputTimeModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiProgressModule,
|
||||
TuiRadioListModule,
|
||||
TuiSelectModule,
|
||||
TuiTextAreaModule,
|
||||
TuiToggleModule,
|
||||
} from '@taiga-ui/kit'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { PreloaderComponent } from './preloader.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, QrCodeModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
QrCodeModule,
|
||||
TuiTooltipModule,
|
||||
TuiErrorModule,
|
||||
TuiInputModule,
|
||||
TuiSvgModule,
|
||||
TuiIconModule,
|
||||
TuiButtonModule,
|
||||
TuiLinkModule,
|
||||
TuiInputTimeModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiInputNumberModule,
|
||||
TuiExpandModule,
|
||||
TuiSelectModule,
|
||||
TuiTextAreaModule,
|
||||
TuiToggleModule,
|
||||
TuiElasticContainerModule,
|
||||
TuiCellModule,
|
||||
TuiProgressModule,
|
||||
TuiScrollbarModule,
|
||||
TuiRadioListModule,
|
||||
],
|
||||
declarations: [PreloaderComponent],
|
||||
exports: [PreloaderComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directive, HostListener, Input } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { SnakePage } from '../../modals/snake/snake.page'
|
||||
import { ApiService } from '../../services/api/embassy-api.service'
|
||||
|
||||
@@ -13,8 +13,8 @@ export class SnekDirective {
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@@ -30,12 +30,7 @@ export class SnekDirective {
|
||||
modal.onDidDismiss().then(async ({ data }) => {
|
||||
if (data?.highScore <= (this.appSnekHighScore || 0)) return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving high score...',
|
||||
backdropDismiss: true,
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Saving high score...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.setDbValue<number>(
|
||||
@@ -43,9 +38,9 @@ export class SnekDirective {
|
||||
data.highScore,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loadingCtrl.dismiss()
|
||||
this.loader.subscribe()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
UnitConversionPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@@ -23,7 +22,6 @@ import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.
|
||||
IonicModule,
|
||||
UnitConversionPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
GenericFormPageModule,
|
||||
],
|
||||
exports: [
|
||||
BackupDrivesComponent,
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { BackupService } from './backup.service'
|
||||
import { ActionSheetController, AlertController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
RR,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import {
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||
import { FormComponent } from '../form.component'
|
||||
import { BackupService } from './backup.service'
|
||||
|
||||
type BackupType = 'create' | 'restore'
|
||||
|
||||
@@ -32,13 +29,13 @@ export class BackupDrivesComponent {
|
||||
loadingText = ''
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly backupService: BackupService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
get loading() {
|
||||
@@ -87,23 +84,19 @@ export class BackupDrivesComponent {
|
||||
}
|
||||
|
||||
async presentModalAddCifs(): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'New Network Folder',
|
||||
spec: CifsSpec,
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'New Network Folder',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(cifsSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Connect',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.addCifs(value)
|
||||
},
|
||||
isSubmit: true,
|
||||
text: 'Execute',
|
||||
handler: async (value: RR.AddBackupTargetReq) =>
|
||||
this.addCifs(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentActionCifs(
|
||||
@@ -151,10 +144,9 @@ export class BackupDrivesComponent {
|
||||
}
|
||||
|
||||
private async addCifs(value: RR.AddBackupTargetReq): Promise<boolean> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader
|
||||
.open('Testing connectivity to shared folder...')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.addBackupTarget(value)
|
||||
@@ -166,10 +158,10 @@ export class BackupDrivesComponent {
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,62 +172,57 @@ export class BackupDrivesComponent {
|
||||
): Promise<void> {
|
||||
const { hostname, path, username } = entry
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'Update Shared Folder',
|
||||
spec: CifsSpec,
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Update Network Folder',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(cifsSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.editCifs({ id, ...value }, index)
|
||||
},
|
||||
isSubmit: true,
|
||||
text: 'Execute',
|
||||
handler: async (value: RR.AddBackupTargetReq) =>
|
||||
this.editCifs({ id, ...value }, index),
|
||||
},
|
||||
],
|
||||
initialValue: {
|
||||
value: {
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
},
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async editCifs(
|
||||
value: RR.UpdateBackupTargetReq,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
})
|
||||
await loader.present()
|
||||
): Promise<boolean> {
|
||||
const loader = this.loader
|
||||
.open('Testing connectivity to shared folder...')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.updateBackupTarget(value)
|
||||
this.backupService.cifs[index].entry = Object.values(res)[0]
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteCifs(id: string, index: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Removing...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Removing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.removeBackupTarget({ id })
|
||||
this.backupService.cifs.splice(index, 1)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,40 +261,33 @@ export class BackupDrivesStatusComponent {
|
||||
@Input() hasValidBackup!: boolean
|
||||
}
|
||||
|
||||
const CifsSpec: ConfigSpec = {
|
||||
hostname: {
|
||||
type: 'string',
|
||||
name: 'Hostname/IP',
|
||||
const cifsSpec = CB.Config.of({
|
||||
hostname: CB.Value.text({
|
||||
name: 'Hostname',
|
||||
description:
|
||||
'The hostname or IP address of the target device on your Local Area Network.',
|
||||
placeholder: `e.g. 'MyComputer.local' OR '192.168.1.4'`,
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
'The hostname of your target device on the Local Area Network.',
|
||||
warning: null,
|
||||
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
|
||||
required: { default: null },
|
||||
patterns: [],
|
||||
}),
|
||||
path: CB.Value.text({
|
||||
name: 'Path',
|
||||
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
|
||||
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: { default: null },
|
||||
}),
|
||||
username: CB.Value.text({
|
||||
name: 'Username',
|
||||
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: { default: null },
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
password: CB.Value.text({
|
||||
name: 'Password',
|
||||
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
|
||||
nullable: true,
|
||||
required: false,
|
||||
masked: true,
|
||||
copyable: false,
|
||||
},
|
||||
}
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<ion-button
|
||||
*ngIf="data.description"
|
||||
class="slot-start"
|
||||
fill="clear"
|
||||
(click)="presentAlertDescription($event)"
|
||||
>
|
||||
<ion-icon name="help-circle-outline" slot="icon-only" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<span>{{ data.name }}</span>
|
||||
|
||||
<ion-text color="success" *ngIf="data.new"> (New)</ion-text>
|
||||
<ion-text color="success" *ngIf="data.newOptions"> (New Options)</ion-text>
|
||||
<ion-text color="warning" *ngIf="data.edited"> (Edited)</ion-text>
|
||||
|
||||
<span *ngIf="data.required"> *</span>
|
||||
@@ -1,372 +0,0 @@
|
||||
<ion-item-group [formGroup]="formGroup">
|
||||
<div *ngFor="let entry of formGroup.controls | keyvalue: asIsOrder">
|
||||
<div *ngIf="objectSpec[entry.key] as spec">
|
||||
<!-- string or number -->
|
||||
<ng-container *ngIf="spec.type === 'string' || spec.type === 'number'">
|
||||
<!-- label -->
|
||||
<h4 class="input-label">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty,
|
||||
required: !spec.nullable
|
||||
}"
|
||||
></form-label>
|
||||
</h4>
|
||||
<ion-item [color]="(theme$ | async) === 'Light' ? 'light' : 'dark'">
|
||||
<ion-textarea
|
||||
*ngIf="spec.type === 'string' && spec.textarea; else notTextArea"
|
||||
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
|
||||
[formControlName]="entry.key"
|
||||
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
|
||||
(ionChange)="handleInputChange()"
|
||||
></ion-textarea>
|
||||
<ng-template #notTextArea>
|
||||
<ion-input
|
||||
type="text"
|
||||
[inputmode]="spec.type === 'number' ? 'tel' : 'text'"
|
||||
[class.redacted]="
|
||||
spec.type === 'string' &&
|
||||
entry.value.value &&
|
||||
spec.masked &&
|
||||
!unmasked[entry.key]
|
||||
"
|
||||
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
|
||||
[formControlName]="entry.key"
|
||||
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
|
||||
(ionChange)="handleInputChange()"
|
||||
></ion-input>
|
||||
</ng-template>
|
||||
<ion-button
|
||||
*ngIf="spec.type === 'string' && spec.masked"
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="unmasked[entry.key] = !unmasked[entry.key]"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
[name]="unmasked[entry.key] ? 'eye-off-outline' : 'eye-outline'"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-note
|
||||
*ngIf="spec.type === 'number' && spec.units"
|
||||
slot="end"
|
||||
color="light"
|
||||
style="font-size: medium"
|
||||
>
|
||||
{{ spec.units }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
<p class="error-message">
|
||||
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
|
||||
{{ errors | getError: $any(spec)['pattern-description'] }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
<!-- boolean or enum -->
|
||||
<ion-item
|
||||
*ngIf="spec.type === 'boolean' || spec.type === 'enum'"
|
||||
style="--padding-start: 0"
|
||||
>
|
||||
<ion-button
|
||||
*ngIf="spec.description"
|
||||
fill="clear"
|
||||
(click)="presentAlertBoolEnumDescription($event, spec)"
|
||||
style="--padding-start: 0"
|
||||
>
|
||||
<ion-icon
|
||||
name="help-circle-outline"
|
||||
slot="icon-only"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-label>
|
||||
<b>
|
||||
{{ spec.name }}
|
||||
<ion-text
|
||||
*ngIf="original?.[entry.key] === undefined"
|
||||
color="success"
|
||||
>
|
||||
(New)
|
||||
</ion-text>
|
||||
<ion-text *ngIf="entry.value.dirty" color="warning">
|
||||
(Edited)
|
||||
</ion-text>
|
||||
</b>
|
||||
</ion-label>
|
||||
<!-- boolean -->
|
||||
<ion-toggle
|
||||
*ngIf="spec.type === 'boolean'"
|
||||
slot="end"
|
||||
[formControlName]="entry.key"
|
||||
(ionChange)="handleBooleanChange(entry.key, spec)"
|
||||
></ion-toggle>
|
||||
<!-- enum -->
|
||||
<!-- class enter-click disables the enter click on the modal behind the select -->
|
||||
<ion-select
|
||||
*ngIf="spec.type === 'enum' && formGroup.get(entry.key) as control"
|
||||
[interfaceOptions]="{
|
||||
message: spec.warning | toWarningText,
|
||||
cssClass: 'enter-click'
|
||||
}"
|
||||
slot="end"
|
||||
placeholder="Select"
|
||||
[formControlName]="entry.key"
|
||||
[selectedText]="spec['value-names'][control.value]"
|
||||
>
|
||||
<ion-select-option
|
||||
*ngFor="let option of spec.values"
|
||||
[value]="option"
|
||||
>
|
||||
{{ spec['value-names'][option] }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
<!-- object -->
|
||||
<ng-container *ngIf="spec.type === 'object'">
|
||||
<!-- label -->
|
||||
<ion-item-divider
|
||||
(click)="toggleExpandObject(entry.key)"
|
||||
style="cursor: pointer"
|
||||
[class.error-border]="entry.value.invalid"
|
||||
>
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty,
|
||||
newOptions: objectDisplay[entry.key].hasNewOptions
|
||||
}"
|
||||
></form-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
name="chevron-up"
|
||||
[color]="entry.value.invalid ? 'danger' : undefined"
|
||||
[ngStyle]="{
|
||||
transform: objectDisplay[entry.key].expanded
|
||||
? 'rotate(0deg)'
|
||||
: 'rotate(180deg)',
|
||||
transition: 'transform 0.42s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</ion-item-divider>
|
||||
<!-- body -->
|
||||
<tui-expand
|
||||
[expanded]="objectDisplay[entry.key].expanded"
|
||||
[id]="objectId | toElementId: entry.key"
|
||||
>
|
||||
<div class="nested-wrapper">
|
||||
<form-object
|
||||
[objectSpec]="spec.spec"
|
||||
[formGroup]="$any(entry.value)"
|
||||
[current]="current?.[entry.key]"
|
||||
[original]="original?.[entry.key]"
|
||||
(hasNewOptions)="setHasNew(entry.key)"
|
||||
></form-object>
|
||||
</div>
|
||||
</tui-expand>
|
||||
</ng-container>
|
||||
<!-- union -->
|
||||
<form-union
|
||||
*ngIf="spec.type === 'union'"
|
||||
[spec]="spec"
|
||||
[formGroup]="$any(entry.value)"
|
||||
[current]="current?.[entry.key]"
|
||||
[original]="original?.[entry.key]"
|
||||
></form-union>
|
||||
<!-- list (not enum) -->
|
||||
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
|
||||
<ng-container
|
||||
*ngIf="formGroup.get(entry.key) as formArr"
|
||||
[formArrayName]="entry.key"
|
||||
>
|
||||
<!-- label -->
|
||||
<ion-item-divider [class.error-border]="entry.value.invalid">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty,
|
||||
required: !!(spec.range | toRange).min
|
||||
}"
|
||||
></form-label>
|
||||
<ion-button
|
||||
strong
|
||||
fill="clear"
|
||||
color="dark"
|
||||
slot="end"
|
||||
(click)="addListItemWrapper(entry.key, spec)"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Add
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<p class="error-message" style="margin-bottom: 8px">
|
||||
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
|
||||
{{ errors | getError }}
|
||||
</span>
|
||||
</p>
|
||||
<!-- body -->
|
||||
<div class="nested-wrapper">
|
||||
<div
|
||||
*ngFor="
|
||||
let abstractControl of $any(formArr).controls;
|
||||
let i = index
|
||||
"
|
||||
>
|
||||
<!-- object or union -->
|
||||
<ng-container
|
||||
*ngIf="spec.subtype === 'object' || spec.subtype === 'union'"
|
||||
>
|
||||
<!-- object/union label -->
|
||||
<ion-item
|
||||
button
|
||||
(click)="toggleExpandListObject(entry.key, i)"
|
||||
[class.error-border]="abstractControl.invalid"
|
||||
>
|
||||
<form-label
|
||||
[data]="{
|
||||
name:
|
||||
objectListDisplay[entry.key][i].displayAs ||
|
||||
'Entry ' + (i + 1),
|
||||
new: false,
|
||||
edited: abstractControl.dirty
|
||||
}"
|
||||
></form-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
name="chevron-up"
|
||||
[color]="abstractControl.invalid ? 'danger' : undefined"
|
||||
[ngStyle]="{
|
||||
transform: objectListDisplay[entry.key][i].expanded
|
||||
? 'rotate(0deg)'
|
||||
: 'rotate(180deg)',
|
||||
transition: 'transform 0.42s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<!-- object/union body -->
|
||||
<tui-expand
|
||||
style="padding-left: 24px"
|
||||
[expanded]="objectListDisplay[entry.key][i].expanded"
|
||||
[id]="objectId | toElementId: entry.key:i"
|
||||
>
|
||||
<form-object
|
||||
*ngIf="spec.subtype === 'object'"
|
||||
[objectSpec]="$any(spec.spec).spec"
|
||||
[formGroup]="abstractControl"
|
||||
[current]="current?.[entry.key]?.[i]"
|
||||
[original]="original?.[entry.key]?.[i]"
|
||||
(onInputChange)="
|
||||
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
|
||||
"
|
||||
></form-object>
|
||||
<form-union
|
||||
*ngIf="spec.subtype === 'union'"
|
||||
[spec]="$any(spec.spec)"
|
||||
[formGroup]="abstractControl"
|
||||
[current]="current?.[entry.key]?.[i]"
|
||||
[original]="original?.[entry.key]?.[i]"
|
||||
(onInputChange)="
|
||||
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
|
||||
"
|
||||
></form-union>
|
||||
<div style="text-align: right; padding-top: 12px">
|
||||
<ion-button
|
||||
fill="clear"
|
||||
(click)="presentAlertDelete(entry.key, i)"
|
||||
color="danger"
|
||||
>
|
||||
<ion-icon slot="start" name="close"></ion-icon>
|
||||
Delete
|
||||
</ion-button>
|
||||
</div>
|
||||
</tui-expand>
|
||||
</ng-container>
|
||||
<!-- string or number -->
|
||||
<div
|
||||
*ngIf="spec.subtype === 'string' || spec.subtype === 'number'"
|
||||
[id]="objectId | toElementId: entry.key:i"
|
||||
>
|
||||
<ion-item
|
||||
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
|
||||
>
|
||||
<ion-input
|
||||
type="text"
|
||||
[inputmode]="spec.subtype === 'number' ? 'tel' : 'text'"
|
||||
[placeholder]="
|
||||
$any(spec.spec).placeholder || 'Enter ' + spec.name
|
||||
"
|
||||
[formControlName]="i"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
strong
|
||||
fill="clear"
|
||||
slot="end"
|
||||
color="danger"
|
||||
(click)="presentAlertDelete(entry.key, i)"
|
||||
>
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error-message">
|
||||
<span
|
||||
*ngIf="
|
||||
(formGroup | getControl: entry.key:i).errors as errors
|
||||
"
|
||||
>
|
||||
{{ errors | getError: $any(spec)['pattern-description'] }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- list (enum) -->
|
||||
<ng-container *ngIf="spec.type === 'list' && spec.subtype === 'enum'">
|
||||
<ng-container
|
||||
*ngIf="formGroup.get(entry.key) as formArr"
|
||||
[formArrayName]="entry.key"
|
||||
>
|
||||
<!-- label -->
|
||||
<p class="input-label">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty
|
||||
}"
|
||||
></form-label>
|
||||
</p>
|
||||
<!-- list -->
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
color="dark"
|
||||
(click)="presentModalEnumList(entry.key, $any(spec), formArr.value)"
|
||||
>
|
||||
<ion-label style="white-space: nowrap !important">
|
||||
<h2>{{ formArr.value | toEnumListDisplay: $any(spec.spec) }}</h2>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" color="light">
|
||||
<ion-icon slot="icon-only" name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error-message">
|
||||
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
|
||||
{{ errors | getError }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
FormObjectComponent,
|
||||
FormUnionComponent,
|
||||
FormLabelComponent,
|
||||
} from './form-object.component'
|
||||
import {
|
||||
GetErrorPipe,
|
||||
ToWarningTextPipe,
|
||||
ToElementIdPipe,
|
||||
GetControlPipe,
|
||||
ToEnumListDisplayPipe,
|
||||
ToRangePipe,
|
||||
} from './form-object.pipes'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiElasticContainerModule } from '@taiga-ui/kit'
|
||||
import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module'
|
||||
import { TuiExpandModule } from '@taiga-ui/core'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FormObjectComponent,
|
||||
FormUnionComponent,
|
||||
FormLabelComponent,
|
||||
ToWarningTextPipe,
|
||||
GetErrorPipe,
|
||||
ToEnumListDisplayPipe,
|
||||
ToElementIdPipe,
|
||||
GetControlPipe,
|
||||
ToRangePipe,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SharedPipesModule,
|
||||
EnumListPageModule,
|
||||
TuiElasticContainerModule,
|
||||
TuiExpandModule,
|
||||
],
|
||||
exports: [FormObjectComponent, FormLabelComponent],
|
||||
})
|
||||
export class FormObjectComponentModule {}
|
||||
@@ -1,42 +0,0 @@
|
||||
.slot-start {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
--padding-start: 0;
|
||||
--padding-end: 7px;
|
||||
}
|
||||
|
||||
.error-border {
|
||||
border-color: var(--ion-color-danger-shade);
|
||||
--border-color: var(--ion-color-danger-shade);
|
||||
}
|
||||
|
||||
.redacted {
|
||||
font-family: 'Redacted'
|
||||
}
|
||||
|
||||
ion-input {
|
||||
font-family: 'Courier New';
|
||||
font-weight: bold;
|
||||
--placeholder-font-weight: 400;
|
||||
}
|
||||
|
||||
ion-item-divider {
|
||||
text-transform: unset;
|
||||
--padding-top: 18px;
|
||||
--padding-start: 0;
|
||||
border-bottom: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))))
|
||||
}
|
||||
|
||||
.nested-wrapper {
|
||||
padding: 0 0 16px 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 2px;
|
||||
font-size: small;
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
|
||||
.indent {
|
||||
margin-left: 24px;
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ChangeDetectionStrategy,
|
||||
Inject,
|
||||
inject,
|
||||
SimpleChanges,
|
||||
} from '@angular/core'
|
||||
import { FormArray, UntypedFormArray, UntypedFormGroup } from '@angular/forms'
|
||||
import { AlertButton, AlertController, ModalController } from '@ionic/angular'
|
||||
import {
|
||||
ConfigSpec,
|
||||
ListValueSpecOf,
|
||||
ValueSpec,
|
||||
ValueSpecBoolean,
|
||||
ValueSpecEnum,
|
||||
ValueSpecList,
|
||||
ValueSpecListOf,
|
||||
ValueSpecUnion,
|
||||
} from 'src/app/pkg-config/config-types'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
|
||||
import { THEME, pauseFor } from '@start9labs/shared'
|
||||
import { v4 } from 'uuid'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
const Mustache = require('mustache')
|
||||
|
||||
interface Config {
|
||||
[key: string]: any
|
||||
}
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
templateUrl: './form-object.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input() objectSpec!: ConfigSpec
|
||||
@Input() formGroup!: UntypedFormGroup
|
||||
@Input() current?: Config
|
||||
@Input() original?: Config
|
||||
@Output() onInputChange = new EventEmitter<void>()
|
||||
@Output() hasNewOptions = new EventEmitter<void>()
|
||||
warningAck: { [key: string]: boolean } = {}
|
||||
unmasked: { [key: string]: boolean } = {}
|
||||
objectDisplay: {
|
||||
[key: string]: { expanded: boolean; hasNewOptions: boolean }
|
||||
} = {}
|
||||
objectListDisplay: {
|
||||
[key: string]: { expanded: boolean; displayAs: string }[]
|
||||
} = {}
|
||||
objectId = v4()
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.setDisplays()
|
||||
|
||||
// setTimeout hack to avoid ExpressionChangedAfterItHasBeenCheckedError
|
||||
setTimeout(() => {
|
||||
if (
|
||||
this.original &&
|
||||
Object.keys(this.current || {}).some(
|
||||
key => this.original![key] === undefined,
|
||||
)
|
||||
)
|
||||
this.hasNewOptions.emit()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
const specChanges = changes['objectSpec']
|
||||
|
||||
if (!specChanges) return
|
||||
|
||||
if (
|
||||
!specChanges.firstChange &&
|
||||
Object.keys({
|
||||
...specChanges.previousValue,
|
||||
...specChanges.currentValue,
|
||||
}).length !== Object.keys(specChanges.previousValue).length
|
||||
) {
|
||||
this.setDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
private setDisplays() {
|
||||
Object.keys(this.objectSpec).forEach(key => {
|
||||
const spec = this.objectSpec[key]
|
||||
|
||||
if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) {
|
||||
this.objectListDisplay[key] = []
|
||||
this.formGroup.get(key)?.value.forEach((obj: any, index: number) => {
|
||||
const displayAs = (spec.spec as ListValueSpecOf<'object'>)[
|
||||
'display-as'
|
||||
]
|
||||
this.objectListDisplay[key][index] = {
|
||||
expanded: false,
|
||||
displayAs: displayAs
|
||||
? (Mustache as any).render(displayAs, obj)
|
||||
: '',
|
||||
}
|
||||
})
|
||||
} else if (spec.type === 'object') {
|
||||
this.objectDisplay[key] = {
|
||||
expanded: false,
|
||||
hasNewOptions: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addListItemWrapper<T extends ValueSpec>(
|
||||
key: string,
|
||||
spec: T extends ValueSpecUnion ? never : T,
|
||||
) {
|
||||
this.presentAlertChangeWarning(key, spec, () => this.addListItem(key))
|
||||
}
|
||||
|
||||
toggleExpandObject(key: string) {
|
||||
this.objectDisplay[key].expanded = !this.objectDisplay[key].expanded
|
||||
}
|
||||
|
||||
toggleExpandListObject(key: string, i: number) {
|
||||
this.objectListDisplay[key][i].expanded =
|
||||
!this.objectListDisplay[key][i].expanded
|
||||
}
|
||||
|
||||
updateLabel(key: string, i: number, displayAs: string) {
|
||||
this.objectListDisplay[key][i].displayAs = displayAs
|
||||
? Mustache.render(displayAs, this.formGroup.get(key)?.value[i])
|
||||
: ''
|
||||
}
|
||||
|
||||
handleInputChange() {
|
||||
this.onInputChange.emit()
|
||||
}
|
||||
|
||||
setHasNew(key: string) {
|
||||
this.hasNewOptions.emit()
|
||||
setTimeout(() => {
|
||||
this.objectDisplay[key].hasNewOptions = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
handleBooleanChange(key: string, spec: ValueSpecBoolean) {
|
||||
if (spec.warning) {
|
||||
const current = this.formGroup.get(key)?.value
|
||||
const cancelFn = () => this.formGroup.get(key)?.setValue(!current)
|
||||
this.presentAlertChangeWarning(key, spec, undefined, cancelFn)
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalEnumList(
|
||||
key: string,
|
||||
spec: ValueSpecListOf<'enum'>,
|
||||
current: string[],
|
||||
) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
key,
|
||||
spec,
|
||||
current,
|
||||
},
|
||||
component: EnumListPage,
|
||||
})
|
||||
|
||||
modal.onWillDismiss<string[]>().then(({ data }) => {
|
||||
if (!data) return
|
||||
this.updateEnumList(key, current, data)
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAlertChangeWarning<T extends ValueSpec>(
|
||||
key: string,
|
||||
spec: T extends ValueSpecUnion ? never : T,
|
||||
okFn?: Function,
|
||||
cancelFn?: Function,
|
||||
) {
|
||||
if (!spec.warning || this.warningAck[key]) return okFn ? okFn() : null
|
||||
this.warningAck[key] = true
|
||||
|
||||
const buttons: AlertButton[] = [
|
||||
{
|
||||
text: 'Ok',
|
||||
handler: () => {
|
||||
if (okFn) okFn()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
]
|
||||
|
||||
if (okFn || cancelFn) {
|
||||
buttons.unshift({
|
||||
text: 'Cancel',
|
||||
handler: () => {
|
||||
if (cancelFn) cancelFn()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
subHeader: `Editing ${spec.name} has consequences:`,
|
||||
message: spec.warning,
|
||||
buttons,
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertDelete(key: string, index: number) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: 'Are you sure you want to delete this entry?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.deleteListItem(key, index)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertBoolEnumDescription(
|
||||
event: Event,
|
||||
spec: ValueSpecBoolean | ValueSpecEnum,
|
||||
) {
|
||||
event.stopPropagation()
|
||||
const { name, description } = spec
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: name,
|
||||
message: description,
|
||||
buttons: [
|
||||
{
|
||||
text: 'OK',
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private addListItem(key: string): void {
|
||||
const arr = this.formGroup.get(key) as UntypedFormArray
|
||||
const listSpec = this.objectSpec[key] as ValueSpecList
|
||||
const newItem = this.formService.getListItem(listSpec, undefined)!
|
||||
|
||||
const index = arr.length
|
||||
arr.insert(index, newItem)
|
||||
|
||||
if (['object', 'union'].includes(listSpec.subtype)) {
|
||||
const displayAs = (listSpec.spec as ListValueSpecOf<'object'>)[
|
||||
'display-as'
|
||||
]
|
||||
this.objectListDisplay[key].push({
|
||||
expanded: false,
|
||||
displayAs: displayAs ? Mustache.render(displayAs, newItem.value) : '',
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const element = this.document.getElementById(
|
||||
getElementId(this.objectId, key, index),
|
||||
)
|
||||
element?.parentElement?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
if (['object', 'union'].includes(listSpec.subtype)) {
|
||||
pauseFor(250).then(() => this.toggleExpandListObject(key, index))
|
||||
}
|
||||
}, 100)
|
||||
|
||||
arr.markAsDirty()
|
||||
}
|
||||
|
||||
private deleteListItem(key: string, index: number, markDirty = true): void {
|
||||
// if (this.objectListDisplay[key])
|
||||
// this.objectListDisplay[key][index].height = '0px'
|
||||
const arr = this.formGroup.get(key) as UntypedFormArray
|
||||
if (markDirty) arr.markAsDirty()
|
||||
pauseFor(250).then(() => {
|
||||
if (this.objectListDisplay[key])
|
||||
this.objectListDisplay[key].splice(index, 1)
|
||||
arr.removeAt(index)
|
||||
})
|
||||
}
|
||||
|
||||
private updateEnumList(key: string, current: string[], updated: string[]) {
|
||||
const arr = this.formGroup.get(key) as FormArray
|
||||
|
||||
for (let i = current.length - 1; i >= 0; i--) {
|
||||
if (!updated.includes(current[i])) {
|
||||
arr.removeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
const listSpec = this.objectSpec[key] as ValueSpecList
|
||||
|
||||
updated.forEach(val => {
|
||||
if (!current.includes(val)) {
|
||||
const newItem = this.formService.getListItem(listSpec, val)!
|
||||
arr.insert(arr.length, newItem)
|
||||
}
|
||||
})
|
||||
|
||||
arr.markAsDirty()
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-union',
|
||||
templateUrl: './form-union.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormUnionComponent {
|
||||
@Input() formGroup!: UntypedFormGroup
|
||||
@Input() spec!: ValueSpecUnion
|
||||
@Input() current?: Config
|
||||
@Input() original?: Config
|
||||
|
||||
get unionValue() {
|
||||
return this.formGroup.get(this.spec.tag.id)?.value
|
||||
}
|
||||
|
||||
get isNew() {
|
||||
return !this.original
|
||||
}
|
||||
|
||||
get hasNewOptions() {
|
||||
const tagId = this.spec.tag.id
|
||||
return (
|
||||
this.original?.[tagId] === this.current?.[tagId] &&
|
||||
!!Object.keys(this.current || {}).find(
|
||||
key => this.original![key] === undefined,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
objectId = v4()
|
||||
|
||||
constructor(private readonly formService: FormService) {}
|
||||
|
||||
updateUnion(e: any): void {
|
||||
const tagId = this.spec.tag.id
|
||||
|
||||
Object.keys(this.formGroup.controls).forEach(control => {
|
||||
if (control === tagId) return
|
||||
this.formGroup.removeControl(control)
|
||||
})
|
||||
|
||||
const unionGroup = this.formService.getUnionObject(
|
||||
this.spec as ValueSpecUnion,
|
||||
e.detail.value,
|
||||
)
|
||||
|
||||
Object.keys(unionGroup.controls).forEach(control => {
|
||||
if (control === tagId) return
|
||||
this.formGroup.addControl(control, unionGroup.controls[control])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-label',
|
||||
templateUrl: './form-label.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormLabelComponent {
|
||||
@Input() data!: {
|
||||
name: string
|
||||
new: boolean
|
||||
edited: boolean
|
||||
description?: string
|
||||
required?: boolean
|
||||
newOptions?: boolean
|
||||
}
|
||||
|
||||
constructor(private readonly alertCtrl: AlertController) {}
|
||||
|
||||
async presentAlertDescription(event: Event) {
|
||||
event.stopPropagation()
|
||||
const { name, description } = this.data
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: name,
|
||||
message: description,
|
||||
buttons: [
|
||||
{
|
||||
text: 'OK',
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
export function getElementId(objectId: string, key: string, index = 0): string {
|
||||
return `${key}-${index}-${objectId}`
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormGroup,
|
||||
UntypedFormArray,
|
||||
ValidationErrors,
|
||||
} from '@angular/forms'
|
||||
import { IonicSafeString } from '@ionic/angular'
|
||||
import { ListValueSpecOf } from 'src/app/pkg-config/config-types'
|
||||
import { Range } from 'src/app/pkg-config/config-utilities'
|
||||
import { getElementId } from './form-object.component'
|
||||
|
||||
@Pipe({
|
||||
name: 'getError',
|
||||
})
|
||||
export class GetErrorPipe implements PipeTransform {
|
||||
transform(errors: ValidationErrors, patternDesc?: string): string {
|
||||
if (errors['required']) {
|
||||
return 'Required'
|
||||
} else if (errors['pattern']) {
|
||||
return patternDesc || 'Invalid pattern'
|
||||
} else if (errors['notNumber']) {
|
||||
return 'Must be a number'
|
||||
} else if (errors['numberNotInteger']) {
|
||||
return 'Must be an integer'
|
||||
} else if (errors['numberNotInRange']) {
|
||||
return errors['numberNotInRange'].value
|
||||
} else if (errors['listNotUnique']) {
|
||||
return errors['listNotUnique'].value
|
||||
} else if (errors['listNotInRange']) {
|
||||
return errors['listNotInRange'].value
|
||||
} else if (errors['listItemIssue']) {
|
||||
return errors['listItemIssue'].value
|
||||
} else {
|
||||
return 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toEnumListDisplay',
|
||||
})
|
||||
export class ToEnumListDisplayPipe implements PipeTransform {
|
||||
transform(arr: string[], spec: ListValueSpecOf<'enum'>): string {
|
||||
return arr.map((v: string) => spec['value-names'][v]).join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toWarningText',
|
||||
})
|
||||
export class ToWarningTextPipe implements PipeTransform {
|
||||
transform(text?: string): IonicSafeString | string {
|
||||
return text
|
||||
? new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toRange',
|
||||
})
|
||||
export class ToRangePipe implements PipeTransform {
|
||||
transform(range: string): Range {
|
||||
return Range.from(range)
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toElementId',
|
||||
})
|
||||
export class ToElementIdPipe implements PipeTransform {
|
||||
transform(objectId: string, key: string, index = 0): string {
|
||||
return getElementId(objectId, key, index)
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'getControl',
|
||||
})
|
||||
export class GetControlPipe implements PipeTransform {
|
||||
transform(
|
||||
formGroup: FormGroup,
|
||||
key: string,
|
||||
index?: number,
|
||||
): AbstractControl {
|
||||
const abstractControl = formGroup.get(key)!
|
||||
if (index !== undefined)
|
||||
return (abstractControl as UntypedFormArray).at(index)
|
||||
return abstractControl
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<div [formGroup]="formGroup">
|
||||
<!-- union enum -->
|
||||
<ion-item-divider [class.error-border]="formGroup.invalid">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.tag.name,
|
||||
description: spec.tag.description,
|
||||
new: isNew,
|
||||
newOptions: hasNewOptions,
|
||||
edited: formGroup.dirty
|
||||
}"
|
||||
></form-label>
|
||||
<!-- class enter-click disables the enter click on the modal behind the select -->
|
||||
<ion-select
|
||||
[interfaceOptions]="{
|
||||
message: spec.tag.warning | toWarningText,
|
||||
cssClass: 'enter-click'
|
||||
}"
|
||||
slot="end"
|
||||
placeholder="Select"
|
||||
[formControlName]="spec.tag.id"
|
||||
[selectedText]="spec.tag['variant-names'][unionValue]"
|
||||
(ionChange)="updateUnion($event)"
|
||||
>
|
||||
<ion-select-option
|
||||
*ngFor="let option of spec.variants | keyvalue"
|
||||
[value]="option.key"
|
||||
>
|
||||
{{ spec.tag['variant-names'][option.key] }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item-divider>
|
||||
|
||||
<tui-elastic-container [id]="objectId | toElementId: 'union'" class="indent">
|
||||
<form-object
|
||||
[objectSpec]="spec.variants[unionValue]"
|
||||
[formGroup]="formGroup"
|
||||
[current]="current"
|
||||
[original]="original"
|
||||
></form-object>
|
||||
</tui-elastic-container>
|
||||
</div>
|
||||
167
web/projects/ui/src/app/components/form.component.ts
Normal file
167
web/projects/ui/src/app/components/form.component.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
import {
|
||||
tuiMarkControlAsTouchedAndValidate,
|
||||
TuiValueChangesModule,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { TuiDialogContext, TuiModeModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiDialogFormService } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { FormModule } from 'src/app/components/form/form.module'
|
||||
import { InvalidService } from 'src/app/components/form/invalid.service'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
|
||||
export interface ActionButton<T> {
|
||||
text: string
|
||||
handler?: (value: T) => Promise<boolean | void> | void
|
||||
link?: string
|
||||
}
|
||||
|
||||
export interface FormContext<T> {
|
||||
spec: CT.InputSpec
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
patch?: Operation[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-form',
|
||||
template: `
|
||||
<form
|
||||
[formGroup]="form"
|
||||
(submit.capture.prevent)="(0)"
|
||||
(reset.capture.prevent.stop)="onReset()"
|
||||
(tuiValueChanges)="markAsDirty()"
|
||||
>
|
||||
<form-group [spec]="spec"></form-group>
|
||||
<footer tuiMode="onDark">
|
||||
<ng-content></ng-content>
|
||||
<ng-container *ngFor="let button of buttons; let last = last">
|
||||
<button
|
||||
*ngIf="button.handler; else link"
|
||||
tuiButton
|
||||
[appearance]="last ? 'primary' : 'flat'"
|
||||
[type]="last ? 'submit' : 'button'"
|
||||
(click)="onClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</button>
|
||||
<ng-template #link>
|
||||
<a
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
[routerLink]="button.link"
|
||||
(click)="close()"
|
||||
>
|
||||
{{ button.text }}
|
||||
</a>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 0;
|
||||
margin: 1rem 0 -1rem;
|
||||
gap: 1rem;
|
||||
background: var(--tui-elevation-01);
|
||||
border-top: 1px solid var(--tui-base-02);
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TuiValueChangesModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
FormModule,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
private readonly dialogFormService = inject(TuiDialogFormService)
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly invalidService = inject(InvalidService)
|
||||
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
{ optional: true },
|
||||
)
|
||||
|
||||
@Input() spec = this.context?.data.spec || {}
|
||||
@Input() buttons = this.context?.data.buttons || []
|
||||
@Input() patch = this.context?.data.patch || []
|
||||
@Input() value?: T = this.context?.data.value
|
||||
|
||||
form = new FormGroup({})
|
||||
|
||||
ngOnInit() {
|
||||
this.dialogFormService.markAsPristine()
|
||||
this.form = this.formService.createForm(this.spec, this.value)
|
||||
this.process(this.patch)
|
||||
}
|
||||
|
||||
onReset() {
|
||||
const { value } = this.form
|
||||
|
||||
this.form = this.formService.createForm(this.spec)
|
||||
this.process(compare(this.form.value, value))
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.markAsDirty()
|
||||
}
|
||||
|
||||
async onClick(handler: Required<ActionButton<T>>['handler']) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.invalidService.scrollIntoView()
|
||||
|
||||
if (this.form.valid && (await handler(this.form.value as T))) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
markAsDirty() {
|
||||
this.dialogFormService.markAsDirty()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.context?.$implicit.complete()
|
||||
}
|
||||
|
||||
private process(patch: Operation[]) {
|
||||
patch.forEach(({ op, path }) => {
|
||||
const control = this.form.get(path.substring(1).split('/'))
|
||||
|
||||
if (!control || !control.parent) return
|
||||
|
||||
if (op !== 'remove') {
|
||||
control.markAsDirty()
|
||||
control.markAsTouched()
|
||||
}
|
||||
|
||||
control.parent.markAsDirty()
|
||||
control.parent.markAsTouched()
|
||||
})
|
||||
}
|
||||
}
|
||||
30
web/projects/ui/src/app/components/form/control.directive.ts
Normal file
30
web/projects/ui/src/app/components/form/control.directive.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ControlContainer, NgControl } from '@angular/forms'
|
||||
import { InvalidService } from './invalid.service'
|
||||
|
||||
@Directive({
|
||||
selector: 'form-control, form-array, form-object',
|
||||
})
|
||||
export class ControlDirective implements OnInit, OnDestroy {
|
||||
private readonly invalidService = inject(InvalidService, { optional: true })
|
||||
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
|
||||
private readonly control =
|
||||
inject(NgControl, { optional: true }) ||
|
||||
inject(ControlContainer, { optional: true })
|
||||
|
||||
get invalid(): boolean {
|
||||
return !!this.control?.invalid
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.invalidService?.add(this)
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.invalidService?.remove(this)
|
||||
}
|
||||
}
|
||||
35
web/projects/ui/src/app/components/form/control.ts
Normal file
35
web/projects/ui/src/app/components/form/control.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
export abstract class Control<Spec extends CT.ValueSpec, Value> {
|
||||
private readonly control: FormControlComponent<Spec, Value> =
|
||||
inject(FormControlComponent)
|
||||
|
||||
get invalid(): boolean {
|
||||
return this.control.touched && this.control.invalid
|
||||
}
|
||||
|
||||
get spec(): Spec {
|
||||
return this.control.spec
|
||||
}
|
||||
|
||||
// TODO: Properly handle already set immutable value
|
||||
get readOnly(): boolean {
|
||||
return (
|
||||
!!this.value && !!this.control.control?.pristine && this.control.immutable
|
||||
)
|
||||
}
|
||||
|
||||
get value(): Value | null {
|
||||
return this.control.value
|
||||
}
|
||||
|
||||
set value(value: Value | null) {
|
||||
this.control.onInput(value)
|
||||
}
|
||||
|
||||
onFocus(focused: boolean) {
|
||||
this.control.onFocus(focused)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="label">
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description || spec.disabled"
|
||||
[content]="spec | hint"
|
||||
></tui-tooltip>
|
||||
<button
|
||||
tuiLink
|
||||
type="button"
|
||||
class="add"
|
||||
[disabled]="!canAdd"
|
||||
(click)="add()"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<tui-error [error]="order | tuiFieldError | async"></tui-error>
|
||||
|
||||
<ng-container *ngFor="let item of array.control.controls; let index = index">
|
||||
<form-object
|
||||
*ngIf="spec.spec.type === 'object'; else control"
|
||||
class="object"
|
||||
[class.object_open]="!!open.get(item)"
|
||||
[formGroup]="$any(item)"
|
||||
[spec]="$any(spec.spec)"
|
||||
[@tuiHeightCollapse]="animation"
|
||||
[@tuiFadeIn]="animation"
|
||||
[open]="!!open.get(item)"
|
||||
(openChange)="open.set(item, $event)"
|
||||
>
|
||||
{{ item.value | mustache: $any(spec.spec).displayAs }}
|
||||
<ng-container *ngTemplateOutlet="remove"></ng-container>
|
||||
</form-object>
|
||||
<ng-template #control>
|
||||
<form-control
|
||||
class="control"
|
||||
tuiTextfieldSize="m"
|
||||
[tuiTextfieldLabelOutside]="true"
|
||||
[tuiTextfieldIcon]="remove"
|
||||
[formControl]="$any(item)"
|
||||
[spec]="$any(spec.spec)"
|
||||
[@tuiHeightCollapse]="animation"
|
||||
[@tuiFadeIn]="animation"
|
||||
></form-control>
|
||||
</ng-template>
|
||||
<ng-template #remove>
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
class="remove"
|
||||
iconLeft="tuiIconTrash"
|
||||
appearance="icon"
|
||||
size="m"
|
||||
title="Remove"
|
||||
(click.stop)="removeAt(index)"
|
||||
></button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,50 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add {
|
||||
font-size: 1rem;
|
||||
padding: 0 1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.object {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&_open::after,
|
||||
&:last-child::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
@include transition(opacity);
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.5rem;
|
||||
height: 1px;
|
||||
left: 3rem;
|
||||
right: 1rem;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, HostBinding, inject, Input } from '@angular/core'
|
||||
import { AbstractControl, FormArrayName } from '@angular/forms'
|
||||
import { TUI_PARENT_ANIMATION, TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TUI_ANIMATION_OPTIONS,
|
||||
TuiDialogService,
|
||||
tuiFadeIn,
|
||||
tuiHeightCollapse,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
|
||||
@Component({
|
||||
selector: 'form-array',
|
||||
templateUrl: './form-array.component.html',
|
||||
styleUrls: ['./form-array.component.scss'],
|
||||
animations: [tuiFadeIn, tuiHeightCollapse, TUI_PARENT_ANIMATION],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class FormArrayComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecList
|
||||
|
||||
@HostBinding('@tuiParentAnimation')
|
||||
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
|
||||
readonly order = ERRORS
|
||||
readonly array = inject(FormArrayName)
|
||||
readonly open = new Map<AbstractControl, boolean>()
|
||||
|
||||
private warned = false
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly destroy$ = inject(TuiDestroyService)
|
||||
|
||||
get canAdd(): boolean {
|
||||
return (
|
||||
!this.spec.disabled &&
|
||||
(!this.spec.maxLength ||
|
||||
this.spec.maxLength >= this.array.control.controls.length)
|
||||
)
|
||||
}
|
||||
|
||||
add() {
|
||||
if (!this.warned && this.spec.warning) {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.addItem()
|
||||
})
|
||||
} else {
|
||||
this.addItem()
|
||||
}
|
||||
|
||||
this.warned = true
|
||||
}
|
||||
|
||||
removeAt(index: number) {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to delete this entry?',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.removeItem(index)
|
||||
})
|
||||
}
|
||||
|
||||
private removeItem(index: number) {
|
||||
this.open.delete(this.array.control.at(index))
|
||||
this.array.control.removeAt(index)
|
||||
}
|
||||
|
||||
private addItem() {
|
||||
this.array.control.insert(0, this.formService.getListItem(this.spec))
|
||||
this.open.set(this.array.control.at(0), true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<tui-input
|
||||
[maskito]="mask"
|
||||
[tuiTextfieldCustomContent]="color"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[readOnly]="readOnly"
|
||||
[disabled]="!!spec.disabled"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input>
|
||||
<ng-template #color>
|
||||
<div class="wrapper" [style.color]="value">
|
||||
<input
|
||||
*ngIf="!readOnly && !spec.disabled"
|
||||
type="color"
|
||||
class="color"
|
||||
tabindex="-1"
|
||||
[(ngModel)]="value"
|
||||
(click.stop)="(0)"
|
||||
/>
|
||||
<tui-icon
|
||||
icon="tuiIconPaintLarge"
|
||||
tuiAppearance="icon"
|
||||
class="icon"
|
||||
></tui-icon>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,33 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: auto;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 0.3rem;
|
||||
width: 1.4rem;
|
||||
bottom: 0.125rem;
|
||||
background: currentColor;
|
||||
border-radius: 0.125rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include fullsize();
|
||||
pointer-events: none;
|
||||
|
||||
input:hover + & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { MaskitoOptions } from '@maskito/core'
|
||||
|
||||
@Component({
|
||||
selector: 'form-color',
|
||||
templateUrl: './form-color.component.html',
|
||||
styleUrls: ['./form-color.component.scss'],
|
||||
})
|
||||
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
|
||||
readonly mask: MaskitoOptions = {
|
||||
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<ng-container [ngSwitch]="spec.type">
|
||||
<form-color *ngSwitchCase="'color'"></form-color>
|
||||
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
|
||||
<form-file *ngSwitchCase="'file'"></form-file>
|
||||
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
|
||||
<form-number *ngSwitchCase="'number'"></form-number>
|
||||
<form-select *ngSwitchCase="'select'"></form-select>
|
||||
<form-text *ngSwitchCase="'text'"></form-text>
|
||||
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
|
||||
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
|
||||
</ng-container>
|
||||
<tui-error [error]="order | tuiFieldError | async"></tui-error>
|
||||
<ng-template
|
||||
*ngIf="spec.warning || immutable"
|
||||
#warning
|
||||
let-completeWith="completeWith"
|
||||
>
|
||||
{{ spec.warning }}
|
||||
<p *ngIf="immutable">This value cannot be changed once set!</p>
|
||||
<div class="buttons">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary"
|
||||
size="s"
|
||||
(click)="completeWith(true)"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="flat"
|
||||
size="s"
|
||||
(click)="completeWith(false)"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiDialogContext,
|
||||
TuiNotification,
|
||||
} from '@taiga-ui/core'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
|
||||
|
||||
@Component({
|
||||
selector: 'form-control',
|
||||
templateUrl: './form-control.component.html',
|
||||
styleUrls: ['./form-control.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: FORM_CONTROL_PROVIDERS,
|
||||
})
|
||||
export class FormControlComponent<
|
||||
T extends CT.ValueSpec,
|
||||
V,
|
||||
> extends AbstractTuiNullableControl<V> {
|
||||
@Input()
|
||||
spec!: T
|
||||
|
||||
@ViewChild('warning')
|
||||
warning?: TemplateRef<TuiDialogContext<boolean>>
|
||||
|
||||
warned = false
|
||||
focused = false
|
||||
readonly order = ERRORS
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
|
||||
get immutable(): boolean {
|
||||
return 'immutable' in this.spec && this.spec.immutable
|
||||
}
|
||||
|
||||
onFocus(focused: boolean) {
|
||||
this.focused = focused
|
||||
this.updateFocused(focused)
|
||||
}
|
||||
|
||||
onInput(value: V | null) {
|
||||
const previous = this.value
|
||||
|
||||
if (!this.warned && this.warning) {
|
||||
this.alerts
|
||||
.open<boolean>(this.warning, {
|
||||
label: 'Warning',
|
||||
status: TuiNotification.Warning,
|
||||
hasCloseButton: false,
|
||||
autoClose: false,
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.value = previous
|
||||
})
|
||||
}
|
||||
|
||||
this.warned = true
|
||||
this.value = value === '' ? null : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { forwardRef, Provider } from '@angular/core'
|
||||
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FormControlComponent } from './form-control.component'
|
||||
|
||||
interface ValidatorsPatternError {
|
||||
actualValue: string
|
||||
requiredPattern: string | RegExp
|
||||
}
|
||||
|
||||
export const FORM_CONTROL_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
deps: [forwardRef(() => FormControlComponent)],
|
||||
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
|
||||
required: 'Required',
|
||||
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
|
||||
('patterns' in control.spec &&
|
||||
control.spec.patterns.find(
|
||||
({ regex }) => String(regex) === String(requiredPattern),
|
||||
)?.description) ||
|
||||
'Invalid format',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
|
||||
<tui-input-time
|
||||
*ngSwitchCase="'time'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[ngModel]="getTime(value)"
|
||||
(ngModelChange)="value = $event?.toString() || null"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-time>
|
||||
<tui-input-date
|
||||
*ngSwitchCase="'date'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[min]="spec.min ? (spec.min | tuiMapper: getLimit)[0] : min"
|
||||
[max]="spec.max ? (spec.max | tuiMapper: getLimit)[0] : max"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-date>
|
||||
<tui-input-date-time
|
||||
*ngSwitchCase="'datetime-local'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[min]="spec.min ? (spec.min | tuiMapper: getLimit) : min"
|
||||
[max]="spec.max ? (spec.max | tuiMapper: getLimit) : max"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-date-time>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
TUI_FIRST_DAY,
|
||||
TUI_LAST_DAY,
|
||||
TuiDay,
|
||||
tuiPure,
|
||||
TuiTime,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-datetime',
|
||||
templateUrl: './form-datetime.component.html',
|
||||
})
|
||||
export class FormDatetimeComponent extends Control<
|
||||
CT.ValueSpecDatetime,
|
||||
string
|
||||
> {
|
||||
readonly min = TUI_FIRST_DAY
|
||||
readonly max = TUI_LAST_DAY
|
||||
|
||||
@tuiPure
|
||||
getTime(value: string | null) {
|
||||
return value ? TuiTime.fromString(value) : null
|
||||
}
|
||||
|
||||
getLimit(limit: string): [TuiDay, TuiTime] {
|
||||
return [
|
||||
TuiDay.jsonParse(limit.slice(0, 10)),
|
||||
limit.length === 10
|
||||
? new TuiTime(0, 0)
|
||||
: TuiTime.fromString(limit.slice(-5)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<tui-input-files
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
<input tuiInputFiles [accept]="spec.extensions.join(',')" />
|
||||
<ng-template let-drop>
|
||||
<div class="template" [class.template_hidden]="drop">
|
||||
<div class="label">
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description"
|
||||
[content]="spec.description"
|
||||
></tui-tooltip>
|
||||
</div>
|
||||
<tui-tag
|
||||
*ngIf="value; else label"
|
||||
class="file"
|
||||
size="l"
|
||||
[value]="value.name"
|
||||
[removable]="true"
|
||||
(edited)="value = null"
|
||||
></tui-tag>
|
||||
<ng-template #label>
|
||||
<small>Click or drop file here</small>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="drop" [class.drop_hidden]="!drop">Drop file here</div>
|
||||
</ng-template>
|
||||
</tui-input-files>
|
||||
@@ -0,0 +1,46 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.template {
|
||||
@include transition(opacity);
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
font: var(--tui-font-text-m);
|
||||
font-weight: bold;
|
||||
|
||||
&_hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drop {
|
||||
@include fullsize();
|
||||
@include transition(opacity);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
|
||||
&_hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
small {
|
||||
max-width: 50%;
|
||||
font-weight: normal;
|
||||
color: var(--tui-text-02);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
tui-tag {
|
||||
z-index: 1;
|
||||
margin: -0.25rem -0.25rem -0.25rem auto;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { TuiFileLike } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-file',
|
||||
templateUrl: './form-file.component.html',
|
||||
styleUrls: ['./form-file.component.scss'],
|
||||
})
|
||||
export class FormFileComponent extends Control<CT.ValueSpecFile, TuiFileLike> {}
|
||||
@@ -0,0 +1,30 @@
|
||||
<ng-container
|
||||
*ngFor="let entry of spec | keyvalue: asIsOrder"
|
||||
tuiMode="onDark"
|
||||
[ngSwitch]="entry.value.type"
|
||||
[tuiTextfieldCleaner]="true"
|
||||
>
|
||||
<form-object
|
||||
*ngSwitchCase="'object'"
|
||||
class="g-form-control"
|
||||
[formGroupName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-object>
|
||||
<form-union
|
||||
*ngSwitchCase="'union'"
|
||||
class="g-form-control"
|
||||
[formGroupName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-union>
|
||||
<form-array
|
||||
*ngSwitchCase="'list'"
|
||||
[formArrayName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-array>
|
||||
<form-control
|
||||
*ngSwitchDefault
|
||||
class="g-form-control"
|
||||
[formControlName]="entry.key"
|
||||
[spec]="entry.value"
|
||||
></form-control>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,35 @@
|
||||
form-group .g-form-control:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
form-group .g-form-group {
|
||||
position: relative;
|
||||
padding-left: var(--tui-height-m);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: calc(1rem - 1px);
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 0.75rem;
|
||||
bottom: 0;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
form-group tui-tooltip {
|
||||
z-index: 1;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
|
||||
|
||||
export const ERRORS = [
|
||||
'required',
|
||||
'pattern',
|
||||
'notNumber',
|
||||
'numberNotInteger',
|
||||
'numberNotInRange',
|
||||
'listNotUnique',
|
||||
'listNotInRange',
|
||||
'listItemIssue',
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'form-group',
|
||||
templateUrl: './form-group.component.html',
|
||||
styleUrls: ['./form-group.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
viewProviders: [FORM_GROUP_PROVIDERS],
|
||||
})
|
||||
export class FormGroupComponent {
|
||||
@Input() spec: CT.InputSpec = {}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Provider, SkipSelf } from '@angular/core'
|
||||
import {
|
||||
TUI_ARROW_MODE,
|
||||
tuiInputDateOptionsProvider,
|
||||
tuiInputTimeOptionsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TUI_DEFAULT_ERROR_MESSAGE } from '@taiga-ui/core'
|
||||
import { ControlContainer } from '@angular/forms'
|
||||
import { identity, of } from 'rxjs'
|
||||
|
||||
export const FORM_GROUP_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: TUI_DEFAULT_ERROR_MESSAGE,
|
||||
useValue: of('Unknown error'),
|
||||
},
|
||||
{
|
||||
provide: ControlContainer,
|
||||
deps: [[new SkipSelf(), ControlContainer]],
|
||||
useFactory: identity,
|
||||
},
|
||||
{
|
||||
provide: TUI_ARROW_MODE,
|
||||
useValue: {
|
||||
interactive: null,
|
||||
disabled: null,
|
||||
},
|
||||
},
|
||||
tuiInputDateOptionsProvider({
|
||||
nativePicker: true,
|
||||
}),
|
||||
tuiInputTimeOptionsProvider({
|
||||
nativePicker: true,
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-multi-select
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[editable]="false"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
[(ngModel)]="selected"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<select
|
||||
tuiSelect
|
||||
multiple
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-multi-select>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'form-multiselect',
|
||||
templateUrl: './form-multiselect.component.html',
|
||||
})
|
||||
export class FormMultiselectComponent extends Control<
|
||||
CT.ValueSpecMultiselect,
|
||||
readonly string[]
|
||||
> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
private readonly isDisabled = (item: string) =>
|
||||
Array.isArray(this.spec.disabled) &&
|
||||
this.spec.disabled.includes(this.inverted[item])
|
||||
|
||||
private readonly isExceedingLimit = (item: string) =>
|
||||
!!this.spec.maxLength &&
|
||||
this.selected.length >= this.spec.maxLength &&
|
||||
!this.selected.includes(item)
|
||||
|
||||
readonly disabledItemHandler = (item: string): boolean =>
|
||||
this.isDisabled(item) || this.isExceedingLimit(item)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
get disabled(): boolean {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
}
|
||||
|
||||
get selected(): string[] {
|
||||
return this.memoize(this.value)
|
||||
}
|
||||
|
||||
set selected(value: string[]) {
|
||||
this.value = Object.entries(this.spec.values)
|
||||
.filter(([_, v]) => value.includes(v))
|
||||
.map(([k]) => k)
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
private memoize(value: null | readonly string[]): string[] {
|
||||
return value?.map(key => this.spec.values[key]) || []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-input-number
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[tuiTextfieldPostfix]="spec.units || ''"
|
||||
[pseudoInvalid]="invalid"
|
||||
[precision]="Infinity"
|
||||
[decimal]="spec.integer ? 'never' : 'not-zero'"
|
||||
[min]="spec.min ?? -Infinity"
|
||||
[max]="spec.max ?? Infinity"
|
||||
[step]="spec.step || 0"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
|
||||
</tui-input-number>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-number',
|
||||
templateUrl: './form-number.component.html',
|
||||
})
|
||||
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
|
||||
protected readonly Infinity = Infinity
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<h3 class="title" (click)="toggle()">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="s"
|
||||
iconLeft="tuiIconChevronDown"
|
||||
type="button"
|
||||
class="button"
|
||||
[class.button_open]="open"
|
||||
[style.border-radius.%]="100"
|
||||
[appearance]="invalid ? 'destructive' : 'secondary'"
|
||||
></button>
|
||||
<ng-content></ng-content>
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description"
|
||||
[content]="spec.description"
|
||||
(click.stop)="(0)"
|
||||
></tui-tooltip>
|
||||
</h3>
|
||||
|
||||
<tui-expand class="expand" [expanded]="open">
|
||||
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
|
||||
<form-group [spec]="spec.spec"></form-group>
|
||||
</div>
|
||||
</tui-expand>
|
||||
@@ -0,0 +1,41 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
margin: 0 0 -0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
@include transition(transform);
|
||||
|
||||
margin-right: 1rem;
|
||||
|
||||
&_open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.expand {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.g-form-group {
|
||||
padding-top: 0.75rem;
|
||||
|
||||
&_invalid::before,
|
||||
&_invalid::after {
|
||||
background: var(--tui-error-bg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
templateUrl: './form-object.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecObject
|
||||
|
||||
@Input()
|
||||
open = false
|
||||
|
||||
@Output()
|
||||
readonly openChange = new EventEmitter<boolean>()
|
||||
|
||||
private readonly container = inject(ControlContainer)
|
||||
|
||||
get invalid() {
|
||||
return !this.container.valid && this.container.touched
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open
|
||||
this.openChange.emit(this.open)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<tui-select
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="disabled"
|
||||
[readOnly]="readOnly"
|
||||
[tuiTextfieldCleaner]="!spec.required"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="selected"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<select
|
||||
tuiSelect
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-select>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-select',
|
||||
templateUrl: './form-select.component.html',
|
||||
})
|
||||
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
readonly disabledItemHandler = (item: string) =>
|
||||
Array.isArray(this.spec.disabled) &&
|
||||
this.spec.disabled.includes(this.inverted[item])
|
||||
|
||||
get disabled(): boolean {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
}
|
||||
|
||||
get selected(): string | null {
|
||||
return (this.value && this.spec.values[this.value]) || null
|
||||
}
|
||||
|
||||
set selected(value: string | null) {
|
||||
this.value = (value && this.inverted[value]) || null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<tui-input
|
||||
[tuiTextfieldCustomContent]="spec.masked || spec.generate ? toggle : ''"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<input
|
||||
tuiTextfield
|
||||
[class.masked]="spec.masked && masked"
|
||||
[placeholder]="spec.placeholder || ''"
|
||||
[attr.minLength]="spec.minLength"
|
||||
[attr.maxLength]="spec.maxLength"
|
||||
[attr.inputmode]="spec.inputmode"
|
||||
/>
|
||||
</tui-input>
|
||||
<ng-template #toggle>
|
||||
<button
|
||||
*ngIf="spec.generate"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Generate"
|
||||
size="xs"
|
||||
class="button"
|
||||
iconLeft="tuiIconRefreshCcw"
|
||||
(click)="generate()"
|
||||
></button>
|
||||
<button
|
||||
*ngIf="spec.masked"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Toggle masking"
|
||||
size="xs"
|
||||
class="button"
|
||||
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
|
||||
(click)="masked = !masked"
|
||||
></button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,8 @@
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT, utils } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-text',
|
||||
templateUrl: './form-text.component.html',
|
||||
styleUrls: ['./form-text.component.scss'],
|
||||
})
|
||||
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
|
||||
masked = true
|
||||
|
||||
generate() {
|
||||
this.value = utils.getDefaultString(this.spec.generate || '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<tui-text-area
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[expandable]="true"
|
||||
[rows]="6"
|
||||
[maxLength]="spec.maxLength"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
|
||||
</tui-text-area>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-textarea',
|
||||
templateUrl: './form-textarea.component.html',
|
||||
})
|
||||
export class FormTextareaComponent extends Control<
|
||||
CT.ValueSpecTextarea,
|
||||
string
|
||||
> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description || spec.disabled"
|
||||
[tuiHintContent]="spec | hint"
|
||||
></tui-tooltip>
|
||||
<tui-toggle
|
||||
size="l"
|
||||
[disabled]="!!spec.disabled || readOnly"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
></tui-toggle>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-toggle',
|
||||
templateUrl: './form-toggle.component.html',
|
||||
host: { class: 'g-toggle' },
|
||||
})
|
||||
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
<form-control
|
||||
[spec]="selectSpec"
|
||||
formControlName="selection"
|
||||
(tuiValueChanges)="onUnion($event)"
|
||||
></form-control>
|
||||
<tui-elastic-container class="g-form-group" formGroupName="value">
|
||||
<form-group
|
||||
class="group"
|
||||
[spec]="(union && spec.variants[union].spec) || {}"
|
||||
></form-group>
|
||||
</tui-elastic-container>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer, FormGroupName } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
|
||||
@Component({
|
||||
selector: 'form-union',
|
||||
templateUrl: './form-union.component.html',
|
||||
styleUrls: ['./form-union.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
viewProviders: [
|
||||
{
|
||||
provide: ControlContainer,
|
||||
useExisting: FormGroupName,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class FormUnionComponent implements OnChanges {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecUnion
|
||||
|
||||
selectSpec!: CT.ValueSpecSelect
|
||||
|
||||
private readonly form = inject(FormGroupName)
|
||||
private readonly formService = inject(FormService)
|
||||
|
||||
get union(): string {
|
||||
return this.form.value.selection
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
onUnion(union: string) {
|
||||
this.form.control.setControl(
|
||||
'value',
|
||||
this.formService.getFormGroup(
|
||||
union ? this.spec.variants[union].spec : {},
|
||||
),
|
||||
{
|
||||
emitEvent: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
|
||||
if (this.union) this.onUnion(this.union)
|
||||
}
|
||||
}
|
||||
109
web/projects/ui/src/app/components/form/form.module.ts
Normal file
109
web/projects/ui/src/app/components/form/form.module.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { MaskitoModule } from '@maskito/angular'
|
||||
import { TuiMapperPipeModule, TuiValueChangesModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiErrorModule,
|
||||
TuiExpandModule,
|
||||
TuiHintModule,
|
||||
TuiLinkModule,
|
||||
TuiModeModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiTooltipModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiAppearanceModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiElasticContainerModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputTimeModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiPromptModule,
|
||||
TuiSelectModule,
|
||||
TuiTagModule,
|
||||
TuiTextAreaModule,
|
||||
TuiToggleModule,
|
||||
} from '@taiga-ui/kit'
|
||||
|
||||
import { FormGroupComponent } from './form-group/form-group.component'
|
||||
import { FormTextComponent } from './form-text/form-text.component'
|
||||
import { FormToggleComponent } from './form-toggle/form-toggle.component'
|
||||
import { FormTextareaComponent } from './form-textarea/form-textarea.component'
|
||||
import { FormNumberComponent } from './form-number/form-number.component'
|
||||
import { FormSelectComponent } from './form-select/form-select.component'
|
||||
import { FormFileComponent } from './form-file/form-file.component'
|
||||
import { FormMultiselectComponent } from './form-multiselect/form-multiselect.component'
|
||||
import { FormUnionComponent } from './form-union/form-union.component'
|
||||
import { FormObjectComponent } from './form-object/form-object.component'
|
||||
import { FormArrayComponent } from './form-array/form-array.component'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
import { MustachePipe } from './mustache.pipe'
|
||||
import { ControlDirective } from './control.directive'
|
||||
import { FormColorComponent } from './form-color/form-color.component'
|
||||
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
|
||||
import { HintPipe } from './hint.pipe'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputFilesModule,
|
||||
TuiTextAreaModule,
|
||||
TuiSelectModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiToggleModule,
|
||||
TuiTooltipModule,
|
||||
TuiHintModule,
|
||||
TuiModeModule,
|
||||
TuiTagModule,
|
||||
TuiButtonModule,
|
||||
TuiExpandModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiLinkModule,
|
||||
TuiPromptModule,
|
||||
TuiErrorModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiValueChangesModule,
|
||||
TuiElasticContainerModule,
|
||||
MaskitoModule,
|
||||
TuiIconModule,
|
||||
TuiAppearanceModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputTimeModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiMapperPipeModule,
|
||||
],
|
||||
declarations: [
|
||||
FormGroupComponent,
|
||||
FormControlComponent,
|
||||
FormColorComponent,
|
||||
FormDatetimeComponent,
|
||||
FormTextComponent,
|
||||
FormToggleComponent,
|
||||
FormTextareaComponent,
|
||||
FormNumberComponent,
|
||||
FormSelectComponent,
|
||||
FormMultiselectComponent,
|
||||
FormFileComponent,
|
||||
FormUnionComponent,
|
||||
FormObjectComponent,
|
||||
FormArrayComponent,
|
||||
MustachePipe,
|
||||
HintPipe,
|
||||
ControlDirective,
|
||||
],
|
||||
exports: [FormGroupComponent],
|
||||
})
|
||||
export class FormModule {}
|
||||
21
web/projects/ui/src/app/components/form/hint.pipe.ts
Normal file
21
web/projects/ui/src/app/components/form/hint.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
name: 'hint',
|
||||
})
|
||||
export class HintPipe implements PipeTransform {
|
||||
transform(spec: CT.ValueSpec): string {
|
||||
const hint = []
|
||||
|
||||
if (spec.description) {
|
||||
hint.push(spec.description)
|
||||
}
|
||||
|
||||
if ('disabled' in spec && typeof spec.disabled === 'string') {
|
||||
hint.push(`Disabled: ${spec.disabled}`)
|
||||
}
|
||||
|
||||
return hint.join('\n\n')
|
||||
}
|
||||
}
|
||||
19
web/projects/ui/src/app/components/form/invalid.service.ts
Normal file
19
web/projects/ui/src/app/components/form/invalid.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ControlDirective } from './control.directive'
|
||||
|
||||
@Injectable()
|
||||
export class InvalidService {
|
||||
private readonly controls: ControlDirective[] = []
|
||||
|
||||
scrollIntoView() {
|
||||
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
|
||||
}
|
||||
|
||||
add(control: ControlDirective) {
|
||||
this.controls.push(control)
|
||||
}
|
||||
|
||||
remove(control: ControlDirective) {
|
||||
this.controls.splice(this.controls.indexOf(control), 1)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user