Profile Page

Updated:

How to build a Profile page in Tecton: required-field validation for personal information, real-time cross-field password validation, and plain integration toggles.

A Profile page groups unrelated settings into distinct sections. This example covers three: a Personal Information form that validates required fields on submit, a Change Password form that evaluates cross-field rules as the user types and required fields on submit, and an Integrations section with plain toggle controls. All state is held in memory. The example stops at validation; a real application would submit to an API once each form passes.

Template

Personal Information

Please use an email you check frequently to ensure you receive alerts in a timely manner.

Save Information Reset

Change Password

Update the password for your user.

Update Password

Integrations

Connect your account to third-party services.

<q2-grid columns="1" md-columns="5" gap="comfortable">
  <q2-grid-area column-span="2">
    <h2>Personal Information</h2>
    <p>Please use an email you check frequently to ensure you receive alerts in a timely manner.</p>
  </q2-grid-area>
  <q2-grid-area column-span="3">
    <q2-form>
      <q2-input id="pi-first-name" label="First Name" value="William"></q2-input>
      <q2-input id="pi-last-name" label="Last Name" value="Banker"></q2-input>
      <q2-input id="pi-email" type="email" label="Email Address" value="bill.banker@my-domain.com"></q2-input>
      <q2-input label="Username" value="bill.banker" readonly></q2-input>
      <q2-action-group>
        <q2-btn id="pi-save" intent="workflow-primary">Save Information</q2-btn>
        <q2-btn id="pi-reset" intent="workflow-secondary">Reset</q2-btn>
      </q2-action-group>
    </q2-form>
  </q2-grid-area>
</q2-grid>

<q2-grid columns="1" md-columns="5" gap="comfortable" style="margin-top: var(--app-scale-12x);">
  <q2-grid-area column-span="2">
    <h2>Change Password</h2>
    <p>Update the password for your user.</p>
  </q2-grid-area>
  <q2-grid-area column-span="3">
    <q2-form>
      <q2-input id="pw-current" type="password" label="Current Password" show-visibility-toggle></q2-input>
      <q2-input id="pw-new" type="password" label="New Password" show-visibility-toggle></q2-input>
      <q2-input id="pw-confirm" type="password" label="Confirm Password" show-visibility-toggle></q2-input>
      <q2-action-group>
        <q2-btn id="pw-update" intent="workflow-primary">Update Password</q2-btn>
      </q2-action-group>
    </q2-form>
  </q2-grid-area>
</q2-grid>

<q2-grid columns="1" md-columns="5" gap="comfortable" style="margin-top: var(--app-scale-12x);">
  <q2-grid-area column-span="2">
    <h2>Integrations</h2>
    <p>Connect your account to third-party services.</p>
  </q2-grid-area>
  <q2-grid-area column-span="3">
    <q2-form>
      <q2-checkbox
        label="Coinbase"
        description="Buy, sell, and manage crypto directly from your account."
        type="toggle"
        alignment="right"
        checked
      >
      </q2-checkbox>
      <q2-checkbox
        label="QuickBooks"
        description="Send and receive money using our convenient API."
        type="toggle"
        alignment="right"
        checked
      >
      </q2-checkbox>
      <q2-checkbox
        label="You Need A Budget (YNAB)"
        description="Build a budget, check your finances, and experience peace of mind."
        type="toggle"
        alignment="right"
        checked
      >
      </q2-checkbox>
      <q2-action-group>
        <q2-link label="Add another" variant="standalone" icon-type="add" href="#"> </q2-link>
      </q2-action-group>
    </q2-form>
  </q2-grid-area>
</q2-grid>

<script>
// Personal Information

// Element references.
const firstNameInput = document.querySelector('#pi-first-name');
const lastNameInput = document.querySelector('#pi-last-name');
const emailInput = document.querySelector('#pi-email');
const saveBtn = document.querySelector('#pi-save');
const resetBtn = document.querySelector('#pi-reset');

// Initial values, used to seed state and reused by the Reset button.
const INITIAL_VALUES = {
    firstName: 'William',
    lastName: 'Banker',
    email: 'bill.banker@my-domain.com',
};

// View state. Each field writes its value here on input, and validation reads
// from state. q2-input only updates its own `value` on the change (blur) event,
// so tracking input in state is what makes validation reliable on submit.
const state = { ...INITIAL_VALUES };

// Each field paired with its state key and the message shown when it is empty.
const fields = [
    [firstNameInput, 'firstName', 'First name is required.'],
    [lastNameInput, 'lastName', 'Last name is required.'],
    [emailInput, 'email', 'Email address is required.'],
];

// Checks every required field against state, sets errors on empty ones, and
// returns true only when all pass.
function validatePersonalInfo() {
    let valid = true;
    fields.forEach(([el, key, message]) => {
        if (!state[key].trim()) {
            el.errors = [message];
            valid = false;
        }
    });
    return valid;
}

// Each field writes to state and clears its own error as the user types.
fields.forEach(([el, key]) => {
    el.addEventListener('tctInput', event => {
        state[key] = event?.detail?.value ?? '';
        if (el.errors?.length) el.errors = [];
    });
});

// Submit validates only. A real application would send the update here.
saveBtn.addEventListener('tctClick', () => {
    if (!validatePersonalInfo()) return;
});

// Reset restores the initial values in both state and the inputs, then clears errors.
resetBtn.addEventListener('tctClick', () => {
    fields.forEach(([el, key]) => {
        state[key] = INITIAL_VALUES[key];
        el.value = INITIAL_VALUES[key];
        el.errors = [];
    });
});

// Change Password

// Element references.
const currentPasswordInput = document.querySelector('#pw-current');
const newPasswordInput = document.querySelector('#pw-new');
const confirmPasswordInput = document.querySelector('#pw-confirm');
const updatePasswordBtn = document.querySelector('#pw-update');

// Tracks the current value of each password field so all three can be
// compared whenever any one of them changes.
const passwordState = { current: '', new: '', confirm: '' };

// Re-evaluates both cross-field rules on every keystroke. Always clears both
// error arrays first so a resolved condition disappears immediately.
function validatePasswords() {
    newPasswordInput.errors = [];
    confirmPasswordInput.errors = [];

    // Only flag the mismatch once both fields have a value, so the user is not
    // penalized for a field they have not yet filled in.
    if (passwordState.current && passwordState.new && passwordState.current === passwordState.new) {
        newPasswordInput.errors = ['New password must differ from your current password.'];
    }

    if (passwordState.new && passwordState.confirm && passwordState.new !== passwordState.confirm) {
        confirmPasswordInput.errors = ['Passwords do not match.'];
    }
}

currentPasswordInput.addEventListener('tctInput', event => {
    passwordState.current = event?.detail?.value ?? '';
    if (currentPasswordInput.errors?.length) currentPasswordInput.errors = [];
    validatePasswords();
});

newPasswordInput.addEventListener('tctInput', event => {
    passwordState.new = event?.detail?.value ?? '';
    validatePasswords();
});

confirmPasswordInput.addEventListener('tctInput', event => {
    passwordState.confirm = event?.detail?.value ?? '';
    validatePasswords();
});

// Submit re-runs the cross-field rules, then requires every field, surfacing an
// error on any that is still empty. A real application would submit when valid.
updatePasswordBtn.addEventListener('tctClick', () => {
    validatePasswords();

    let valid = !newPasswordInput.errors?.length && !confirmPasswordInput.errors?.length;
    const required = [
        [currentPasswordInput, passwordState.current, 'Enter your current password.'],
        [newPasswordInput, passwordState.new, 'Enter a new password.'],
        [confirmPasswordInput, passwordState.confirm, 'Confirm your new password.'],
    ];
    required.forEach(([el, value, message]) => {
        if (!value) {
            el.errors = [message];
            valid = false;
        }
    });

    if (!valid) return;
});
</script>

How it works

The page holds its three sections in a two-column grid layout. Each section is independent: the Personal Information and Change Password forms share no state and respond to different user gestures. The Integrations toggles require no save logic.

All validation writes to the errors property on q2-input. Setting it to a non-empty array activates the error state (red border and icon). Setting it back to an empty array clears it.

// Activate the error state.
myInput.errors = ['This field is required.'];

// Clear the error state.
myInput.errors = [];
The error indicator is always visible when errors is set. The error message text appears below the field only when the field is focused. This is the expected behavior for q2-input.

Personal Information: validate on submit

Validation fires once, when the user clicks Save. Each field writes its current value into a state object as the user types, and validatePersonalInfo checks that state, setting errors on any empty field and returning false to stop the submit logic. It reads from state rather than each input's value because q2-input only updates its own value on the change event (which fires on blur), so state is the reliable source for what the user has actually typed.

const state = { ...INITIAL_VALUES };

const fields = [
    [firstNameInput, 'firstName', 'First name is required.'],
    [lastNameInput, 'lastName', 'Last name is required.'],
    [emailInput, 'email', 'Email address is required.'],
];

function validatePersonalInfo() {
    let valid = true;
    fields.forEach(([el, key, message]) => {
        if (!state[key].trim()) {
            el.errors = [message];
            valid = false;
        }
    });
    return valid;
}

// Submit validates only. A real application would send the update here.
saveBtn.addEventListener('tctClick', () => {
    if (!validatePersonalInfo()) return;
});

Each field listens for tctInput, writes its latest value into state, and clears its own errors array immediately, so the feedback that a problem is fixed is instant.

fields.forEach(([el, key]) => {
    el.addEventListener('tctInput', event => {
        state[key] = event?.detail?.value ?? '';
        if (el.errors?.length) el.errors = [];
    });
});

Reset: restore without triggering validation

The Reset button restores the initial values in both state and the inputs. Assigning .value directly updates the displayed value without firing tctInput, so the input handlers do not interfere, and resetting state keeps validation in step. Errors are then cleared explicitly.

resetBtn.addEventListener('tctClick', () => {
    fields.forEach(([el, key]) => {
        state[key] = INITIAL_VALUES[key];
        el.value = INITIAL_VALUES[key];
        el.errors = [];
    });
});

Change Password: validate in real time

Password validation runs on every keystroke. Each tctInput handler stores the latest value in passwordState, then calls validatePasswords(), which evaluates both cross-field rules.

const passwordState = { current: '', new: '', confirm: '' };

function validatePasswords() {
    newPasswordInput.errors = [];
    confirmPasswordInput.errors = [];

    if (passwordState.current && passwordState.new && passwordState.current === passwordState.new) {
        newPasswordInput.errors = ['New password must differ from your current password.'];
    }

    if (passwordState.new && passwordState.confirm && passwordState.new !== passwordState.confirm) {
        confirmPasswordInput.errors = ['Passwords do not match.'];
    }
}

Two details worth noting:

  • Both rules guard with && before comparing. If either field involved in a check is still empty, the rule is skipped. The user should not see an error for a field they have not yet filled in.
  • validatePasswords() always clears both error arrays before re-evaluating. This ensures an error disappears the moment the condition is no longer true, with no need to track which errors were previously set.

The real-time checks keep the cross-field rules honest, but they stay silent while a field is empty, so they cannot stand in for a final check. The submit handler adds that: it re-runs validatePasswords(), then flags any field that is still empty, and only proceeds when everything passes.

updatePasswordBtn.addEventListener('tctClick', () => {
    validatePasswords();

    let valid = !newPasswordInput.errors?.length && !confirmPasswordInput.errors?.length;
    const required = [
        [currentPasswordInput, passwordState.current, 'Enter your current password.'],
        [newPasswordInput, passwordState.new, 'Enter a new password.'],
        [confirmPasswordInput, passwordState.confirm, 'Confirm your new password.'],
    ];
    required.forEach(([el, value, message]) => {
        if (!value) {
            el.errors = [message];
            valid = false;
        }
    });

    if (!valid) return;
});

Related