Agent Skillsauthgear/authgear-server › add-portal-screen

add-portal-screen

GitHub

指导在门户前端添加新屏幕或组件的规范,包括定位现有模式、文件结构命名及屏幕解剖结构。

.claude/skills/add-portal-screen/SKILL.md authgear/authgear-server

Trigger Scenarios

创建新的门户页面 添加新的 UI 组件

Install

npx skills add authgear/authgear-server --skill add-portal-screen -g -y
More Options

Non-standard path

npx skills add https://github.com/authgear/authgear-server/tree/main/.claude/skills/add-portal-screen -g -y

Use without installing

npx skills use authgear/authgear-server@add-portal-screen

指定 Agent (Claude Code)

npx skills add authgear/authgear-server --skill add-portal-screen -a claude-code -g -y

安装 repo 全部 skill

npx skills add authgear/authgear-server --all -g -y

预览 repo 内 skill

npx skills add authgear/authgear-server --list

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

  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):

{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

  1. Read the relevant schema file to understand available types and fields.
  2. Create a .graphql file in the appropriate query/ 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
    }
  }
}
  1. 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 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.

/* ✅ 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 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

Version History

  • 2dd6d88 Current 2026-07-24 16:30

Same Skill Collection

.claude/skills/add-go-test/SKILL.md
.claude/skills/add-portal-admin-api-mutation/SKILL.md
.claude/skills/add-portal-admin-api-query/SKILL.md
.claude/skills/api-design/SKILL.md
.claude/skills/bootstrap-local-dev/SKILL.md
.claude/skills/dep-audit/SKILL.md
.claude/skills/generate-schemas-and-gentype/SKILL.md
.claude/skills/new-siteadmin-api/SKILL.md
.claude/skills/review-pr/SKILL.md
.claude/skills/update-deps/SKILL.md
.claude/skills/update-email-templates/SKILL.md
.claude/skills/update-feature-config/SKILL.md
.claude/skills/update-important-modules/SKILL.md
.claude/skills/update-portal-ui/SKILL.md
.claude/skills/update-vettedpositions/SKILL.md
.claude/skills/write-e2e-test/SKILL.md
.claude/skills/write-implementation-plan/SKILL.md
.claude/skills/update-go-version/SKILL.md
.claude/skills/portal-admin-api-graphql/SKILL.md

Metadata

Files
0
Version
e619570
Hash
54a744f9
Indexed
2026-07-24 16:30

Главная - Вики-сайт
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-17 07:27
浙ICP备14020137号-1 $Гость$