@nan0web/ui-cli 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.
@@ -0,0 +1,37 @@
1
+ import { UIForm, FormInput } from '@nan0web/ui'
2
+
3
+ /**
4
+ * Creates an address information form
5
+ * @param {Function} t - Translation function
6
+ * @returns {UIForm} Address form
7
+ */
8
+ export function createAddressForm(t) {
9
+ return new UIForm({
10
+ title: t('Address information'),
11
+ id: 'address-form',
12
+ state: {},
13
+ fields: [
14
+ new FormInput({
15
+ name: 'street',
16
+ label: t('Street'),
17
+ required: true
18
+ }),
19
+ new FormInput({
20
+ name: 'city',
21
+ label: t('City'),
22
+ required: true
23
+ }),
24
+ new FormInput({
25
+ name: 'postalCode',
26
+ label: t('Postal code'),
27
+ type: FormInput.TYPES.TEXT,
28
+ required: false
29
+ }),
30
+ new FormInput({
31
+ name: 'country',
32
+ label: t('Country'),
33
+ required: true
34
+ })
35
+ ]
36
+ })
37
+ }
@@ -0,0 +1,26 @@
1
+ import { UIForm, FormInput } from '@nan0web/ui'
2
+
3
+ /**
4
+ * Creates an age confirmation form
5
+ * @param {Function} t - Translation function
6
+ * @returns {UIForm} Age confirmation form
7
+ */
8
+ export function createAgeForm(t) {
9
+ return new UIForm({
10
+ title: t('Age confirmation form'),
11
+ id: 'age-confirmation',
12
+ state: {},
13
+ fields: [
14
+ new FormInput({
15
+ name: 'age',
16
+ label: t('Please enter your age'),
17
+ type: FormInput.TYPES.NUMBER,
18
+ validator: (value) => {
19
+ const num = Number(value)
20
+ return num >= 18 ? null : t('You must be at least 18 years old')
21
+ },
22
+ required: true
23
+ })
24
+ ]
25
+ })
26
+ }
@@ -0,0 +1,33 @@
1
+ import { UIForm, FormInput } from '@nan0web/ui'
2
+
3
+ /**
4
+ * Creates a profile update form
5
+ * @param {Function} t - Translation function
6
+ * @returns {UIForm} Profile update form
7
+ */
8
+ export function createProfileForm(t) {
9
+ return new UIForm({
10
+ title: t('Profile update form'),
11
+ id: 'profile-update',
12
+ state: {},
13
+ fields: [
14
+ new FormInput({
15
+ name: 'username',
16
+ label: t('Username'),
17
+ required: true
18
+ }),
19
+ new FormInput({
20
+ name: 'bio',
21
+ label: t('Biography'),
22
+ type: FormInput.TYPES.TEXTAREA,
23
+ required: false
24
+ }),
25
+ new FormInput({
26
+ name: 'newsletter',
27
+ label: t('Subscribe to newsletter'),
28
+ type: FormInput.TYPES.CHECKBOX,
29
+ required: false
30
+ })
31
+ ]
32
+ })
33
+ }
@@ -0,0 +1,36 @@
1
+ import { UIForm, FormInput } from '@nan0web/ui'
2
+
3
+ /**
4
+ * Creates a user registration form
5
+ * @param {Function} t - Translation function
6
+ * @returns {UIForm} User registration form
7
+ */
8
+ export function createUserForm(t) {
9
+ return new UIForm({
10
+ title: t('User Registration Form'),
11
+ id: 'user-registration',
12
+ state: {},
13
+ fields: [
14
+ new FormInput({
15
+ name: 'name',
16
+ label: t('Full name'),
17
+ required: true
18
+ }),
19
+ new FormInput({
20
+ name: 'email',
21
+ label: t('Email address'),
22
+ type: FormInput.TYPES.EMAIL,
23
+ required: true
24
+ }),
25
+ new FormInput({
26
+ name: 'phone',
27
+ label: t('Phone number'),
28
+ type: FormInput.TYPES.TEXT,
29
+ validator: (value) => {
30
+ return value.length >= 10 && value.length <= 15 ? null : t('Invalid phone number')
31
+ },
32
+ required: false
33
+ })
34
+ ]
35
+ })
36
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import Logger from '@nan0web/log'
4
+ import { CLIInputAdapter } from '../src/index.js'
5
+ import { createUserForm } from './forms/userForm.js'
6
+ import { createAgeForm } from './forms/ageForm.js'
7
+ import { createAddressForm } from './forms/addressForm.js'
8
+ import { createProfileForm } from './forms/profileForm.js'
9
+ import createT, { localesMap } from "./vocabs/index.js"
10
+
11
+ console = new Logger()
12
+
13
+ const adapter = new CLIInputAdapter()
14
+
15
+ console.info(Logger.style(Logger.LOGO, { color: "magenta" }))
16
+ console.warn('=== @nan0web/ui CLI Playground ===\n')
17
+
18
+ // Language selection
19
+ const langResult = await adapter.requestSelect({
20
+ title: "Language selection",
21
+ prompt: "Choose language (1-2): ",
22
+ options: localesMap,
23
+ elementId: "language-select"
24
+ })
25
+
26
+ if (langResult.action === 'select-cancel') {
27
+ console.warn('Language selection cancelled')
28
+ process.exit(0)
29
+ }
30
+
31
+ const selectedLang = langResult.value
32
+ const t = createT(selectedLang)
33
+
34
+ console.success(`${t('Language selection')}: ${t(selectedLang)}`)
35
+ console.info()
36
+
37
+ // User registration form
38
+ const userForm = createUserForm(t)
39
+ const userResult = await adapter.requestForm(userForm, { silent: false })
40
+
41
+ if (userResult.action === 'form-submit') {
42
+ console.success(t('Welcome to our platform!'))
43
+ console.info('User data:', userResult.data)
44
+ console.info()
45
+ } else {
46
+ console.warn('User registration cancelled')
47
+ }
48
+
49
+ // Age confirmation form
50
+ const ageForm = createAgeForm(t)
51
+ const ageResult = await adapter.requestForm(ageForm, { silent: false })
52
+
53
+ if (ageResult.action === 'form-submit') {
54
+ console.success(`Confirmed age: ${ageResult.data.age}`)
55
+ } else {
56
+ console.warn('Age confirmation cancelled')
57
+ }
58
+
59
+ // Address information form
60
+ const addressForm = createAddressForm(t)
61
+ const addressResult = await adapter.requestForm(addressForm, { silent: false })
62
+
63
+ if (addressResult.action === 'form-submit') {
64
+ console.success('Address information collected')
65
+ console.info('Address data:', addressResult.data)
66
+ console.info()
67
+ } else {
68
+ console.warn('Address form cancelled')
69
+ }
70
+
71
+ // Profile update form
72
+ const profileForm = createProfileForm(t)
73
+ const profileResult = await adapter.requestForm(profileForm, { silent: false })
74
+
75
+ if (profileResult.action === 'form-submit') {
76
+ console.success('Profile updated')
77
+ console.info('Profile data:', profileResult.data)
78
+ console.info()
79
+ } else {
80
+ console.warn('Profile update cancelled')
81
+ }
@@ -0,0 +1,25 @@
1
+ export default {
2
+ "User Registration Form": "User Registration Form",
3
+ "Full name": "Full name",
4
+ "Email address": "Email address",
5
+ "Phone number": "Phone number",
6
+ "Age confirmation form": "Age confirmation form",
7
+ "Please enter your age": "Please enter your age",
8
+ "Language selection": "Language selection",
9
+ "Choose language": "Choose language",
10
+ "English": "English",
11
+ "Ukrainian": "Ukrainian",
12
+ "You must be at least 18 years old": "You must be at least 18 years old",
13
+ "Invalid phone number": "Invalid phone number",
14
+ "Welcome to our platform!": "Welcome to our platform!",
15
+ "Address information": "Address information",
16
+ "Street": "Street",
17
+ "City": "City",
18
+ "Postal code": "Postal code",
19
+ "Country": "Country",
20
+ "Profile update form": "Profile update form",
21
+ "Username": "Username",
22
+ "Biography": "Biography",
23
+ "Newsletter subscription": "Newsletter subscription",
24
+ "Subscribe to newsletter": "Subscribe to newsletter"
25
+ }
@@ -0,0 +1,12 @@
1
+ import i18n, { createT } from "@nan0web/i18n"
2
+ import en from "./en.js"
3
+ import uk from "./uk.js"
4
+
5
+ const getVocab = i18n({ en, uk })
6
+
7
+ export const localesMap = new Map([
8
+ ["en", "English"],
9
+ ["uk", "Українська"],
10
+ ])
11
+
12
+ export default (locale) => createT(getVocab(locale))
@@ -0,0 +1,25 @@
1
+ export default {
2
+ "User Registration Form": "Форма реєстрації користувача",
3
+ "Full name": "Повне ім'я",
4
+ "Email address": "Електронна пошта",
5
+ "Phone number": "Номер телефону",
6
+ "Age confirmation form": "Форма підтвердження віку",
7
+ "Please enter your age": "Будь ласка, введіть ваш вік",
8
+ "Language selection": "Вибір мови",
9
+ "Choose language": "Виберіть мову",
10
+ "English": "Англійська",
11
+ "Ukrainian": "Українська",
12
+ "You must be at least 18 years old": "Вам має бути не менше 18 років",
13
+ "Invalid phone number": "Невірний номер телефону",
14
+ "Welcome to our platform!": "Ласкаво просимо на нашу платформу!",
15
+ "Address information": "Інформація про адресу",
16
+ "Street": "Вулиця",
17
+ "City": "Місто",
18
+ "Postal code": "Поштовий код",
19
+ "Country": "Країна",
20
+ "Profile update form": "Форма оновлення профілю",
21
+ "Username": "Ім'я користувача",
22
+ "Biography": "Біографія",
23
+ "Newsletter subscription": "Підписка на розсилку",
24
+ "Subscribe to newsletter": "Підписатися на розсилку"
25
+ }
@@ -0,0 +1,219 @@
1
+ import { Message } from '@nan0web/co'
2
+ import { UIForm, InputAdapter as BaseInputAdapter, InputMessage as BaseInputMessage } from '@nan0web/ui'
3
+ import { ask } from './ui/input.js'
4
+ import { select } from './ui/select.js'
5
+
6
+ /** @typedef {Partial<UIForm>} FormMessageValue */
7
+ /** @typedef {Partial<Message> | null} InputMessageValue */
8
+
9
+ /**
10
+ * Extends the generic {@link BaseInputMessage} to carry a {@link UIForm}
11
+ * instance alongside the usual input message payload.
12
+ *
13
+ * The original {@link BaseInputMessage} expects a `value` of type
14
+ * {@link InputMessageValue} (a {@link Message} payload). To remain
15
+ * compatible we keep `value` unchanged and store the form data in a
16
+ * separate `form` property.
17
+ */
18
+ class FormMessage extends BaseInputMessage {
19
+ /** @type {UIForm} Form data associated with the message */
20
+ form
21
+
22
+ /**
23
+ * Creates a new {@link FormMessage}.
24
+ *
25
+ * @param {object} props - Message properties.
26
+ * @param {FormMessageValue} [props.form={}] UIForm instance or data.
27
+ * @param {InputMessageValue} [props.value=null] Retained for compatibility.
28
+ * @param {string[]|string} [props.options=[]] Available options.
29
+ * @param {boolean} [props.waiting=false] Waiting flag.
30
+ * @param {boolean} [props.escaped=false] Escape flag.
31
+ */
32
+ constructor(props = {}) {
33
+ const {
34
+ form = {},
35
+ ...rest
36
+ } = props
37
+ // Initialise the parent with the remaining properties.
38
+ // Cast to `any` to avoid type‑mismatch between duplicated InputMessage
39
+ // definitions across packages.
40
+ super(/** @type {any} */ (rest))
41
+
42
+ // Store the UIForm; accept an object, UIForm or a plain payload.
43
+ this.form = UIForm.from(form)
44
+ }
45
+
46
+ /**
47
+ * Creates a {@link FormMessage} from an existing instance or plain data.
48
+ *
49
+ * @param {FormMessage|object} input – Existing message or raw data.
50
+ * @returns {FormMessage}
51
+ */
52
+ static from(input) {
53
+ if (input instanceof FormMessage) return input
54
+ const {
55
+ form = {},
56
+ ...rest
57
+ } = input ?? {}
58
+ return new FormMessage({ form, ...rest })
59
+ }
60
+ }
61
+
62
+ /**
63
+ * CLI specific adapter extending the generic {@link BaseInputAdapter}.
64
+ * Implements concrete `ask` and `select` helpers that rely on the CLI utilities.
65
+ */
66
+ export default class CLIInputAdapter extends BaseInputAdapter {
67
+ /**
68
+ * Interactively fill a {@link UIForm} field‑by‑field.
69
+ *
70
+ * @param {UIForm} form – Form definition to be filled.
71
+ * @param {Object} options – Request options.
72
+ * @param {boolean} [options.silent=true] Suppress title output when `true`.
73
+ * @returns {Promise<FormMessage>} Message with `escaped` = true on cancel,
74
+ * otherwise `escaped` = false and the completed form attached as `form`.
75
+ */
76
+ async requestForm(form, options = {}) {
77
+ const { silent = true } = options
78
+
79
+ if (!silent) {
80
+ console.log(`\n${form.title}\n`)
81
+ }
82
+
83
+ let formData = { ...form.state }
84
+ let currentFieldIndex = 0
85
+
86
+ while (currentFieldIndex < form.fields.length) {
87
+ const field = form.fields[currentFieldIndex]
88
+ const prompt = field.label || field.name
89
+
90
+ const input = await this.ask(`${prompt}${field.required ? ' *' : ''}: `)
91
+
92
+ // Cancel (Esc or empty string)
93
+ if ([FormMessage.ESCAPE, ''].includes(input)) {
94
+ return FormMessage.from({
95
+ form: {},
96
+ escaped: true,
97
+ action: 'form-cancel',
98
+ id: form.id,
99
+ })
100
+ }
101
+
102
+ // Navigation shortcuts
103
+ const trimmed = input.trim()
104
+ if (trimmed === '::prev' || trimmed === '::back') {
105
+ currentFieldIndex = Math.max(0, currentFieldIndex - 1)
106
+ continue
107
+ }
108
+ if (trimmed === '::next' || trimmed === '::skip') {
109
+ currentFieldIndex++
110
+ continue
111
+ }
112
+
113
+ // Skip optional fields when empty
114
+ if (trimmed === '' && !field.required) {
115
+ currentFieldIndex++
116
+ continue
117
+ }
118
+
119
+ // Validate using the form's schema / field definition
120
+ const schema = field.constructor
121
+ const { isValid, errors } = form.validateValue(field.name, trimmed, schema)
122
+ if (!isValid) {
123
+ const errorMessages = Object.values(errors)
124
+ console.log(`\x1b[31mError: ${errorMessages.join(', ')}\x1b[0m`)
125
+ continue // stay on current field
126
+ }
127
+
128
+ // Store validated value
129
+ formData[field.name] = trimmed
130
+ currentFieldIndex++
131
+ }
132
+
133
+ // Final form validation
134
+ const finalForm = form.setData(formData)
135
+ const { isValid, errors } = finalForm.validate()
136
+ if (!isValid) {
137
+ console.log('\n' + Object.values(errors).join('\n'))
138
+ return await this.requestForm(form, options) // retry recursively
139
+ }
140
+
141
+ return FormMessage.from({
142
+ form: finalForm,
143
+ escaped: false,
144
+ action: 'form-submit',
145
+ id: form.id,
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Request a selection from a list of options.
151
+ *
152
+ * @param {Object} config – Selection configuration.
153
+ * @param {string} config.title – Title displayed before the list.
154
+ * @param {string} config.prompt – Prompt text.
155
+ * @param {Array<string>|Map<string,string>|Array<{label:string,value:string}>} config.options – Options to choose from.
156
+ * @param {string} config.id – Identifier for the resulting message.
157
+ * @returns {Promise<BaseInputMessage>} Message containing chosen value and metadata.
158
+ */
159
+ async requestSelect(config) {
160
+ try {
161
+ const result = await this.select({
162
+ title: config.title ?? 'Select an option:',
163
+ prompt: config.prompt ?? 'Choose (1‑N): ',
164
+ options: config.options,
165
+ console: console,
166
+ })
167
+ return BaseInputMessage.from({
168
+ value: result.value,
169
+ data: result,
170
+ id: config.id,
171
+ })
172
+ } catch (error) {
173
+ if (error instanceof this.CancelError) {
174
+ return BaseInputMessage.from({
175
+ value: '',
176
+ id: config.id,
177
+ })
178
+ }
179
+ throw error
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Simple string input request.
185
+ *
186
+ * @param {Object} config Input configuration.
187
+ * @param {string} config.prompt Prompt text.
188
+ * @param {string} config.id Identifier for the resulting message.
189
+ * @param {string} [config.label] Optional label used as fallback.
190
+ * @param {string} [config.name] Optional name used as fallback.
191
+ * @returns {Promise<BaseInputMessage>} Message containing the entered text.
192
+ */
193
+ async requestInput(config) {
194
+ const prompt = config.prompt ?? `${config.label ?? config.name}: `
195
+ const input = await this.ask(prompt)
196
+
197
+ if (input === '') {
198
+ return BaseInputMessage.from({
199
+ value: '',
200
+ id: config.id,
201
+ })
202
+ }
203
+
204
+ return BaseInputMessage.from({
205
+ value: input,
206
+ id: config.id,
207
+ })
208
+ }
209
+
210
+ /** @inheritDoc */
211
+ async ask(question) {
212
+ return ask(question)
213
+ }
214
+
215
+ /** @inheritDoc */
216
+ async select(config) {
217
+ return select(config)
218
+ }
219
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { UIForm, FormInput, InputMessage } from '@nan0web/ui'
4
+ import { CancelError } from '@nan0web/ui/core'
5
+ import CLIInputAdapter from './InputAdapter.js'
6
+
7
+ // Awaits for the user input.
8
+ describe('CLIInputAdapter', () => {
9
+ it('should create instance', () => {
10
+ const adapter = new CLIInputAdapter()
11
+ assert.ok(adapter instanceof CLIInputAdapter)
12
+ })
13
+
14
+ it('should validate form fields', async () => {
15
+ const adapter = new CLIInputAdapter()
16
+
17
+ // Mock ask for testing
18
+ adapter.ask = () => Promise.resolve('test@example.com')
19
+
20
+ const form = new UIForm({
21
+ title: 'Test Form',
22
+ elementId: 'test-form',
23
+ fields: [
24
+ new FormInput({
25
+ name: 'email',
26
+ label: 'Email',
27
+ type: 'email',
28
+ required: true
29
+ })
30
+ ]
31
+ })
32
+
33
+ const result = await adapter.requestForm(form, { silent: true })
34
+ assert.equal(result.form.state.email, 'test@example.com')
35
+ })
36
+
37
+ it('should handle form cancellation', async () => {
38
+ const adapter = new CLIInputAdapter()
39
+
40
+ // Mock ask for cancellation
41
+ adapter.ask = () => Promise.resolve(InputMessage.ESCAPE)
42
+
43
+ const form = new UIForm({
44
+ title: 'Test Form',
45
+ elementId: 'test-form',
46
+ fields: [
47
+ new FormInput({
48
+ name: 'name',
49
+ label: 'Name',
50
+ required: true
51
+ })
52
+ ]
53
+ })
54
+
55
+ const result = await adapter.requestForm(form, { silent: true })
56
+ assert.equal(result.escaped, true)
57
+ })
58
+
59
+ it('should have CancelError static property', () => {
60
+ assert.ok(CLIInputAdapter.CancelError)
61
+ const error = new CLIInputAdapter.CancelError()
62
+ assert.equal(error.name, "CancelError")
63
+ assert.equal(error.message, "Operation cancelled by user")
64
+ })
65
+
66
+ it('should implement ask method', async () => {
67
+ const adapter = new CLIInputAdapter()
68
+ // Mock internal ask to avoid stdin interaction
69
+ const originalAsk = adapter.ask
70
+ adapter.ask = () => Promise.resolve('test answer')
71
+
72
+ const answer = await adapter.ask('Test question?')
73
+ assert.equal(answer, 'test answer')
74
+
75
+ // Restore original
76
+ adapter.ask = originalAsk
77
+ })
78
+
79
+ it('should implement select method', async () => {
80
+ const adapter = new CLIInputAdapter()
81
+ // Mock internal select to avoid stdin interaction
82
+ const originalSelect = adapter.select
83
+ adapter.select = () => Promise.resolve({ value: 'option1', index: 0 })
84
+
85
+ const result = await adapter.select({
86
+ title: 'Test Select',
87
+ prompt: 'Choose:',
88
+ options: ['option1', 'option2']
89
+ })
90
+ assert.equal(result.value, 'option1')
91
+ assert.equal(result.index, 0)
92
+
93
+ // Restore original
94
+ adapter.select = originalSelect
95
+ })
96
+
97
+ it('should handle select cancellation', async () => {
98
+ const adapter = new CLIInputAdapter()
99
+
100
+ // Mock select to throw CancelError
101
+ const originalSelect = adapter.select
102
+ adapter.select = () => Promise.reject(new CancelError())
103
+
104
+ const config = {
105
+ title: 'Test Select',
106
+ prompt: 'Choose:',
107
+ id: 'test-select',
108
+ options: ['option1', 'option2']
109
+ }
110
+
111
+ const result = await adapter.requestSelect(config)
112
+ assert.deepStrictEqual(result.value, InputMessage.from({}).value)
113
+
114
+ // Restore original
115
+ adapter.select = originalSelect
116
+ })
117
+ })