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