From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id 69F021FF0AF for ; Fri, 06 Nov 2026 16:47:54 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id B2266216D1; Fri, 06 Nov 2026 16:46:59 +0100 (CET) From: "Max R. Carrara" To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema Date: Fri, 6 Nov 2026 16:45:47 +0100 Message-ID: <20261106154554.2182986-12-m.carrara@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261106154554.2182986-1-m.carrara@proxmox.com> References: <20261106154554.2182986-1-m.carrara@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793979977995 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.278 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy ENA_SUBJ_ODD_CASE 1.2 Subject has odd case KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: C6GOYS5GSREJQWUAIX7OLEDMTE3NRJJS X-Message-ID-Hash: C6GOYS5GSREJQWUAIX7OLEDMTE3NRJJS X-MailFrom: m.carrara@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Alfonso Kuen , Thomas Ellmenreich , Jakob Klocker X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This helper takes the JSON schema of a single property and transforms it into an object representing an Ext JS field. Currently, four types for properties are supported: `string`, `integer`, `number` and `boolean`. Many JSON schema validation keywords are also supported and converted to Ext JS analogues. Types --> Ext JS field types: - boolean --> proxmoxcheckbox - integer --> proxmoxintegerfield - number --> proxmoxintegerfield, with decimals allowed - string --> proxmoxtextfield The integer field is used for numbers as well, because Ext JS' own number field submits an empty string for an empty field, which the API rejects for numeric parameters. Keywords --> Ext JS properties / behavior: - minLength --> minLength - maxLength --> maxLength - minimum --> minValue - maximum --> maxValue - title --> fieldLabel (using the field's name if no title) - optional --> allowBlank, and deleteEmpty when editing - description --> shown on hover - enum --> use proxmoxKVComboBox as field instead, with an additional "Default" entry if optional - default --> used as `emptyText`, and as initial state of checkboxes and comboboxes; when editing, a checkbox of an optional property set to its default deletes the property Defaults are deliberately not used as the value of text and number fields. Otherwise, creating a record would write every default into the config explicitly, unlike the rest of the UI, which leaves unset properties unset. Some defaults also describe a behavior instead of being an actual value (e.g. "Unlimited for users with ..."), which would make the field invalid. Checkboxes always submit a value, so callers that do not want defaults written on creation have to drop those themselves. Furthermore, add support for the `multiline` JSON schema `format`. This means that any property with the `multiline` format becomes a `proxmoxtextarea` field. Additionally, the helper also takes "extra attributes" and a "context" which allows for more fine-grained control over how the ExtJS field is actually constructed and also makes it possible to express different UI-specific behaviors that cannot be modeled via JSONSchema. The only variable in the context is `isCreate`, which is used to hint whether the field is used when creating a record or not. The following extra attributes are added: - sensitive Marks a field as "sensitive", which is useful for password fields for example. This causes most fields to become `proxmoxtextfield`s instead, with their inputs obfuscated. As the current value is never sent to the client, leaving the field empty when editing keeps the value unchanged, so the field is never required when editing. Fields with the `multiline` format are kept as `proxmoxtextarea` to allow copy-pasting private keys into the field or similar. - readonly Causes a field to become a `displayfield` instead. Boolean fields are rendered as "Yes / No" (locale-aware). The displayed value is HTML-encoded, as it comes from the record. Signed-off-by: Max R. Carrara --- src/Utils.js | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/Utils.js b/src/Utils.js index 9d6ac79..835e16e 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -1609,10 +1609,212 @@ Ext.define('Proxmox.Utils', { if (location.name) { return `${location.name} (${degrees})`; } return degrees; }, + + /** + * Takes a JSONSchema of a single property and transforms it into an + * object representing an ExtJS field. + * + * @param {string} fieldName - The value for the `name` property of the resulting object. + * @param {Object} propSchema - The JSON schema of a property. + * @param {Object} extraAttributes - Additional attributes describing the + * field which do not exist as keywords in JSON schema. + * Note that none of these attributes represent any specific ExtJS property. + * @param {Boolean} extraAttributes.readonly - Whether the field shall be read-only. + * "Read-only" here means that the field's `xtype` will be set to + * `'displayfield'`, regardless of what data type the field will + * display. Additionally, checkboxes (booleans) will be displayed as + * "Yes" or "No" depending on their value (locale-aware). + * @param {Boolean} extraAttributes.sensitive - Whether the field + * contains sensitive data, such as a password or similar. + * @param {Object} context - The context within which the field is being used. + * @param {Boolean} context.isCreate - Whether the field is used when creating a record. + */ + getFieldDefFromPropertySchema: function ( + fieldName, + propSchema, + extraAttributes = {}, + context = {}, + ) { + let hasDefault = propSchema.default !== undefined && propSchema.default !== null; + let isOptional = Boolean(propSchema.optional); + + const fieldPropsByType = { + string: { + xtype: 'proxmoxtextfield', + minLength: propSchema.minLength, + maxLength: propSchema.maxLength, + }, + integer: { + xtype: 'proxmoxintegerfield', + minValue: propSchema.minimum, + maxValue: propSchema.maximum, + }, + // Ext JS' own number field submits empty values as empty string, which the + // API rejects for numbers, so reuse the integer field and allow decimals. + number: { + xtype: 'proxmoxintegerfield', + allowDecimals: true, + // Ext JS rounds to two decimals by default + decimalPrecision: 15, + minValue: propSchema.minimum, + maxValue: propSchema.maximum, + }, + boolean: { + xtype: 'proxmoxcheckbox', + uncheckedValue: 0, + }, + }; + + const commonFieldProps = { + name: fieldName, + fieldLabel: Ext.htmlEncode(fieldName), + emptyText: hasDefault ? String(propSchema.default) : '', + allowBlank: isOptional, + }; + + const fieldPropsByFormat = { + multiline: { + xtype: 'proxmoxtextarea', + }, + }; + + // same values as accepted by PVE::JSONSchema::parse_boolean() + const anyToBool = (value) => { + if (typeof value === 'string' || value instanceof String) { + return /^(?:1|on|yes|true)$/i.test(value.trim()); + } + + return Boolean(value); + }; + + let fieldProps = fieldPropsByType[propSchema.type]; + if (fieldProps === undefined) { + console.warn(`Unhandled property type '${propSchema.type}'`); + fieldProps = fieldPropsByType.string; + } + + let formatFieldProps = fieldPropsByFormat[propSchema.format] ?? {}; + + let field = { + ...fieldProps, + ...commonFieldProps, + ...formatFieldProps, + }; + + if (propSchema.title) { + field.fieldLabel = Ext.htmlEncode(propSchema.title); + } + + if (propSchema.description) { + field.autoEl = { + tag: 'div', + 'data-qtip': Ext.htmlEncode(Ext.htmlEncode(propSchema.description)), + }; + } + + // NOTE: Some context for this branch. + // + // Text and number fields only show the default as empty text + // instead of using it as value. This keeps unset properties unset, + // like in the rest of the UI, and also works for defaults that + // describe a behavior instead of being an actual value. + // + // However, checkboxes cannot show an empty text, AND they *always* + // submit a value, even if left empty. This means that a property + // with `default => 1` (in Perl) that was never explicitly set to + // `true` in the UI would then be silently set to `0` on creation. + if (propSchema.type === 'boolean') { + let defaultValue = hasDefault && anyToBool(propSchema.default); + field.checked = defaultValue; + + // Delete an optional property instead of setting it to its + // default when editing, so that unset properties stay unset. + // The submitted values are '1' and 0. + // + // When editing (this branch), we delete an optional property + // instead of setting it to its default. This makes it so that + // unset properties stay unset. + if (!context.isCreate && isOptional) { + // We set either '1' or 0 here, because that's what + // `getSubmitValue()` from Ext JS returns when checked or + // unchecked. The check in `getSubmitData()` would fail + // otherwise, e.g. `'1' === true` is false. + field.defaultValue = defaultValue ? '1' : 0; + field.deleteDefaultValue = true; + } + } + + // NOTE: Like above, checkboxes also cannot show emptyText, so they + // too use the default as initial value. + if (propSchema.enum) { + let comboItems = propSchema.enum.map((item) => [item, Ext.htmlEncode(item)]); + + if (isOptional) { + let defaultText = Proxmox.Utils.defaultText; + if (hasDefault) { + defaultText += ` (${Ext.htmlEncode(propSchema.default)})`; + } + comboItems.unshift(['__default__', defaultText]); + field.value = '__default__'; + } else if (context.isCreate && hasDefault) { + field.value = propSchema.default; + } + + Object.assign(field, { + xtype: 'proxmoxKVComboBox', + comboItems: comboItems, + deleteEmpty: !context.isCreate, + }); + } else if (!context.isCreate && isOptional) { + // Clearing an optional field must delete the property, otherwise it would keep + // its old value. Checkboxes always submit a value and are unaffected. + field.deleteEmpty = true; + } + + if (extraAttributes.sensitive) { + // textarea fields for multiline strings stay as-is, because + // Ext JS will turn them into elements otherwise, which + // in turn causes newlines to be replaced with spaces. + // 'inputType' unfortunately has no effect for textarea fields. + if (field.xtype !== 'proxmoxtextarea') { + field.xtype = 'proxmoxtextfield'; + field.inputType = 'password'; + } + + // The current value is never sent to the client, so an empty sensitive field + // means "unchanged" when editing. + field.value = ''; + field.deleteEmpty = false; + field.allowBlank = isOptional || !context.isCreate; + field.emptyText = context.isCreate ? Proxmox.Utils.NoneText : gettext('Unchanged'); + } + + if (extraAttributes.readonly) { + field.xtype = 'displayfield'; + // the displayed values are defined by the user and must not be rendered as HTML + field.htmlEncode = true; + + if (propSchema.type === 'boolean') { + field.renderer = Proxmox.Utils.format_boolean; + field.value = field.checked ? 1 : 0; + } else if (field.value === '__default__') { + field.value = hasDefault + ? `${Proxmox.Utils.defaultText} (${propSchema.default})` + : Proxmox.Utils.defaultText; + } + + if (extraAttributes.sensitive) { + field.value = '*********'; + field.emptyText = ''; + } + } + + return field; + }, }, singleton: true, constructor: function () { let me = this; -- 2.47.3