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