Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use for any work in the Operate legacy frontend (operate/client/) — bugs, changes, tests, components, API hooks, styled-components, MobX stores, React Router, or questions about Operate patterns, conventions, or architecture.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 177% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 175% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 111% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 11% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 342% | 0% |
Operate is the process monitoring frontend at operate/client/. It is legacy code being phased out — the orchestration cluster webapp at webapp/client/ is replacing it. Work in Operate should be limited to bug fixes, small adjustments, and maintenance. For substantial new features, build them in the new app instead (see the frontend-feature and frontend-migrator skills).
Follow the existing conventions described here. Don't introduce new architectural patterns — consistency matters more than modernization in a codebase that's winding down.
| Category | Technology | |--------------|---------------------------------------------------------------------------------| | UI framework | React 18, React DOM 18 | | Language | TypeScript 5.9 (strict mode) | | Bundler | Vite 8 | | Routing | react-router-dom 7 (React Router v6 API) | | Server state | TanStack React Query 5 | | Client state | MobX 6 + mobx-react / mobx-react-lite | | Styling | styled-components 6, Carbon Design System (@carbon/react, @carbon/elements) | | Forms | React Final Form + final-form-arrays | | Testing | Vitest (jsdom) + Testing Library + MSW 2, Playwright (E2E) | | BPMN/DMN | bpmn-js 18, dmn-js 17 | | Code editor | Monaco Editor (@monaco-editor/react) |
operate/client/src/
index.tsx # Entry point, renders <App />
App/ # App shell + page components
index.tsx # Router, providers, route tree
Layout/ # App shell (header, sidebar, content)
Dashboard/ # Dashboard page
Processes/ # Process instances list
ProcessInstance/ # Process instance detail (BPMN + tabs)
Decisions/ # Decision instances list
DecisionInstance/ # Decision instance detail
BatchOperations/ # Batch operations list + detail
OperationsLog/ # Operations log
Login/ # Login page
RedirectDeprecatedRoutes.tsx # Migrates old /instances URLs
modules/ # Shared concerns
api/v2/ # API endpoint functions (one per endpoint)
queries/ # React Query hooks (useQuery, useInfiniteQuery)
mutations/ # React Query mutations (useMutation)
react-query/ # QueryClient provider + config
stores/ # MobX stores (UI state)
hooks/ # Custom React hooks
components/ # Shared UI components
mock-server/ # MSW setup (node for tests)
mocks/ # Mock data + mock request builders
request/ # HTTP request utilities
Routes.tsx # Centralized path builders (Paths, Locations)
testing-library.ts # Custom render() with userEvent
types/ # Shared TypeScript types
utils/ # General utilitiesComponents follow a consistent directory layout: index.tsx (component), styled.ts (styled-components), index.test.tsx (tests). Some components place tests in a tests/ subdirectory instead.
Routes are defined in src/App/index.tsx using React Router v6's createBrowserRouter with createRoutesFromElements. Every page-level route is lazy-loaded:
tsx<Route path={Paths.processes()} lazy={async () => { const {Processes} = await import('./Processes/index'); return {Component: Processes}; }} />
All route paths are centralized in modules/Routes.tsx via the Paths object. Never hardcode path strings — always use Paths:
tsximport {Paths} from 'modules/Routes'; Paths.processes() Paths.processInstance('123') Paths.processInstance() Paths.decisionInstance('456') Paths.batchOperation('789')
The Locations object builds {pathname, search} objects with default filter params:
tsximport {Locations} from 'modules/Routes'; Locations.processes() Locations.decisions()
Route params use useParams with a type argument:
tsxconst {processInstanceId} = useParams<{processInstanceId: string}>();
The useProcessInstancePageParams hook in App/ProcessInstance/ wraps this for the process instance detail pages — use it instead of calling useParams directly in that context.
Search params drive filter state. The useFilters hook in modules/hooks/useFilters.tsx provides getFilters() and setFilters() that read/write URL search params via useNavigate and useLocation. Filters are fully URL-driven — no MobX store for filter state.
All authenticated routes are wrapped in AuthenticationCheck (redirects to /login if not logged in) and AuthorizationCheck (redirects to /forbidden if user lacks permissions). These are composed in the dashboard route's lazy loader. If you add a new authenticated route, nest it under the dashboard route in the route tree — don't duplicate the guards.
A single PageErrorBoundary is attached to the root route via React Router's ErrorBoundary prop. It uses useRouteError() to render error details. Individual pages don't define their own error boundaries — the root one catches everything.
Data fetching has three layers. Follow this architecture — don't bypass it.
modules/api/v2/)Each endpoint gets a thin typed function. Endpoints come from @camunda/camunda-api-zod-schemas/8.10:
tsximport {endpoints, type QueryProcessInstancesRequestBody, type QueryProcessInstancesResponseBody} from '@camunda/camunda-api-zod-schemas/8.10'; import {requestWithThrow} from 'modules/request';
requestWithThrow returns {response, error} — a discriminated union, not a thrown exception (despite the name). response is the parsed data on success, null on failure. error is a RequestError on failure, null on success. The underlying request function handles 401s automatically by disabling the session.
There is also requestAndParse — this is an older utility used by a few legacy stores. Don't use it for new code; use requestWithThrow.
modules/queries/)Query hooks wrap the API functions. Query keys are centralized in modules/queries/queryKeys.ts:
tsxconst useProcessInstance = () => { const {processInstanceId} = useProcessInstancePageParams(); return useQuery({
The standard pattern: destructure {response, error} from the API function, return response on success, throw error on failure. React Query catches the thrown error and surfaces it via query.error.
Components call the query hooks directly. There is no route-level data prefetching — components initiate their own fetches:
tsxfunction ProcessInstanceHeader() { const {data: processInstance, isLoading} = useProcessInstance(); if (isLoading) return <SkeletonText />; }
Mutations follow the same {response, error} pattern. Some mutations poll for eventual consistency using queryClient.fetchQuery with retry: true:
tsxawait queryClient.fetchQuery({ queryKey: queryKeys.processInstance.get(key), queryFn: async () => { const {response} = await fetchProcessInstance(key); if (response.state === 'ACTIVE') throw new Error('Still running'); return response; }, retry: true, retryDelay: 1000, });
Live data uses refetchInterval on query hooks (standard interval is 5000ms). Conditional polling is common — only poll when the instance is running or active.
State is split across three mechanisms. When you encounter state, identify which category it belongs to:
| What | Where | Why | |-----------------------------------------------------------------|-------------------------------------------------------|------------------------------------------------------------| | Server data (API responses) | React Query via modules/queries/ | Automatic caching, deduplication, background refresh | | Filters, sort, pagination, element selection | URL search params via useFilters, useSearchParams | Shareable, survives refresh, back/forward works | | UI mode (modification mode, panel visibility, selection, theme) | MobX stores in modules/stores/ | Ephemeral client-side state that doesn't belong in the URL |
There are ~20 MobX stores. The important ones:
| Store | What it manages | |----------------------------|-----------------------------------------------------------------------------| | authentication | Session state, login/logout flow | | modifications | Process instance modification mode (add/cancel/move tokens, variable edits) | | notifications | Toast notification queue (max 5 visible) | | instancesSelection | Selected process instances for batch operations | | processInstanceMigration | Migration wizard state | | panelStates | UI panel open/closed state | | batchModification | Batch modification mode | | currentTheme | Light/dark theme preference |
Components that read MobX stores must be wrapped with observer():
tsximport {observer} from 'mobx-react'; import {panelStatesStore} from 'modules/stores/panelStates'; const MyComponent = observer(() => { const isOpen = panelStatesStore.isFiltersOpen; }); export {MyComponent};
Don't wrap components that don't access stores — observer() adds overhead.
All component styling uses styled-components. There are no SCSS modules in this codebase — don't introduce them. Don't use inline style={{}} props either — all styling belongs in a styled.ts file.
Each component directory has a styled.ts file exporting styled components:
tsximport styled, {css} from 'styled-components'; import {Tile as BaseTile} from '@carbon/react'; import {styles} from '@carbon/elements';
styled(CarbonComponent) applies additional styles on top of Carbon's defaults.@carbon/elements styles object (${styles.productiveHeading02}), spacing/color via CSS custom properties (var(--cds-spacing-05), var(--cds-text-primary)).$ prefix ($isActive, $size) to avoid passing props to the DOM. Type them with generics: styled.div<{$isActive: boolean}>.css helper: use for conditional style blocks inside template literals.index.tsx for the component, styled.ts for styles, index.test.tsx for tests.export {MyComponent}. Never export default.observer() wrapping: wrap the component function, not the export: const Comp = observer(() => {...}); export {Comp};React.FC typing: most components use const Component: React.FC<Props> = ({...}) => {...}.Tests use Vitest with jsdom, @testing-library/react, and MSW v2 for API mocking.
Import render from modules/testing-library, not from @testing-library/react directly. It bundles a pre-configured userEvent instance:
tsximport {render, screen, waitFor} from 'modules/testing-library'; const {user} = render(<MyComponent />, {wrapper: getWrapper()}); await user.click(screen.getByRole('button', {name: /submit/i}));
Tests that render components needing context use a wrapper function composing QueryClientProvider + MemoryRouter:
tsximport {QueryClientProvider} from '@tanstack/react-query'; import {MemoryRouter, Routes, Route} from 'react-router-dom'; import {getMockQueryClient} from 'modules/react-query/mockQueryClient'; import {Paths} from 'modules/Routes'; const getWrapper = (initialPath = Paths.processes()) => { const Wrapper: React.FC<{children?: React.ReactNode}> = ({children}) => ( <QueryClientProvider client={getMockQueryClient()}> <MemoryRouter initialEntries={[initialPath]}> <Routes> <Route path={Paths.processes()} element={children} /> </Routes> </MemoryRouter> </QueryClientProvider> ); return Wrapper; };
getMockQueryClient() creates a QueryClient with retry: false, gcTime: Infinity, staleTime: Infinity — no retries, no cache expiration, always fresh.
Each endpoint has a typed mock builder in modules/mocks/api/. The fluent builder pattern:
tsximport {mockFetchProcessInstance} from 'modules/mocks/api/v2/processInstances/fetchProcessInstance'; mockFetchProcessInstance().withSuccess(processInstanceData); mockFetchProcessInstance().withServerError(404); mockFetchProcessInstance().withDelay(processInstanceData); mockFetchProcessInstance().withNetworkError();
All handlers except withNetworkError are registered with {once: true} — they're consumed on first match, then removed. This lets you chain multiple setups for sequential requests in the same test. The MSW server resets all handlers in afterEach via setupTests.tsx.
To create a new mock, use mockGetRequest, mockPostRequest, etc. from modules/mocks/api/mockRequest.ts:
tsximport {mockPostRequest} from 'modules/mocks/api/mockRequest'; import type {MyResponseType} from '@camunda/camunda-api-zod-schemas/8.10'; const mockSearchMyEntity = (contextPath = '') => mockPostRequest<MyResponseType>(`${contextPath}/v2/my-entities/search`);
Use factory functions from modules/mocks/ to create typed test data:
tsximport {createUser} from 'modules/mocks/user'; import {createProcessDefinition} from 'modules/mocks/processDefinition'; mockMe().withSuccess(createUser()); mockSearchProcessDefinitions().withSuccess(searchResult([createProcessDefinition({name: 'Test'})]));
tsxexpect(screen.getByRole('button', {name: /cancel/i})).toBeInTheDocument(); expect(await screen.findByText('10 running instances')).toBeInTheDocument(); expect(screen.queryByText('Error')).not.toBeInTheDocument(); await waitFor(() => { expect(screen.getByRole('cell', {name: 'completed'})).toBeInTheDocument(); }); const row = screen.getByRole('row', {name: /my-process/i}); expect(within(row).getByText('v2')).toBeInTheDocument();
All MobX stores are reset automatically in afterEach via resetAllStores() in setupTests.tsx. You don't need to reset stores manually in tests unless you need a mid-test reset.
Forms use React Final Form. Two main patterns:
<Form> with field components that sync to URL search params via useFilters.<Form onSubmit={...}> with <Field> components and explicit submit buttons. Variable editing uses FieldArray from final-form-arrays.Run from operate/client/:
bashnpm start # Dev server on :3000 (proxies API to :8080) npm test # Unit tests (Vitest, jsdom) npm run lint # TypeScript check + ESLint + Prettier npm run ts-check # TypeScript only (tsc -b) npm run build # Production build npm run knip # Dead code/dependency analysis
.withSuccess() handlers are {once: true} — if a component makes the same request twice, the second call gets no handler. Chain two .withSuccess() calls or use a non-one-shot approach.observer(): if a component reads a MobX store but isn't wrapped in observer(), it won't re-render when the store changes. Symptoms: stale UI, tests that pass individually but fail in sequence.requestAndParse vs requestWithThrow: requestAndParse is legacy. Use requestWithThrow for all new code. They have different return shapes — don't mix them up.i18next or translation files..module.scss files.Follow existing conventions:
mockFetchX().withSuccess() pattern for test mockingindex.tsx entry filesPaths object for all route pathsDon't introduce:
For substantial new features: use the frontend-migrator skill to build them in the orchestration cluster webapp instead. Operate is winding down — invest engineering effort in the replacement.
Other measured skills in the registry, with their headline benchmark lift.