Custom Form Tab

The code is encapsulated in a function that receives as parameters: entityData, callback

  • entityData contains the data of the entity in json format. This can contain a fully flattened entity object or a simple key-value object depending on the type of wizard (provisioning or advanced).

  • callback is a function that must be called in order to paint the vanjs form created (useful when using promises or async code).

Function must return a vanjs element or a function that returns a vanjs element if no callback is provided.

Example:

async function (entityData, callback) { 
    // YOUR CODE HERE WITHOUT FUNCTION DECLARATION
    
    const { div } = van.tags;

    return div("Hello Form");
}

Available utils

$api -> use it to create http petitions to OpenGate Api Rest doc

$user -> Logged user

Example:

{
    "email": "email@amplia.es",
    "workgroup": "workgroup",
    "domain": "domain",
    "profile": "profile",
    "countryCode": "ES",
    "langCode": "en",
    "timezone": "Europe/Madrid"
}

$moment -> use it to format date doc

console -> display messages in navigator console

Promise -> allows easy execution of multiple promises

http -> javascript encapsulation of a useFetch (Nuxt 4) instance doc. Returns { data, error, status, refresh, clear } where data and error are reactive Refs.

Functions and Variables

onDataUpdate(callback): callback to be called when data is updated from other steps. Callback receives all the data introduced in other steps as parameter.

onStepEnter(callback): callback to be called when entering the step. Use it to control when the step is entered.

setModelData(boolean[, data]): Use it to (in)validate the form and send the data to other tabs.

alert: alert navigator method

van: A vanjs instance (https://vanjs.org/)

vanui: A vanui instance (https://vanjs.org/)

echarts: Echarts instance (https://echarts.apache.org/)

ecstat: Ecstat instance (https://github.com/ecomfe/echarts-stat?tab=readme-ov-file#api-reference)

openWidget -> (widgetId[, entityKey, title, extraConf]) -> Opens the selected widget in a modal panel

openDashboard -> (workspaceId, dashboardId, newPage) -> Opens the selected dashboard in selected workspace

openEntityDashboard -> (entityIdentifier[, organization[user if empty], resourceType[’entity.device’ if empty] , newPage]) -> Opens the entity’s temporary dashboard

openWizard -> (wizardId[, wizardData, isEdit]) -> Opens the selected wizard in a modal panel. A list of available wizards will be available to use directly.

Examples


Subsections of Custom Form Tab

Example 1

Description

This example demonstrates the basics of a Custom Form Tab with VanJS:

  1. Renders a single required field, a Sampling period selector.
  2. Validates the selection on every change and shows an inline error message while the value is not valid.
  3. Reports the step validity and its data to the wizard with setModelData(isValid, data), so the wizard only lets the user continue once the field is filled in.

Code

// 1. Import only the VanJS tags used below
const { div, span, strong, label, select, option, p } = van.tags;

// 2. Form state
const samplingPeriod = van.state(0);
const errorMessage = van.state("The sampling period cannot be 0.");

// 3. Validate the current value and report validity and data to the wizard
function updateValidity() {
    const value = Number(samplingPeriod.val);

    if (value === 0) {
        errorMessage.val = "The sampling period cannot be 0.";
        setModelData(false);
        return;
    }

    errorMessage.val = "";
    setModelData(true, { samplingPeriod: value });
}

// 4. Invalidate the step until a valid value is selected
updateValidity();

// 5. Render the component
return div({ class: "pa-4 subtitle-2" },
           div({ style: "margin-bottom: 2em" },
               "Select how often the device must report its measurements. ",
               div({ style: "margin-top: 0.5em" },
                   "Fields marked with ", span({ style: "color: red" }, "*"), " are mandatory."
                  )
              ),

           van.tags.form({ },
                         div({ style: "margin-bottom: 1em;" },
                             label({ for: "samplingPeriod", style: "display: block; font-weight: bold; margin-bottom: 0.5em;" },
                                   "Sampling period (minutes) ", span({ style: "color: red" }, "*")
                                  ),

                             select({
                                 id: "samplingPeriod",
                                 value: samplingPeriod,
                                 onchange: (e) => {
                                     samplingPeriod.val = e.target.value;
                                     updateValidity();
                                 },
                                 style: "padding: 0.5em; width: 100%; max-width: 200px; display: block;"
                             },
                                    option({ value: 0 }, "0 (not set)"),
                                    option({ value: 5 }, "5"),
                                    option({ value: 15 }, "15"),
                                    option({ value: 60 }, "60")
                                   ),

                             p({ style: "font-size: 0.9em; margin-top: 0.5em; color: #555;" },
                               strong("NOTE"), ": the shorter the period, the faster the device drains its battery."
                              )
                            ),

                         () => errorMessage.val ? p({ style: "color: red; font-weight: bold;" }, errorMessage.val) : null
                        )
          );

Example 2

Description

This example demonstrates how to build a Custom Form Tab with VanJS that:

  1. Captures device identification data: Device Identifier, Battery Level (%), and Storage (GB).
  2. Validates against OpenGate in real time (using $api.entitiesSearchBuilder()) to verify that the device exists in the platform.
  3. Dynamically validates all inputs and communicates the validity and data to the wizard using setModelData(isValid, data).

Code

// 1. Import the VanJS tags used below
const { div, span, strong, label, input, button, p, small } = van.tags;

// 2. Take the default identifier when entityData already carries one
const defaultDeviceId = entityData?.['provision.administration.identifier']?._value?._current?.value
    || entityData?.deviceId
    || "";

// 3. VanJS reactive state
const deviceId = van.state(defaultDeviceId);
const battery = van.state("");
const storage = van.state("");

const isValidating = van.state(false);
const deviceValidated = van.state(false);
const validatedEntity = van.state(null);

const deviceError = van.state("");
const batteryError = van.state("");
const storageError = van.state("");
const statusMessage = van.state("");

// 4. Reusable inline styles
const styles = {
    container: "padding: 1rem; font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 580px;",
    fieldGroup: "margin-bottom: 1.25rem;",
    label: "display: block; font-weight: 600; font-size: 0.875rem; margin-bottom: 0.35rem; color: #334155;",
    inputRow: "display: flex; gap: 0.5rem;",
    input: "width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.875rem; box-sizing: border-box;",
    button: "padding: 0.5rem 1rem; background-color: #2563eb; color: white; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; white-space: nowrap;",
    buttonDisabled: "padding: 0.5rem 1rem; background-color: #94a3b8; color: white; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: not-allowed; white-space: nowrap;",
    errorText: "color: #dc2626; font-size: 0.8rem; margin-top: 0.3rem; margin-bottom: 0;",
    successCard: "background-color: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 0.75rem; margin-top: 0.5rem; font-size: 0.85rem; color: #166534;",
    infoText: "color: #64748b; font-size: 0.8rem; margin-top: 0.25rem; margin-bottom: 0;"
};

// 5. Update the overall validity and send the data to the wizard
function updateModelValidity() {
    const isBatValid = validateBattery(battery.val);
    const isStorageValid = validateStorage(storage.val);
    const isDevValid = deviceValidated.val === true && !deviceError.val;

    const isAllValid = isDevValid && isBatValid && isStorageValid;

    if (isAllValid) {
        setModelData(true, {
            deviceId: deviceId.val.trim(),
            battery: Number(battery.val),
            storage: Number(storage.val),
            deviceDetails: validatedEntity.val
        });
    } else {
        setModelData(false);
    }
}

// 6. Per-field validation
function validateBattery(val) {
    if (val === "" || val === null || val === undefined) {
        batteryError.val = "The battery level is mandatory.";
        return false;
    }
    const num = Number(val);
    if (isNaN(num) || num < 0 || num > 100) {
        batteryError.val = "The battery must be a percentage between 0 and 100.";
        return false;
    }
    batteryError.val = "";
    return true;
}

function validateStorage(val) {
    if (val === "" || val === null || val === undefined) {
        storageError.val = "The storage capacity is mandatory.";
        return false;
    }
    const num = Number(val);
    if (isNaN(num) || num <= 0) {
        storageError.val = "The storage must be a number greater than 0 GB.";
        return false;
    }
    storageError.val = "";
    return true;
}

// 7. Asynchronous validation against OpenGate through $api
async function validateDeviceInOpenGate(targetId) {
    const id = (targetId || "").trim();

    if (!id) {
        deviceError.val = "Please enter a device identifier.";
        deviceValidated.val = false;
        validatedEntity.val = null;
        statusMessage.val = "";
        updateModelValidity();
        return;
    }

    isValidating.val = true;
    deviceError.val = "";
    statusMessage.val = `Querying OpenGate to verify "${id}"...`;

    try {
        const response = await $api.entitiesSearchBuilder()
            .filter({
                eq: {
                    'provision.administration.identifier': id
                }
            })
            .flattened()
            .limit(1)
            .build()
            .execute();

        const entity = response?.data?.entities?.[0];

        if (entity) {
            deviceValidated.val = true;
            deviceError.val = "";
            validatedEntity.val = {
                identifier: id,
                name: entity['provision.device.name']?._value?._current?.value || id,
                specificType: entity['provision.device.specificType']?._value?._current?.value || "N/A",
                operationalStatus: entity['provision.device.operationalStatus']?._value?._current?.value || "N/A"
            };
            statusMessage.val = "";
        } else {
            deviceValidated.val = false;
            validatedEntity.val = null;
            deviceError.val = `The device "${id}" does not exist in OpenGate.`;
            statusMessage.val = "";
        }
    } catch (err) {
        console.error("Error validating against OpenGate:", err);
        deviceValidated.val = false;
        validatedEntity.val = null;
        deviceError.val = `Error querying OpenGate: ${err.message || err}`;
        statusMessage.val = "";
    } finally {
        isValidating.val = false;
        updateModelValidity();
    }
}

// 8. When entityData already carries an identifier, validate it on start
if (defaultDeviceId) {
    validateDeviceInOpenGate(defaultDeviceId);
} else {
    setModelData(false);
}

// 9. Render the reactive form with VanJS
return div({ style: styles.container },
    div({ style: "margin-bottom: 1.5rem;" },
        strong({ style: "font-size: 1.1rem; display: block; margin-bottom: 0.25rem;" }, "Device Data"),
        p({ style: styles.infoText },
            "Fill in the identification data. Fields marked with ",
            span({ style: "color: #dc2626;" }, "*"),
            " are mandatory."
        )
    ),

    van.tags.form({ onsubmit: (e) => e.preventDefault() },
        // Field: device identifier + validation button
        div({ style: styles.fieldGroup },
            label({ style: styles.label },
                "Device Identifier ",
                span({ style: "color: #dc2626;" }, "*")
            ),
            div({ style: styles.inputRow },
                input({
                    type: "text",
                    placeholder: "e.g. DEV-001234",
                    value: deviceId,
                    style: styles.input,
                    oninput: (e) => {
                        deviceId.val = e.target.value;
                        // When the identifier changes, drop the previous OpenGate validation
                        deviceValidated.val = false;
                        validatedEntity.val = null;
                        deviceError.val = "";
                        statusMessage.val = "";
                        updateModelValidity();
                    },
                    onkeydown: (e) => {
                        if (e.key === "Enter") {
                            validateDeviceInOpenGate(deviceId.val);
                        }
                    }
                }),
                button({
                    type: "button",
                    style: () => isValidating.val ? styles.buttonDisabled : styles.button,
                    disabled: () => isValidating.val,
                    onclick: () => validateDeviceInOpenGate(deviceId.val)
                }, () => isValidating.val ? "Validating..." : "Validate in OpenGate")
            ),

            // Validation status, error and success messages
            () => statusMessage.val ? p({ style: styles.infoText }, statusMessage.val) : null,
            () => deviceError.val ? p({ style: styles.errorText }, deviceError.val) : null,
            () => deviceValidated.val && validatedEntity.val ? div({ style: styles.successCard },
                strong("✓ Device verified in OpenGate"),
                div({ style: "margin-top: 0.25rem;" }, `Name: ${validatedEntity.val.name}`),
                div(`Type: ${validatedEntity.val.specificType} | Status: ${validatedEntity.val.operationalStatus}`)
            ) : null
        ),

        // Field: battery (%)
        div({ style: styles.fieldGroup },
            label({ style: styles.label },
                "Battery Level (%) ",
                span({ style: "color: #dc2626;" }, "*")
            ),
            input({
                type: "number",
                min: "0",
                max: "100",
                step: "1",
                placeholder: "0 - 100",
                value: battery,
                style: styles.input,
                oninput: (e) => {
                    battery.val = e.target.value;
                    validateBattery(battery.val);
                    updateModelValidity();
                }
            }),
            p({ style: styles.infoText }, "Current battery charge percentage (0 to 100%)."),
            () => batteryError.val ? p({ style: styles.errorText }, batteryError.val) : null
        ),

        // Field: storage (GB)
        div({ style: styles.fieldGroup },
            label({ style: styles.label },
                "Storage (GB) ",
                span({ style: "color: #dc2626;" }, "*")
            ),
            input({
                type: "number",
                min: "1",
                step: "1",
                placeholder: "e.g. 64",
                value: storage,
                style: styles.input,
                oninput: (e) => {
                    storage.val = e.target.value;
                    validateStorage(storage.val);
                    updateModelValidity();
                }
            }),
            p({ style: styles.infoText }, "Total internal storage capacity in gigabytes."),
            () => storageError.val ? p({ style: styles.errorText }, storageError.val) : null
        )
    )
);

Example 3

Description

This example demonstrates how to:

  1. Fetch data from an external API (randomuser.me) using the platform’s http utility (Nuxt 4 useFetch instance).
  2. Automatically refresh users whenever the tab is entered or refreshed by utilizing the onStepEnter hook, without requiring a manual reload button.
  3. Correctly build and populate the <select> dropdown elements using VanJS (...userOptions), avoiding invalid array returns from reactive binding functions.
  4. Paint the VanJS form exclusively when refreshing/loading users by calling callback(formElement) within the refresh routine and never before.
  5. Pass the selected user’s full name to subsequent wizard tabs using setModelData(true, { userName: ... }).
  6. Invalidate the step (setModelData(false)) until a user is explicitly selected.

Code

// 1. Import only the VanJS tags used below
const { div, span, label, select, option, img, p } = van.tags;

// 2. Reusable inline styles
const styles = {
    container: "padding: 1rem; font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 580px;",
    header: "margin-bottom: 1.25rem;",
    title: "font-size: 1.1rem; font-weight: 600; margin: 0 0 0.25rem 0; color: #0f172a;",
    subtitle: "color: #64748b; font-size: 0.85rem; margin: 0;",
    select: "width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.875rem; background-color: white; color: #1e293b; box-sizing: border-box;",
    userCard: "margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 8px; background-color: #f8fafc; display: flex; gap: 1rem; align-items: center;",
    avatar: "width: 56px; height: 56px; border-radius: 50%; border: 2px solid #3b82f6; object-fit: cover;",
    userInfo: "flex: 1; min-width: 0;",
    userName: "font-weight: 600; font-size: 0.95rem; color: #0f172a; margin: 0 0 0.2rem 0;",
    userDetail: "font-size: 0.8rem; color: #64748b; margin: 0;",
    successBadge: "display: inline-block; margin-top: 0.35rem; padding: 0.2rem 0.5rem; background-color: #dcfce7; color: #166534; font-size: 0.75rem; font-weight: 600; border-radius: 4px;",
    errorText: "color: #dc2626; font-size: 0.85rem; margin-top: 0.5rem;"
};

// 3. VanJS reactive state
const usersList = van.state([]);
const selectedUserId = van.state("");
const selectedUser = van.state(null);
let isFetching = false;

// 4. Invalidate the step until a user is selected
setModelData(false);

// 5. User selection handler
function onUserSelected(uuid) {
    selectedUserId.val = uuid;
    const user = usersList.val.find((u) => {
        return u.login.uuid === uuid;
    });

    if (user) {
        selectedUser.val = user;
        const fullName = `${user.name.first} ${user.name.last}`;

        // Store the selected name and data for the other tabs
        setModelData(true, {
            userName: fullName,
            userEmail: user.email,
            userCity: user.location?.city,
            userAvatar: user.picture?.medium
        });
    } else {
        selectedUser.val = null;
        setModelData(false);
    }
}

// 6. Build the VanJS component with the select options already resolved
function buildForm() {
    const userOptions = usersList.val.map((u) => {
        return option(
            { value: u.login.uuid },
            `${u.name.first} ${u.name.last} (${u.email})`
        );
    });

    return div({ style: styles.container },
        // Header
        div({ style: styles.header },
            p({ style: styles.title }, "Responsible User Selection"),
            p({ style: styles.subtitle },
                "Fetches users from an external service to pass the selected name as a parameter to the following steps."
            )
        ),

        // User selector
        div(
            label({ style: "display: block; font-weight: 600; font-size: 0.85rem; margin-bottom: 0.35rem; color: #334155;" },
                "User ",
                span({ style: "color: #dc2626;" }, "*")
            ),
            select({
                style: styles.select,
                value: selectedUserId,
                onchange: (e) => {
                    onUserSelected(e.target.value);
                }
            },
                option({ value: "" }, "-- Select a user --"),
                ...userOptions
            )
        ),

        // Preview card for the selected user
        () => {
            if (!selectedUser.val) {
                return null;
            }

            const u = selectedUser.val;
            const fullName = `${u.name.first} ${u.name.last}`;

            return div({ style: styles.userCard },
                img({
                    src: u.picture?.medium || u.picture?.thumbnail,
                    alt: fullName,
                    style: styles.avatar
                }),
                div({ style: styles.userInfo },
                    p({ style: styles.userName }, fullName),
                    p({ style: styles.userDetail }, `✉ ${u.email}`),
                    p({ style: styles.userDetail }, `📍 ${u.location?.city}, ${u.location?.country}`),
                    span({ style: styles.successBadge }, "✓ Parameter 'userName' stored for the other tabs")
                )
            );
        }
    );
}

// 7. Fetch the users and paint the component through the callback
async function refreshUsers() {
    if (isFetching) {
        return;
    }

    isFetching = true;
    selectedUserId.val = "";
    selectedUser.val = null;
    setModelData(false);

    try {
        // Request through the 'http' utility (a Nuxt 4 useFetch instance)
        const { data, error } = await http("https://randomuser.me/api/?results=8");

        if (error && error.value) {
            throw new Error(error.value.message || "Could not fetch the users");
        }

        const payload = data && data.value ? data.value : data;

        if (payload && Array.isArray(payload.results)) {
            usersList.val = payload.results;
        } else {
            usersList.val = [];
        }

        // Call the callback only once the user refresh has completed
        callback(buildForm());
    } catch (err) {
        console.error("Error refreshing the users:", err);
        const errorView = div({ style: styles.container },
            p({ style: styles.errorText }, `The users could not be loaded: ${err.message || err}`)
        );
        callback(errorView);
    } finally {
        isFetching = false;
    }
}

// 8. Refresh the users automatically when the wizard tab is entered or refreshed
onStepEnter(() => {
    refreshUsers();
});

Example 4

Description

This example demonstrates how to:

  1. Fetch data from an external API (randomuser.me) using the platform’s http utility (Nuxt 4 useFetch instance).
  2. Render an interactive data <table> with VanJS displaying user details (photo, name, email, location) with clickable, highlightable rows.
  3. Automatically refresh users whenever the tab is entered or refreshed using the onStepEnter hook.
  4. Paint the VanJS form exclusively when refreshing/loading users by calling callback(formElement) within the refresh routine and never before.
  5. Pass the selected user’s full name to subsequent wizard tabs using setModelData(true, { userName: ... }).
  6. Invalidate the step (setModelData(false)) until a user is explicitly selected.

Code

// 1. Import only the VanJS tags used below
const { div, span, table, thead, tbody, tr, th, td, img, p } = van.tags;

// 2. Reusable inline styles
const styles = {
    container: "padding: 1rem; font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 680px;",
    header: "margin-bottom: 1rem;",
    title: "font-size: 1.1rem; font-weight: 600; margin: 0 0 0.25rem 0; color: #0f172a;",
    subtitle: "color: #64748b; font-size: 0.85rem; margin: 0;",
    table: "width: 100%; border-collapse: collapse; font-size: 0.85rem; margin-top: 0.75rem; border: 1px solid #e2e8f0; border-radius: 6px; overflow: hidden;",
    th: "background-color: #f1f5f9; color: #475569; font-weight: 600; text-align: left; padding: 0.6rem 0.75rem; border-bottom: 1px solid #cbd5e1;",
    row: "cursor: pointer; transition: background-color 0.15s ease; border-bottom: 1px solid #f1f5f9; background-color: white;",
    selectedRow: "cursor: pointer; transition: background-color 0.15s ease; border-bottom: 1px solid #93c5fd; background-color: #eff6ff;",
    td: "padding: 0.5rem 0.75rem; vertical-align: middle;",
    tableAvatar: "width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: block;",
    userCard: "margin-top: 1.25rem; padding: 1rem; border: 1px solid #bfdbfe; border-radius: 8px; background-color: #f0f9ff; display: flex; gap: 1rem; align-items: center;",
    cardAvatar: "width: 52px; height: 52px; border-radius: 50%; border: 2px solid #3b82f6; object-fit: cover;",
    userInfo: "flex: 1; min-width: 0;",
    userName: "font-weight: 600; font-size: 0.95rem; color: #0f172a; margin: 0 0 0.2rem 0;",
    userDetail: "font-size: 0.8rem; color: #64748b; margin: 0;",
    successBadge: "display: inline-block; margin-top: 0.35rem; padding: 0.2rem 0.5rem; background-color: #dcfce7; color: #166534; font-size: 0.75rem; font-weight: 600; border-radius: 4px;",
    errorText: "color: #dc2626; font-size: 0.85rem; margin-top: 0.5rem;"
};

// 3. VanJS reactive state
const usersList = van.state([]);
const selectedUserId = van.state("");
const selectedUser = van.state(null);
let isFetching = false;

// 4. Invalidate the step until a user is selected
setModelData(false);

// 5. User selection handler, triggered by clicking a row
function onUserSelected(uuid) {
    selectedUserId.val = uuid;
    const user = usersList.val.find((u) => {
        return u.login.uuid === uuid;
    });

    if (user) {
        selectedUser.val = user;
        const fullName = `${user.name.first} ${user.name.last}`;

        // Store the selected name and data for the other tabs
        setModelData(true, {
            userName: fullName,
            userEmail: user.email,
            userCity: user.location?.city,
            userAvatar: user.picture?.medium
        });
    } else {
        selectedUser.val = null;
        setModelData(false);
    }
}

// 6. Build the component with an interactive table
function buildForm() {
    const rows = usersList.val.map((u) => {
        const fullName = `${u.name.first} ${u.name.last}`;

        return tr({
            style: () => {
                if (selectedUserId.val === u.login.uuid) {
                    return styles.selectedRow;
                }
                return styles.row;
            },
            onclick: () => {
                onUserSelected(u.login.uuid);
            }
        },
            td({ style: styles.td },
                img({
                    src: u.picture?.thumbnail,
                    alt: fullName,
                    style: styles.tableAvatar
                })
            ),
            td({ style: styles.td }, fullName),
            td({ style: styles.td }, u.email),
            td({ style: styles.td }, `${u.location?.city}, ${u.location?.country}`)
        );
    });

    return div({ style: styles.container },
        // Header
        div({ style: styles.header },
            p({ style: styles.title }, "Responsible User Selection"),
            p({ style: styles.subtitle },
                "Click a table row to select the responsible user. Fields marked with ",
                span({ style: "color: #dc2626;" }, "*"),
                " are mandatory."
            )
        ),

        // Users table
        table({ style: styles.table },
            thead(
                tr(
                    th({ style: styles.th }, "Photo"),
                    th({ style: styles.th }, "Name"),
                    th({ style: styles.th }, "Email"),
                    th({ style: styles.th }, "Location")
                )
            ),
            tbody(...rows)
        ),

        // Confirmation card for the selected user
        () => {
            if (!selectedUser.val) {
                return null;
            }

            const u = selectedUser.val;
            const fullName = `${u.name.first} ${u.name.last}`;

            return div({ style: styles.userCard },
                img({
                    src: u.picture?.medium || u.picture?.thumbnail,
                    alt: fullName,
                    style: styles.cardAvatar
                }),
                div({ style: styles.userInfo },
                    p({ style: styles.userName }, fullName),
                    p({ style: styles.userDetail }, `✉ ${u.email}`),
                    p({ style: styles.userDetail }, `📍 ${u.location?.city}, ${u.location?.country}`),
                    span({ style: styles.successBadge }, "✓ Parameter 'userName' stored for the other tabs")
                )
            );
        }
    );
}

// 7. Fetch the users and paint the component through the callback
async function refreshUsers() {
    if (isFetching) {
        return;
    }

    isFetching = true;
    selectedUserId.val = "";
    selectedUser.val = null;
    setModelData(false);

    try {
        // Request through the 'http' utility (a Nuxt 4 useFetch instance)
        const { data, error } = await http("https://randomuser.me/api/?results=8");

        if (error && error.value) {
            throw new Error(error.value.message || "Could not fetch the users");
        }

        const payload = data && data.value ? data.value : data;

        if (payload && Array.isArray(payload.results)) {
            usersList.val = payload.results;
        } else {
            usersList.val = [];
        }

        // Call the callback only once the user refresh has completed
        callback(buildForm());
    } catch (err) {
        console.error("Error refreshing the users:", err);
        const errorView = div({ style: styles.container },
            p({ style: styles.errorText }, `The users could not be loaded: ${err.message || err}`)
        );
        callback(errorView);
    } finally {
        isFetching = false;
    }
}

// 8. Refresh the users automatically when the wizard tab is entered or refreshed
onStepEnter(() => {
    refreshUsers();
});

Example 5

Description

This example demonstrates how to build a summary or review step that:

  1. Tracks data updates across the wizard using onDataUpdate.
  2. Avoids granular reactivity issues by repainting the entire form on demand when selecting/entering the tab via onStepEnter and callback(buildForm(currentData)).
  3. Recursively parses objects, arrays, and primitive values into a collapsible VanJS tree structure using native <details> and <summary> elements.
  4. Highlights different data types (strings, numbers, booleans, objects, arrays, and null/undefined) with distinct typography and colors.
  5. Keeps the step valid using setModelData(true).

Code

// 1. Import only the VanJS tags used below
const { div, span, strong, p, details, summary, ul, li } = van.tags;

// 2. Reusable inline styles
const styles = {
    container: "padding: 1rem; font-family: system-ui, -apple-system, sans-serif; color: #1e293b; max-width: 720px;",
    header: "margin-bottom: 1.25rem;",
    title: "font-size: 1.1rem; font-weight: 600; margin: 0 0 0.25rem 0; color: #0f172a;",
    subtitle: "color: #64748b; font-size: 0.85rem; margin: 0;",
    treeBox: "background-color: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.85rem; overflow-x: auto;",
    details: "margin-left: 0.5rem; margin-top: 0.25rem;",
    summary: "cursor: pointer; user-select: none; font-weight: 600; color: #2563eb; padding: 0.15rem 0;",
    badge: "font-size: 0.75rem; color: #64748b; font-weight: normal; margin-left: 0.35rem;",
    list: "list-style: none; margin: 0.2rem 0 0.2rem 0.5rem; padding-left: 0.75rem; border-left: 1px dashed #cbd5e1;",
    item: "margin: 0.25rem 0;",
    key: "color: #0f172a; font-weight: 600;",
    strVal: "color: #166534;",
    numVal: "color: #2563eb;",
    boolVal: "color: #7c3aed;",
    nullVal: "color: #94a3b8; font-style: italic;",
    emptyText: "color: #94a3b8; font-style: italic; margin: 0;"
};

// 3. Latest data accumulated by the wizard
let currentData = entityData || {};

// 4. Mark the step as valid, since this is a read-only summary view
setModelData(true);

// 5. Listen for and store the updates coming from the other wizard steps
onDataUpdate((updatedData) => {
    currentData = updatedData || {};
});

// 6. Helpers to format primitive values
function renderPrimitive(val) {
    if (val === null) {
        return span({ style: styles.nullVal }, "null");
    }
    if (val === undefined) {
        return span({ style: styles.nullVal }, "undefined");
    }
    if (typeof val === "string") {
        return span({ style: styles.strVal }, `"${val}"`);
    }
    if (typeof val === "number") {
        return span({ style: styles.numVal }, String(val));
    }
    if (typeof val === "boolean") {
        return span({ style: styles.boolVal }, String(val));
    }
    return span(String(val));
}

// 7. Static recursive rendering as a tree
function renderTree(data) {
    if (!data || typeof data !== "object" || Object.keys(data).length === 0) {
        return p({ style: styles.emptyText }, "No data has been entered yet.");
    }

    const keys = Object.keys(data);
    const childItems = keys.map((k) => {
        const itemVal = data[k];

        if (itemVal !== null && typeof itemVal === "object") {
            const isArray = Array.isArray(itemVal);
            const count = Object.keys(itemVal).length;
            const badgeLabel = isArray ? `[${count} items]` : `{${count} fields}`;

            return li({ style: styles.item },
                details({ style: styles.details, open: true },
                    summary({ style: styles.summary },
                        span({ style: styles.key }, k),
                        span({ style: styles.badge }, badgeLabel)
                    ),
                    renderTree(itemVal)
                )
            );
        }

        return li({ style: styles.item },
            span({ style: styles.key }, `${k}: `),
            renderPrimitive(itemVal)
        );
    });

    return ul({ style: styles.list }, ...childItems);
}

// 8. Build the component with the current data
function buildForm(data) {
    return div({ style: styles.container },
        div({ style: styles.header },
            p({ style: styles.title },
                strong("Wizard Data Tree")
            ),
            p({ style: styles.subtitle },
                "Hierarchical structure of every parameter entered in the wizard so far."
            )
        ),

        div({ style: styles.treeBox },
            renderTree(data)
        )
    );
}

// 9. Repaint the form only when the tab is entered or selected
onStepEnter(() => {
    setModelData(true);
    callback(buildForm(currentData));
});

// 10. Initial paint
callback(buildForm(currentData));