Operate Legacy Frontend

SkillDev tools

Guides your agent to correctly fix bugs and make changes in the Operate legacy frontend codebase.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Operate Legacy Frontend skill

About this capability

Use for any Operate frontend work — the target pod at webapp/client/apps/orchestration-cluster-webapp/src/operate/ and the legacy client at operate/client/. Covers routing, data fetching, state, styling, testing, forms and conventions in both. For porting a page from legacy to target, use frontend-o

What this skill tells your AI

The instructions your AI receives, as published by camunda/camunda in .claude/skills/operate-frontend/SKILL.md and read by ahel’s review.

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.

Tech stack

CategoryTechnology
UI frameworkReact 18, React DOM 18
LanguageTypeScript 5.9 (strict mode)
BundlerVite 8
Routingreact-router-dom 7 (React Router v6 API)
Server stateTanStack React Query 5
Client stateMobX 6 + mobx-react / mobx-react-lite
Stylingstyled-components 6, Carbon Design System (@carbon/react, @carbon/elements)
FormsReact Final Form + final-form-arrays
TestingVitest (jsdom) + Testing Library + MSW 2, Playwright (E2E)
BPMN/DMNbpmn-js 18, dmn-js 17
Code editorMonaco Editor (@monaco-editor/react)

Project structure

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 utilities

Components 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.

Routing

Routes are defined in src/App/index.tsx using React Router v6's createBrowserRouter with createRoutesFromElements. Every page-level route is lazy-loaded:

<Route
  path={Paths.processes()}
  lazy={async () => {
    const {Processes} = await import('./Processes/index');
    return {Component: Processes};
  }}
/>

Path builders

All route paths are centralized in modules/Routes.tsx via the Paths object. Never hardcode path strings — always use Paths:

import {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:

import {Locations} from 'modules/Routes';

Locations.processes()
Locations.decisions()

Route params and search params

Route params use useParams with a type argument:

const {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.

Authentication guards

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.

Error boundaries

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

Data fetching has three layers. Follow this architecture — don't bypass it.

Layer 1: API functions (modules/api/v2/)

Each endpoint gets a thin typed function. Endpoints come from @camunda/camunda-api-zod-schemas/8.10:

import {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.

Layer 2: React Query hooks (modules/queries/)

Query hooks wrap the API functions. Query keys are centralized in modules/queries/queryKeys.ts:

const 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.

Layer 3: Components

Components call the query hooks directly. There is no route-level data prefetching — components initiate their own fetches:

function ProcessInstanceHeader() {
  const {data: processInstance, isLoading} = useProcessInstance();
  if (isLoading) return <SkeletonText />;
}

Mutations

Mutations follow the same {response, error} pattern. Some mutations poll for eventual consistency using queryClient.fetchQuery with retry: true:

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

Polling

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 management

State is split across three mechanisms. When you encounter state, identify which category it belongs to:

WhatWhereWhy
Server data (API responses)React Query via modules/queries/Automatic caching, deduplication, background refresh
Filters, sort, pagination, element selectionURL search params via useFilters, useSearchParamsShareable, 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

MobX stores

There are ~20 MobX stores. The important ones:

StoreWhat it manages
authenticationSession state, login/logout flow
modificationsProcess instance modification mode (add/cancel/move tokens, variable edits)
notificationsToast notification queue (max 5 visible)
instancesSelectionSelected process instances for batch operations
processInstanceMigrationMigration wizard state
panelStatesUI panel open/closed state
batchModificationBatch modification mode
currentThemeLight/dark theme preference

Components that read MobX stores must be wrapped with observer():

import {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.

Styling

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.

File convention

Each component directory has a styled.ts file exporting styled components:

import styled, {css} from 'styled-components';
import {Tile as BaseTile} from '@carbon/react';
import {styles} from '@carbon/elements';

Key patterns

  • Wrapping Carbon components: styled(CarbonComponent) applies additional styles on top of Carbon's defaults.
  • Respect Carbon's defaults: Carbon components ship with intentional styling — colors, spacing, typography, interactive states. Before applying any style that changes a Carbon component's visual defaults, you must first tell the user what you're about to override and why it's risky — even if the user explicitly asked for the change, because they may not realize it touches Carbon defaults. Explain what the override is (e.g., "This would set a custom height on SkeletonText, overriding Carbon's built-in sizing"), suggest the Carbon-native alternative if one exists (e.g., a prop, a token, a different component), and ask the user to confirm before proceeding. Only apply the override after the user says to go ahead.
  • Carbon tokens: typography via @carbon/elements styles object (${styles.productiveHeading02}), spacing/color via CSS custom properties (var(--cds-spacing-05), var(--cds-text-primary)).
  • Transient props: use the $ 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.

Component structure

  • File naming: index.tsx for the component, styled.ts for styles, index.test.tsx for tests.
  • Exports: always use named exports at the end of the file: 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> = ({...}) => {...}.
  • No comments: don't generate code comments. The code should be self-explanatory. If something needs a comment, the code itself should be rewritten to be clearer instead.

Testing

Tests use Vitest with jsdom, @testing-library/react, and MSW v2 for API mocking.

Custom render

Import render from modules/testing-library, not from @testing-library/react directly. It bundles a pre-configured userEvent instance:

import {render, screen, waitFor} from 'modules/testing-library';

const {user} = render(<MyComponent />, {wrapper: getWrapper()});
await user.click(screen.getByRole('button', {name: /submit/i}));

Test wrapper

Tests that render components needing context use a wrapper function composing QueryClientProvider + MemoryRouter:

import {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.

API mocking

Each endpoint has a typed mock builder in modules/mocks/api/. The fluent builder pattern:

import {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:

import {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`);

Test data factories

Use factory functions from modules/mocks/ to create typed test data:

import {createUser} from 'modules/mocks/user';
import {createProcessDefinition} from 'modules/mocks/processDefinition';

mockMe().withSuccess(createUser());
mockSearchProcessDefinitions().withSuccess(searchResult([createProcessDefinition({name: 'Test'})]));

Assertion patterns

expect(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();

Store cleanup

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

Forms use React Final Form. Two main patterns:

  1. Filter forms: use <Form> with field components that sync to URL search params via useFilters.

  2. Modal/editing forms: standard <Form onSubmit={...}> with <Field> components and explicit submit buttons. Variable editing uses FieldArray from final-form-arrays.

Commands

Run from operate/client/:

npm 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

Common pitfalls

  • Mock handler consumption: .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.
  • Missing 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.
  • No i18n: all strings are hardcoded in English. Don't add i18next or translation files.
  • No SCSS modules: styling is entirely styled-components. Don't introduce .module.scss files.
  • No data prefetching: components fetch their own data via query hooks. There are no route-level loaders. Don't add them — that's the new app's pattern.

Boundaries

Follow existing conventions:

  • styled-components for styling
  • React Final Form for forms
  • mockFetchX().withSuccess() pattern for test mocking
  • Named exports, index.tsx entry files
  • Paths object for all route paths

Don't introduce:

  • SCSS modules or CSS modules
  • i18n / translation files
  • TanStack Router or file-based routing
  • Route-level data loaders / prefetching
  • New UI component libraries

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.

Signals

GitHub stars
4k
Forks
818
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
operate-frontend
Source
github.com/camunda/camunda