| name | add-portal-screen |
| description | Add screens or components to the portal frontend. Use when the user asks to create a new portal page, screen, tab, or UI component. |
| argument-hint | <feature name or description> |
Follow this guide when adding screens or components to the portal.
Step 1: Locate existing patterns
Before writing any code, read at least one existing screen from portal/src/screens/ that is similar to the feature being built. Reading the full file is preferred so you understand the complete pattern.
Good reference screens:
- Config-form screen with tabs:
portal/src/screens/fraud-protection/FraudProtectionConfigurationScreen.tsx
- Screen with sub-tab files:
portal/src/screens/api-resources/APIResourceDetailsScreen.tsx
Also read any components under portal/src/components/ in the same feature area that exist already.
Step 2: File structure
Screens
New screens go in:
portal/src/screens/<feature-name>/
<FeatureName>Screen.tsx
<FeatureName>Screen.module.css
- Use kebab-case for the directory name (e.g.,
fraud-protection, api-resources).
- Use PascalCase for file names (e.g.,
FraudProtectionConfigurationScreen.tsx).
- Sub-tabs of a screen that are complex enough to deserve their own file live in the same screen directory, not in
components/.
Components
Reusable or extracted sub-components go in:
portal/src/components/<feature-name>/
<ComponentName>.tsx
<ComponentName>.module.css
- A component that is only used by one screen is still placed in
components/ when it is large enough to deserve its own file.
- Shared low-level UI primitives (buttons, inputs, etc.) go in
portal/src/components/common/.
CSS modules
Every component or screen that has its own styles gets its own .module.css file. Name it identically to the .tsx file (e.g., FraudProtectionOverviewTab.module.css).
When two sibling components share many CSS classes (e.g., two card variants), they may both import from a single shared .module.css file. Name it after the dominant component (e.g., OverviewMetricCard.module.css shared by OverviewMetricCard.tsx and OverviewEnforcementCard.tsx).
Step 3: Screen anatomy
A screen file always has two components:
Outer component (data shell)
Handles routing params, loading/error states, and form setup. Renders nothing except loading spinners and error views until data is ready. Then renders FormContainer wrapping the inner content.
const MyFeatureScreen: React.VFC = function MyFeatureScreen() {
const { appID } = useParams() as { appID: string };
const form = useAppConfigForm({ appID, constructFormState, constructConfig });
const featureConfig = useAppFeatureConfigQuery(appID);
const { selectedKey, onLinkClick, onChangeKey } =
usePivotNavigation<MyTab>(["overview", "settings"]);
if (form.isLoading || featureConfig.isLoading) {
return <ShowLoading />;
}
if (form.loadError) {
return <ShowError error={form.loadError} onRetry={form.reload} />;
}
if (featureConfig.loadError) {
return <ShowError error={featureConfig.loadError} onRetry={featureConfig.reload} />;
}
return (
<FormContainer form={form} canSave={isModifiable} ...>
<MyFeatureContent
form={form}
featureConfig={featureConfig.effectiveFeatureConfig?.my_feature}
selectedKey={selectedKey}
onLinkClick={onLinkClick}
onChangeKey={onChangeKey}
/>
</FormContainer>
);
};
Inner content component
Receives the form model and config as props. Handles user interactions and renders the full UI. Callbacks (e.g., onChange handlers) are defined here using useCallback.
interface MyFeatureContentProps {
form: AppConfigFormModel<FormState>;
featureConfig?: MyFeatureConfig;
selectedKey: MyTab;
onLinkClick: (item?: PivotItem) => void;
onChangeKey: (key: MyTab) => void;
}
const MyFeatureContent: React.VFC<MyFeatureContentProps> =
function MyFeatureContent(props) {
const { form, featureConfig, selectedKey, onLinkClick, onChangeKey } = props;
const { state, setState } = form;
const onSomeFieldChange = useCallback(
(_event, value?: string) => {
setState((current) => ({ ...current, someField: value ?? "" }));
},
[setState]
);
return (
<ScreenContent layout="list">
<ScreenTitle className={styles.widget}>...</ScreenTitle>
{/* ... */}
</ScreenContent>
);
};
Form state helpers
Define constructFormState and constructConfig as plain functions (not inside components) near the top of the file, after the FormState interface:
interface FormState {
enabled: boolean;
someField: string;
}
function constructFormState(config: PortalAPIAppConfig): FormState {
return {
enabled: config.my_feature?.enabled ?? false,
someField: config.my_feature?.some_field ?? "",
};
}
function constructConfig(
config: PortalAPIAppConfig,
_initialState: FormState,
currentState: FormState,
_effectiveConfig: PortalAPIAppConfig
): PortalAPIAppConfig {
return produce(config, (draft) => {
draft.my_feature ??= {};
draft.my_feature.enabled = currentState.enabled;
draft.my_feature.some_field = currentState.someField || undefined;
clearEmptyObject(draft);
});
}
Step 4: Register the screen in AppRoot
After creating the screen file, register it as a lazy-loaded route in portal/src/AppRoot.tsx.
Add the lazy import (near other similar imports)
const MyFeatureScreen = lazy(
async () => import("./screens/my-feature/MyFeatureScreen")
);
Add the route (inside the JSX route tree)
Find the appropriate <Route> section and add:
<Route path="my-feature" element={<MyFeatureScreen />} />
Step 5: Breaking down large components
When a component grows large, extract sub-components into portal/src/components/<feature>/. Good candidates for extraction:
- A tab's content when the tab has its own state or data-fetching logic
- A repeated card/row pattern rendered multiple times with different props
- A self-contained panel (e.g., a sidebar widget with its own toggle state)
Extraction rules
- Each extracted component gets its own
.tsx and .module.css.
- Do not pass raw
form or setState into sub-components. Instead, pass the specific values and typed callback functions they need.
- Keep state as close to its consumer as possible. If state is only used inside a sub-component (e.g.,
showAll for a list toggle), define it inside that sub-component — do not lift it up.
- A sub-component that is only rendered conditionally (e.g., only when a tab is active) does not need an
isActive prop — it is simply not rendered when inactive, so queries inside it should use skip: !enabled (or similar) rather than checking tab state.
Example: extracting a tab
Before (all in one component):
{selectedKey === "overview" ? (
<section>
{/* 200 lines of overview JSX */}
</section>
) : null}
After:
export interface MyFeatureOverviewTabProps {
enabled: boolean;
onChangeToSettings: () => void;
}
const MyFeatureOverviewTab: React.VFC<MyFeatureOverviewTabProps> = ...
{selectedKey === "overview" ? (
<MyFeatureOverviewTab enabled={state.enabled} onChangeToSettings={() => onChangeKey("settings")} />
) : null}
Step 6: GraphQL queries
The portal has two separate GraphQL APIs with separate code-generation configs:
| API | Schema | Query files | Generated output | Use for |
|---|
| Admin API | portal/src/graphql/adminapi/schema.graphql | portal/src/graphql/adminapi/query/*.graphql | *.generated.ts next to the .graphql file | App-level data (users, audit logs, fraud protection, etc.) |
| Portal API | portal/src/graphql/portal/schema.graphql | portal/src/graphql/portal/query/*.graphql | *.generated.ts next to the .graphql file | Portal-level data (subscriptions, feature config, app list, etc.) |
Writing a query
- Read the relevant schema file to understand available types and fields.
- Create a
.graphql file in the appropriate query/ directory:
query myFeatureQuery($appID: ID!, $rangeFrom: DateTime, $rangeTo: DateTime) {
myFeature(appID: $appID, rangeFrom: $rangeFrom, rangeTo: $rangeTo) {
totalCount
someField
nestedItems {
id
value
}
}
}
- Run code generation from
portal/:
npm run gentype
This produces a myFeatureQuery.generated.ts file next to the .graphql file containing typed hooks, variables types, and result types.
Using the generated hook
Always import from the .generated.ts file, never inline gql in .ts/.tsx files.
import { useMyFeatureQueryQuery } from "../../graphql/adminapi/query/myFeatureQuery.generated";
const MY_QUERY = gql`query myFeatureQuery { ... }`;
The generated hook is a standard Apollo hook. Use Apollo options directly:
const {
data,
loading,
error,
refetch,
} = useMyFeatureQueryQuery({
skip: !enabled,
variables: { appID, rangeFrom, rangeTo },
});
const result = data?.myFeature ?? null;
const onRetry = useCallback(() => {
void refetch();
}, [refetch]);
When to use which API
- Admin API: data tied to a specific app (audit logs, user data, app config overrides, fraud protection stats). The query variables typically include
appID.
- Portal API: portal-level data (subscription info, feature flags, app list). The existing hooks like
useAppFeatureConfigQuery use this API.
Step 7: CSS module conventions
Use Tailwind utility classes via @apply. Never write raw CSS values when a Tailwind class exists.
.metricCard {
@apply flex flex-col rounded-lg border border-[#edebe9] bg-white px-[14px] py-4;
}
.metricCard {
display: flex;
flex-direction: column;
border-radius: 0.5rem;
padding: 1rem;
}
For icon/badge variants that share a base style, group them in one selector then override per-variant:
.metricIcon,
.metricIconSuccess,
.metricIconWarning,
.metricIconBlocked {
@apply inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-[16px];
}
.metricIcon { @apply bg-[#edf6ff] text-[#176df3]; }
.metricIconSuccess { @apply bg-[#eef6ef] text-[#16a34a]; }
.metricIconWarning { @apply bg-[#fff4ce] text-[#d97706]; }
.metricIconBlocked { @apply bg-[#fef2f2] text-[#dc2626]; }
Map variants via a lookup object in the component rather than building class strings dynamically:
const iconVariantClass: Record<MetricIconVariant, string> = {
default: styles.metricIcon,
success: styles.metricIconSuccess,
warning: styles.metricIconWarning,
blocked: styles.metricIconBlocked,
};
Step 8: Quality checks
After any changes to portal files, always run both checks and fix all errors before finishing:
cd portal
npm run typecheck
npm run eslint
Common issues to watch for:
@typescript-eslint/no-unnecessary-condition: A condition that is always true/false because TypeScript can prove it at the call site. Usually caused by checking a prop value that is already guaranteed by context (e.g., isActive={selectedKey === "overview"} inside a branch where selectedKey === "overview" is already true).
- Unused imports: Remove any imports that are no longer referenced after refactoring.
- Missing
useCallback/useMemo: Inline arrow functions passed as props to components should be wrapped in useCallback to avoid unnecessary re-renders.
Quick reference
| Task | Command |
|---|
Generate TypeScript types from .graphql files | cd portal && npm run gentype |
| Type-check all TypeScript | cd portal && npm run typecheck |
| Lint all TypeScript/TSX | cd portal && npm run eslint |
| Auto-fix lint issues | cd portal && npm run eslint:format |
| Check CSS | cd portal && npm run stylelint |