Skip to main content

add-portal-screen

Add screens or components to the portal frontend. Use when the user asks to create a new portal page, screen, tab, or UI component.

Ir para a instalação

Informações da origem

Repositório
authgear/authgear-server
Última atividade na origem
14 de abril de 2026 às 05:57
Idioma detectado do SKILL.md
inglês
Estrelas
2.051
Forks
124

Opções de instalação

Por padrão, está selecionado o prompt que primeiro revisa a origem. Você pode mudar para um comando direto ou baixar uma cópia local.

Revise os arquivos de origem

Leia o SKILL.md e os arquivos complementares exibidos pelo SkillsMP antes de decidir se vai instalar.

Exibindo SKILL.md

SKILL.md
Instruções da origem · Visualização somente leitura
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. ```tsx 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`. ```tsx 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: ```tsx 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) ```tsx const MyFeatureScreen = lazy( async () => import("./screens/my-feature/MyFeatureScreen") ); ``` ### Add the route (inside the JSX route tree) Find the appropriate `<Route>` section and add: ```tsx <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 1. **Each extracted component gets its own `.tsx` and `.module.css`.** 2. **Do not pass raw `form` or `setState` into sub-components.** Instead, pass the specific values and typed callback functions they need. 3. **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. 4. **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): ```tsx {selectedKey === "overview" ? ( <section> {/* 200 lines of overview JSX */} </section> ) : null} ``` After: ```tsx // 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 1. Read the relevant schema file to understand available types and fields. 2. Create a `.graphql` file in the appropriate `query/` directory: ```graphql # 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 } } } ``` 3. 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.** ```tsx // ✅ 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: ```tsx 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. ```css /* ✅ 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: ```css /* 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: ```tsx 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` |
Ver no GitHub