add-portal-screen
GitHub指导在门户前端添加新屏幕或组件的规范,包括定位现有模式、文件结构命名及屏幕解剖结构。
Trigger Scenarios
Install
npx skills add authgear/authgear-server --skill add-portal-screen -g -y
SKILL.md
Frontmatter
{
"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 };
// Data hooks
const form = useAppConfigForm({ appID, constructFormState, constructConfig });
const featureConfig = useAppFeatureConfigQuery(appID);
// Tab navigation (if the screen has tabs)
const { selectedKey, onLinkClick, onChangeKey } =
usePivotNavigation<MyTab>(["overview", "settings"]);
// Loading / error guards — render nothing else until resolved
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
.tsxand.module.css. - Do not pass raw
formorsetStateinto 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.,
showAllfor 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
isActiveprop — it is simply not rendered when inactive, so queries inside it should useskip: !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:
// In components/my-feature/MyFeatureOverviewTab.tsx
export interface MyFeatureOverviewTabProps {
enabled: boolean;
onChangeToSettings: () => void;
}
const MyFeatureOverviewTab: React.VFC<MyFeatureOverviewTabProps> = ...
// In the screen:
{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
.graphqlfile in the appropriatequery/directory:
# portal/src/graphql/adminapi/query/myFeatureQuery.graphql
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.
// ✅ Correct
import { useMyFeatureQueryQuery } from "../../graphql/adminapi/query/myFeatureQuery.generated";
// ❌ Wrong — do not write inline gql documents
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
useAppFeatureConfigQueryuse this API.
Step 7: CSS module conventions
Use Tailwind utility classes via @apply. Never write raw CSS values when a Tailwind class exists.
/* ✅ Good */
.metricCard {
@apply flex flex-col rounded-lg border border-[#edebe9] bg-white px-[14px] py-4;
}
/* ❌ Avoid raw CSS when Tailwind equivalent exists */
.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:
/* Shared base */
.metricIcon,
.metricIconSuccess,
.metricIconWarning,
.metricIconBlocked {
@apply inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-[16px];
}
/* Per-variant color */
.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 whereselectedKey === "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 inuseCallbackto 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 |
Version History
- 2dd6d88 Current 2026-07-24 16:30


