Phase 09A: Frontend
Objective: Build a generic admin UI (the "Desk") that renders forms and lists from metadata.
[!IMPORTANT] The React "Desk" is OPTIONAL. The REST API is the primary interface.
- Indie: Use the built-in React Desk (no custom code).
- Plugin: Extend the Desk with custom React components (Shadow Build).
- Eject: Ignore the Desk entirely. Build your own frontend (Vue, Svelte, Next.js, mobile).
- Headless: No frontend at all. Just API + Studio.
1. Frontend Architecture
Decision: Use Refine.dev as the frontend meta-framework. See ADR-0005: Use Refine for Frontend for rationale.
1.1 Tech Stack (Per ADR-0005)
| Layer | Technology | Purpose |
|---|---|---|
| Data/State | @refinedev/core | CRUD, caching, auth, access control |
| Forms | RJSF + react-hook-form | Schema-driven form rendering |
| Tables | @tanstack/react-table | Flexible data tables |
| UI Components | shadcn/ui | Accessible, Tailwind-based components |
| Styling | Tailwind CSS | Utility-first styling |
| Charts | Tremor | Dashboard analytics |
| Code Editor | Monaco Editor | Studio code preview |
| Drag & Drop | @dnd-kit | Studio field editor |
1.2 Project Setup
-
Option A: Use Refine CLI (Recommended)
Refine provides an interactive CLI that scaffolds the project with all dependencies:
npm create refine-app@latest frontendWhen prompted, select:
- React Platform: Vite
- UI Framework: Headless (we'll add shadcn/ui manually)
- Router: React Router v6
- Data Provider: REST API (custom)
- Auth Provider: Custom
-
Option B: Manual Setup (Full Control)
pnpm create vite@latest frontend -- --template react-ts
cd frontend
pnpm add @refinedev/core @refinedev/react-router @refinedev/react-table
pnpm add @tanstack/react-table @tanstack/react-query react-router-dom -
Install form libraries (RJSF):
pnpm add @rjsf/core @rjsf/utils @rjsf/validator-ajv8
pnpm add react-hook-form @hookform/resolvers zod -
Install UI layer (shadcn/ui):
pnpm add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
pnpm dlx shadcn@latest init -
Install dashboard/charts (Tremor):
pnpm add @tremor/react -
Install Studio-specific tools:
pnpm add @monaco-editor/react @dnd-kit/core @dnd-kit/sortable
1.3 Refine Data Provider
- Create
frameworkMDataProvider.ts:- Implements Refine's
DataProviderinterface - Maps to Framework M REST API (
/api/v1/{resource}) - Handles pagination, sorting, filtering
- Implements Refine's
// src/providers/frameworkMDataProvider.ts
import { DataProvider } from "@refinedev/core";
const API_URL = window.__FRAMEWORK_CONFIG__?.apiBaseUrl || "/api/v1";
export const frameworkMDataProvider: DataProvider = {
getList: async ({ resource, pagination, sorters, filters }) => {
const params = new URLSearchParams();
if (pagination) {
params.set("limit", String(pagination.pageSize));
params.set("offset", String((pagination.current - 1) * pagination.pageSize));
}
// ... handle sorters, filters
const response = await fetch(`${API_URL}/${resource}?${params}`);
const data = await response.json();
return { data: data.items, total: data.total };
},
getOne: async ({ resource, id }) => {
const response = await fetch(`${API_URL}/${resource}/${id}`);
return { data: await response.json() };
},
create: async ({ resource, variables }) => {
const response = await fetch(`${API_URL}/${resource}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variables),
});
return { data: await response.json() };
},
update: async ({ resource, id, variables }) => {
const response = await fetch(`${API_URL}/${resource}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(variables),
});
return { data: await response.json() };
},
deleteOne: async ({ resource, id }) => {
await fetch(`${API_URL}/${resource}/${id}`, { method: "DELETE" });
return { data: { id } };
},
getApiUrl: () => API_URL,
};
1.4 Base URL Configuration
- Support configurable base URLs for different deployment scenarios:
| Scenario | UI Base | API Base | Config |
|---|---|---|---|
| Same Origin (Default) | / | /api/v1 | No config needed |
| Subdirectory | /portal | /api | API_BASE_URL=/api |
| Separate Subdomains | portal.example.com | api.example.com | Full URL in config |
- Inject configuration via
window.__FRAMEWORK_CONFIG__:
// Injected by backend or build-time env
window.__FRAMEWORK_CONFIG__ = {
apiBaseUrl: "/api/v1",
metaBaseUrl: "/api/meta",
wsBaseUrl: "/api/v1/stream",
};
1.5 App Entry Point
- Create Refine app entry (
src/App.tsx):
import { Refine } from "@refinedev/core";
import routerProvider from "@refinedev/react-router";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { frameworkMDataProvider } from "./providers/frameworkMDataProvider";
import { authProvider } from "./providers/authProvider";
export const App = () => (
<BrowserRouter>
<Refine
dataProvider={frameworkMDataProvider}
authProvider={authProvider}
routerProvider={routerProvider}
resources={[
// Resources dynamically loaded from /api/meta
]}
>
<Routes>
{/* Routes populated from metadata */}
</Routes>
</Refine>
</BrowserRouter>
);
1.6 Architecture Alignment (Refine ↔ Framework M)
Refine's Provider pattern maps directly to Framework M's Ports & Adapters:
| Framework M (Python) | Refine (TypeScript) |
|---|---|
RepositoryProtocol | DataProvider |
PermissionProtocol | AccessControlProvider |
AuthContextProtocol | AuthProvider |
EventBusProtocol | LiveProvider |
DocType | Resource |
BaseController | Resource hooks |
2. Metadata-Driven UI
2.1 Metadata Fetcher
-
Create
useDocTypeMetahook:const { schema, layout, permissions, workflow } = useDocTypeMeta("Todo"); -
Fetch from
GET /api/meta/{doctype} -
Cache metadata aggressively using React Query
2.2 Auto Form Generator (RJSF Integration)
-
Create
AutoFormcomponent using RJSF:- Accept JSON Schema from
GET /api/meta/{doctype} - Generate form fields dynamically
- Map schema types to shadcn/ui components:
-
string→<Input /> -
number→<Input type="number" /> -
boolean→<Checkbox /> -
enum→<Select /> -
date→<DatePicker /> - Link →
<Combobox />with async search (LinkWidget)
-
- Accept JSON Schema from
-
Use RJSF with custom widget registry for shadcn/ui
-
Architecture: Export
AutoFormas standalone component- Allow developers to use it inside custom pages ("Ejection Strategy")
- Do not couple strictly to a "Meta-Driven Page" route
2.3 Auto Table Generator (TanStack Table)
- Create
AutoTablecomponent using@refinedev/react-table:- Columns generated from metadata
- Sorting, filtering, pagination built-in
- Row selection for bulk actions
2.4 Workflow UI (State Buttons)
- State Indicator:
- Show current state badge (e.g., "Draft", "Approved") in Form Header
- Action Buttons:
- Fetch available transitions:
GET /api/workflow/{doctype}/{id}/actions - Render buttons: "Submit", "Approve", "Reject"
- Handle transition API call on click
- Fetch available transitions:
2.5 Plugin System (The "Shadow Build")
- Goal: "Easy as Frappe" but with Vite/React tooling
- Strategy:
-
m startdetectsfrontend/index.tsin all installed apps - Generates a temporary
entry.tsxthat imports all app plugins - Runs Vite Dev Server on this generated entry
- Result: Hot Module Replacement (HMR) for custom apps inside the main Desk
- No Monkey Patching: Plugins register via
app.registerPlugin()
-
2.6 Progressive Customization (The "Zero Cliff")
- Level 1: Configuration (Low Code)
- Edit Labels/Visibility via
Client Scriptor JSON overrides - No build step required
- Edit Labels/Visibility via
- Level 2: Injection (Mid Code)
- Custom Fields:
app.registerField('rating', StarRating) - Slot Injection:
app.registerSlot('form-header', PaymentButton) - Uses Shadow Build (HMR)
- Custom Fields:
- Level 3: Ejection (Pro Code)
- Page Override: Replace
FormViewentirely with custom React component - Whitelabel: Replace
Layout/Themevia Plugin - Uses Shadow Build (HMR)
- Page Override: Replace
2.7 Frontend Extension Examples (Custom Apps)
[!NOTE] Just like backend DocType extension (Phase 08), frontend forms and lists can be extended by custom apps without forking the base app. Uses Refine.dev directly — no wrapper package.
Level 1: Configuration (No Build)
// custom_app/frontend/config/depot.json
{
"doctype": "Depot",
"form": {
"fields": {
"capacity": { "label": "Max Capacity (MT)", "hidden": false },
"internal_code": { "hidden": true } // Hide field from form
},
"sections": [
{ "title": "Location", "fields": ["location", "region"] },
{ "title": "Capacity", "fields": ["capacity", "manager"] }
]
},
"list": {
"columns": ["name", "location", "capacity", "manager"],
"defaultSort": { "field": "capacity", "order": "desc" }
}
}
Level 2: Custom Components with Refine.dev Hooks
// custom_app/frontend/components/DepotCapacityGauge.tsx
import { useOne } from '@refinedev/core';
import { Progress } from 'antd';
export const DepotCapacityGauge = ({ id }: { id: string }) => {
const { data } = useOne({ resource: 'Depot', id });
const depot = data?.data;
if (!depot) return null;
const usedPercent = (depot.current_stock / depot.capacity) * 100;
return (
<Progress percent={usedPercent} status={usedPercent > 90 ? 'exception' : 'normal'} />
);
};
// custom_app/frontend/components/DepotListActions.tsx
import { Button } from 'antd';
import { useNavigation } from '@refinedev/core';
export const DepotListActions = ({ record }: { record: any }) => {
const { push } = useNavigation();
return (
<Button onClick={() => push(`/stock/${record.id}`)}>
View Stock
</Button>
);
};
Level 3: Custom Form/List with Refine.dev
// custom_app/frontend/pages/DepotEdit.tsx
import { useForm, Edit } from '@refinedev/antd';
import { Form, Input, InputNumber } from 'antd';
import { RegionSelect } from '../components/RegionSelect';
import { DepotCapacityGauge } from '../components/DepotCapacityGauge';
export const DepotEdit = () => {
const { formProps, saveButtonProps, queryResult } = useForm({
resource: 'Depot',
action: 'edit',
});
const depotId = queryResult?.data?.data?.id;
return (
<Edit saveButtonProps={saveButtonProps}>
{/* Custom header component */}
{depotId && <DepotCapacityGauge id={depotId} />}
<Form {...formProps} layout="vertical">
<Form.Item name="name" label="Depot Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="location" label="Location">
<Input />
</Form.Item>
{/* Custom field component */}
<Form.Item name="region" label="Region">
<RegionSelect />
</Form.Item>
<Form.Item name="capacity" label="Capacity (MT)">
<InputNumber min={0} />
</Form.Item>
</Form>
</Edit>
);
};
// custom_app/frontend/pages/DepotList.tsx
import { useTable, List } from '@refinedev/antd';
import { Table, Space } from 'antd';
import { DepotListActions } from '../components/DepotListActions';
export const DepotList = () => {
const { tableProps } = useTable({ resource: 'Depot' });
return (
<List>
<Table {...tableProps} rowKey="id">
<Table.Column dataIndex="name" title="Name" />
<Table.Column dataIndex="location" title="Location" />
<Table.Column dataIndex="capacity" title="Capacity" />
<Table.Column
title="Actions"
render={(_, record) => (
<Space>
<DepotListActions record={record} />
</Space>
)}
/>
</Table>
</List>
);
};
Registering Custom Pages in Refine
// custom_app/frontend/App.tsx
import { Refine } from '@refinedev/core';
import { DepotList, DepotEdit, DepotCreate } from './pages';
export const App = () => (
<Refine
resources={[
{
name: 'Depot',
list: '/depots',
edit: '/depots/edit/:id',
create: '/depots/create',
},
]}
>
{/* Routes */}
<Route path="/depots" element={<DepotList />} />
<Route path="/depots/edit/:id" element={<DepotEdit />} />
<Route path="/depots/create" element={<DepotCreate />} />
</Refine>
);
2.8 Frappe UI Patterns → Refine.dev Migration
[!NOTE] This section documents common Frappe frontend patterns and their Refine.dev equivalents. For developers migrating from Frappe or building familiar ERP patterns.
Reactive Field Changes (Dependent Fields)
Frappe:
frappe.ui.form.on('Invoice', {
quantity: function(frm) {
frm.set_value('amount', frm.doc.quantity * frm.doc.rate);
},
rate: function(frm) {
frm.set_value('amount', frm.doc.quantity * frm.doc.rate);
}
});
Refine.dev + Ant Design:
import { Form, InputNumber } from 'antd';
import { useForm } from '@refinedev/antd';
import { useEffect } from 'react';
export const InvoiceEdit = () => {
const { formProps } = useForm({ resource: 'Invoice' });
const [form] = Form.useForm();
// Watch fields for reactive updates
const quantity = Form.useWatch('quantity', form);
const rate = Form.useWatch('rate', form);
// Auto-calculate when dependencies change
useEffect(() => {
if (quantity !== undefined && rate !== undefined) {
form.setFieldValue('amount', quantity * rate);
}
}, [quantity, rate, form]);
return (
<Form {...formProps} form={form}>
<Form.Item name="quantity"><InputNumber /></Form.Item>
<Form.Item name="rate"><InputNumber /></Form.Item>
<Form.Item name="amount"><InputNumber disabled /></Form.Item>
</Form>
);
};
Fetch and Set from Link Field
Frappe:
frappe.ui.form.on('Invoice', {
customer: function(frm) {
if (frm.doc.customer) {
frappe.db.get_value('Customer', frm.doc.customer, ['customer_name', 'address'])
.then(r => {
frm.set_value('customer_name', r.message.customer_name);
frm.set_value('billing_address', r.message.address);
});
}
}
});
Refine.dev:
import { useOne } from '@refinedev/core';
const CustomerFields = ({ form }) => {
const customerId = Form.useWatch('customer', form);
const { data: customer } = useOne({
resource: 'Customer',
id: customerId,
queryOptions: { enabled: !!customerId },
});
useEffect(() => {
if (customer?.data) {
form.setFieldsValue({
customer_name: customer.data.customer_name,
billing_address: customer.data.address,
});
}
}, [customer, form]);
return null; // Logic-only component
};
Child Table with Row Calculations
Frappe:
frappe.ui.form.on('Invoice Item', {
quantity: function(frm, cdt, cdn) {
var row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, 'amount', row.quantity * row.rate);
calculate_total(frm);
},
rate: function(frm, cdt, cdn) {
var row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, 'amount', row.quantity * row.rate);
calculate_total(frm);
}
});
function calculate_total(frm) {
var total = 0;
frm.doc.items.forEach(item => total += item.amount);
frm.set_value('grand_total', total);
}
Refine.dev:
export const InvoiceWithItems = () => {
const { formProps } = useForm({ resource: 'Invoice' });
const [form] = Form.useForm();
const items = Form.useWatch('items', form) || [];
// Calculate grand total reactively
const grandTotal = useMemo(() =>
items.reduce((sum, item) => sum + (item?.amount || 0), 0),
[items]
);
// Update row amount when qty/rate changes
const updateRowAmount = (index: number) => {
const currentItems = form.getFieldValue('items') || [];
const row = currentItems[index];
if (row) {
row.amount = (row.quantity || 0) * (row.rate || 0);
form.setFieldValue('items', [...currentItems]);
}
};
return (
<Form {...formProps} form={form}>
<Form.List name="items">
{(fields, { add, remove }) => (
<Table dataSource={fields} pagination={false} rowKey="key">
<Table.Column title="Item" render={(_, field) => (
<Form.Item name={[field.name, 'item_code']}><Input /></Form.Item>
)} />
<Table.Column title="Qty" render={(_, field) => (
<Form.Item name={[field.name, 'quantity']}>
<InputNumber onChange={() => updateRowAmount(field.name)} />
</Form.Item>
)} />
<Table.Column title="Rate" render={(_, field) => (
<Form.Item name={[field.name, 'rate']}>
<InputNumber onChange={() => updateRowAmount(field.name)} />
</Form.Item>
)} />
<Table.Column title="Amount" render={(_, field) => (
<Form.Item name={[field.name, 'amount']}>
<InputNumber disabled />
</Form.Item>
)} />
<Table.Column render={(_, field) => (
<Button danger onClick={() => remove(field.name)}>×</Button>
)} />
</Table>
)}
</Form.List>
<Button onClick={() => form.getFieldValue('items')?.push({})}>Add Row</Button>
<Divider />
<Text strong>Grand Total: {grandTotal.toFixed(2)}</Text>
</Form>
);
};
Custom Field Component
Frappe:
frappe.ui.form.ControlGeolocation = class extends frappe.ui.form.Control {
make_input() {
this.$input = $('<div class="map-picker">');
// Custom map implementation
}
set_value(value) {
// Update map
}
get_value() {
return this.coordinates;
}
};
Refine.dev (Standard React pattern):
// Custom component follows { value, onChange } contract
const GeolocationPicker = ({ value, onChange }) => {
return (
<MapComponent
coordinates={value}
onSelect={(coords) => onChange(coords)}
/>
);
};
// Usage in form - Ant Design auto-binds value/onChange
<Form.Item name="coordinates" label="Location">
<GeolocationPicker />
</Form.Item>
Hide/Show Fields Conditionally
Frappe:
frappe.ui.form.on('Invoice', {
refresh: function(frm) {
frm.toggle_display('discount_field', frm.doc.apply_discount);
},
apply_discount: function(frm) {
frm.toggle_display('discount_field', frm.doc.apply_discount);
}
});
Refine.dev:
const applyDiscount = Form.useWatch('apply_discount', form);
return (
<Form>
<Form.Item name="apply_discount" valuePropName="checked">
<Checkbox>Apply Discount</Checkbox>
</Form.Item>
{applyDiscount && (
<Form.Item name="discount_amount" label="Discount">
<InputNumber />
</Form.Item>
)}
</Form>
);
Quick Reference Table
| Frappe Pattern | Refine.dev + Ant Design |
|---|---|
frm.doc.field | Form.useWatch('field', form) |
frm.set_value('field', val) | form.setFieldValue('field', val) |
frm.set_df_property('field', 'hidden', 1) | Conditional rendering: {show && <Field />} |
frm.refresh_field('field') | Not needed — React auto re-renders |
frm.toggle_display('field', bool) | {condition && <Form.Item>...</Form.Item>} |
frappe.db.get_value() | useOne({ resource, id }) hook |
frappe.call({ method }) | useCustomMutation() or fetch() |
frm.doc.items (child table) | Form.List + Form.useWatch('items') |
frappe.model.set_value(cdt, cdn) | form.setFieldValue(['items', idx, 'field'], val) |
frm.add_custom_button() | <Button onClick={handler}> in component |
cur_frm (global) | No equivalent — pass form instance explicitly |
frappe.ui.form.on() | useEffect + Form.useWatch |
Dropdown Filter in Child Table (Dynamic Options)
Frappe:
// Filter items based on warehouse selected in same row
frappe.ui.form.on('Stock Entry Detail', {
s_warehouse: function(frm, cdt, cdn) {
var row = locals[cdt][cdn];
frm.fields_dict.items.grid.get_field('item_code').get_query = function(doc, cdt, cdn) {
var child = locals[cdt][cdn];
return {
filters: { 'default_warehouse': child.s_warehouse }
};
};
}
});
Refine.dev:
import { useList } from '@refinedev/core';
import { Select, Form, Table, InputNumber } from 'antd';
// Component for filtered item dropdown within a child table row
const ItemSelect = ({ rowIndex, form, ...props }) => {
// Watch the warehouse field in the same row
const warehouse = Form.useWatch(['items', rowIndex, 'warehouse'], form);
// Fetch items filtered by warehouse
const { data: items, isLoading } = useList({
resource: 'Item',
filters: warehouse ? [{ field: 'default_warehouse', operator: 'eq', value: warehouse }] : [],
queryOptions: { enabled: !!warehouse },
});
return (
<Select
{...props}
loading={isLoading}
showSearch
placeholder={warehouse ? 'Select Item' : 'Select Warehouse first'}
disabled={!warehouse}
options={items?.data?.map(item => ({
label: item.item_name,
value: item.id,
}))}
/>
);
};
// Usage in child table
export const StockEntryForm = () => {
const { formProps } = useForm({ resource: 'StockEntry' });
const [form] = Form.useForm();
return (
<Form {...formProps} form={form}>
<Form.List name="items">
{(fields, { add, remove }) => (
<Table dataSource={fields} pagination={false} rowKey="key">
<Table.Column title="Warehouse" render={(_, field) => (
<Form.Item name={[field.name, 'warehouse']}>
<WarehouseSelect /> {/* Your warehouse dropdown */}
</Form.Item>
)} />
<Table.Column title="Item" render={(_, field) => (
<Form.Item name={[field.name, 'item_code']}>
{/* Pass row index so ItemSelect can watch the right warehouse */}
<ItemSelect rowIndex={field.name} form={form} />
</Form.Item>
)} />
<Table.Column title="Qty" render={(_, field) => (
<Form.Item name={[field.name, 'qty']}>
<InputNumber />
</Form.Item>
)} />
</Table>
)}
</Form.List>
</Form>
);
};
Cascading Dropdowns (Parent → Child → Grandchild)
Frappe:
// Country → State → City cascading filters
frm.set_query('state', () => ({ filters: { country: frm.doc.country } }));
frm.set_query('city', () => ({ filters: { state: frm.doc.state } }));
Refine.dev:
const CascadingLocation = ({ form }) => {
const country = Form.useWatch('country', form);
const state = Form.useWatch('state', form);
const { data: states } = useList({
resource: 'State',
filters: [{ field: 'country', operator: 'eq', value: country }],
queryOptions: { enabled: !!country },
});
const { data: cities } = useList({
resource: 'City',
filters: [{ field: 'state', operator: 'eq', value: state }],
queryOptions: { enabled: !!state },
});
// Clear dependent fields when parent changes
useEffect(() => {
form.setFieldsValue({ state: undefined, city: undefined });
}, [country]);
useEffect(() => {
form.setFieldValue('city', undefined);
}, [state]);
return (
<>
<Form.Item name="country" label="Country">
<CountrySelect />
</Form.Item>
<Form.Item name="state" label="State">
<Select
disabled={!country}
options={states?.data?.map(s => ({ label: s.name, value: s.id }))}
/>
</Form.Item>
<Form.Item name="city" label="City">
<Select
disabled={!state}
options={cities?.data?.map(c => ({ label: c.name, value: c.id }))}
/>
</Form.Item>
</>
);
};
Anti-Patterns to Avoid
❌ Don't: Create global form state like cur_frm
✅ Do: Pass form instance via props or context
❌ Don't: Mutate form data directly
✅ Do: Use form.setFieldValue() for updates
❌ Don't: Use refresh_field() — it's a Frappe workaround
✅ Do: React re-renders automatically when state changes
3. Core UI Pages
3.1 List View
- Create
ListViewcomponent:- Route:
/app/{doctype}/list - Fetch metadata
- Render
AutoTable - Add "New" button
- Add search and filters
- Add bulk actions
- Route:
3.2 Form View
- Create
FormViewcomponent:- Route - Create:
/app/{doctype}/new - Route - Edit:
/app/{doctype}/detail/{id} - Benefit: No reserved keywords for IDs. An ID can be "new", "create", or "dashboard" without collision.
- Fetch metadata
- Fetch document (if editing)
- Render
AutoForm - Render Workflow Actions (if applicable)
- Save/Submit/Cancel buttons
- Show validation errors
- Route - Create:
3.3 Dashboard
- Create
Dashboardcomponent:- Route:
/app/dashboard - Show recent documents
- Show notifications
- Show quick links
- Route:
4. Navigation & Auth
Refine provides
authProviderfor authentication integration. See Refine Auth Provider Docs
4.1 Auth Configuration
- Inject
window.__FRAMEWORK_CONFIG__(or fetch/api/v1/config):-
authStrategy:"cookie"(Indie/BFF) or"oidc"(Client-Side) -
loginUrl: URL to redirect to if 401 (for Cookie mode) -
oidcConfig:{ authority, clientId, redirectUri }(for OIDC mode)
-
4.2 Refine Auth Provider Implementation
- Create
authProvider.tsimplementing Refine'sAuthProviderinterface:
// src/providers/authProvider.ts
import { AuthProvider } from "@refinedev/core";
const API_URL = window.__FRAMEWORK_CONFIG__?.apiBaseUrl || "/api/v1";
export const authProvider: AuthProvider = {
login: async ({ email, password }) => {
const response = await fetch(`${API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
credentials: "include", // For cookie-based auth
});
if (response.ok) {
return { success: true, redirectTo: "/" };
}
return { success: false, error: { message: "Invalid credentials" } };
},
logout: async () => {
await fetch(`${API_URL}/auth/logout`, { method: "POST", credentials: "include" });
return { success: true, redirectTo: "/login" };
},
check: async () => {
const response = await fetch(`${API_URL}/auth/me`, { credentials: "include" });
if (response.ok) {
return { authenticated: true };
}
return { authenticated: false, redirectTo: "/login" };
},
getIdentity: async () => {
const response = await fetch(`${API_URL}/auth/me`, { credentials: "include" });
if (response.ok) {
const user = await response.json();
return { id: user.id, name: user.name, email: user.email, avatar: user.avatar };
}
return null;
},
onError: async (error) => {
if (error.status === 401) {
return { logout: true, redirectTo: "/login" };
}
return { error };
},
};
4.3 Auth Strategy Variants
-
Strategy A: Cookie (Indie / Gateway BFF):
- "It Just Works". Relies on browser's HttpOnly cookie
-
credentials: "include"in all fetch calls - No token management in frontend
-
Strategy B: OIDC (Client-Side PKCE):
- Uses
oidc-client-tslibrary - Store tokens in memory (not localStorage for security)
- Inject
Authorization: Bearer <token>header via custom fetch
- Uses
// OIDC variant - wrap authProvider with token injection
import { UserManager } from 'oidc-client-ts';
const userManager = new UserManager({
authority: window.__FRAMEWORK_CONFIG__?.oidcConfig?.authority,
client_id: window.__FRAMEWORK_CONFIG__?.oidcConfig?.clientId,
redirect_uri: window.__FRAMEWORK_CONFIG__?.oidcConfig?.redirectUri,
});
export const oidcAuthProvider: AuthProvider = {
login: async () => {
await userManager.signinRedirect();
return { success: true };
},
// ... other methods using userManager
};
4.4 Login Page (Indie Only)
- Create
LoginPagecomponent:- Route:
/login - Use Refine's
useLoginhook:
- Route:
import { useLogin } from "@refinedev/core";
export const LoginPage = () => {
const { mutate: login, isLoading } = useLogin();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
login({ email: formData.get("email"), password: formData.get("password") });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit" disabled={isLoading}>Login</button>
</form>
);
};
4.5 Sidebar
- Create
Sidebarcomponent:- List all DocTypes (grouped by module)
- Use Refine's
useMenu()hook for resource navigation - Collapsible sections
- Search DocTypes
4.6 Navbar
- Create
Navbarcomponent:- App logo/name
- Global search
- Use Refine's
useGetIdentity()for user menu - Use Refine's
useLogout()for logout button - Notifications icon
5. Advanced UI Features
5.1 Custom Views
-
Support custom view types:
- Kanban view (for status-based DocTypes)
- Calendar view (for date-based DocTypes)
- Gantt view (for project management)
-
Register custom views in DocType Config:
class Config:
views = ["list", "kanban", "calendar"]
5.2 Inline Editing
- Add inline edit for list view:
- Click cell to edit
- Save on blur
- Show validation errors
5.3 Bulk Operations
- Add checkbox column to table
- Select multiple rows
- Bulk actions: Delete, Export, Update field
6. Real-time Updates
Refine provides
liveProviderfor real-time data synchronization. See Refine Live Provider Docs
6.1 Refine Live Provider Implementation
- Create
liveProvider.tsimplementing Refine'sLiveProviderinterface:
// src/providers/liveProvider.ts
import { LiveProvider } from "@refinedev/core";
const WS_URL = window.__FRAMEWORK_CONFIG__?.wsBaseUrl || "/api/v1/stream";
export const liveProvider: LiveProvider = {
subscribe: ({ channel, types, callback }) => {
const ws = new WebSocket(`${WS_URL}?channel=${channel}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (types.includes(data.type)) {
callback(data);
}
};
// Return unsubscribe function
return () => ws.close();
},
unsubscribe: (subscription) => {
subscription();
},
publish: async ({ channel, type, payload }) => {
// Optional: publish events (usually server-driven)
await fetch(`${WS_URL}/publish`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel, type, payload }),
});
},
};
6.2 Register Live Provider with Refine
- Add
liveProviderto Refine app:
import { Refine } from "@refinedev/core";
import { liveProvider } from "./providers/liveProvider";
export const App = () => (
<Refine
dataProvider={dataProvider}
authProvider={authProvider}
liveProvider={liveProvider}
options={{ liveMode: "auto" }} // auto | manual | off
>
{/* ... */}
</Refine>
);
6.3 Automatic UI Updates
-
With
liveMode: "auto", Refine automatically:- Refetches list data on
created,updated,deletedevents - Shows notification on document changes
- Invalidates React Query cache
- Refetches list data on
-
Manual control with
useLiveMode()hook:
import { useTable } from "@refinedev/react-table";
const { tableProps } = useTable({
resource: "Invoice",
liveMode: "auto", // Override per-component
onLiveEvent: (event) => {
// Custom handler for live events
console.log("Live event:", event);
},
});
6.4 Desk Workspace Configuration (Code-First)
[!NOTE] Workspace Terminology
- Desk Workspace = End-user navigation groupings in sidebar (this section)
- Studio Workspace = The Git monorepo where code lives (Phase 07, Section 4)
Desk Workspaces are defined as code within a Studio Workspace (monorepo).
- Define Workspace Schema (Pydantic):
-
name: str— unique identifier -
label: str— display name in sidebar -
icon: str— sidebar icon -
items: list[Shortcut | Link | Chart]— workspace contents -
roles: list[str]— who can see this workspace
-
- Implement
desktop.pyloader:- Scan apps for
desktop.py - Load workspace definitions
- Merge into unified Desk navigation
- Respect role-based visibility
- Scan apps for
6.5 Global Command Palette
- Implement
Ctrl+KGlobal Search:- Search DocTypes (Navigation)
- Search Documents (Title)
- Run Commands (e.g., "New User")
6.6 Public API Specs
- Ensure strict OpenAPI 3.1 generation:
-
m docs:generate --json— Litestar provides/schema/openapi.json - Verify compatibility with low-code tools (Retool, Refine)
-
7. Frontend Build & Serving
[!NOTE] The
m buildCLI command is registered in Phase 05, but the implementation logic is here.
7.1 Build Command (m build)
- Implement build logic:
- Detect Mode from
framework_config.toml:-
frontend_mode = "indie"(default) -
frontend_mode = "plugin"(Shadow Build) -
frontend_mode = "eject"(skip, user handles build)
-
- Indie Mode:
- No custom frontend code.
- Build default Desk:
cd framework_m/frontend && pnpm build - Output:
framework_m/static/dist/
- Plugin Mode (Shadow Build):
- Detect
frontend/index.tsin all installed apps. - Generate temp
entry.tsximporting all app plugins. - Run
vite buildon generated entry. - Output:
dist/(unified build with all app assets)
- Detect
- Eject Mode:
- Skip. User has their own frontend repo and build process.
- Framework M just serves API.
- Detect Mode from
7.2 Static File Serving (Production)
- Configure Litestar
StaticFilesConfig:- Serve from
dist/orstatic/dist/ - Route:
/static/*or/assets/*
- Serve from
- Option: Configure CDN URL in
framework_config.toml:[frontend]
mode = "plugin"
cdn_url = "https://cdn.example.com/assets/" # Optional - If CDN configured, inject CDN prefix into HTML asset URLs.