3. Department screens#

What this step does. Builds the first entity’s full UX with five small files — a list grid, a “New” modal, a detail page, a read-only view form, and an “Edit” modal. Nothing touches axios; the grid and forms resolve endpoints on their own through the store factory context from step 2.

Five files per entity, following the clinic’s masterdata layout: grid page + new-form dialog at the top level; detail page + view form + edit dialog under view/.

src/pages/department/
  DepartmentGridPage.tsx
  DepartmentNewForm.tsx
  view/
    DepartmentViewPage.tsx
    DepartmentViewForm.tsx
    DepartmentEditForm.tsx

Grid page#

SummaryGrid handles the table, pagination, sort, and quick-search. A useDisclosure toggle opens the new-form modal; gridRef.current.refresh() reloads after a successful save.

// src/pages/department/DepartmentGridPage.tsx
import { useRef } from 'react';
import { useDisclosure } from '@mantine/hooks';
import { SummaryGrid } from '@palmyralabs/template-tribble';
import type { ColumnDefinition, IPageQueryable } from '@palmyralabs/rt-forms';
import { ServiceEndpoint } from '../../config/ServiceEndpoints';
import DepartmentNewForm from './DepartmentNewForm';

const fields: ColumnDefinition[] = [
  { attribute: 'code',        name: 'code',        label: 'Code',
    searchable: true, sortable: true, type: 'string' },
  { attribute: 'name',        name: 'name',        label: 'Name',
    searchable: true, sortable: true, type: 'string' },
  { attribute: 'description', name: 'description', label: 'Description',
    type: 'string' },
];

export default function DepartmentGridPage() {
  const gridRef = useRef<IPageQueryable>(null);
  const [opened, { open, close }] = useDisclosure(false);

  const endPoint = ServiceEndpoint.department.restApi;

  const onRefresh = () => gridRef.current?.refresh();

  const getPluginOptions = (): any => ({
    addText:    'Add Department',
    onNewClick: open,
    export:     { visible: false },
    backOption: false,
  });

  return (
    <>
      <SummaryGrid
        title="Department"
        pageName=""
        columns={fields}
        pageSize={[15, 30, 45]}
        gridRef={gridRef}
        getPluginOptions={getPluginOptions}
        options={{ endPoint }}
      />
      <DepartmentNewForm
        open={opened}
        onClose={close}
        onRefresh={onRefresh}
        size="lg"
        pageName="Department"
        title="New Department"
      />
    </>
  );
}

New-form dialog#

DialogNewForm wraps PalmyraNewForm — you supply the field set and it handles submit, close, and the success callback. Field inputs come from @palmyralabs/rt-forms-mantine; fieldMsg.mandatory is the shared string from step 2.

// src/pages/department/DepartmentNewForm.tsx
import { DialogNewForm } from '@palmyralabs/template-tribble';
import { FieldGroupContainer } from '@palmyralabs/rt-forms';
import { MantineTextField, MantineTextArea } from '@palmyralabs/rt-forms-mantine';
import { ServiceEndpoint } from '../../config/ServiceEndpoints';
import { fieldMsg } from '../../config/ErrorMsgConfig';

interface Props {
  open:      boolean;
  onClose:   () => void;
  onRefresh: () => void;
  size?:     string;
  pageName:  string;
  title:     string;
}

export default function DepartmentNewForm(props: Props) {
  const apiEndPoint = ServiceEndpoint.department.restApi;

  return (
    <DialogNewForm
      endPoint={apiEndPoint}
      open={props.open}
      onClose={props.onClose}
      onRefresh={props.onRefresh}
      size={props.size}
      pageName={props.pageName}
      title={props.title}
    >
      <div className="palmyra-form-field-container-wrapper">
        <FieldGroupContainer columns={1}>
          <MantineTextField attribute="code" label="Code" autoFocus required
                            placeholder="HR / ENG / SALES"
                            invalidMessage={fieldMsg.mandatory} />
          <MantineTextField attribute="name" label="Name" required
                            invalidMessage={fieldMsg.mandatory} />
          <MantineTextArea  attribute="description" label="Description" />
        </FieldGroupContainer>
      </div>
    </DialogNewForm>
  );
}

View page (detail)#

ProductViewPage-equivalent — a thin shell around the read-only form that hosts the “Edit” dialog. useParams() gives the id from /departments/view/:id.

// src/pages/department/view/DepartmentViewPage.tsx
import { useRef } from 'react';
import { useParams } from 'react-router-dom';
import DepartmentViewForm from './DepartmentViewForm';

export default function DepartmentViewPage() {
  const params = useParams();
  const formRef = useRef<any>(null);

  return (
    <div style={{ padding: 16 }}>
      <DepartmentViewForm
        ref={formRef}
        id={String(params.id)}
        pageName="Department"
      />
    </div>
  );
}

View form (read-only) + Edit dialog trigger#

PalmyraViewForm hydrates from GET /department/{id} and the read-only MantineTextView / MantineTextArea widgets display the values. A Back / Edit header gives the user navigation and opens the edit modal.

// src/pages/department/view/DepartmentViewForm.tsx
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Group, Title } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { PalmyraViewForm, FieldGroupContainer, type IViewForm } from '@palmyralabs/rt-forms';
import { MantineTextView } from '@palmyralabs/rt-forms-mantine';   // from form/view/
import { ServiceEndpoint } from '../../../config/ServiceEndpoints';
import DepartmentEditForm from './DepartmentEditForm';

interface Props { id: string; pageName: string }

const DepartmentViewForm = forwardRef(function DepartmentViewForm(props: Props, ref) {
  const navigate = useNavigate();
  const [opened, { open, close }] = useDisclosure(false);
  const formRef = useRef<IViewForm>(null);

  useImperativeHandle(ref, () => ({
    refresh: () => formRef.current?.refresh(),
  }));

  return (
    <>
      <Group justify="space-between" mb="md">
        <Title order={3}>Department</Title>
        <Group>
          <Button variant="default" onClick={() => navigate('/departments')}>Back</Button>
          <Button onClick={open}>Edit</Button>
        </Group>
      </Group>

      <PalmyraViewForm
        formRef={formRef}
        endPoint={`${ServiceEndpoint.department.restApi}/{id}`}
        id={props.id}
      >
        <FieldGroupContainer columns={2}>
          <MantineTextView attribute="code"        label="Code" />
          <MantineTextView attribute="name"        label="Name" />
          <MantineTextView attribute="description" label="Description" colspan={2} />
        </FieldGroupContainer>
      </PalmyraViewForm>

      <DepartmentEditForm
        open={opened}
        onClose={close}
        onRefresh={() => formRef.current?.refresh()}
        departmentId={props.id}
        size="lg"
        pageName={props.pageName}
        title="Edit Department"
      />
    </>
  );
});

export default DepartmentViewForm;

Edit-form dialog#

Same shape as the new-form — duplicate the field set verbatim. Just swaps DialogNewForm for DialogEditForm and adds the id prop.

// src/pages/department/view/DepartmentEditForm.tsx
import { DialogEditForm } from '@palmyralabs/template-tribble';
import { FieldGroupContainer } from '@palmyralabs/rt-forms';
import { MantineTextField, MantineTextArea } from '@palmyralabs/rt-forms-mantine';
import { ServiceEndpoint } from '../../../config/ServiceEndpoints';
import { fieldMsg } from '../../../config/ErrorMsgConfig';

interface Props {
  open:          boolean;
  onClose:       () => void;
  onRefresh:     () => void;
  departmentId:  any;
  size?:         string;
  pageName:      string;
  title:         string;
}

export default function DepartmentEditForm(props: Props) {
  const apiEndPoint = ServiceEndpoint.department.restApi;

  return (
    <DialogEditForm
      endPoint={apiEndPoint}
      open={props.open}
      onClose={props.onClose}
      onRefresh={props.onRefresh}
      id={props.departmentId}
      size={props.size}
      pageName={props.pageName}
      title={props.title}
    >
      <div className="palmyra-form-field-container-wrapper">
        <FieldGroupContainer columns={1}>
          <MantineTextField attribute="code" label="Code" required
                            invalidMessage={fieldMsg.mandatory} />
          <MantineTextField attribute="name" label="Name" required
                            invalidMessage={fieldMsg.mandatory} />
          <MantineTextArea  attribute="description" label="Description" />
        </FieldGroupContainer>
      </div>
    </DialogEditForm>
  );
}

Why duplicate the field set?#

The clinic reference keeps new-form and edit-form field blocks verbatim duplicates — it’s an accepted trade-off. Each form has its own template wrapper (DialogNewForm / DialogEditForm) and its own lifecycle; factoring a shared <Fields/> component adds props drilling without removing real work. Start duplicated, extract a shared component only once a third caller needs it.