Provision Wizard

Here you can view/configure provision wizards for your organization’s website.

Provision Wizard

Wizard provision

When editing an item from the listing, you will see the wizard with its previously entered data.

Administrative data step

Here you must define administrative data for the provision wizard. This data will be used to identify the wizard in the system and the kind of resource that will be provisioned with this wizard.

  • Identifier: Identifies the wizard in the system. Useful when you need to invoke the wizard programmatically.
  • Title: Title of the wizard. This will be the visible part of the wizard in the system.
  • Icon: Icon of the wizard. This will be the visible part of the wizard in the system.
  • Wizard Type: Type of the wizard. This will be the visible part of the wizard in the system.
  • Specific Type: Specific type of the wizard. This will be the visible part of the wizard in the system.

Fields (tabs) step

Here you can define tabs and fields for the provision wizard. Each tab will be a tab in the wizard and each field will be a field in the tab.

You will find the following type of tabs/steps:

  • Default tabs: Tabs with predefined fields for the selected resource type (Only when not an Advanced Wizard)
  • Custom tabs: Tabs with custom fields/form for the selected resource type.

Custom tabs have their own fields:

  • Name: Name of the tab
  • Description: Description of the tab

Below you have to select how to configure the layout of the fields within this tab. You have 3 options:

  • Fields selection: a combo field will be displayed in final wizard with a list of all fields of this specific type. Select the fields you want to display.

  • Preselected fields: You can preselect some fields with default values. These fields will be displayed in final wizard with the preselected values.

  • Custom form: A fully customized form using vanjs will be displayed in final wizard. See Custom Form Tab for more information.

Default values step

Here you can define default values for the operation that will be executed within this custom view (Only when not an Advanced Wizard)

Previous validations step

In this step you can write a script to validate the data introduced in the previous steps before the final action is executed.

Note that in Advanced Wizards you have to perform the final action here because there is no default provision action as in other provision wizards.

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 validate the introduced data.

Callback must be called in order to validate the introduced data and has these params:

  • result: (boolean) determines if the execution can continue. If false, the execution will stop and the messages will be shown to the user.
  • messages: (array) messages to be shown in the execution log.

Example:

async function (entityData,callback) { 
    // YOUR CODE HERE WITHOUT FUNCTION DECLARATION
    
    // Example of validation:
    let validationResult = true;
    let messages = [];
    if (entityData["name"] === "") {
        validationResult = false;
        messages.push("Name is required");
    }
    callback(validationResult, messages);
}

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.

Post execution step

In this step you can write a script to perform the final action of the wizard after the completion of the previous actions. If previous validations returned false this step would have never been executed.

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

  • entityData contains the data of the entity in json format.
  • callback is a function that must be called to indicate the end of the execution.

Callback must be called in order to validate the introduced data and has these params:

  • result: (boolean) indicates the result of the execution. If false, the execution will be marked as failed and the messages will be shown to the user.
  • messages: (array) messages to be shown in the execution log.

Example:

async function (entityData,callback) { 
    // YOUR CODE HERE WITHOUT FUNCTION DECLARATION
    
    // Example:
    let executionResult = true;
    let messages = [];
    if (entityData["name"] === "") {
        executionResult = false;
        messages.push("Name is required");
    }
    callback(executionResult, messages);
}

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.


Subsections of Provision Wizard

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));