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.tsxGrid 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.