Ejecting from SimpleCrud: Advanced UI Patterns
This guide explains when and how to "eject" from Stack9's simpleCrud screen type to build rich, custom user interfaces using the listView + detailView pattern with custom components.
Overview
Stack9's simpleCrud screen type provides a complete CRUD interface out-of-the-box, perfect for standard data management screens. However, when you need advanced UI capabilities, custom layouts, or complex business logic, you'll want to eject to a more flexible architecture.
The Ejection Pattern combines:
- listView: A customizable list screen with filtering and pagination
- detailView: A blank canvas for building rich detail screens
- Custom ActionBar: Drawer-based creation forms with advanced controls
- Custom Components: Full React components for complete UI control
When to Eject from SimpleCrud
Use SimpleCrud When
- ✅ Standard CRUD operations are sufficient
- ✅ Default form layouts meet your needs
- ✅ Basic validation and field types are adequate
- ✅ Quick development is the priority
- ✅ UI requirements are straightforward
Eject to ListView + DetailView When
- ✅ You need custom form layouts or multi-step wizards
- ✅ Complex field interdependencies or conditional logic
- ✅ Custom UI components (charts, maps, rich editors)
- ✅ Multiple related data sections on one screen
- ✅ Advanced validation or real-time calculations
- ✅ Custom navigation flows or modal interactions
- ✅ Integration with external services during form entry
Architecture Deep Dive
SimpleCrud Architecture
SimpleCrud provides everything in one configuration file:
- List columns
- Form fields for create/update
- Standard CRUD operations
- Basic validation
Ejected Architecture
The ejected pattern separates concerns:
- List View: Focuses on data display and filtering
- Detail View: Provides a canvas for custom components
- ActionBar: Manages creation through drawer components
- Custom Components: Implement rich UI requirements
Implementation Guide
Step 1: Convert SimpleCrud to ListView
Transform your simpleCrud screen to listView:
Before (simpleCrud):
{
"head": {
"title": "Club Management",
"key": "club_management",
"route": "club-management"
},
"screenType": "simpleCrud",
"entityKey": "club",
"listQuery": "getclublist",
"detailQuery": "getclubdetails",
"columnsConfiguration": [...],
"formFieldset": {
"create": [...],
"update": [...]
}
}
After (listView):
{
"head": {
"title": "Club list",
"key": "club_list",
"route": "club-list",
"app": "crm",
"description": "club list"
},
"screenType": "listView",
"listQuery": "getclublist",
"columnsConfiguration": [
{
"field": "club_code",
"label": "Club code",
"value": "{{club_code}}",
"renderAs": "Text",
"options": {
"linkProp": "/crm/club-detail/{{id}}"
}
},
{
"field": "name",
"label": "Name",
"value": "{{name}}",
"renderAs": "Text",
"options": {
"linkProp": "/crm/club-detail/{{id}}"
}
}
// ... other columns
]
}
Key Changes:
- Changed
screenTypefromsimpleCrudtolistView - Removed
formFieldsetconfiguration - Added
linkPropto columns pointing to custom detail route - Removed
entityKeyanddetailQuery(no longer needed)
Step 2: Create the DetailView Screen
Create a minimal detailView configuration that serves as a container:
File: src/screens/club_detail.json
{
"head": {
"title": "Club detail",
"key": "club_detail",
"route": "club-detail/:id",
"app": "crm"
},
"screenType": "detailView",
"components": {
"ROOT": {
"type": {
"resolvedName": "PageRoot"
},
"isCanvas": true,
"props": {},
"displayName": "Container",
"custom": {},
"parent": "",
"hidden": false,
"nodes": [],
"linkedNodes": {}
}
},
"queries": [
{
"name": "getClubDetails",
"queryKey": "getclubdetails",
"userParams": {
"id": ""
}
},
{
"name": "getClubCommissionByClubId",
"queryKey": "getclubcommissionbyclubid",
"userParams": {
"id": ""
}
}
]
}
Important Elements:
screenType: "detailView"provides a blank canvas- Route includes
:idparameter for entity identification queriesarray defines data fetching operations- Minimal component structure (will be overridden by custom component)
Step 3: Build the Custom Detail Component
Create a rich React component for the detail view:
File: src/pages/ClubDetail/ClubDetail.tsx
import { useRouteAndQueryParams } from '@april9au/stack9-react';
import { S9CustomEntityFormPage, S9PageSection } from '@april9au/stack9-ui';
import { ClubAccruedCommissionTable } from '../../components/ClubAccruedCommissionTable';
import { ClubDetailFieldset } from '../../components/ClubDetailFieldset';
import { AppRoutes } from '../../constants/appRoutes';
import { entityNames } from '../../constants/entities';
const CustomEntityFormPage = S9CustomEntityFormPage.default;
const PageSection = S9PageSection.default;
export const ClubDetail = () => {
const {
routeParams: { id = '' },
} = useRouteAndQueryParams();
return (
<CustomEntityFormPage
entityKey={entityNames.CLUB}
entityId={+id}
title={`Club #${id}`}
cancelLink={AppRoutes.ClubList}
screenQueryName="getClubDetails"
>
<PageSection title="" size="sm">
<ClubDetailFieldset />
</PageSection>
<PageSection title="Club commissions" size="sm">
<ClubAccruedCommissionTable clubId={+id} />
</PageSection>
</CustomEntityFormPage>
);
};
Key Patterns:
S9CustomEntityFormPageprovides form context and save/cancel actionsPageSectioncomponents organize content areas- Multiple sections allow complex layouts
- Custom components can be embedded (tables, charts, etc.)
- Route parameters extracted via
useRouteAndQueryParams
Step 4: Create the CreateDrawer Screen Definition
Every CreateDrawer component requires a companion screen definition JSON file that defines the queries (including the create mutation) used by the drawer. Without this file, the drawer won't be able to execute queries.
Create the screen definition JSON file for your CreateDrawer component:
File: src/screens/club_create_drawer.json
{
"head": {
"title": "Club create drawer",
"key": "club_create_drawer",
"route": "/club-create-drawer",
"app": "crm",
"description": "club create drawer"
},
"screenType": "detailView",
"components": {
"ROOT": {
"type": {
"resolvedName": "PageRoot"
},
"isCanvas": true,
"props": {},
"displayName": "Container",
"custom": {},
"parent": "",
"hidden": false,
"nodes": [],
"linkedNodes": {}
}
},
"queries": [
{
"name": "searchAddressSuggestions",
"queryKey": "searchaddresssuggestions",
"userParams": {
"address": "{{address}}",
"countryISO": "{{countryISO}}"
}
},
{
"name": "getFormattedAddressWithKey",
"queryKey": "getformattedaddresswithkey",
"userParams": {
"global_address_key": "{{global_address_key}}"
}
},
{
"name": "createClub",
"queryKey": "createclub"
}
]
}
Key Elements:
screenType: "detailView"for drawer screensqueriesarray must include the create mutation query (e.g.,createClub)- Include any additional queries needed by form fields (e.g., address lookup)
- The
keyfield (e.g.,club_create_drawer) is used byScreenProviderto load the correct screen context - Route doesn't need to be an actual navigable route, but should follow naming conventions
Connection to Component:
The ScreenProvider wraps the drawer component and loads the screen definition, making all queries available to the component through hooks like useScreen() and queryService.runNamedQuery().
Step 5: Build the CreateDrawer Component
Build a drawer component for entity creation that references the screen definition:
File: src/components/ClubCreateDrawer/ClubCreateDrawer.tsx
import { useCallback, useState } from 'react';
import { useEntitySchema, useStack9 } from '@april9au/stack9-react';
import { ep } from '@april9au/stack9-sdk';
import {
S9Button,
S9Drawer,
S9Form,
S9Space,
ScreenProvider,
useScreen,
} from '@april9au/stack9-ui';
import { AppRoutes } from '../../constants/appRoutes';
import { entityNames } from '../../constants/entities';
import { ClubDetailFieldset } from '../ClubDetailFieldset/ClubDetailFieldset';
const Drawer = S9Drawer.default;
const Form = S9Form.default;
const Space = S9Space.default;
const Button = S9Button.default;
const { useForm } = S9Form;
// Internal form component
const ClubForm = ({ onSaved }: { onSaved?: () => void }) => {
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [form] = useForm();
const { queryService } = useStack9();
const { isValidating } = useEntitySchema(entityNames.CLUB);
const { screen } = useScreen();
const screenKey = ep(screen && screen.head.key);
const handleOnSave = useCallback(
async (values: DBClub) => {
if (isProcessing) {
return;
}
setIsProcessing(true);
await queryService.runNamedQuery(screenKey, 'createClub', {
vars: {
name: values.name ?? null,
club_code: values.club_code ?? null,
is_active: values.is_active ?? false,
contact_person: values.contact_person ?? null,
contact_email: values.contact_email ?? null,
contact_phone: values.contact_phone ?? null,
commission_percentage: values.commission_percentage ?? null,
// ... other fields
},
});
onSaved?.();
setIsProcessing(false);
},
[isProcessing, onSaved, queryService, screenKey],
);
return (
<Form
form={form}
loading={isValidating}
onFinish={handleOnSave}
initialValues={{ is_active: true }}
>
<Drawer.Header
title="Create club"
showCancel
extra={
<Space>
<Button label="Save" onClick={form.submit} />
</Space>
}
/>
<Drawer.Content>
<ClubDetailFieldset />
</Drawer.Content>
</Form>
);
};
// Main drawer component - follows standard pattern
export const ClubCreateDrawer = ({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) => (
<ScreenProvider relativePath={AppRoutes.ClubCreateDrawer}>
<Drawer open={open} onClose={onClose} width={650} closable>
<ClubForm onSaved={onClose} />
</Drawer>
</ScreenProvider>
);
Critical Patterns:
- Standard Props Interface: Always use
{ open: boolean; onClose: () => void } - ScreenProvider: Wraps drawer and references the screen definition via
relativePath(e.g.,AppRoutes.ClubCreateDrawerwhich maps to thekeyin the JSON:club_create_drawer) - Form Submission: Use
queryService.runNamedQuery(screenKey, 'createClub', ...)- the query name must match a query defined in the screen definition JSON - Callback Pattern:
onSavedtriggersonCloseafter successful save - Reusable Fieldsets: Share form fields between create and edit views
- Screen Context: The
useScreen()hook provides access to queries defined in the companion screen definition JSON
Step 6: Add Actions to ActionBarComponent
Register your create action in the ActionBar:
File: src/components/ActionBarComponent.tsx
import { memo, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import * as ui from '@april9au/stack9-ui';
import { Space } from 'antd';
import { ClubCreateDrawer } from './ClubCreateDrawer/ClubCreateDrawer';
const S9Button = ui.S9Button.default;
const { useRefreshList } = ui;
// Action component for Club list page
const ClubActions = () => {
const [isOpen, setIsOpen] = useState(false);
const refresh = useRefreshList();
return (
<>
<ClubCreateDrawer
open={isOpen}
onClose={() => {
setIsOpen(false);
refresh(); // Critical: refresh list after creation
}}
/>
<Space>
<S9Button
label="Create new"
onClick={() => setIsOpen(true)}
type="primary"
/>
</Space>
</>
);
};
// Main ActionBar component
export const ActionBarComponent = memo(
({ defaultActions }: ui.ActionBarComponentProps) => (
<>
{defaultActions}
<Routes>
{/* Route matches the list screen route */}
<Route path="/club-list" element={<ClubActions />} />
{/* Add other custom actions here */}
</Routes>
</>
),
);
Key Elements:
- Route Matching: Path must match your list screen's route
- useRefreshList Hook: Refreshes list data after creation
- State Management: Simple
useStatefor drawer open/close - Space Wrapper: Consistent button spacing
- Memo Optimization: Prevents unnecessary re-renders
Step 7: Register Custom Routes
Register your custom detail component in the application:
File: src/index.tsx
import { ClubDetail } from './pages/ClubDetail/ClubDetail';
// In your routes configuration:
const customRoutes = [
{
route: '/club-detail/:id',
component: <ClubDetail />,
},
// ... other custom routes
];
Route Pattern: /entity-detail/:id for consistency