diff --git a/packages/react/src/Timeline/Timeline.stories.module.css b/packages/react/src/Timeline/Timeline.stories.module.css index 12232a15721..beaf0839b4c 100644 --- a/packages/react/src/Timeline/Timeline.stories.module.css +++ b/packages/react/src/Timeline/Timeline.stories.module.css @@ -50,3 +50,18 @@ margin-top: var(--base-size-4); color: var(--fgColor-muted); } + +/* + * Timeline Playground: story-local styles for the representative event bodies and the empty + * state rendered by the `TimelinePlayground` story (its controls are Storybook argTypes). + */ + +.PlaygroundEmpty { + max-width: 1012px; + color: var(--fgColor-muted); +} + +.Strong { + font-weight: var(--base-text-weight-semibold); + color: var(--fgColor-default); +} diff --git a/packages/react/src/Timeline/Timeline.stories.tsx b/packages/react/src/Timeline/Timeline.stories.tsx index 7dd6a50dfd5..d77135d4b79 100644 --- a/packages/react/src/Timeline/Timeline.stories.tsx +++ b/packages/react/src/Timeline/Timeline.stories.tsx @@ -7,6 +7,7 @@ import {TimelineBadgeVariants} from './constants' import Avatar from '../Avatar' import {Button} from '../Button' import Link from '../Link' +import Label from '../Label' import RelativeTime from '../RelativeTime' import { AlertIcon, @@ -41,7 +42,24 @@ import { TrashIcon, UnlockIcon, XCircleIcon, + // Additional badge icons used by the Timeline Playground story below. + BlockedIcon, + CheckIcon, + CommentIcon, + DotFillIcon, + type Icon, + LinkExternalIcon, + MarkGithubIcon, + NoteIcon, + ShieldCheckIcon, + ShieldSlashIcon, + ShieldXIcon, + SyncIcon, + XIcon, } from '@primer/octicons-react' +import {FeatureFlags} from '../FeatureFlags' +import Text from '../Text' +import {BoldLink, EventSubRow, Examples, MONALISA_AVATAR, MutedTime, UserActor} from './internal/timelineStoryHelpers' import classes from './Timeline.stories.module.css' export default { @@ -86,8 +104,8 @@ export const Default = () => ( ) -// Helpers for the Custom Event playground (declared above the story export). -// The story-level JSDoc lives on the `Playground` export so Storybook attaches it +// Helpers for the Event Playground (declared above the story export). +// The story-level JSDoc lives on the `EventPlayground` export so Storybook attaches it // to the Docs tab. const BADGE_ICONS = { alert: AlertIcon, @@ -138,7 +156,7 @@ type PlaygroundArgs = { appPreset: AppPreset customAppName: string customAppAvatar: string - eventScope: 'shared' | 'pr' | 'issue' | 'dependabot' | 'custom' + eventScope: 'pull' | 'issue' | 'dependabot' | 'code-scanning' | 'secret-scanning' | 'license-compliance' eventType: string badgeIcon: BadgeIconName badgeVariant: TimelineBadgeVariant | 'none' @@ -161,6 +179,19 @@ const ACTOR_AVATARS: Record = { copilot: 'https://avatars.githubusercontent.com/in/1143301?v=4', } +// `data-actor-type` distinguishes only human (`user`) from automated (`bot`) +// actors, matching the authoritative @github-ui/timeline-taxonomy `ActorType`. +// The four actor presets above are a visual convenience (distinct avatars and +// names). `app` and `copilot` are automated identities, so they serialize to +// `bot`, mirroring how `actorTypeForLogin` collapses GitHub Apps and Copilot to +// `bot`. The playground never emits `data-actor-type="app"` or `"copilot"`. +const DATA_ACTOR_TYPE: Record = { + user: 'user', + bot: 'bot', + app: 'bot', + copilot: 'bot', +} + // Apps that can be appended via the PR `viaApp` slot. Avatar and name are paired // so toggling the preset swaps both at once (mirrors how real "... \u2014 with // [appAvatar] [appName]" rows render on PR timelines). @@ -235,16 +266,17 @@ type TimestampPreset = | 'Absolute (full timestamp)' /** - * Recreates the Figma "Custom event" component (Primer-Web library, node `46191-13560`) - * as a compositional Storybook playground. Every slot is built from existing public primitives + * The **Event Playground**: recreates the Figma "Custom event" component (Primer-Web + * library, node `46191-13560`) as a compositional Storybook playground for a SINGLE + * configurable Timeline event. Every slot is built from existing public primitives * (`Timeline`, `Timeline.Item`, `Timeline.Badge`, `Timeline.Body`, `Timeline.Avatar`, * `Timeline.Actions`, `Avatar`, `Link`, `RelativeTime`) — no public API changes. * * **`data-*` filtering convention** (applied to `Timeline.Item`): * - * - `data-event-scope` — `'shared' | 'pr' | 'issue' | 'dependabot' | 'custom'` + * - `data-event-scope` — `'pull' | 'issue' | 'dependabot' | 'code-scanning' | 'secret-scanning' | 'license-compliance'` * - `data-event-type` — short identifier (e.g. `assigned`, `merged`, `subscribed`) - * - `data-actor-type` — `'user' | 'bot' | 'app' | 'copilot'` + * - `data-actor-type` — `'user' | 'bot'` (app and Copilot actor presets serialize to `bot`) * * These have no visual effect today; they're reserved for Phase 4 filtering work * (e.g. "hide all `subscribed` rows", or the planned summary-events rollup). @@ -260,7 +292,7 @@ type TimestampPreset = // avatar (via `Timeline.Avatar`) has room to display. The gutter wrapper is only // needed because the playground is a standalone demo — in product code the page // layout typically provides the gutter already. -export const Playground: StoryFn = args => { +export const EventPlayground: StoryFn = args => { const Icon = BADGE_ICONS[args.badgeIcon] const isAppLike = args.actorType === 'bot' || args.actorType === 'app' // Allow the `actorAvatarSrc` control to override the default user avatar; for @@ -310,7 +342,7 @@ export const Playground: StoryFn = args => { {args.actorSize === 'large' && ( @@ -366,9 +398,9 @@ export const Playground: StoryFn = args => { ) } -Playground.parameters = { +EventPlayground.parameters = { // Compact Controls panel (no inline Description / Default columns). The story-level - // JSDoc on the Playground export plus the auto-generated props table on the Docs tab + // JSDoc on the EventPlayground export plus the auto-generated props table on the Docs tab // cover the longer-form context. controls: {expanded: false}, } @@ -386,7 +418,7 @@ const DEFAULT_ACTOR_NAMES: Record = { // Sync the visible `actorName` field whenever `actorType` changes, so the field // reflects a sensible default for the new type rather than carrying over a value // from the previous type. Users can still edit the field from there. -Playground.decorators = [ +EventPlayground.decorators = [ (Story, context) => { const [args, updateArgs] = useArgs() const previousActorType = React.useRef(args.actorType) @@ -400,7 +432,7 @@ Playground.decorators = [ }, ] -Playground.args = { +EventPlayground.args = { actorSize: 'small', actorType: 'user', actorAvatarSrc: 'https://avatars.githubusercontent.com/u/92997159?v=4', @@ -417,11 +449,11 @@ Playground.args = { noteText: 'Additional context or details', showActions: false, actionsPreset: 'Single button' as ActionsPreset, - eventScope: 'custom', + eventScope: 'issue', eventType: '', } -Playground.argTypes = { +EventPlayground.argTypes = { actorSize: { control: {type: 'inline-radio'}, options: ['small', 'large'], @@ -431,7 +463,7 @@ Playground.argTypes = { control: {type: 'select'}, options: ['user', 'bot', 'app', 'copilot'], description: - '`bot` and `copilot` use baked-in canonical names (`dependabot`, `Copilot`); `user` and `app` allow a custom name and avatar.', + '`bot` and `copilot` use baked-in canonical names (`dependabot`, `Copilot`); `user` and `app` allow a custom name and avatar. Emitted `data-actor-type` is `user` for the user preset, otherwise `bot`.', table: {category: 'Actor'}, }, actorAvatarSrc: { @@ -505,7 +537,7 @@ Playground.argTypes = { // Descriptions are useful here because the controls' purpose isn't visually obvious. eventScope: { control: {type: 'select'}, - options: ['shared', 'pr', 'issue', 'dependabot', 'custom'], + options: ['pull', 'issue', 'dependabot', 'code-scanning', 'secret-scanning', 'license-compliance'], description: 'Sets `data-event-scope` on the Timeline.Item. Identifies which timeline an event belongs to. Reserved for Phase 4 filtering work.', table: {category: 'DOM attributes'}, @@ -517,3 +549,855 @@ Playground.argTypes = { table: {category: 'DOM attributes'}, }, } + +// ============================================================================ +// Timeline Playground +// ============================================================================ + +/** + * ILLUSTRATIVE, REPRESENTATIVE DATA — read this before treating anything below as canonical. + * + * The **Timeline Playground** demonstrates how filtering `data-*` attributes + * (`data-event-scope`, `data-event-type`, `data-event-category`, `data-event-visibility`, + * `data-actor-type`) are embedded on each `Timeline.Item` across GitHub surfaces. It mirrors + * the Figma prototype: a `surface` control picks a surface, a categories control filters by the + * categories that surface offers, and an event-types control selects which event types render. + * Changing the surface reveals that surface's category and event-type controls and hides the + * others. + * + * The `PLAYGROUND_SURFACES` map below is a small, hardcoded, story-local sample of + * github-flavored events (a handful per surface, NOT the full ~160-row catalog). Its + * surface/category/type shape and every copy string are ILLUSTRATIVE examples for this demo + * only. The AUTHORITATIVE, per-surface timeline taxonomy is a GitHub product concern owned by + * `github-ui` (the product repositories that render these timelines). Primer does not host an + * authoritative taxonomy; any internal Primer taxonomy code is non-authoritative and not public + * API. Do not treat this inline map as the real catalog, do not export it, and do not promote it + * into a reusable module — it is intentionally confined to this story file. + * + * The picker is structured so a future `Timeline.Filter` can drive it: the render pipeline + * derives `visibleRows` from the selected categories and types, then maps each row to a + * ``. + */ + +type PlaygroundSurfaceId = 'code-scanning' | 'secret-scanning' | 'dependabot' | 'license-compliance' | 'issue' +type PlaygroundCategoryId = 'findings' | 'status' | 'reviews' | 'references' | 'moderation' | 'metadata' +// Actor classification, mirrors the authoritative `ActorType` value space (`user | bot`): +// first-party automation such as the GitHub secret-scanning system actor is `bot`. +type PlaygroundActorType = 'user' | 'bot' + +type PlaygroundEvent = { + /** `data-event-type` value */ + type: string + /** `data-event-category` value */ + category: PlaygroundCategoryId + /** Human-readable label shown in the event-type picker */ + label: string + /** + * `data-event-visibility` value. Matches the authoritative `EventVisibility` value space + * (`@github-ui/timeline-taxonomy`): `primary` rows render in the main timeline; `auditOnly` + * rows are metadata-only. + */ + visibility: 'primary' | 'auditOnly' + /** `data-actor-type` value; omit for actor-less rows so no `data-actor-type` attribute renders */ + actorType?: PlaygroundActorType + badge: {icon: Icon; variant?: TimelineBadgeVariant} + /** Contents of `Timeline.Body` */ + body: React.ReactNode + /** Optional contents of the right-aligned `Timeline.Actions` slot */ + actions?: React.ReactNode +} + +type PlaygroundSurface = { + label: string + /** Accessible name for the rendered `` */ + ariaLabel: string + categories: Partial> +} + +// Story-local demo avatars (MONALISA_AVATAR is imported from the shared helpers). +const DEPENDABOT_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/27347476?v=4' +const LICENSE_BOT_AVATAR = 'https://avatars.githubusercontent.com/u/9919?s=40&v=4' +const HUBOT_AVATAR = 'https://avatars.githubusercontent.com/u/480938?v=4' + +// ILLUSTRATIVE representative data (see the canon note above). Each event mirrors the +// badge/icon/copy/actor of the matching VariantSection in that surface's existing +// `Timeline..features.stories.tsx`, reduced to a demonstrative subset. The four +// security surfaces offer findings/status/reviews; issues offers status/references/moderation. +const PLAYGROUND_SURFACES: Record = { + 'code-scanning': { + label: 'Code scanning', + ariaLabel: 'Code scanning alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'detected', + category: 'findings', + label: 'First detected in commit', + visibility: 'primary', + badge: {icon: ShieldIcon}, + body: ( + <> + First detected in commit{' '} + + + ), + }, + { + type: 'fixed', + category: 'findings', + label: 'Fixed in branch', + visibility: 'primary', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + Fixed in branch main{' '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as false positive', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldXIcon, variant: 'danger'}, + body: ( + <> + + {'closed this as '} + false positive{' '} + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: DotFillIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'dismissal_requested', + category: 'reviews', + label: 'Requested to dismiss', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this as false positive '} + + This finding is a test-only helper, safe to dismiss. + + ), + actions: ( + + ), + }, + { + type: 'dismissal_reviewed', + category: 'reviews', + label: 'Approved dismissal', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckIcon}, + body: ( + <> + + {'approved dismissal '} + + + ), + }, + ], + }, + }, + }, + 'secret-scanning': { + label: 'Secret scanning', + ariaLabel: 'Secret scanning alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'detected', + category: 'findings', + label: 'Created', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {'opened this alert '} + + + ), + }, + { + type: 'validity_active', + category: 'findings', + label: 'Validity: active', + visibility: 'primary', + actorType: 'bot', + badge: {icon: AlertIcon, variant: 'danger'}, + body: ( + <> + + {'verified this secret is active '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as revoked', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {'closed this as '} + revoked + + Rotated the leaked token and confirmed the provider revoked it. + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: SyncIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'closure_requested', + category: 'reviews', + label: 'Requested to dismiss', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this as false positive '} + + + ), + actions: ( + + ), + }, + { + type: 'bypass_approved', + category: 'reviews', + label: 'Bypass approved', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckCircleIcon}, + body: ( + <> + + {'approved a bypass '} + + + ), + }, + ], + }, + }, + }, + dependabot: { + label: 'Dependabot', + ariaLabel: 'Dependabot alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'opened', + category: 'findings', + label: 'Opened', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {'opened this '} + + + ), + }, + { + type: 'fixed', + category: 'findings', + label: 'Fixed', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {'closed this as completed '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: SyncIcon, variant: 'success'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + { + type: 'dismissed', + category: 'status', + label: 'Dismissed', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldSlashIcon}, + body: ( + <> + + {'dismissed this as '} + risk is tolerable{' '} + + Only reachable from a dev-only script we do not ship. + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'dismissal_requested', + category: 'reviews', + label: 'Dismissal requested', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {'requested to dismiss this '} + + + ), + actions: ( + + ), + }, + ], + }, + }, + }, + 'license-compliance': { + label: 'License compliance', + ariaLabel: 'License compliance alert timeline', + categories: { + findings: { + label: 'Findings', + events: [ + { + type: 'opened', + category: 'findings', + label: 'Opened', + visibility: 'primary', + actorType: 'bot', + badge: {icon: ShieldIcon, variant: 'success'}, + body: ( + <> + + {' opened this alert '} + + + ), + }, + { + type: 'appeared_in_branch', + category: 'findings', + label: 'Appeared in branch', + visibility: 'primary', + badge: {icon: GitBranchIcon}, + body: ( + <> + {'Appeared in branch '} + feature-branch{' '} + + + ), + }, + ], + }, + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as amendment', + visibility: 'primary', + actorType: 'user', + badge: {icon: ShieldCheckIcon, variant: 'done'}, + body: ( + <> + + {' closed as amendment '} + + Added a policy exception covering this package. + + ), + }, + ], + }, + reviews: { + label: 'Reviews', + events: [ + { + type: 'review_requested', + category: 'reviews', + label: 'Requested to close', + visibility: 'primary', + actorType: 'user', + badge: {icon: CommentIcon}, + body: ( + <> + + {' requested to close '} + + + ), + actions: ( + + ), + }, + { + type: 'review_approved', + category: 'reviews', + label: 'Approved closure request', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckIcon}, + body: ( + <> + + {' approved closure request '} + + + ), + }, + { + type: 'review_denied', + category: 'reviews', + label: 'Denied closure request', + visibility: 'primary', + actorType: 'user', + badge: {icon: XIcon}, + body: ( + <> + + {' denied closure request '} + + + ), + }, + ], + }, + }, + }, + issue: { + label: 'Issues', + ariaLabel: 'Issue timeline', + categories: { + status: { + label: 'Status', + events: [ + { + type: 'closed', + category: 'status', + label: 'Closed as completed', + visibility: 'primary', + actorType: 'user', + badge: {icon: CheckCircleIcon, variant: 'done'}, + body: ( + <> + + {'closed this as '} + + completed + {' '} + + + ), + }, + { + type: 'reopened', + category: 'status', + label: 'Reopened', + visibility: 'primary', + actorType: 'user', + badge: {icon: IssueReopenedIcon, variant: 'open'}, + body: ( + <> + + {'reopened this '} + + + ), + }, + ], + }, + references: { + label: 'References', + events: [ + { + type: 'connected', + category: 'references', + label: 'Linked pull request', + visibility: 'primary', + actorType: 'user', + badge: {icon: CrossReferenceIcon}, + body: ( + <> + + {'linked a pull request that will close this issue '} + Add retry logic to the uploader + {' #42 '} + + + ), + }, + { + type: 'cross_referenced', + category: 'references', + label: 'Mentioned in an issue', + visibility: 'primary', + actorType: 'user', + badge: {icon: LinkExternalIcon}, + body: ( + <> + + {'mentioned this '} + + + + Track flaky upload retries + + {' #128'} + + + ), + }, + ], + }, + moderation: { + label: 'Moderation', + events: [ + { + type: 'user_blocked', + category: 'moderation', + label: 'User blocked', + visibility: 'primary', + actorType: 'user', + badge: {icon: BlockedIcon}, + body: ( + <> + + {'blocked '} + six7 + + ), + }, + { + type: 'comment_pinned', + category: 'moderation', + label: 'Comment pinned', + visibility: 'primary', + actorType: 'user', + badge: {icon: PinIcon}, + body: ( + <> + + {'pinned a '} + + comment + {' '} + + + ), + }, + ], + }, + metadata: { + label: 'Metadata', + events: [ + { + type: 'labeled', + category: 'metadata', + label: 'Labeled (audit only)', + visibility: 'auditOnly', + actorType: 'user', + badge: {icon: TagIcon}, + body: ( + <> + + {'added the '} + + {' label '} + + + ), + }, + ], + }, + }, + }, +} + +const PLAYGROUND_SURFACE_IDS = Object.keys(PLAYGROUND_SURFACES) as PlaygroundSurfaceId[] + +const playgroundCategoryIds = (surface: PlaygroundSurfaceId): PlaygroundCategoryId[] => + Object.keys(PLAYGROUND_SURFACES[surface].categories) as PlaygroundCategoryId[] + +const playgroundEvents = (surface: PlaygroundSurfaceId, categories: PlaygroundCategoryId[]): PlaygroundEvent[] => + categories.flatMap(category => PLAYGROUND_SURFACES[surface].categories[category]?.events ?? []) + +/** + * Args for the {@link TimelinePlayground} story. A single `surface` selector, plus a categories + * multi-select and an event-types multi-select for each of the five surfaces. Only the selected + * surface's two controls are shown (see the surface-gated `argTypes` below); the render reads the + * active surface's two arrays and filters the representative rows by them. + */ +type TimelinePlaygroundArgs = { + surface: PlaygroundSurfaceId + codeScanningCategories: PlaygroundCategoryId[] + codeScanningTypes: string[] + secretScanningCategories: PlaygroundCategoryId[] + secretScanningTypes: string[] + dependabotCategories: PlaygroundCategoryId[] + dependabotTypes: string[] + licenseComplianceCategories: PlaygroundCategoryId[] + licenseComplianceTypes: string[] + issueCategories: PlaygroundCategoryId[] + issueTypes: string[] +} + +// Storybook args share a single flat namespace, so each surface needs distinctly named +// category/type args. This maps a surface to its two arg names for both the render and defaults. +const PLAYGROUND_ARG_KEYS: Record< + PlaygroundSurfaceId, + {categories: keyof TimelinePlaygroundArgs; types: keyof TimelinePlaygroundArgs} +> = { + 'code-scanning': {categories: 'codeScanningCategories', types: 'codeScanningTypes'}, + 'secret-scanning': {categories: 'secretScanningCategories', types: 'secretScanningTypes'}, + dependabot: {categories: 'dependabotCategories', types: 'dependabotTypes'}, + 'license-compliance': {categories: 'licenseComplianceCategories', types: 'licenseComplianceTypes'}, + issue: {categories: 'issueCategories', types: 'issueTypes'}, +} + +const playgroundTypeIds = (surface: PlaygroundSurfaceId): string[] => + playgroundEvents(surface, playgroundCategoryIds(surface)).map(event => event.type) + +// Surface-gated `check` control for a surface's categories. Gated with `if: {arg: 'surface', +// eq: id}` so it only shows when that surface is selected. +const playgroundCategoryControl = (surface: PlaygroundSurfaceId) => { + const categoryIds = playgroundCategoryIds(surface) + return { + control: { + type: 'check' as const, + labels: Object.fromEntries( + categoryIds.map(category => [category, PLAYGROUND_SURFACES[surface].categories[category]!.label]), + ), + }, + options: categoryIds, + if: {arg: 'surface', eq: surface}, + table: {category: PLAYGROUND_SURFACES[surface].label}, + } +} + +// Surface-gated `check` control for a surface's representative event types. +const playgroundTypeControl = (surface: PlaygroundSurfaceId) => { + const events = playgroundEvents(surface, playgroundCategoryIds(surface)) + return { + control: { + type: 'check' as const, + labels: Object.fromEntries(events.map(event => [event.type, event.label])), + }, + options: events.map(event => event.type), + if: {arg: 'surface', eq: surface}, + table: {category: PLAYGROUND_SURFACES[surface].label}, + } +} + +/** + * The **Timeline Playground** (see the canon note above for the illustrative-data caveat). + * + * The picker is built from real Storybook controls, not in-canvas form elements. Storybook cannot + * repopulate one control's options from another control's value, so instead of a single dependent + * surface -> category -> type chain, every surface's category and event-type controls are declared + * up front and each is gated with `if: {arg: 'surface', eq: ''}`. Only the selected + * surface's two controls are shown; the other four surfaces' pairs are hidden, faking the dynamic + * swap. The render then reads the active surface's selected categories and types and filters the + * representative rows, mapping each to a `` carrying the `data-*` event contract, + * the same shape a future `Timeline.Filter` would consume. + */ +export const TimelinePlayground: StoryFn = args => { + // Defensive fallback in case Storybook restores a stale/empty `surface` from the URL. + const surface = args.surface in PLAYGROUND_SURFACES ? args.surface : 'code-scanning' + const surfaceDef = PLAYGROUND_SURFACES[surface] + const argKeys = PLAYGROUND_ARG_KEYS[surface] + const selectedCategories = (args[argKeys.categories] as PlaygroundCategoryId[] | undefined) ?? [] + const selectedTypes = (args[argKeys.types] as string[] | undefined) ?? [] + + // Category -> type filtering lives here because `if:` gates a control on one arg's scalar + // value, not on array membership. Keep rows whose category and type are both selected for the + // active surface, then map to Timeline.Item rows (the future Timeline.Filter shape). + const visibleRows = playgroundEvents(surface, playgroundCategoryIds(surface)).filter( + event => selectedCategories.includes(event.category) && selectedTypes.includes(event.type), + ) + + return ( + + + {visibleRows.length > 0 ? ( + + {visibleRows.map(event => { + const BadgeIcon = event.badge.icon + return ( + + + {/* Decorative: the summary text in Timeline.Body is the accessible description. */} + + + {event.body} + {event.actions ? {event.actions} : null} + + ) + })} + + ) : ( + + No rows match. In the Controls panel, pick a surface then check its categories and event types. + + )} + + + ) +} + +TimelinePlayground.parameters = { + controls: {expanded: false}, +} + +// Default to the first surface with all of its categories and event types checked, so the story +// renders a populated timeline on load. Generated from the surface ids so the per-surface +// defaults can't drift from PLAYGROUND_SURFACES. +const buildPlaygroundDefaults = (): TimelinePlaygroundArgs => { + const perSurface: Record = {} + for (const id of PLAYGROUND_SURFACE_IDS) { + const argKeys = PLAYGROUND_ARG_KEYS[id] + perSurface[argKeys.categories] = playgroundCategoryIds(id) + perSurface[argKeys.types] = playgroundTypeIds(id) + } + return {surface: 'code-scanning', ...perSurface} as TimelinePlaygroundArgs +} + +TimelinePlayground.args = buildPlaygroundDefaults() + +TimelinePlayground.argTypes = { + surface: { + control: {type: 'inline-radio'}, + options: PLAYGROUND_SURFACE_IDS, + description: + "Which surface's events to show. Switching it reveals that surface's category and event-type controls and hides the others.", + table: {category: 'Surface'}, + }, + codeScanningCategories: playgroundCategoryControl('code-scanning'), + codeScanningTypes: playgroundTypeControl('code-scanning'), + secretScanningCategories: playgroundCategoryControl('secret-scanning'), + secretScanningTypes: playgroundTypeControl('secret-scanning'), + dependabotCategories: playgroundCategoryControl('dependabot'), + dependabotTypes: playgroundTypeControl('dependabot'), + licenseComplianceCategories: playgroundCategoryControl('license-compliance'), + licenseComplianceTypes: playgroundTypeControl('license-compliance'), + issueCategories: playgroundCategoryControl('issue'), + issueTypes: playgroundTypeControl('issue'), +}