all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: "Max R. Carrara" <m.carrara@proxmox.com>
To: pve-devel@lists.proxmox.com
Cc: Alfonso Kuen <gerencia@idkmanager.com>,
	Thomas Ellmenreich <t.ellmenreich@proxmox.com>,
	Jakob Klocker <j.klocker@proxmox.com>
Subject: [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema
Date: Fri,  6 Nov 2026 16:45:47 +0100	[thread overview]
Message-ID: <20261106154554.2182986-12-m.carrara@proxmox.com> (raw)
In-Reply-To: <20261106154554.2182986-1-m.carrara@proxmox.com>

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 <m.carrara@proxmox.com>
---
 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 <input> 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





  parent reply	other threads:[~2026-11-06 15:47 UTC|newest]

Thread overview: 18+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-common v4 01/17] json schema: add multiline string format Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 02/17] api: plugins/storage: add initial routes and endpoints Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 03/17] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 04/17] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 05/17] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 06/17] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 07/17] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 08/17] all plugins, api: mark certain properties as advanced in plugindata() Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 09/17] all plugins, api: mark certain properties as hidden " Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 10/17] form: introduce new 'proxmoxtextarea' field Max R. Carrara
2026-11-06 15:45 ` Max R. Carrara [this message]
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 12/17] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 13/17] api: sort modules and API method registrations Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 14/17] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 15/17] ui: storage view: display error when no editor for storage type exists Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 16/17] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage-plugin-examples v4 17/17] sshfs: Update SSHFS plugin Max R. Carrara

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261106154554.2182986-12-m.carrara@proxmox.com \
    --to=m.carrara@proxmox.com \
    --cc=gerencia@idkmanager.com \
    --cc=j.klocker@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=t.ellmenreich@proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal