Skip to main content

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)

LayerTechnologyPurpose
Data/State@refinedev/coreCRUD, caching, auth, access control
FormsRJSF + react-hook-formSchema-driven form rendering
Tables@tanstack/react-tableFlexible data tables
UI Componentsshadcn/uiAccessible, Tailwind-based components
StylingTailwind CSSUtility-first styling
ChartsTremorDashboard analytics
Code EditorMonaco EditorStudio code preview
Drag & Drop@dnd-kitStudio 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 frontend

    When 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 DataProvider interface
    • Maps to Framework M REST API (/api/v1/{resource})
    • Handles pagination, sorting, filtering
// 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:
ScenarioUI BaseAPI BaseConfig
Same Origin (Default)//api/v1No config needed
Subdirectory/portal/apiAPI_BASE_URL=/api
Separate Subdomainsportal.example.comapi.example.comFull 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)
RepositoryProtocolDataProvider
PermissionProtocolAccessControlProvider
AuthContextProtocolAuthProvider
EventBusProtocolLiveProvider
DocTypeResource
BaseControllerResource hooks

2. Metadata-Driven UI

2.1 Metadata Fetcher

  • Create useDocTypeMeta hook:

    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 AutoForm component 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)
  • Use RJSF with custom widget registry for shadcn/ui

  • Architecture: Export AutoForm as 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 AutoTable component 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

2.5 Plugin System (The "Shadow Build")

  • Goal: "Easy as Frappe" but with Vite/React tooling
  • Strategy:
    • m start detects frontend/index.ts in all installed apps
    • Generates a temporary entry.tsx that 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 Script or JSON overrides
    • No build step required
  • Level 2: Injection (Mid Code)
    • Custom Fields: app.registerField('rating', StarRating)
    • Slot Injection: app.registerSlot('form-header', PaymentButton)
    • Uses Shadow Build (HMR)
  • Level 3: Ejection (Pro Code)
    • Page Override: Replace FormView entirely with custom React component
    • Whitelabel: Replace Layout / Theme via Plugin
    • Uses Shadow Build (HMR)

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>
);
};

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 PatternRefine.dev + Ant Design
frm.doc.fieldForm.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

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_frmDo: 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 ListView component:
    • Route: /app/{doctype}/list
    • Fetch metadata
    • Render AutoTable
    • Add "New" button
    • Add search and filters
    • Add bulk actions

3.2 Form View

  • Create FormView component:
    • 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

3.3 Dashboard

  • Create Dashboard component:
    • Route: /app/dashboard
    • Show recent documents
    • Show notifications
    • Show quick links

4. Navigation & Auth

Refine provides authProvider for 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.ts implementing Refine's AuthProvider interface:
// 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-ts library
    • Store tokens in memory (not localStorage for security)
    • Inject Authorization: Bearer <token> header via custom fetch
// 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 LoginPage component:
    • Route: /login
    • Use Refine's useLogin hook:
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 Sidebar component:
    • List all DocTypes (grouped by module)
    • Use Refine's useMenu() hook for resource navigation
    • Collapsible sections
    • Search DocTypes

4.6 Navbar

  • Create Navbar component:
    • 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 liveProvider for real-time data synchronization. See Refine Live Provider Docs

6.1 Refine Live Provider Implementation

  • Create liveProvider.ts implementing Refine's LiveProvider interface:
// 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 liveProvider to 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, deleted events
    • Shows notification on document changes
    • Invalidates React Query cache
  • 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.py loader:
    • Scan apps for desktop.py
    • Load workspace definitions
    • Merge into unified Desk navigation
    • Respect role-based visibility

6.5 Global Command Palette

  • Implement Ctrl+K Global 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 build CLI 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.ts in all installed apps.
      • Generate temp entry.tsx importing all app plugins.
      • Run vite build on generated entry.
      • Output: dist/ (unified build with all app assets)
    • Eject Mode:
      • Skip. User has their own frontend repo and build process.
      • Framework M just serves API.

7.2 Static File Serving (Production)

  • Configure Litestar StaticFilesConfig:
    • Serve from dist/ or static/dist/
    • Route: /static/* or /assets/*
  • 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.