4. Employee screens#
What this step does. Repeats the Department five-file pattern for Employees, plus two idioms the first entity didn’t need: a dotted
department.codecolumn that reads the FK join inline, and aMantineServerLookupagainst/departmentthat turns the Department dropdown into a live server query.
Same five-file layout as Department — grid + new-form at the top level, view subfolder for the detail page and its edit dialog. The interesting bits here are the flattened FK column in the grid and the MantineServerLookup against /department inside the form.
src/pages/employee/
EmployeeGridPage.tsx
EmployeeNewForm.tsx
view/
EmployeeViewPage.tsx
EmployeeViewForm.tsx
EmployeeEditForm.tsxGrid page#
Columns use dotted attribute paths to project joined data — department.code reads the department code via the FK join without a second fetch. A module-level statusRenderer attaches as a cellRenderer on the status column.
// src/pages/employee/EmployeeGridPage.tsx
import { useRef } from 'react';
import { Badge } from '@mantine/core';
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 EmployeeNewForm from './EmployeeNewForm';
const statusColor: Record<string, string> = {
ACTIVE: 'green', INACTIVE: 'gray', RESIGNED: 'red',
};
const statusRenderer = (p: any) => {
const v = p.getValue();
return <Badge color={statusColor[v] ?? 'gray'}>{v}</Badge>;
};
const fields: ColumnDefinition[] = [
{ attribute: 'loginName', name: 'loginName', label: 'Email',
searchable: true, sortable: true, type: 'string' },
{ attribute: 'firstName', name: 'firstName', label: 'First Name',
searchable: true, sortable: true, type: 'string' },
{ attribute: 'lastName', name: 'lastName', label: 'Last Name',
searchable: true, sortable: true, type: 'string' },
{ attribute: 'department.code', name: 'departmentCode', label: 'Department',
searchable: true, sortable: true, type: 'string' },
{ attribute: 'joiningDate', name: 'joiningDate', label: 'Joined',
sortable: true, type: 'date',
serverPattern: 'YYYY-MM-DD',
displayPattern: 'DD MMM YYYY' },
{ attribute: 'status', name: 'status', label: 'Status',
sortable: true, type: 'string', cellRenderer: statusRenderer },
];
export default function EmployeeGridPage() {
const gridRef = useRef<IPageQueryable>(null);
const [opened, { open, close }] = useDisclosure(false);
const endPoint = ServiceEndpoint.employee.restApi;
const onRefresh = () => gridRef.current?.refresh();
const getPluginOptions = (): any => ({
addText: 'Add Employee',
onNewClick: open,
export: { visible: false },
backOption: false,
});
return (
<>
<SummaryGrid
title="Employee"
pageName=""
columns={fields}
pageSize={[15, 30, 45]}
gridRef={gridRef}
getPluginOptions={getPluginOptions}
options={{ endPoint }}
/>
<EmployeeNewForm
open={opened}
onClose={close}
onRefresh={onRefresh}
size="lg"
pageName="Employee"
title="New Employee"
/>
</>
);
}New-form dialog#
MantineServerLookup on department runs a debounced query against /department, displays code / name in the dropdown, and writes back a { department: { id } } FK on submit.
// src/pages/employee/EmployeeNewForm.tsx
import { DialogNewForm } from '@palmyralabs/template-tribble';
import { FieldGroupContainer } from '@palmyralabs/rt-forms';
import {
MantineTextField, MantineDateInput,
MantineSelect, MantineServerLookup,
} from '@palmyralabs/rt-forms-mantine';
import { ServiceEndpoint, LookupEndPoint } 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 EmployeeNewForm(props: Props) {
const apiEndPoint = ServiceEndpoint.employee.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={2}>
<MantineTextField attribute="loginName" label="Email" required autoFocus
validRule="email"
invalidMessage={fieldMsg.mandatory} />
<MantineServerLookup
attribute="department"
label="Department"
placeholder="Select Department"
queryOptions={{
endPoint: LookupEndPoint.department,
queryAttribute: 'name',
labelAttribute: 'name',
idAttribute: 'id',
delay: 250,
}}
displayAttribute={['code', 'name']}
required
invalidMessage={fieldMsg.mandatory}
/>
<MantineTextField attribute="firstName" label="First Name" required
invalidMessage={fieldMsg.mandatory} />
<MantineTextField attribute="lastName" label="Last Name" required
invalidMessage={fieldMsg.mandatory} />
<MantineDateInput attribute="joiningDate" label="Joining Date" required
serverPattern="YYYY-MM-DD"
displayPattern="DD MMM YYYY"
invalidMessage={fieldMsg.mandatory} />
<MantineSelect attribute="status" label="Status"
defaultValue="ACTIVE"
options={[
{ value: 'ACTIVE', label: 'Active' },
{ value: 'INACTIVE', label: 'Inactive' },
{ value: 'RESIGNED', label: 'Resigned' },
]} />
</FieldGroupContainer>
</div>
</DialogNewForm>
);
}View page#
// src/pages/employee/view/EmployeeViewPage.tsx
import { useRef } from 'react';
import { useParams } from 'react-router-dom';
import EmployeeViewForm from './EmployeeViewForm';
export default function EmployeeViewPage() {
const params = useParams();
const formRef = useRef<any>(null);
return (
<div style={{ padding: 16 }}>
<EmployeeViewForm
ref={formRef}
id={String(params.id)}
pageName="Employee"
/>
</div>
);
}View form (read-only)#
Display-only widgets from rt-forms-mantine’s form/view/ — MantineTextView for scalars, MantineLookupView for the FK so the department code + name are rendered without another round-trip.
// src/pages/employee/view/EmployeeViewForm.tsx
import { forwardRef, useImperativeHandle, useRef } 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, MantineDateView, MantineLookupView, MantineOptionsView,
} from '@palmyralabs/rt-forms-mantine';
import { ServiceEndpoint } from '../../../config/ServiceEndpoints';
import EmployeeEditForm from './EmployeeEditForm';
interface Props { id: string; pageName: string }
const statusOptions = { ACTIVE: 'Active', INACTIVE: 'Inactive', RESIGNED: 'Resigned' };
const EmployeeViewForm = forwardRef(function EmployeeViewForm(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}>Employee</Title>
<Group>
<Button variant="default" onClick={() => navigate('/employees')}>Back</Button>
<Button onClick={open}>Edit</Button>
</Group>
</Group>
<PalmyraViewForm
formRef={formRef}
endPoint={`${ServiceEndpoint.employee.restApi}/{id}`}
id={props.id}
>
<FieldGroupContainer columns={2}>
<MantineTextView attribute="loginName" label="Email" />
<MantineLookupView attribute="department" label="Department"
lookupOptions={{ idAttribute: 'id', labelAttribute: 'name' }} />
<MantineTextView attribute="firstName" label="First Name" />
<MantineTextView attribute="lastName" label="Last Name" />
<MantineDateView attribute="joiningDate" label="Joined"
serverPattern="YYYY-MM-DD"
displayPattern="DD MMM YYYY" />
<MantineOptionsView attribute="status" label="Status"
options={statusOptions} />
</FieldGroupContainer>
</PalmyraViewForm>
<EmployeeEditForm
open={opened}
onClose={close}
onRefresh={() => formRef.current?.refresh()}
employeeId={props.id}
size="lg"
pageName={props.pageName}
title="Edit Employee"
/>
</>
);
});
export default EmployeeViewForm;Edit-form dialog#
Mirror of the new-form, with DialogEditForm + id prop:
// src/pages/employee/view/EmployeeEditForm.tsx
import { DialogEditForm } from '@palmyralabs/template-tribble';
import { FieldGroupContainer } from '@palmyralabs/rt-forms';
import {
MantineTextField, MantineDateInput,
MantineSelect, MantineServerLookup,
} from '@palmyralabs/rt-forms-mantine';
import { ServiceEndpoint, LookupEndPoint } from '../../../config/ServiceEndpoints';
import { fieldMsg } from '../../../config/ErrorMsgConfig';
interface Props {
open: boolean;
onClose: () => void;
onRefresh: () => void;
employeeId: any;
size?: string;
pageName: string;
title: string;
}
export default function EmployeeEditForm(props: Props) {
const apiEndPoint = ServiceEndpoint.employee.restApi;
return (
<DialogEditForm
endPoint={apiEndPoint}
open={props.open}
onClose={props.onClose}
onRefresh={props.onRefresh}
id={props.employeeId}
size={props.size}
pageName={props.pageName}
title={props.title}
>
<div className="palmyra-form-field-container-wrapper">
<FieldGroupContainer columns={2}>
<MantineTextField attribute="loginName" label="Email" required
validRule="email"
invalidMessage={fieldMsg.mandatory} />
<MantineServerLookup
attribute="department"
label="Department"
placeholder="Select Department"
queryOptions={{
endPoint: LookupEndPoint.department,
queryAttribute: 'name',
labelAttribute: 'name',
idAttribute: 'id',
delay: 250,
}}
displayAttribute={['code', 'name']}
required
invalidMessage={fieldMsg.mandatory}
/>
<MantineTextField attribute="firstName" label="First Name" required
invalidMessage={fieldMsg.mandatory} />
<MantineTextField attribute="lastName" label="Last Name" required
invalidMessage={fieldMsg.mandatory} />
<MantineDateInput attribute="joiningDate" label="Joining Date" required
serverPattern="YYYY-MM-DD"
displayPattern="DD MMM YYYY"
invalidMessage={fieldMsg.mandatory} />
<MantineSelect attribute="status" label="Status"
options={[
{ value: 'ACTIVE', label: 'Active' },
{ value: 'INACTIVE', label: 'Inactive' },
{ value: 'RESIGNED', label: 'Resigned' },
]} />
</FieldGroupContainer>
</div>
</DialogEditForm>
);
}With the grid, new-form, view page, and edit-form wired, the Employee screens are complete. Head to Recap and next steps for an end-to-end wiring summary and pointers on where to take the app from here.