s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Capabilities

Field policies 02

Build a form that reads its own policy

The Svelte primitives take a resolved policy as a prop and contribute visibility, ordering, labels, help, and default pre-fill. A hand-written form can adopt them one field at a time, or a whole form can be generated from the manifest.

Adopt policy one field at a time

FieldPolicyProvider owns the basic/advanced mode and publishes the resolved policy. PolicyField wraps any input. It supplies the label, help hint, required marker, and current-mode visibility. It supplies the default value only for new records. Outside a provider, PolicyField renders its children without changes. Thus, adoption can occur in steps.

  • ModeSwitch toggles between basic and advanced; AdvancedFields is the disclosure the advanced tier lives in.
  • ModeSwitch, AdvancedFields, and FormHelp require a provider and fail visibly when one is missing; only PolicyField supports provider-free incremental adoption.
  • Use exactly one FieldPolicyProvider per form. FormHelp must stay under that same provider so it follows the form mode; pass the object-level description to its objectDescription prop.
  • Set isNewRecord={false} on an edit form so a resolved default never overwrites a loaded value.
ArticleForm.svelte
typescript
<script lang="ts">
  import {
    AdvancedFields,
    FieldPolicyProvider,
    ModeSwitch,
    PolicyField
  } from '@happyvertical/smrt-fields/svelte';
  import { Input, Textarea } from '@happyvertical/smrt-ui/forms';

  let { policy, record, isNew = true } = $props();
</script>

<FieldPolicyProvider {policy} mode="basic">
  <ModeSwitch />

  <PolicyField name="title" isNewRecord={isNew}>
    <Input id="title" bind:value={record.title} />
  </PolicyField>

  <AdvancedFields>
    <PolicyField name="summary" isNewRecord={isNew}>
      <Textarea id="summary" bind:value={record.summary} />
    </PolicyField>
  </AdvancedFields>
</FieldPolicyProvider>

Render a whole object from its manifest

ObjectForm renders fields that occur in the generated browser definitions and the resolved policy, which sets the order. Generated definitions omit sensitive and transient fields, so these fields cannot reach the form. The batch resolve endpoint omits read-permission-gated fields. A server policy from resolveFieldPolicy still includes them, and this policy overlap does not filter permissions. Continue to enforce read permission at the existing boundary. The host supplies both inputs, and ObjectForm creates the FieldPolicyProvider that its fields and actions share.

  • Pass generated browser definitions, never raw server registry fields.
  • Do not wrap ObjectForm in another FieldPolicyProvider. The actions snippet renders inside its provider and native form. It is the supported location for FormHelp. A plain submit button keeps native submission and the form validation.
  • To reuse a mounted create form for another new record, replace the bound record with an empty object or change createSessionKey.
ArticleWorkbench.svelte
typescript
<script lang="ts">
  import { ObjectForm } from '@happyvertical/smrt-fields/svelte';
  import { articles } from '$lib/generated-clients';

  let { definition, policy } = $props();
  let record = $state({});

  async function save(event: SubmitEvent) {
    event.preventDefault();
    await articles.create(record);
  }
</script>

<ObjectForm
  objectRef="@happyvertical/smrt-content:Article"
  fields={definition.fields}
  {policy}
  bind:value={record}
  isNewRecord
  showModeSwitch
  onsubmit={save}
>
  {#snippet actions()}
    <button type="submit">Save</button>
  {/snippet}
</ObjectForm>

Register the generated collections once

An application can register every generated collection definition in one place. It can put the batch resolve client behind ObjectFormSourceProvider. Forms under the provider need only their canonical object reference. The registry validates the generated definition and the untyped custom-action response. It fails closed with an accessible error state instead of a partial form.

  • The registry takes a policy client: anything with resolveBatch({ objectRefs }), normally the generated FieldPolicy collection client.
  • Generated custom-action clients are typed as any; the registry is where that boundary is checked.
  • assertObjectFormCollectionDefinition validates a definition before it enters the registry.
  • The canonical reference is @package/name:ClassName, for example @happyvertical/smrt-content:Article.
src/lib/object-form-source.ts
typescript
import { collectionDefinitions } from '@happyvertical/smrt-virt-web';
import {
  assertObjectFormCollectionDefinition,
  ObjectFormSourceRegistry
} from '@happyvertical/smrt-fields/svelte';
import { fieldPolicies } from '$lib/generated-clients';

// fieldPolicies only has to satisfy { resolveBatch({ objectRefs }) }.
export const objectFormSource = new ObjectFormSourceRegistry(fieldPolicies);

for (const definition of Object.values(collectionDefinitions)) {
  assertObjectFormCollectionDefinition(definition);
  objectFormSource.register(definition);
}

// <ObjectFormSourceProvider source={objectFormSource}>
//   <ObjectForm objectRef="@happyvertical/smrt-content:Article" bind:value={record} />
// </ObjectFormSourceProvider>

Open the generated routes the form needs

ObjectForm renders and binds a record. The generated application API still saves it. Objects declared with api: false have no save routes. A form for such an object usually needs a narrow API include list. Do not open the complete model.

  • Keep delete out of the include list when the interface never deletes.
  • writable narrows which fields a generated write will accept, independently of what the form displays.
  • Field policy never widens this. A field hidden by policy is still writable by the API unless the model says otherwise.
  • The example below is the SaaS starter’s own settings object; the operations guide walks the rest of its integration.
packages/app-objects/src/models/StarterAppSetting.ts
typescript
@smrt({
  tableName: 'starter_app_settings',
  conflictColumns: ['key'],
  api: {
    include: ['list', 'get', 'create', 'update'],
    principalContext: true,
    writable: ['key', 'value', 'metadata']
  },
  mcp: false,
  cli: false
})
export class StarterAppSetting extends SmrtObject {
  // ...
}

Replace an input without inventing a wire type

The built-in inputs cover text, integer, decimal, boolean, datetime, JSON, and reference identifiers. s-m-r-t has no select wire type. A field with fixed choices keeps its persisted type, which is usually text. The application registers a select-like renderer for that field. A field-specific registration wins over a wire-type registration.

  • createFieldInputRegistry returns a registry scoped to one application root rather than a global.
  • Reference fields stay identifier inputs on purpose until an application supplies a chooser.
  • policyToVisibleColumnIds adapts the resolved policy to a smrt-ui DataTable. It never reveals a statically hidden column. It does not hide unmapped action or computed columns.
src/lib/field-inputs.ts
typescript
import { createFieldInputRegistry } from '@happyvertical/smrt-fields/svelte';
import StatusSelect from '$lib/components/StatusSelect.svelte';

export const inputRegistry = createFieldInputRegistry();

// The column stays text; only the rendering changes.
inputRegistry.registerField(
  '@happyvertical/smrt-content:Article',
  'status',
  StatusSelect
);