Skip to main content

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

graph LR
A[SimpleCrud Screen] --> B[Auto List View]
A --> C[Auto Detail View]
A --> D[Auto Create/Edit Forms]
B --> E[Standard Actions]
C --> F[Standard Layout]

SimpleCrud provides everything in one configuration file:

  • List columns
  • Form fields for create/update
  • Standard CRUD operations
  • Basic validation

Ejected Architecture

graph TB
A[List View Screen] --> B[Custom ActionBar]
B --> C[CreateDrawer Component]
A --> D[Custom Detail Route]
D --> E[DetailView Screen]
E --> F[Custom React Component]
F --> G[Rich UI Components]
C --> H[Refresh List Hook]

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 screenType from simpleCrud to listView
  • Removed formFieldset configuration
  • Added linkProp to columns pointing to custom detail route
  • Removed entityKey and detailQuery (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 :id parameter for entity identification
  • queries array 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 '@april9/stack9-react';
import { S9CustomEntityFormPage, S9PageSection } from '@april9/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:

  • S9CustomEntityFormPage provides form context and save/cancel actions
  • PageSection components 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

Important

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 screens
  • queries array must include the create mutation query (e.g., createClub)
  • Include any additional queries needed by form fields (e.g., address lookup)
  • The key field (e.g., club_create_drawer) is used by ScreenProvider to load the correct screen context
  • Route doesn't need to be an actual navigable route, but should follow naming conventions

Connection to Component:

graph LR
A[ClubCreateDrawer.tsx] -->|ScreenProvider relativePath| B[club_create_drawer.json]
B -->|queries array| C[createClub query]
A -->|runNamedQuery| C
B -->|queries array| D[Other queries]
A -->|useScreen hook| D

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 '@april9/stack9-react';
import { ep } from '@april9/stack9-sdk';
import {
S9Button,
S9Drawer,
S9Form,
S9Space,
ScreenProvider,
useScreen,
} from '@april9/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.ClubCreateDrawer which maps to the key in 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: onSaved triggers onClose after 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 '@april9/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 useState for 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

Best Practices

Naming Conventions

✅ List Screen: entity_list.json
✅ Detail Screen: entity_detail.json
✅ Create Drawer Screen: entity_create_drawer.json
✅ List Route: /entity-list
✅ Detail Route: /entity-detail/:id
✅ Create Drawer Route: /entity-create-drawer (in JSON, not actually navigable)
✅ Action Component: EntityActions
✅ Drawer Component: EntityCreateDrawer
✅ Detail Component: EntityDetail
✅ Fieldset Component: EntityDetailFieldset (shared between create/edit)

State Management Patterns

// Always use this pattern for drawer state
const [isOpen, setIsOpen] = useState(false);

// Always refresh after mutations
const refresh = useRefreshList();
onClose={() => {
setIsOpen(false);
refresh();
}}

Component Organization

apps/stack9-frontend/src/
components/
EntityCreateDrawer/
EntityCreateDrawer.tsx
EntityDetailFieldset/
EntityDetailFieldset.tsx
ActionBarComponent.tsx
pages/
EntityDetail/
EntityDetail.tsx
packages/stack9-stack/src/screens/
entity_list.json
entity_detail.json
entity_create_drawer.json

Common Patterns

Conditional UI Elements

const EntityActions = () => {
const { isAllowed } = usePermission();
const [isOpen, setIsOpen] = useState(false);
const refresh = useRefreshList();

return (
<>
{isAllowed && (
<EntityCreateDrawer
open={isOpen}
onClose={() => {
setIsOpen(false);
refresh();
}}
/>
)}
<Space>
<S9Button
disabled={!isAllowed}
label="Create new"
onClick={() => setIsOpen(true)}
type="primary"
/>
</Space>
</>
);
};

Shared Fieldsets

// Reusable fieldset component
export const EntityFieldset = () => {
const { schema } = useEntitySchema('entity');
const formInContext = useFormInstance();

const fields: FormConfiguration[] = [
{ field: 'name', colSize: 12 },
{ field: 'code', colSize: 12 },
{
field: 'status',
renderAs: 'Select',
options: ['Active', 'Inactive'],
colSize: 24
}
];

return <Fieldset fields={fields} schema={schema} />;
};

Custom Validation

const handleOnSave = useCallback(
async (values) => {
// Custom validation
if (values.startDate > values.endDate) {
message.error('Start date must be before end date');
return;
}

if (!validateEmail(values.email)) {
message.error('Invalid email format');
return;
}

// Proceed with save
await queryService.runNamedQuery(screenKey, 'createEntity', {
vars: values,
});
},
[...]
);

Migration Checklist

When migrating from simpleCrud to the ejected pattern:

  • Create listView screen configuration (e.g., entity_list.json)
  • Update column linkProp to point to custom detail route
  • Create detailView screen configuration (e.g., entity_detail.json)
  • Create detailView screen definition for the create drawer (e.g., entity_create_drawer.json)
  • Ensure create drawer screen includes the create mutation query in its queries array
  • Build shared fieldset component (e.g., EntityDetailFieldset.tsx)
  • Build custom detail page component (e.g., EntityDetail.tsx)
  • Create drawer component for entity creation (e.g., EntityCreateDrawer.tsx)
  • Add action component to ActionBarComponent with proper route matching
  • Register custom detail route in index.tsx
  • Test list → detail navigation
  • Test create flow with list refresh
  • Test edit/update functionality
  • Verify all validations work correctly
  • Update any related screens that link to this entity

Troubleshooting

Common Issues

List doesn't refresh after create:

  • Ensure useRefreshList() is called in the onClose callback
  • Verify the list query name matches

Detail page shows blank:

  • Check route registration in index.tsx
  • Verify the route pattern matches the screen configuration
  • Ensure queries are properly configured

Create drawer doesn't open:

  • Verify ActionBar route matches list screen route
  • Check that state is properly managed with useState

Form validation not working:

  • Ensure entity schema is loaded with useEntitySchema
  • Check that required fields are marked in the entity definition
  • Verify form is wrapped with proper providers

CreateDrawer shows "Query not found" error:

  • Verify the companion screen definition JSON exists (e.g., entity_create_drawer.json)
  • Ensure the query name in runNamedQuery matches a query in the screen definition's queries array
  • Check that ScreenProvider relativePath correctly references the screen key
  • Confirm the screen JSON is in the correct location and properly formatted

Summary

The ejection pattern from simpleCrud to listView + detailView provides the flexibility needed for complex UI requirements while maintaining Stack9's powerful data management capabilities. Use this pattern when standard CRUD interfaces don't meet your needs, but remember that with greater flexibility comes additional development complexity.

Key Takeaways:

  • SimpleCrud is perfect for standard CRUD operations
  • Eject when you need rich UI or complex business logic
  • Follow the standard patterns for consistency
  • Always refresh lists after mutations
  • Reuse components where possible
  • Test navigation flows thoroughly

By following this guide, you can build sophisticated, user-friendly interfaces that go beyond basic CRUD while leveraging Stack9's robust backend capabilities.