@nan0web/ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.datasets/README.dataset.jsonl +12 -0
- package/.editorconfig +20 -0
- package/CONTRIBUTING.md +42 -0
- package/LICENSE +15 -0
- package/README.md +238 -0
- package/docs/uk/README.md +240 -0
- package/package.json +64 -0
- package/playground/User.js +52 -0
- package/playground/currency.exchange.js +48 -0
- package/playground/i18n/index.js +21 -0
- package/playground/i18n/uk.js +53 -0
- package/playground/language.form.js +25 -0
- package/playground/main.js +72 -0
- package/playground/registration.form.js +58 -0
- package/playground/topup.telephone.js +62 -0
- package/src/App/Command/Options.js +78 -0
- package/src/App/Command/index.js +9 -0
- package/src/App/Core/CoreApp.js +129 -0
- package/src/App/Core/UI.js +116 -0
- package/src/App/Core/Widget.js +67 -0
- package/src/App/Core/index.js +11 -0
- package/src/App/Scenario.js +45 -0
- package/src/App/User/Command/Message.js +44 -0
- package/src/App/User/Command/Options.js +48 -0
- package/src/App/User/Command/index.js +11 -0
- package/src/App/User/UserApp.js +73 -0
- package/src/App/User/UserApp.test.js +56 -0
- package/src/App/User/UserUI.js +20 -0
- package/src/App/User/UserUI.test.js +51 -0
- package/src/App/User/index.js +15 -0
- package/src/App/index.js +22 -0
- package/src/Component/Process/Input.js +70 -0
- package/src/Component/Process/Process.js +26 -0
- package/src/Component/Process/index.js +5 -0
- package/src/Component/Welcome/Input.js +50 -0
- package/src/Component/Welcome/Welcome.js +26 -0
- package/src/Component/Welcome/index.js +5 -0
- package/src/Component/index.js +9 -0
- package/src/Frame/Frame.js +591 -0
- package/src/Frame/Frame.test.js +429 -0
- package/src/Frame/Props.js +102 -0
- package/src/Locale.js +119 -0
- package/src/Model/User/User.js +56 -0
- package/src/Model/index.js +7 -0
- package/src/README.md.js +371 -0
- package/src/StdIn.js +111 -0
- package/src/StdOut.js +99 -0
- package/src/View/RenderOptions.js +48 -0
- package/src/View/View.js +289 -0
- package/src/View/View.test.js +77 -0
- package/src/core/Form/Form.js +289 -0
- package/src/core/Form/Form.test.js +116 -0
- package/src/core/Form/Input.js +116 -0
- package/src/core/Form/Input.test.js +58 -0
- package/src/core/Form/Message.js +86 -0
- package/src/core/Form/Message.test.js +54 -0
- package/src/core/Form/index.js +11 -0
- package/src/core/InputAdapter.js +41 -0
- package/src/core/InputAdapter.test.js +35 -0
- package/src/core/Message/InputMessage.js +119 -0
- package/src/core/Message/InputMessage.test.js +45 -0
- package/src/core/Message/Message.js +77 -0
- package/src/core/Message/Message.test.js +58 -0
- package/src/core/Message/OutputMessage.js +143 -0
- package/src/core/Message/OutputMessage.test.js +61 -0
- package/src/core/Message/index.js +7 -0
- package/src/core/OutputAdapter.js +50 -0
- package/src/core/OutputAdapter.test.js +35 -0
- package/src/core/Stream.js +71 -0
- package/src/core/Stream.test.js +78 -0
- package/src/core/StreamEntry.js +59 -0
- package/src/core/index.js +13 -0
- package/src/functions.js +38 -0
- package/src/index.js +34 -0
- package/src/index.test.js +14 -0
- package/src/models/SimpleUser.js +18 -0
- package/stories/App/AppView.js +15 -0
- package/stories/App/AppView.test.js +22 -0
- package/stories/App/RenderOptions.js +14 -0
- package/stories/nodejs/interface.test.js +27 -0
- package/system.md +187 -0
- package/system1.md +137 -0
- package/task.md +181 -0
- package/tsconfig.json +23 -0
- package/types/App/Command/Options.d.ts +46 -0
- package/types/App/Command/index.d.ts +8 -0
- package/types/App/Core/CoreApp.d.ts +70 -0
- package/types/App/Core/UI.d.ts +49 -0
- package/types/App/Core/Widget.d.ts +40 -0
- package/types/App/Core/index.d.ts +10 -0
- package/types/App/Scenario.d.ts +26 -0
- package/types/App/User/Command/Message.d.ts +30 -0
- package/types/App/User/Command/Options.d.ts +27 -0
- package/types/App/User/Command/index.d.ts +8 -0
- package/types/App/User/UserApp.d.ts +31 -0
- package/types/App/User/UserUI.d.ts +18 -0
- package/types/App/User/index.d.ts +12 -0
- package/types/App/index.d.ts +14 -0
- package/types/Component/Process/Input.d.ts +48 -0
- package/types/Component/Process/Process.d.ts +13 -0
- package/types/Component/Process/index.d.ts +4 -0
- package/types/Component/Welcome/Input.d.ts +34 -0
- package/types/Component/Welcome/Welcome.d.ts +13 -0
- package/types/Component/Welcome/index.d.ts +4 -0
- package/types/Component/index.d.ts +8 -0
- package/types/Frame/Frame.d.ts +186 -0
- package/types/Frame/Props.d.ts +77 -0
- package/types/Locale.d.ts +55 -0
- package/types/Model/User/User.d.ts +36 -0
- package/types/Model/index.d.ts +6 -0
- package/types/StdIn.d.ts +62 -0
- package/types/StdOut.d.ts +52 -0
- package/types/View/RenderOptions.d.ts +29 -0
- package/types/View/View.d.ts +115 -0
- package/types/core/Form/Form.d.ts +123 -0
- package/types/core/Form/Input.d.ts +69 -0
- package/types/core/Form/Message.d.ts +28 -0
- package/types/core/Form/index.d.ts +5 -0
- package/types/core/InputAdapter.d.ts +28 -0
- package/types/core/Message/InputMessage.d.ts +71 -0
- package/types/core/Message/Message.d.ts +50 -0
- package/types/core/Message/OutputMessage.d.ts +53 -0
- package/types/core/Message/index.d.ts +5 -0
- package/types/core/OutputAdapter.d.ts +33 -0
- package/types/core/Stream.d.ts +27 -0
- package/types/core/StreamEntry.d.ts +45 -0
- package/types/core/index.d.ts +9 -0
- package/types/functions.d.ts +3 -0
- package/types/index.d.ts +20 -0
- package/types/models/SimpleUser.d.ts +21 -0
- package/vitest.config.js +26 -0
package/src/View/View.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { empty, equal, typeOf } from "@nan0web/types"
|
|
2
|
+
import Frame, { FrameRenderMethod } from "../Frame/Frame.js"
|
|
3
|
+
import Locale from "../Locale.js"
|
|
4
|
+
import StdOut from "../StdOut.js"
|
|
5
|
+
import StdIn from "../StdIn.js"
|
|
6
|
+
import InputMessage from "../core/Message/InputMessage.js"
|
|
7
|
+
import RenderOptions from "./RenderOptions.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} ComponentFn
|
|
11
|
+
* @property {string} name
|
|
12
|
+
* @property {(input: InputMessage) => Promise<any>} ask
|
|
13
|
+
* @property {Function} bind
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export default class View {
|
|
17
|
+
/** @type {typeof RenderOptions} */
|
|
18
|
+
static RenderOptions = RenderOptions
|
|
19
|
+
/** @type {typeof FrameRenderMethod} */
|
|
20
|
+
static RenderMethod = Frame.RenderMethod
|
|
21
|
+
/** @type {StdIn} */
|
|
22
|
+
stdin
|
|
23
|
+
/** @type {StdOut} */
|
|
24
|
+
stdout
|
|
25
|
+
/** @type {number} */
|
|
26
|
+
startedAt
|
|
27
|
+
/** @type {Frame} */
|
|
28
|
+
frame
|
|
29
|
+
/** @type {Locale} */
|
|
30
|
+
locale
|
|
31
|
+
/** @type {Map} */
|
|
32
|
+
vocab
|
|
33
|
+
/** @type {number[]} */
|
|
34
|
+
windowSize
|
|
35
|
+
/** @type {Map<string, ComponentFn>} */
|
|
36
|
+
components
|
|
37
|
+
/** @type {string} */
|
|
38
|
+
renderMethod
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} [input]
|
|
41
|
+
* @param {StdIn} [input.stdin]
|
|
42
|
+
* @param {StdOut} [input.stdout]
|
|
43
|
+
* @param {number} [input.startedAt]
|
|
44
|
+
* @param {Frame} [input.frame]
|
|
45
|
+
* @param {Locale} [input.locale]
|
|
46
|
+
* @param {Map} [input.vocab]
|
|
47
|
+
* @param {number[]} [input.windowSize]
|
|
48
|
+
* @param {Map<string, ComponentFn>} [input.components]
|
|
49
|
+
* @param {string} [input.renderMethod]
|
|
50
|
+
*/
|
|
51
|
+
constructor(input = {}) {
|
|
52
|
+
const {
|
|
53
|
+
stdin = new StdIn(),
|
|
54
|
+
stdout = new StdOut(),
|
|
55
|
+
startedAt = Date.now(),
|
|
56
|
+
frame = new Frame(),
|
|
57
|
+
locale = Locale.from("uk-UA"),
|
|
58
|
+
vocab = new Map(),
|
|
59
|
+
windowSize = [0, 0],
|
|
60
|
+
components = new Map(),
|
|
61
|
+
renderMethod = Frame.RenderMethod.VISIBLE,
|
|
62
|
+
} = input
|
|
63
|
+
this.stdin = stdin
|
|
64
|
+
this.stdout = stdout
|
|
65
|
+
this.startedAt = startedAt
|
|
66
|
+
this.frame = frame
|
|
67
|
+
this.locale = locale
|
|
68
|
+
this.vocab = vocab
|
|
69
|
+
this.windowSize = null === windowSize ? this.stdout.getWindowSize() : windowSize
|
|
70
|
+
this.components = components
|
|
71
|
+
this.renderMethod = renderMethod
|
|
72
|
+
if (!empty(frame)) {
|
|
73
|
+
this.render(1)(this.frame)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
get empty() {
|
|
77
|
+
return empty(this.frame)
|
|
78
|
+
}
|
|
79
|
+
get RenderMethod() {
|
|
80
|
+
return /** @type {typeof View} */ (this.constructor).RenderMethod
|
|
81
|
+
}
|
|
82
|
+
get RenderOptions() {
|
|
83
|
+
return /** @type {typeof View} */ (this.constructor).RenderOptions
|
|
84
|
+
}
|
|
85
|
+
getWindowSize() {
|
|
86
|
+
return equal(this.windowSize, [0, 0]) ? this.stdout.getWindowSize() : this.windowSize
|
|
87
|
+
}
|
|
88
|
+
setWindowSize(width, height) {
|
|
89
|
+
this.windowSize = [width, height]
|
|
90
|
+
}
|
|
91
|
+
startTimer() {
|
|
92
|
+
this.startedAt = Date.now()
|
|
93
|
+
}
|
|
94
|
+
spent(checkpoint = this.startedAt) {
|
|
95
|
+
return Math.round((Date.now() - checkpoint) / 1000)
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* @param {boolean | number | Function | ComponentFn} [shouldRender=0]
|
|
99
|
+
* @param {RenderOptions} [options]
|
|
100
|
+
* @returns {(value: Frame|string|string[], ...args: any) => Frame}
|
|
101
|
+
*/
|
|
102
|
+
render(shouldRender = 0, options = new this.RenderOptions()) {
|
|
103
|
+
const [width, height] = this.getWindowSize()
|
|
104
|
+
options = this.RenderOptions.from({
|
|
105
|
+
...options,
|
|
106
|
+
renderMethod: this.renderMethod, width, height,
|
|
107
|
+
// @ts-ignore
|
|
108
|
+
})
|
|
109
|
+
const renderFn = "function" === typeof shouldRender // no errors.
|
|
110
|
+
// const renderFn = typeOf(Function)(shouldRender) // Property 'bind' does not exist on type 'number | boolean | Function'.
|
|
111
|
+
? shouldRender.bind(this) : "string" === typeof shouldRender
|
|
112
|
+
? this.components.get(shouldRender)?.bind(this)
|
|
113
|
+
: null
|
|
114
|
+
|
|
115
|
+
return (value, ...args) => {
|
|
116
|
+
if (renderFn) {
|
|
117
|
+
/** @type {Frame} */
|
|
118
|
+
let rendered = renderFn.bind(this)(value, ...args)
|
|
119
|
+
if (!(rendered instanceof Frame)) {
|
|
120
|
+
rendered = new Frame({
|
|
121
|
+
value: rendered,
|
|
122
|
+
renderMethod: options.renderMethod,
|
|
123
|
+
width: options.width,
|
|
124
|
+
height: options.height,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
rendered = View.fixFrame(rendered, options)
|
|
128
|
+
rendered = rendered.transform(this.t.bind(this))
|
|
129
|
+
rendered.render()
|
|
130
|
+
if (options.render) {
|
|
131
|
+
this.stdout.write(String(rendered))
|
|
132
|
+
this.frame = rendered
|
|
133
|
+
}
|
|
134
|
+
return rendered
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let frame = Frame.from({ ...options, value })
|
|
138
|
+
frame = View.fixFrame(frame, options)
|
|
139
|
+
let clearFrame = false
|
|
140
|
+
if (String(frame.value[0] ?? "") === Frame.BOF) {
|
|
141
|
+
frame.value = frame.value.slice(1)
|
|
142
|
+
clearFrame = true
|
|
143
|
+
}
|
|
144
|
+
let translated = options.translateFrame ? frame.transform(this.t.bind(this)) : frame
|
|
145
|
+
translated = View.fixFrame(translated, options)
|
|
146
|
+
|
|
147
|
+
let rendered = translated
|
|
148
|
+
rendered.render()
|
|
149
|
+
if (shouldRender) {
|
|
150
|
+
if (clearFrame) {
|
|
151
|
+
const distance = options.height - frame.value.length
|
|
152
|
+
this.stdout.write(Frame.BOF)
|
|
153
|
+
for (let i = 0; i < distance; i++) {
|
|
154
|
+
this.stdout.write(Frame.EOL)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this.stdout.write(Frame.RESET + String(rendered))
|
|
158
|
+
this.frame = rendered
|
|
159
|
+
}
|
|
160
|
+
return rendered
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clear(shouldRender = 0) {
|
|
165
|
+
const [width, height] = this.windowSize
|
|
166
|
+
const frame = new Frame({ width, height, renderMethod: Frame.RenderMethod.REPLACE })
|
|
167
|
+
frame.value = []
|
|
168
|
+
for (let i = 0; i < height; i++) frame.value.push([])
|
|
169
|
+
return this.render(shouldRender)(frame)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
progress(shouldRender = false) {
|
|
173
|
+
return (value) => {
|
|
174
|
+
const frame = Frame.from(value)
|
|
175
|
+
frame.renderMethod = Frame.RenderMethod.REPLACE
|
|
176
|
+
return this.render(!!shouldRender)(frame)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
t(value) {
|
|
181
|
+
if (typeOf(Array)(value)) {
|
|
182
|
+
value = value.map(row => {
|
|
183
|
+
if (typeOf(Array)(row)) {
|
|
184
|
+
return row.map(col => {
|
|
185
|
+
return this.vocab.has(col) ? this.vocab.get(col) : col
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
return this.vocab.has(row) ? this.vocab.get(row) : row
|
|
189
|
+
})
|
|
190
|
+
return value
|
|
191
|
+
}
|
|
192
|
+
return this.vocab.has(value) ? this.vocab.get(value) : value
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
debug(...args) {
|
|
196
|
+
return this.render(1)(
|
|
197
|
+
[StdOut.STYLES.dim,
|
|
198
|
+
"Debug: ", args.join(" "), Frame.EOL, StdOut.RESET],
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
info(...args) {
|
|
203
|
+
return this.render(1)(
|
|
204
|
+
[StdOut.COLORS.green,
|
|
205
|
+
"Info : ", args.join(" "), Frame.EOL, StdOut.RESET],
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
warn(...args) {
|
|
210
|
+
return this.render(1)(
|
|
211
|
+
[StdOut.COLORS.yellow,
|
|
212
|
+
"Warn : ", args.join(" "), Frame.EOL, StdOut.RESET],
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
error(...args) {
|
|
217
|
+
return this.render(1)(
|
|
218
|
+
[StdOut.COLORS.red, StdOut.STYLES.bold,
|
|
219
|
+
"Error: ", args.join(" "), Frame.EOL, StdOut.RESET],
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* @param {string} name
|
|
225
|
+
* @param {ComponentFn} component
|
|
226
|
+
*/
|
|
227
|
+
register(name, component) {
|
|
228
|
+
if (undefined === component && "function" === typeof name) {
|
|
229
|
+
component = name
|
|
230
|
+
name = component.name
|
|
231
|
+
}
|
|
232
|
+
this.components.set(name, component)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {string} name
|
|
237
|
+
*/
|
|
238
|
+
unregister(name) {
|
|
239
|
+
this.components.delete(name)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {string} name
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
has(name) {
|
|
247
|
+
return this.components.has(name)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {string} name
|
|
252
|
+
* @returns {ComponentFn | undefined}
|
|
253
|
+
*/
|
|
254
|
+
get(name) {
|
|
255
|
+
return this.components.get(name)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {InputMessage} input
|
|
260
|
+
* @returns {Promise<InputMessage | null>}
|
|
261
|
+
*/
|
|
262
|
+
async ask(input) {
|
|
263
|
+
const name = input.constructor.name.replace(/Input$/, "")
|
|
264
|
+
const component = this.get(name)
|
|
265
|
+
if (component) {
|
|
266
|
+
return await component.ask.apply(this, [input])
|
|
267
|
+
}
|
|
268
|
+
let result = null
|
|
269
|
+
do {
|
|
270
|
+
const answer = await this.stdin.read()
|
|
271
|
+
result = /** @type {typeof InputMessage} */ (input.constructor).from(answer)
|
|
272
|
+
} while (!result.isValid && !result.escaped)
|
|
273
|
+
return result.escaped ? null : result
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {Frame} frame
|
|
278
|
+
* @param {RenderOptions} [options]
|
|
279
|
+
* @returns {Frame}
|
|
280
|
+
*/
|
|
281
|
+
static fixFrame(frame, options = new RenderOptions()) {
|
|
282
|
+
if (options.resizeToView && !equal(options.width, frame.width, options.height, frame.height)) {
|
|
283
|
+
frame.setWindowSize(options.width, options.height)
|
|
284
|
+
}
|
|
285
|
+
// @todo add multiline visibility, for instance extended frame row into rows if it's wider than width.
|
|
286
|
+
return frame
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it } from "node:test"
|
|
2
|
+
import { strict as assert } from "node:assert"
|
|
3
|
+
import stringWidth from "string-width"
|
|
4
|
+
import View from "./View.js"
|
|
5
|
+
import Frame from "../Frame/Frame.js"
|
|
6
|
+
import FrameProps from "../Frame/Props.js"
|
|
7
|
+
import Welcome from "../Component/Welcome/index.js"
|
|
8
|
+
|
|
9
|
+
describe("View", () => {
|
|
10
|
+
it("should create empty instance", () => {
|
|
11
|
+
const view = new View()
|
|
12
|
+
assert.ok(view)
|
|
13
|
+
})
|
|
14
|
+
it("should print frame", () => {
|
|
15
|
+
const view = new View()
|
|
16
|
+
view.render(1)(["Hello"])
|
|
17
|
+
assert.equal(String(view.frame), Frame.CLEAR_LINE + "\r" + "Hello")
|
|
18
|
+
const value = view.render(0)(["No output"])
|
|
19
|
+
assert.deepStrictEqual({ ...value }, {
|
|
20
|
+
imprint: Frame.CLEAR_LINE + "\r" + "No output",
|
|
21
|
+
value: [["No output"]],
|
|
22
|
+
width: 144,
|
|
23
|
+
height: 33,
|
|
24
|
+
renderMethod: "visible",
|
|
25
|
+
defaultProps: new FrameProps(),
|
|
26
|
+
})
|
|
27
|
+
view.render(() => (["fn result"]))(["Even with a function"])
|
|
28
|
+
assert.equal(String(view.frame), Frame.CLEAR_LINE + "\r" + "fn result")
|
|
29
|
+
assert.deepStrictEqual(view.stdout.stream, [
|
|
30
|
+
Frame.RESET + Frame.CLEAR_LINE + "\r" + "Hello",
|
|
31
|
+
Frame.CLEAR_LINE + "\r" + "fn result"
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
it("should print frame with render method set to append", () => {
|
|
35
|
+
const view = new View({ renderMethod: View.RenderMethod.APPEND })
|
|
36
|
+
view.render(1)(["Hello"])
|
|
37
|
+
assert.equal(String(view.frame), "Hello" + " ".repeat(139))
|
|
38
|
+
view.render(1)(["world"])
|
|
39
|
+
assert.equal(String(view.frame), "world" + " ".repeat(139))
|
|
40
|
+
const value = view.render(0)(["No output"])
|
|
41
|
+
assert.deepStrictEqual({ ...value }, {
|
|
42
|
+
imprint: "No output" + " ".repeat(144 - "No output".length),
|
|
43
|
+
value: [["No output"]],
|
|
44
|
+
width: 144,
|
|
45
|
+
height: 33,
|
|
46
|
+
renderMethod: View.RenderMethod.APPEND,
|
|
47
|
+
defaultProps: new FrameProps(),
|
|
48
|
+
})
|
|
49
|
+
view.render(() => (["fn result"]))(["Even with a function"])
|
|
50
|
+
assert.equal(String(view.frame), "fn result" + " ".repeat(144 - 9))
|
|
51
|
+
assert.deepStrictEqual(view.stdout.stream, [
|
|
52
|
+
Frame.RESET + "Hello",
|
|
53
|
+
Frame.RESET + "world",
|
|
54
|
+
"fn result"
|
|
55
|
+
].map(
|
|
56
|
+
row => row + " ".repeat(144 - stringWidth(row))
|
|
57
|
+
))
|
|
58
|
+
})
|
|
59
|
+
it("should render Welcome component", () => {
|
|
60
|
+
const view = new View({
|
|
61
|
+
renderMethod: View.RenderMethod.APPEND,
|
|
62
|
+
width: 144,
|
|
63
|
+
height: 33,
|
|
64
|
+
})
|
|
65
|
+
view.register(Welcome)
|
|
66
|
+
const frame = view.render("Welcome")({ user: { name: "View" } })
|
|
67
|
+
assert.ok(frame instanceof Frame)
|
|
68
|
+
const expected = [
|
|
69
|
+
"Welcome View!",
|
|
70
|
+
"What can we do today great?",
|
|
71
|
+
"",
|
|
72
|
+
].map(
|
|
73
|
+
row => (row + " ".repeat(144 - row.length))
|
|
74
|
+
).join("\n")
|
|
75
|
+
assert.deepStrictEqual(view.stdout.stream, [expected])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import FormMessage from "./Message.js"
|
|
2
|
+
import FormInput from "./Input.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Abstract form for data entry.
|
|
6
|
+
*
|
|
7
|
+
* @class UIForm
|
|
8
|
+
* @extends FormMessage
|
|
9
|
+
* @property {FormInput[]} fields - Form fields.
|
|
10
|
+
* @property {Object} state - Current form state (field values).
|
|
11
|
+
* @property {string} title - Form title.
|
|
12
|
+
* @property {Object} schema - Validation schema (optional).
|
|
13
|
+
*/
|
|
14
|
+
export default class UIForm extends FormMessage {
|
|
15
|
+
/** @type {FormInput[]} */ fields = []
|
|
16
|
+
/** @type {Object} */ state = {}
|
|
17
|
+
/** @type {string} */ title = ''
|
|
18
|
+
/** @type {Object} */ schema = {}
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------------ */
|
|
21
|
+
/* static validator registry */
|
|
22
|
+
/* ------------------------------------------------------------------ */
|
|
23
|
+
|
|
24
|
+
/** @type {Object<string,Function>} */
|
|
25
|
+
static _validators = {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a custom validator that can be referenced by name in a schema.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} name - Identifier used in schema.validator.
|
|
31
|
+
* @param {(value:any)=>true|string} fn - Function returns true if valid,
|
|
32
|
+
* otherwise returns an error message.
|
|
33
|
+
*/
|
|
34
|
+
static addValidator(name, fn) {
|
|
35
|
+
if (typeof name !== "string" || typeof fn !== "function") {
|
|
36
|
+
throw new Error("Validator name must be a string and fn must be a function")
|
|
37
|
+
}
|
|
38
|
+
UIForm._validators[name] = fn
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a new UIForm.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} [props={}] - Form properties.
|
|
45
|
+
* @param {string} [props.title] - Form title.
|
|
46
|
+
* @param {FormInput[]} [props.fields=[]] - Form fields.
|
|
47
|
+
* @param {Object} [props.state={}] - Initial form state.
|
|
48
|
+
* @param {Object} [props.schema] - Validation schema.
|
|
49
|
+
*/
|
|
50
|
+
constructor(props = {}) {
|
|
51
|
+
super(props)
|
|
52
|
+
|
|
53
|
+
const {
|
|
54
|
+
title = '',
|
|
55
|
+
fields = [],
|
|
56
|
+
state = {},
|
|
57
|
+
schema = {},
|
|
58
|
+
...rest
|
|
59
|
+
} = props
|
|
60
|
+
|
|
61
|
+
// Normalise fields
|
|
62
|
+
this.fields = fields.map(f => FormInput.from(f))
|
|
63
|
+
this.title = title
|
|
64
|
+
this.state = { ...state }
|
|
65
|
+
this.schema = schema
|
|
66
|
+
|
|
67
|
+
// Update meta with form data
|
|
68
|
+
this.meta = {
|
|
69
|
+
title: this.title,
|
|
70
|
+
fields: this.fields.map(f => f.toJSON ? f.toJSON() : f),
|
|
71
|
+
initialState: this.state
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ------------------------------------------------------------------ */
|
|
76
|
+
/* API */
|
|
77
|
+
/* ------------------------------------------------------------------ */
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns a new UIForm instance with updated state.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} data - Partial state to merge.
|
|
83
|
+
* @returns {UIForm}
|
|
84
|
+
*/
|
|
85
|
+
setData(data) {
|
|
86
|
+
return new UIForm({
|
|
87
|
+
...this,
|
|
88
|
+
state: { ...this.state, ...data }
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Retrieves a field definition by its name.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} name - Field name.
|
|
96
|
+
* @returns {FormInput|undefined}
|
|
97
|
+
*/
|
|
98
|
+
getField(name) {
|
|
99
|
+
return this.fields.find(f => f.name === name)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns current form values.
|
|
104
|
+
*
|
|
105
|
+
* @returns {Object}
|
|
106
|
+
*/
|
|
107
|
+
getValues() {
|
|
108
|
+
return { ...this.state }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validates the entire form.
|
|
113
|
+
*
|
|
114
|
+
* @returns {{isValid: boolean, errors: Object}} Validation result.
|
|
115
|
+
*/
|
|
116
|
+
validate() {
|
|
117
|
+
const errors = {}
|
|
118
|
+
let isValid = true
|
|
119
|
+
|
|
120
|
+
this.fields.forEach((field) => {
|
|
121
|
+
const fieldValue = this.state[field.name]
|
|
122
|
+
|
|
123
|
+
// Required validation based on field definition or schema
|
|
124
|
+
if (field.required && (fieldValue === '' || fieldValue === null || fieldValue === undefined)) {
|
|
125
|
+
errors[field.name] = 'This field is required'
|
|
126
|
+
isValid = false
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validation via schema (if provided) or field type
|
|
131
|
+
const { isValid: fieldValid, errors: fieldErrors } = this.validateField(field.name, fieldValue)
|
|
132
|
+
|
|
133
|
+
if (!fieldValid) {
|
|
134
|
+
Object.assign(errors, fieldErrors)
|
|
135
|
+
isValid = false
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return { isValid, errors }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validates a single field.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} fieldName - Name of the field.
|
|
146
|
+
* @param {*} value - Value to validate.
|
|
147
|
+
* @returns {{isValid: boolean, errors: Object}}
|
|
148
|
+
*/
|
|
149
|
+
validateField(fieldName, value) {
|
|
150
|
+
const field = this.getField(fieldName)
|
|
151
|
+
if (!field) return { isValid: false, errors: { [fieldName]: 'Field not found' } }
|
|
152
|
+
|
|
153
|
+
// Merge schema from UIForm.schema with field.type if schema does not specify a type
|
|
154
|
+
const schemaFromField = this.schema?.[fieldName] ? { ...this.schema[fieldName] } : {}
|
|
155
|
+
if (!schemaFromField.type && field.type) {
|
|
156
|
+
schemaFromField.type = field.type
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return this.validateValue(fieldName, value, schemaFromField)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validates a value against a schema.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} fieldName - Name of the field.
|
|
166
|
+
* @param {*} value - Value to validate.
|
|
167
|
+
* @param {Object} schema - Validation schema.
|
|
168
|
+
* @returns {{isValid: boolean, errors: Object}}
|
|
169
|
+
*/
|
|
170
|
+
validateValue(fieldName, value, schema) {
|
|
171
|
+
const errors = {}
|
|
172
|
+
let isValid = true
|
|
173
|
+
|
|
174
|
+
// Required rule (if defined in schema)
|
|
175
|
+
if (schema.required && (value === '' || value === null || value === undefined)) {
|
|
176
|
+
errors[fieldName] = 'This field is required'
|
|
177
|
+
isValid = false
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (schema.minLength && value && value.length < schema.minLength) {
|
|
181
|
+
errors[fieldName] = `Minimum length is ${schema.minLength}`
|
|
182
|
+
isValid = false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (schema.maxLength && value && value.length > schema.maxLength) {
|
|
186
|
+
errors[fieldName] = `Maximum length is ${schema.maxLength}`
|
|
187
|
+
isValid = false
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (schema.pattern && value && !schema.pattern.test(value)) {
|
|
191
|
+
errors[fieldName] = schema.errorMessage || 'Invalid format'
|
|
192
|
+
isValid = false
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Type validation
|
|
196
|
+
if (schema.type) {
|
|
197
|
+
if (schema.type === 'email') {
|
|
198
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
199
|
+
if (value && !emailRegex.test(value)) {
|
|
200
|
+
errors[fieldName] = 'Invalid email format'
|
|
201
|
+
isValid = false
|
|
202
|
+
}
|
|
203
|
+
} else if (schema.type === 'number') {
|
|
204
|
+
if (value !== '' && isNaN(Number(value))) {
|
|
205
|
+
errors[fieldName] = 'Must be a number'
|
|
206
|
+
isValid = false
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Custom validator – can be a function or a string referencing a static validator
|
|
212
|
+
if (schema.validator) {
|
|
213
|
+
let result
|
|
214
|
+
if (typeof schema.validator === 'function') {
|
|
215
|
+
result = schema.validator(value)
|
|
216
|
+
} else if (typeof schema.validator === 'string') {
|
|
217
|
+
const fn = UIForm._validators[schema.validator]
|
|
218
|
+
if (!fn) {
|
|
219
|
+
throw new Error(`Validator "${schema.validator}" not registered`)
|
|
220
|
+
}
|
|
221
|
+
result = fn(value)
|
|
222
|
+
}
|
|
223
|
+
if (result !== true) {
|
|
224
|
+
errors[fieldName] = result || 'Invalid value'
|
|
225
|
+
isValid = false
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { isValid, errors }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Serialises the form to a plain JSON object.
|
|
234
|
+
*
|
|
235
|
+
* @returns {Object}
|
|
236
|
+
*/
|
|
237
|
+
toJSON() {
|
|
238
|
+
return {
|
|
239
|
+
id: this.id,
|
|
240
|
+
type: this.type,
|
|
241
|
+
time: this.time.toISOString(),
|
|
242
|
+
title: this.title,
|
|
243
|
+
fields: this.fields.map(f => f.toJSON ? f.toJSON() : f),
|
|
244
|
+
state: this.state,
|
|
245
|
+
meta: this.meta
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param {*} input
|
|
251
|
+
* @returns {UIForm}
|
|
252
|
+
*/
|
|
253
|
+
static from(input) {
|
|
254
|
+
if (input instanceof UIForm) return input
|
|
255
|
+
return new UIForm(input)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Auto‑generates form fields from a plain object.
|
|
260
|
+
*
|
|
261
|
+
* @param {Object} data - Example data object; its own enumerable keys become field names.
|
|
262
|
+
* @param {Object<string, Partial<import("./Input.js").default>>} [overrides={}]
|
|
263
|
+
* Optional per‑field overrides (e.g. type, required, label).
|
|
264
|
+
*
|
|
265
|
+
* @returns {UIForm} Form with Array of `FormInput` instances as form.fields
|
|
266
|
+
*
|
|
267
|
+
* Example:
|
|
268
|
+
* const fields = generateFieldsFromObject({ name: "", age: 0 }, {
|
|
269
|
+
* age: { type: FormInput.TYPES.NUMBER, required: true }
|
|
270
|
+
* })
|
|
271
|
+
*/
|
|
272
|
+
static parse(data, overrides = {}) {
|
|
273
|
+
const fields = Object.keys(data).map((name) => {
|
|
274
|
+
const custom = overrides[name] || {}
|
|
275
|
+
const label = custom.label ?? name.charAt(0).toUpperCase() + name.slice(1)
|
|
276
|
+
return new FormInput({
|
|
277
|
+
name,
|
|
278
|
+
label,
|
|
279
|
+
type: custom.type ?? FormInput.TYPES.TEXT,
|
|
280
|
+
required: !!custom.required,
|
|
281
|
+
placeholder: custom.placeholder ?? "",
|
|
282
|
+
options: custom.options ?? [],
|
|
283
|
+
validator: custom.validator ?? undefined,
|
|
284
|
+
defaultValue: custom.defaultValue ?? "",
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
return new UIForm({ fields })
|
|
288
|
+
}
|
|
289
|
+
}
|