diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31d2c4026d9a..856c328da897 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3999,6 +3999,9 @@ importers: '@jetpack-premium-analytics/formatters': specifier: link:packages/formatters version: link:packages/formatters + '@jetpack-premium-analytics/icons': + specifier: link:packages/icons + version: link:packages/icons '@jetpack-premium-analytics/routing': specifier: link:packages/routing version: link:packages/routing @@ -4008,6 +4011,9 @@ importers: '@jetpack-premium-analytics/ui': specifier: link:packages/ui version: link:packages/ui + '@jetpack-premium-analytics/widgets-toolkit': + specifier: link:packages/widgets-toolkit + version: link:packages/widgets-toolkit '@tanstack/react-query': specifier: 5.90.8 version: 5.90.8(react@18.3.1) diff --git a/projects/js-packages/storybook/changelog/add-average-items-order-widget-story b/projects/js-packages/storybook/changelog/add-average-items-order-widget-story new file mode 100644 index 000000000000..27410992d609 --- /dev/null +++ b/projects/js-packages/storybook/changelog/add-average-items-order-widget-story @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add a Premium Analytics Average items per order widget story. diff --git a/projects/packages/premium-analytics/changelog/add-average-items-per-order-widget b/projects/packages/premium-analytics/changelog/add-average-items-per-order-widget new file mode 100644 index 000000000000..bc2072729512 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-average-items-per-order-widget @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port the Average items per order dashboard widget from next-woocommerce-analytics, composed from the widgets-toolkit and data packages. diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs index 0e3f236bbb13..26499b983533 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -105,5 +105,22 @@ export default defineConfig( '@wordpress/no-unsafe-wp-apis': 'off', '@wordpress/no-unknown-ds-tokens': 'off', }, + }, + { + // Ported widget code keeps the upstream next-woocommerce-analytics + // JSDoc style, and imports internal `@jetpack-premium-analytics/*` + // link: packages whose deps are declared on the parent manifest. + files: [ 'widgets/**' ], + rules: { + 'import/order': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/escape-inline-tags': 'off', + 'import/no-extraneous-dependencies': 'off', + }, } ); diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 64e79e53425b..024318130e0e 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -45,9 +45,11 @@ "@jetpack-premium-analytics/datetime": "link:packages/datetime", "@jetpack-premium-analytics/fields": "link:packages/fields", "@jetpack-premium-analytics/formatters": "link:packages/formatters", + "@jetpack-premium-analytics/icons": "link:packages/icons", "@jetpack-premium-analytics/routing": "link:packages/routing", "@jetpack-premium-analytics/site-sync": "link:packages/site-sync", "@jetpack-premium-analytics/ui": "link:packages/ui", + "@jetpack-premium-analytics/widgets-toolkit": "link:packages/widgets-toolkit", "@tanstack/react-query": "5.90.8", "@wordpress/admin-ui": "2.3.1", "@wordpress/api-fetch": "7.48.1", diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss index 2f4c511d9c7b..da1949600279 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss @@ -1,23 +1,40 @@ .chart { height: 100%; + min-height: 0; + flex: 1 1 auto; + + .chartContent { + flex: 1 1 auto; + height: 100%; + min-height: 0; + } .legend { flex: 0 0 auto; - height: var(--wpds-typography-line-height-lg); + width: 100%; + height: auto; min-height: var(--wpds-typography-line-height-lg); - flex-wrap: nowrap; + align-content: center; + align-items: center; + justify-content: center; + flex-wrap: wrap; + row-gap: var(--wpds-dimension-gap-xs) !important; } .legendItem { min-width: 0; + max-width: 100%; + flex: 0 1 auto; gap: var(--wpds-dimension-gap-sm); + justify-content: flex-start; padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); // 4px 6px->8px } .legendLabel { // font-size and color come from chartTheme.legendLabelStyles min-width: 0; - flex: 1 1 0 !important; // Override the 0 0 auto default value + flex: 0 1 auto !important; // Override the 0 0 auto default value + text-align: start; span { display: block; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx index b65f1f95780c..0218024be9f8 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx @@ -3,6 +3,7 @@ */ import { LineChart } from '@automattic/charts'; import { formatDate, formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { Stack } from '@wordpress/ui'; import clsx from 'clsx'; import { useCallback, useMemo } from 'react'; import { type ComponentProps } from 'react'; @@ -317,35 +318,37 @@ export function ComparativeLineChart( { ] ); return ( - - - + + + + + ); } diff --git a/projects/packages/premium-analytics/widgets/average-items-per-order/package.json b/projects/packages/premium-analytics/widgets/average-items-per-order/package.json new file mode 100644 index 000000000000..eab2c17bec43 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/average-items-per-order/package.json @@ -0,0 +1,13 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widget-average-items-per-order", + "version": "0.1.0-alpha", + "private": true, + "type": "module", + "dependencies": { + "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "@wordpress/ui": "0.13.0", + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/widgets/average-items-per-order/render.tsx b/projects/packages/premium-analytics/widgets/average-items-per-order/render.tsx new file mode 100644 index 000000000000..b56bce693a78 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/average-items-per-order/render.tsx @@ -0,0 +1,27 @@ +import { + OrderMetricWidget, + WidgetRoot, + type ReportParamsFieldAttributes, +} from '@jetpack-premium-analytics/widgets-toolkit'; + +type AverageItemsPerOrderRenderProps = { + attributes?: Partial< ReportParamsFieldAttributes >; +}; + +/** + * Average items per order widget. + * + * Thin composition over the widgets-toolkit: WidgetRoot provides the query + * client, chart theme, and resolved report params; OrderMetricWidget fetches + * the orders report and renders the avg_items metric with a comparison delta + * and sparkline. + */ +export default function AverageItemsPerOrderRender( { + attributes, +}: AverageItemsPerOrderRenderProps ) { + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/widgets/average-items-per-order/stories/average-items-per-order-widget.stories.tsx b/projects/packages/premium-analytics/widgets/average-items-per-order/stories/average-items-per-order-widget.stories.tsx new file mode 100644 index 000000000000..b1ea6d7901cb --- /dev/null +++ b/projects/packages/premium-analytics/widgets/average-items-per-order/stories/average-items-per-order-widget.stories.tsx @@ -0,0 +1,220 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { SELECTABLE_PRESETS, type SelectablePresetId } from '@jetpack-premium-analytics/datetime'; +import { + DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + WidgetDashboardWithWidget as WidgetDashboardWithWidgetStory, + widgetDashboardWithWidgetArgTypes, + type WidgetDashboardWithWidgetControls, +} from '../../stories/widget-dashboard-with-widget'; +import { registerReportMocks } from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks'; +import AverageItemsPerOrderRender from '../render'; +import widgetDefinition from '../widget'; +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import type { WidgetRenderProps } from '@wordpress/widget-primitives'; +import type { ComponentProps, ComponentType } from 'react'; + +registerReportMocks(); + +const AVERAGE_ITEMS_RENDER_MODULE = 'storybook/average-items-per-order'; +const DEFAULT_PRESET = 'last-30-days' satisfies SelectablePresetId; +const PRESET_OPTIONS = SELECTABLE_PRESETS; + +type AverageItemsPerOrderRenderProps = ComponentProps< typeof AverageItemsPerOrderRender >; + +interface AverageItemsPerOrderStoryControls { + withComparison: boolean; + preset: SelectablePresetId; +} + +type AverageItemsPerOrderStoryProps = AverageItemsPerOrderRenderProps & + AverageItemsPerOrderStoryControls; + +interface AverageItemsPerOrderDashboardStoryProps + extends WidgetDashboardWithWidgetControls, + AverageItemsPerOrderStoryControls {} + +const withWidgetCanvas: Decorator = Story => ( +
+ +
+); + +function getAverageItemsPerOrderAttributes( + withComparison = false, + preset: SelectablePresetId = DEFAULT_PRESET +): AverageItemsPerOrderRenderProps[ 'attributes' ] { + return { + reportParams: getDefaultQueryParams( withComparison, preset ), + }; +} + +function getDefaultQueryParamsSource( { + withComparison, + preset, +}: Partial< AverageItemsPerOrderStoryControls > ) { + const hasComparison = Boolean( withComparison ); + const storyPreset = preset ?? DEFAULT_PRESET; + + if ( ! hasComparison && storyPreset === DEFAULT_PRESET ) { + return 'getDefaultQueryParams()'; + } + + if ( hasComparison && storyPreset === DEFAULT_PRESET ) { + return 'getDefaultQueryParams( true )'; + } + + return `getDefaultQueryParams( ${ hasComparison ? 'true' : 'false' }, '${ storyPreset }' )`; +} + +function getAverageItemsPerOrderSource( args: Partial< AverageItemsPerOrderStoryControls > ) { + return `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; + +`; +} + +function renderAverageItemsPerOrder( { + withComparison, + preset, +}: AverageItemsPerOrderStoryControls ) { + return ( + + ); +} + +function AverageItemsPerOrderDashboardStory( { + withComparison, + preset, + ...dashboardStoryArgs +}: AverageItemsPerOrderDashboardStoryProps ) { + return ( + > + } + attributes={ getAverageItemsPerOrderAttributes( withComparison, preset ) } + /> + ); +} + +const meta = { + title: 'Packages/Premium Analytics/Widgets/AverageItemsPerOrder', + component: AverageItemsPerOrderRender, + tags: [ 'autodocs' ], + argTypes: { + preset: { + control: 'select', + options: PRESET_OPTIONS, + description: 'Date-range preset used to generate the widget report params.', + }, + withComparison: { + control: 'boolean', + description: 'Include previous-period comparison report params.', + }, + }, + parameters: { + docs: { + description: { + component: + 'The "Average items per order" widget. Fetches the orders report and displays the average number of items per order with optional period-over-period comparison and a sparkline.', + }, + }, + }, +} satisfies Meta< AverageItemsPerOrderStoryProps >; + +export default meta; + +type Story = StoryObj< typeof meta >; +type DashboardStory = StoryObj< AverageItemsPerOrderDashboardStoryProps >; + +/** + * Default state for the current report period. + */ +export const Default: Story = { + render: renderAverageItemsPerOrder, + args: { + preset: DEFAULT_PRESET, + withComparison: false, + }, + decorators: [ withWidgetCanvas ], + parameters: { + docs: { + source: { + transform: ( + _source: string, + storyContext: { args: Partial< AverageItemsPerOrderStoryControls > } + ) => getAverageItemsPerOrderSource( storyContext.args ), + }, + }, + }, +}; + +/** + * Comparison period enabled, showing period-over-period change and sparkline data. + */ +export const WithComparison: Story = { + render: renderAverageItemsPerOrder, + args: { + preset: DEFAULT_PRESET, + withComparison: true, + }, + decorators: [ withWidgetCanvas ], + parameters: { + docs: { + source: { + transform: ( + _source: string, + storyContext: { args: Partial< AverageItemsPerOrderStoryControls > } + ) => getAverageItemsPerOrderSource( storyContext.args ), + }, + }, + }, +}; + +/** + * Renders the widget through the shared dashboard harness. + */ +export const WidgetDashboardWithWidget: DashboardStory = { + render: args => , + args: { + ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + preset: DEFAULT_PRESET, + withComparison: true, + }, + argTypes: { + ...widgetDashboardWithWidgetArgTypes, + preset: { + control: 'select', + options: PRESET_OPTIONS, + description: 'Date-range preset used to generate the widget report params.', + }, + withComparison: { + control: 'boolean', + description: 'Include previous-period comparison report params.', + }, + }, + parameters: { + docs: { + source: { + code: `import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; + +`, + }, + }, + }, +}; diff --git a/projects/packages/premium-analytics/widgets/average-items-per-order/widget.json b/projects/packages/premium-analytics/widgets/average-items-per-order/widget.json new file mode 100644 index 000000000000..5547536c1e47 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/average-items-per-order/widget.json @@ -0,0 +1,6 @@ +{ + "name": "jpa/average-items-per-order", + "title": "Average items per order", + "description": "Show the average number of products per order over a set period of time.", + "category": "orders" +} diff --git a/projects/packages/premium-analytics/widgets/average-items-per-order/widget.ts b/projects/packages/premium-analytics/widgets/average-items-per-order/widget.ts new file mode 100644 index 000000000000..c62915e58d5c --- /dev/null +++ b/projects/packages/premium-analytics/widgets/average-items-per-order/widget.ts @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { chartBar } from '@wordpress/icons'; + +/** + * Widget type definition. + * + * Ported from `woocommerce-analytics/average-items-per-order` in + * woocommerce/woocommerce-analytics (next-woocommerce-analytics). + * + * Report params intentionally come from the analytics dashboard's global + * date-range state for now. Adding widget-level overrides needs a host-level + * control registry so analytics dashboards can hide the field while other + * dashboards can opt in. + */ +export default { + name: 'jpa/average-items-per-order', + title: __( 'Average items per order', 'jetpack-premium-analytics' ), + description: __( + 'Show the average number of products per order over a set period of time.', + 'jetpack-premium-analytics' + ), + icon: chartBar, +}; diff --git a/projects/packages/premium-analytics/widgets/stories/widget-dashboard-with-widget.tsx b/projects/packages/premium-analytics/widgets/stories/widget-dashboard-with-widget.tsx new file mode 100644 index 000000000000..e60f5f02e3c2 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/stories/widget-dashboard-with-widget.tsx @@ -0,0 +1,190 @@ +import { Page } from '@wordpress/admin-ui'; +import { WidgetDashboard, type DashboardWidget } from '@wordpress/widget-dashboard'; +import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react'; +import type { + ResolveWidgetModule, + WidgetRenderProps, + WidgetType, +} from '@wordpress/widget-primitives'; + +const DASHBOARD_ROW_HEIGHT = 300; +const DASHBOARD_GRID_GAP = 24; +const DASHBOARD_ONE_COLUMN_WIDTH = 381; +const DASHBOARD_PAGE_INLINE_PADDING = 48; + +export const WIDGET_DASHBOARD_STORY_WIDTHS = { + desktop: `${ + DASHBOARD_ONE_COLUMN_WIDTH * 4 + DASHBOARD_GRID_GAP * 3 + DASHBOARD_PAGE_INLINE_PADDING + }px`, + narrow: '640px', + mobile: '370px', +} as const; + +export type WidgetDashboardStoryHostEnvironment = 'wordpress-admin' | 'storybook'; + +const HOST_ROOT_FONT_SIZE: Record< WidgetDashboardStoryHostEnvironment, string | undefined > = { + 'wordpress-admin': '13px', + storybook: undefined, +}; + +export interface WidgetDashboardWithWidgetControls { + dashboardWidth: string; + widgetWidth: number; + widgetHeight: number; + rowHeight: number; + editMode: boolean; + hostEnvironment: WidgetDashboardStoryHostEnvironment; +} + +export const DEFAULT_WIDGET_DASHBOARD_STORY_ARGS: WidgetDashboardWithWidgetControls = { + dashboardWidth: WIDGET_DASHBOARD_STORY_WIDTHS.desktop, + widgetWidth: 1, + widgetHeight: 2, + rowHeight: DASHBOARD_ROW_HEIGHT, + editMode: false, + hostEnvironment: 'wordpress-admin', +}; + +export const widgetDashboardWithWidgetArgTypes = { + dashboardWidth: { + control: 'select', + options: Object.values( WIDGET_DASHBOARD_STORY_WIDTHS ), + }, + widgetWidth: { + control: { type: 'number', min: 1, max: 4, step: 1 }, + }, + widgetHeight: { + control: { type: 'number', min: 1, max: 4, step: 1 }, + }, + rowHeight: { + control: { type: 'number', min: 200, max: 400, step: 100 }, + }, + editMode: { + control: 'boolean', + }, + hostEnvironment: { + control: 'select', + options: Object.keys( HOST_ROOT_FONT_SIZE ), + }, +}; + +type StoryWidgetMetadata = { + name: string; + title: WidgetType[ 'title' ]; +} & Partial< Omit< WidgetType, 'apiVersion' | 'name' | 'renderModule' | 'title' > >; + +interface WidgetDashboardWithWidgetProps extends WidgetDashboardWithWidgetControls { + widgetType: StoryWidgetMetadata; + renderModule: string; + renderComponent: ComponentType< WidgetRenderProps< unknown > >; + attributes?: DashboardWidget[ 'attributes' ]; + pageTitle?: string; + widgetUuid?: string; +} + +function HostRootFontSize( { + children, + hostEnvironment, +}: { + children: ReactNode; + hostEnvironment: WidgetDashboardStoryHostEnvironment; +} ) { + useEffect( () => { + const { fontSize } = document.documentElement.style; + const rootFontSize = HOST_ROOT_FONT_SIZE[ hostEnvironment ]; + + if ( rootFontSize ) { + document.documentElement.style.fontSize = rootFontSize; + } + + return () => { + document.documentElement.style.fontSize = fontSize; + }; + }, [ hostEnvironment ] ); + + return children; +} + +export function WidgetDashboardWithWidget( { + widgetType, + renderModule, + renderComponent, + attributes, + dashboardWidth, + widgetWidth, + widgetHeight, + rowHeight, + editMode, + hostEnvironment, + pageTitle = 'Analytics', + widgetUuid = `${ widgetType.name }-story`, +}: WidgetDashboardWithWidgetProps ) { + const storyWidgetType = useMemo< WidgetType >( + () => ( { + ...widgetType, + apiVersion: 1, + name: widgetType.name as WidgetType[ 'name' ], + renderModule, + } ), + [ renderModule, widgetType ] + ); + + const resolveWidgetModule = useMemo< ResolveWidgetModule >( + () => async moduleId => { + if ( moduleId !== renderModule ) { + throw new Error( `Unknown widget render module: ${ moduleId }` ); + } + + return { + default: renderComponent, + }; + }, + [ renderComponent, renderModule ] + ); + + const widget = useMemo< DashboardWidget >( + () => ( { + uuid: widgetUuid, + type: storyWidgetType.name, + attributes, + placement: { + width: widgetWidth, + height: widgetHeight, + order: 0, + }, + } ), + [ attributes, storyWidgetType.name, widgetHeight, widgetUuid, widgetWidth ] + ); + + const [ layout, setLayout ] = useState< DashboardWidget[] >( () => [ widget ] ); + const [ currentEditMode, setCurrentEditMode ] = useState( editMode ); + + useEffect( () => { + setLayout( [ widget ] ); + }, [ widget ] ); + + useEffect( () => { + setCurrentEditMode( editMode ); + }, [ editMode ] ); + + return ( + +
+ + } hasPadding> + + + + +
+
+ ); +}