# View logic

`ViewLogic` controls how a form behaves in the browser. It handles field visibility, requiredness, read-only state, and lifecycle hooks - everything that shapes the user experience after the page loads.

The code lives in `views/logic/index.ts` inside your app's [package structure](/developer-guide/app-architecture/package-structure.md). It runs entirely client-side. Server-side behavior belongs in [actions](/developer-guide/building-blocks/actions.md); view logic is for the UI layer.

## Export pattern[​](#export-pattern "Direct link to Export pattern")

View logic files export a default object typed with `satisfies ViewLogic<typeof entity>`. Each property is a method that the form engine calls at the appropriate time:

```
import type { EntityFieldName } from '#typings';
import { entity } from '#typings';
import type { ViewLogic } from '@comind/api';

export default {
    onReady,
    getReadonlyFields,
    getInvisibleFields,
    onBeforeSave,
} satisfies ViewLogic<typeof entity>;

function onReady() {
    const user = ComindView.currentUser;
    const mode = ComindView.actionMode;
}

function getReadonlyFields() {
    const fields: EntityFieldName[] = [];
    if (!ComindView.currentUser.isWorkspaceAdmin) {
        fields.push('c_admin_notes');
    }
    return fields;
}

function getInvisibleFields() {
    if (entity.c_budget <= 10000) {
        return ['c_approver'] as EntityFieldName[];
    }
    return [] as EntityFieldName[];
}

function onBeforeSave() {
    if (entity.c_budget > 10000 && !entity.c_approver) {
        return 'An approver is required for budgets over 10,000';
    }
}
```

Do not use `as const satisfies ViewLogic` - omit `as const`. The build system will reject it.

## Lifecycle hooks[​](#lifecycle-hooks "Direct link to Lifecycle hooks")

These methods run at specific points in the form's lifecycle. Include them in your export object to register them.

| Method            | Purpose                                                               |
| ----------------- | --------------------------------------------------------------------- |
| `onReady()`       | Form loaded and ready                                                 |
| `onRefresh()`     | Form data refreshed (entity not yet updated)                          |
| `onDestroy()`     | Form being destroyed                                                  |
| `onReset()`       | Form reset                                                            |
| `onBeforeSave()`  | Pre-save validation - return `false` or an error string to block save |
| `onGridLoaded()`  | Embedded grid finished loading                                        |
| `onInputChange()` | Field value changed                                                   |

`onBeforeSave` is the most common hook. Use it to enforce cross-field validation that goes beyond single-field requiredness:

```
function onBeforeSave() {
    if (entity.c_budget > 10000 && !entity.c_approver) {
        return 'An approver is required for budgets over 10,000';
    }
}
```

Returning `false` or a string stops the save and shows the message to the user. Returning nothing (or `true`) allows the save to proceed.

## Dynamic field control[​](#dynamic-field-control "Direct link to Dynamic field control")

These methods let you change field behavior at runtime based on the current record state, the user's role, or the form mode. Each returns an array of field db names.

| Method                 | Purpose                                      |
| ---------------------- | -------------------------------------------- |
| `getInvisibleFields()` | Return field names to hide                   |
| `getRequiredFields()`  | Return field names to make required          |
| `getReadonlyFields()`  | Return field names to make read-only         |
| `getFilterKeys()`      | Filter keys for lookups                      |
| `getFieldOverrides()`  | Override field schema properties dynamically |
| `helperFunction()`     | Register helper functions                    |

The engine re-evaluates these methods whenever the record changes, so the UI stays in sync with the data. A field hidden by `getInvisibleFields` disappears from the form; a field marked by `getRequiredFields` shows a validation marker and blocks save if left empty.

```
function getInvisibleFields() {
    // Hide the "approver" field unless the budget exceeds 10,000
    if (entity.c_budget <= 10000) {
        return ['c_approver'] as EntityFieldName[];
    }
    return [] as EntityFieldName[];
}

function getRequiredFields() {
    // Require "reason" when the state is "rejected"
    if (entity.state === 'rejected') {
        return ['c_reason'] as EntityFieldName[];
    }
    return [] as EntityFieldName[];
}
```

Always use field db names (e.g. `c_priority`, not the caption). See [Fields](/developer-guide/building-blocks/fields-and-field-options.md) for naming conventions.

## Accessing context[​](#accessing-context "Direct link to Accessing context")

View logic often needs information beyond the current record. Access field values directly on the `entity` global. The `ComindView` global exposes context, navigation, and utility methods.

**Form context:**

* `ComindView.action` - the current action name
* `ComindView.actionMode` - form mode: `'mass-edit'`, `'editable'`, `'editing'`, `'full-form'`, `'plain'`, etc.
* `ComindView.schema` - the loaded app schema with fields, actions, lookups, and `publishing_alias`

**Workspace context:**

* `ComindView.workspace` - the current workspace with `.alias`, `.current_user_id`, `.group_participants`, `.tabs`

**User context:**

* `ComindView.currentUser` - object with `id`, `email`, `isWorkspaceAdmin`, `isGlobalAdmin`, and `isInGroup()`
* `ComindView.getVars()` - get app variables
* `ComindView.getSetting(name)` - get an app setting value

**Record navigation:**

* `ComindView.parentRecord(fieldName?)` - access the parent record's data
* `ComindView.childRecords(appAlias?, fieldName?)` - access child records
* `ComindView.siblingRecords()` - access sibling records
* `ComindView.getLinkedRecordAsync(fieldName)` - async fetch of a linked record
* `ComindView.getRecordById(id)` - async fetch any record by ID

Use `actionMode` to tailor behavior by context. For example, you might skip expensive validation in `'plain'` mode since the user is only viewing the record.

For the full property and method reference, see [Client-side API](/developer-guide/reference/client-side-api.md).

## Working with grids and requests[​](#working-with-grids-and-requests "Direct link to Working with grids and requests")

A few additional `ComindView` methods handle less common scenarios:

* `ComindView.request(url, config?)` - make an HTTP request from the browser
* `ComindView.addGridRow(values, appAlias?, fieldName?)` - programmatically add a row to an embedded grid

These are useful when your app embeds child grids or needs to call external APIs from the client. For the full method reference, see [Client-side API](/developer-guide/reference/client-side-api.md).

## Plugin combination[​](#plugin-combination "Direct link to Plugin combination")

When multiple [plugins](/developer-guide/building-blocks/plugins-and-inheritfrom.md) define `ViewLogic`, the engine combines their methods. Each plugin's hooks run in sequence, and your app's view logic runs last - after all plugin view logic has executed. This means you can override or extend behavior inherited from plugins.

If two plugins both define `onBeforeSave`, both handlers run. If either returns `false`, the save is blocked. Plan your validation accordingly to avoid conflicting rules.

Tutorials

For step-by-step walkthroughs of view logic in practice, see:

* [Create new visibility rule](/developer-guide/tutorials/tutorial-customize-app/create-new-visibility-rule.md) - conditionally show or hide fields based on user role
* [Create new before-save hook](/developer-guide/tutorials/tutorial-customize-app/create-new-before-save-hook.md) - add client-side validation before a record is saved
