Repository navigation
Premium Analytics: add a per-section header with title and date controls #50894
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4076f90
e2cb2de
261e4c3
9776382
75b9406
d247644
d4d6ce2
8c3c0cb
37fa494
af67b86
e98e9f0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| Significance: patch | ||
| Type: added | ||
|
|
||
| Dashboard: add a per-section header with the section title and date controls. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { SectionHeader } from '../section-header'; | ||
|
|
||
| const SUBTITLE = 'Jul 21 – Jul 27 (7 days)'; | ||
|
|
||
| describe( 'SectionHeader', () => { | ||
| it( 'renders the title as a level-2 heading', () => { | ||
| render( <SectionHeader title="Traffic" /> ); | ||
|
|
||
| expect( screen.getByRole( 'heading', { level: 2 } ) ).toHaveTextContent( 'Traffic' ); | ||
| } ); | ||
|
|
||
| it( 'renders the subtitle when one is given', () => { | ||
| render( <SectionHeader title="Traffic" subtitle={ SUBTITLE } /> ); | ||
|
|
||
| expect( screen.getByText( SUBTITLE ) ).toBeInTheDocument(); | ||
| } ); | ||
|
|
||
| it( 'drops the subtitle once there is nothing to describe', () => { | ||
| const { rerender } = render( <SectionHeader title="Traffic" subtitle={ SUBTITLE } /> ); | ||
|
|
||
| rerender( <SectionHeader title="Traffic" /> ); | ||
|
|
||
| expect( screen.queryByText( SUBTITLE ) ).not.toBeInTheDocument(); | ||
| } ); | ||
|
|
||
| it( 'renders the date controls passed to the slot', () => { | ||
| render( | ||
| <SectionHeader title="Traffic"> | ||
| <button type="button">Last 7 days</button> | ||
| </SectionHeader> | ||
| ); | ||
|
|
||
| expect( screen.getByRole( 'button', { name: 'Last 7 days' } ) ).toBeInTheDocument(); | ||
| } ); | ||
| } ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { SectionHeader } from './section-header'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { Stack, Text } from '@wordpress/ui'; | ||
| import { ReactNode } from 'react'; | ||
|
|
||
| type SectionHeaderProps = { | ||
| /** | ||
| * The name of the section. | ||
| */ | ||
| title: string; | ||
|
|
||
| /** | ||
| * Subtitle describing the active date configuration. Omit it while the | ||
| * consumer has nothing to describe. | ||
| */ | ||
| subtitle?: string; | ||
|
|
||
| /** | ||
| * Date controls anchored to the end of the header row. | ||
| */ | ||
| children?: ReactNode; | ||
| }; | ||
|
|
||
| /** | ||
| * Two-halves section header: title plus optional subtitle on the left, and a | ||
| * slot for the date controls anchored on the right. | ||
| * | ||
| * @param {SectionHeaderProps} props - The props for the SectionHeader component. | ||
| * @return The section header element. | ||
| */ | ||
| export function SectionHeader( { title, subtitle, children }: SectionHeaderProps ) { | ||
| return ( | ||
| <Stack direction="row" align="flex-start" justify="space-between"> | ||
| <Stack direction="column" align="flex-start" justify="flex-start"> | ||
| <Text variant="heading-2xl" render={ <h2 /> }> | ||
| { title } | ||
| </Text> | ||
| { subtitle ? <Text variant="body-lg">{ subtitle }</Text> : null } | ||
| </Stack> | ||
|
|
||
| <Stack direction="row" align="center" justify="flex-end"> | ||
| { children } | ||
| </Stack> | ||
| </Stack> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| import { | ||
| computePrimaryRange, | ||
| type ComparisonPresetId, | ||
| type PrimaryPresetId, | ||
| type YearSurfacePresetId, | ||
| } from '@jetpack-premium-analytics/datetime'; | ||
| import { useCallback, useRef, useState } from 'react'; | ||
| import { DateFiltersPanel } from '../../date-filters-panel'; | ||
| import { DateYearFilter } from '../../date-year-filter'; | ||
| import { SectionHeader } from '../section-header'; | ||
| import type { DateRange } from '../../date-filters-panel/date-filters-panel'; | ||
| import type { Meta, StoryObj } from '@storybook/react'; | ||
|
|
||
| const meta: Meta< typeof SectionHeader > = { | ||
| title: 'Packages/Premium Analytics/UI/SectionHeader', | ||
| component: SectionHeader, | ||
| tags: [ 'autodocs' ], | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| component: | ||
| 'Two-halves header for an analytics surface. The left half anchors the ' + | ||
| 'instance: a **title** naming the section plus a **subtitle** describing ' + | ||
| 'the active date configuration.\n\n' + | ||
| 'The right half is a slot: the consumer passes the date controls as ' + | ||
| '`children`, keeps the date state, and derives the subtitle from the ' + | ||
| '*applied* range, so the header stays purely presentational.', | ||
| }, | ||
| }, | ||
| }, | ||
| argTypes: { | ||
| children: { control: false }, | ||
| }, | ||
| }; | ||
| export default meta; | ||
|
|
||
| type Story = StoryObj< typeof SectionHeader >; | ||
|
|
||
| const STORYBOOK_TIMEZONE = 'America/New_York'; | ||
|
|
||
| type PrimaryFilterState = { | ||
| range: DateRange; | ||
| presetId: PrimaryPresetId; | ||
| }; | ||
|
|
||
| /** | ||
| * Committed/staged seed mirroring the dashboard default: Last 30 days. | ||
| * | ||
| * @return The initial primary filter state. | ||
| */ | ||
| function buildInitialPrimaryState(): PrimaryFilterState { | ||
| const range = computePrimaryRange( 'last-30-days', STORYBOOK_TIMEZONE ); | ||
|
|
||
| return { | ||
| presetId: 'last-30-days', | ||
| range: { from: range?.from, to: range?.to }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Rolling date controls for the slot, wired like the dashboard: staged primary | ||
| * edits committed on Apply. A trimmed copy of the DateFiltersPanel story | ||
| * harness. | ||
| * | ||
| * @param props - Harness props. | ||
| * @param props.containerElement - Measured row element for responsive layout. | ||
| * @return The wired date filters panel. | ||
| */ | ||
| function RollingDateControls( { containerElement }: { containerElement: HTMLElement | null } ) { | ||
| const initial = buildInitialPrimaryState(); | ||
|
|
||
| const [ committed, setCommitted ] = useState( initial ); | ||
| const [ staged, setStaged ] = useState( initial ); | ||
| const stagedRef = useRef( staged ); | ||
| stagedRef.current = staged; | ||
|
|
||
| const [ comparisonPresetId, setComparisonPresetId ] = useState< ComparisonPresetId | undefined >( | ||
| undefined | ||
| ); | ||
|
|
||
| const handleChange = useCallback( ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { | ||
| const next: PrimaryFilterState = { | ||
| range: nextRange ?? stagedRef.current.range, | ||
| presetId: nextPresetId ?? stagedRef.current.presetId, | ||
| }; | ||
|
|
||
| stagedRef.current = next; | ||
| setStaged( next ); | ||
| }, [] ); | ||
|
|
||
| const handleApply = useCallback( () => { | ||
| setCommitted( stagedRef.current ); | ||
| }, [] ); | ||
|
|
||
| const handleCancel = useCallback( () => { | ||
| stagedRef.current = committed; | ||
| setStaged( committed ); | ||
| }, [ committed ] ); | ||
|
|
||
| const handleComparisonChange = useCallback( | ||
| ( _range: DateRange | undefined, nextPresetId?: ComparisonPresetId ) => { | ||
| setComparisonPresetId( nextPresetId ); | ||
| }, | ||
| [] | ||
| ); | ||
|
|
||
| const canApply = | ||
| staged.range.from !== committed.range.from || | ||
| staged.range.to !== committed.range.to || | ||
| staged.presetId !== committed.presetId; | ||
|
|
||
| return ( | ||
| <DateFiltersPanel | ||
| presetId={ staged.presetId } | ||
| range={ staged.range } | ||
| appliedPresetId={ committed.presetId } | ||
| appliedRange={ committed.range } | ||
| comparisonPresetId={ comparisonPresetId } | ||
| onChange={ handleChange } | ||
| onComparisonChange={ handleComparisonChange } | ||
| onApply={ handleApply } | ||
| onCancel={ handleCancel } | ||
| canApply={ canApply } | ||
| timeZone={ STORYBOOK_TIMEZONE } | ||
| containerElement={ containerElement } | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Year-surface controls for the slot: all time plus calendar years, no | ||
| * comparison, as the Insights instance specifies. | ||
| * | ||
| * @param props - Harness props. | ||
| * @param props.containerElement - Measured row element for responsive layout. | ||
| * @return The wired year filter. | ||
| */ | ||
| function YearDateControls( { containerElement }: { containerElement: HTMLElement | null } ) { | ||
| const [ presetId, setPresetId ] = useState< PrimaryPresetId >( 'all-time' as PrimaryPresetId ); | ||
|
|
||
| return ( | ||
| <DateYearFilter | ||
| value={ presetId } | ||
| onSelect={ ( _range, nextPresetId: YearSurfacePresetId ) => setPresetId( nextPresetId ) } | ||
| timeZone={ STORYBOOK_TIMEZONE } | ||
| containerElement={ containerElement } | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| type SectionHeaderStoryProps = { | ||
| title: string; | ||
| subtitle: string; | ||
| }; | ||
|
|
||
| function RollingSectionHeaderStory( { title, subtitle }: SectionHeaderStoryProps ) { | ||
| const [ container, setContainer ] = useState< HTMLDivElement | null >( null ); | ||
|
|
||
| return ( | ||
| <div ref={ setContainer }> | ||
| <SectionHeader title={ title } subtitle={ subtitle }> | ||
| <RollingDateControls containerElement={ container } /> | ||
| </SectionHeader> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function YearSectionHeaderStory( { title, subtitle }: SectionHeaderStoryProps ) { | ||
| const [ container, setContainer ] = useState< HTMLDivElement | null >( null ); | ||
|
|
||
| return ( | ||
| <div ref={ setContainer }> | ||
| <SectionHeader title={ title } subtitle={ subtitle }> | ||
| <YearDateControls containerElement={ container } /> | ||
| </SectionHeader> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The **Traffic-like** instance: rolling presets, custom range, and comparison | ||
| * in the slot. | ||
| * | ||
| * The subtitle is a static arg here; in product it derives from the *applied* | ||
| * preset/range and, once the interval control lands, the active interval. | ||
| */ | ||
| export const Default: Story = { | ||
| args: { | ||
| title: 'Site traffic', | ||
| subtitle: 'Last 30 days', | ||
| }, | ||
| render: ( { title, subtitle } ) => ( | ||
| <RollingSectionHeaderStory title={ title } subtitle={ subtitle } /> | ||
| ), | ||
| }; | ||
|
|
||
| /** | ||
| * The **Insights-like** instance: the year surface (all time plus calendar | ||
| * years) in the slot. | ||
| * | ||
| * Per the design's instances table, this surface carries *no comparison | ||
| * control*. | ||
| */ | ||
| export const YearSurface: Story = { | ||
| args: { | ||
| title: 'Insights', | ||
| subtitle: 'All time', | ||
| }, | ||
| render: ( { title, subtitle } ) => ( | ||
| <YearSectionHeaderStory title={ title } subtitle={ subtitle } /> | ||
| ), | ||
| }; | ||
|
|
||
| /** | ||
| * The left half on its own: nothing in the controls slot. | ||
| */ | ||
| export const WithoutControls: Story = { | ||
| args: { | ||
| title: 'Site traffic', | ||
| subtitle: 'Last 30 days', | ||
| }, | ||
| render: ( { title, subtitle } ) => <SectionHeader title={ title } subtitle={ subtitle } />, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,7 +12,7 @@ | |
| .content { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I noticed that in mobile view, or on a tall screen, the sticky header may scroll away once we pass the first screen of widgets. Screen.Recording.2026-07-30.at.10.46.01.AM.mov
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes, same comment as the previous ones. Let's address it in a follow-up. I don't want to introduce too many changes in this PR |
||
| flex: 1 1 auto; | ||
| min-block-size: 0; | ||
| overflow-y: auto; | ||
|
|
||
| // Reserve paint space for the widget host's outer focus outline. | ||
| padding-block-start: calc(var(--wpds-border-width-focus) + 2px); | ||
| // Match the horizontal padding of the page header and the section tabs so the | ||
|
|
@@ -21,10 +21,8 @@ | |
| padding-inline: var(--wpds-dimension-padding-2xl); | ||
| } | ||
|
|
||
| .dateFilters { | ||
| // Sits below the section tabs and above the widgets. The tab list already | ||
| // adds a bottom margin, so only pad below here to keep the spacing even. | ||
| // Inline padding matches the tabs and widget grid so it lines up. | ||
| .sectionHeader { | ||
| // Separates the header row from the widget grid below. The inline gutter | ||
| // comes from the panel, which already pads to match the tabs. | ||
| padding-block-end: var(--wpds-dimension-gap-lg); | ||
| padding-inline: var(--wpds-dimension-padding-2xl); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Worth adding a
gaphere?Stackdoesn't set one, so a long translated title can end up right up against the date controls.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, 👍 . This will be addressed in a follow-up.