4. Forms and ACL#

Forms use @palmyralabs/rt-forms for state/validation and @palmyralabs/rt-forms-mantine for the Mantine-styled input widgets. You do not wire Mantine’s useForm directly — the Palmyra form layer takes care of binding, validation, submit, and store integration.

Dialog create form#

The clinic sample uses a DialogNewForm template that renders a modal, collects attributes into a FieldGroupContainer, and POSTs to endPoint on submit.

// src/pages/masterdata/manufacturer/ManufacturerNewForm.tsx
import { DialogNewForm } from '@palmyralabs/template-tribble';
import { FieldGroupContainer } from '@palmyralabs/rt-forms';
import { TextField, TextArea, CurrencyField }
  from '@palmyralabs/rt-forms-mantine';

import { ServiceEndpoint } from '@/config/ServiceEndpoints';
import { MobileRegex, fieldMsg } from '@/config/validation';

export default function ManufacturerNewForm(props: {
  open: boolean;
  onClose: () => void;
  onRefresh: () => void;
  pageName: string;
  title: string;
}) {
  const apiEndPoint = ServiceEndpoint.masterData.manufacturer.restApi;

  return (
    <DialogNewForm
      size="lg"
      endPoint={apiEndPoint}
      open={props.open}
      onClose={props.onClose}
      onRefresh={props.onRefresh}
      pageName={props.pageName}
      title={props.title}
    >
      <FieldGroupContainer columns={2}>
        <TextField
          attribute="name"
          label="Name"
          autoFocus required
          invalidMessage={fieldMsg.mandatory}
        />
        <TextField
          attribute="contactMobile"
          label="Mobile"
          validRule="number"
          regExp={{ regex: MobileRegex, errorMessage: 'Invalid phone number' }}
        />
        <TextField
          attribute="contactEmail"
          label="Email"
          validRule="email"
        />
        <CurrencyField
          attribute="rating"
          label="Rating"
          min={0} max={5} defaultValue={0}
        />
        <TextArea
          attribute="address"
          label="Address"
          colspan={2}
        />
      </FieldGroupContainer>
    </DialogNewForm>
  );
}

The attribute prop on each field is the POJO attribute name declared in @PalmyraField. That’s the only cross-stack coupling — rename the Java field and the JSON key, and this form changes in one place.

Validation shapes declaratively:

  • required — non-empty.
  • validRule="number" | "email" — built-in checks.
  • regExp={{ regex, errorMessage }} — custom pattern.
  • invalidMessage — per-field override for the error text.

Edit / delete flows#

Edit forms reuse the same field components inside DialogEditForm (rt-forms-mantine), passing keyValue={id} so the form hydrates from GET /mstManufacturer/{id} and submits via PUT. Delete is an imperative call on the grid store triggered from the row-action toolbar — no form at all.

ACL codes#

The backend’s palmyra-dbacl-mgmt extension returns each user’s permission codes at login. The frontend stashes them in local state and exposes a hook.

// src/config/AclPermissions.ts
export const AclPermission = {
  manufacturer: {
    GET_ALL : 'CUTAP001',
    CREATE  : 'CUTAP002',
    UPDATE  : 'CUTAP003',
    DELETE  : 'CUTAP004',
  },
  stockEntry: {
    GET_ALL : 'CUTAP101',
    CREATE  : 'CUTAP102',
  },
};
// src/admin/components/checkAclAccess/AclAccessCheck.ts
import { topic } from '@palmyralabs/ts-utils';

let cache = readFromStorage();

topic.subscribe('resetAcl', () => { cache = readFromStorage(); });

export function useAclAccess() {
  return {
    hasAclAccess: (code: string) => cache.includes(code),
  };
}

function readFromStorage(): string[] {
  try { return JSON.parse(localStorage.getItem('acl') ?? '[]'); }
  catch { return []; }
}

The topic.subscribe('resetAcl', ...) pub-sub refreshes the cache after login/logout — no full page reload needed.

Gating UI on codes#

import { useAclAccess } from '@/admin/components/checkAclAccess/AclAccessCheck';
import { AclPermission } from '@/config/AclPermissions';

function ManufacturerGridPage() {
  const { hasAclAccess } = useAclAccess();

  return (
    <SummaryGrid
      {...gridProps}
      getPluginOptions={() => ({
        onNewClick: hasAclAccess(AclPermission.manufacturer.CREATE) ? open : undefined,
        addText:    hasAclAccess(AclPermission.manufacturer.CREATE) ? 'Add' : '',
        export:     { visible: hasAclAccess(AclPermission.manufacturer.GET_ALL) },
      })}
    />
  );
}

A hidden control isn’t a security boundary — the backend’s ACL filter still rejects unauthorized requests with 403. The useAclAccess hook only avoids showing controls that the server would refuse.