From 2d46e9ef9f880f5739215436ca263cbd5b8362c5 Mon Sep 17 00:00:00 2001 From: Arnab Ghosh Date: Wed, 21 Oct 2026 18:06:51 +0530 Subject: [PATCH] feat(progress-bar): Add progress bar component Adds Pega_Extensions_ProgressBar, a theme-aware, accessible progress widget driven by a data page, with determinate and indeterminate modes, data-driven tone, completed-fraction display, and messaging/interval/onLoad update strategies. --- .../Pega_Extensions_ProgressBar/Docs.mdx | 54 +++ .../Pega_Extensions_ProgressBar/config.json | 214 +++++++++ .../demo.stories.tsx | 342 ++++++++++++++ .../Pega_Extensions_ProgressBar/demo.test.tsx | 430 ++++++++++++++++++ .../Pega_Extensions_ProgressBar/index.tsx | 218 +++++++++ .../Pega_Extensions_ProgressBar/mock.ts | 50 ++ .../Pega_Extensions_ProgressBar/styles.ts | 249 ++++++++++ .../Pega_Extensions_ProgressBar/utils.ts | 52 +++ 8 files changed, 1609 insertions(+) create mode 100644 src/components/Pega_Extensions_ProgressBar/Docs.mdx create mode 100644 src/components/Pega_Extensions_ProgressBar/config.json create mode 100644 src/components/Pega_Extensions_ProgressBar/demo.stories.tsx create mode 100644 src/components/Pega_Extensions_ProgressBar/demo.test.tsx create mode 100644 src/components/Pega_Extensions_ProgressBar/index.tsx create mode 100644 src/components/Pega_Extensions_ProgressBar/mock.ts create mode 100644 src/components/Pega_Extensions_ProgressBar/styles.ts create mode 100644 src/components/Pega_Extensions_ProgressBar/utils.ts diff --git a/src/components/Pega_Extensions_ProgressBar/Docs.mdx b/src/components/Pega_Extensions_ProgressBar/Docs.mdx new file mode 100644 index 0000000..58ab52e --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/Docs.mdx @@ -0,0 +1,54 @@ +import { Meta, Primary, Controls, Story } from '@storybook/addon-docs/blocks'; +import * as DemoStories from './demo.stories'; + + + +# Progress Bar + +`Pega_Extensions_ProgressBar` is a theme-aware, accessible progress widget for jobs that report their progress through a data page. It has no bound property of its own: it reads the current value, minimum, maximum, and optional tone from the configured `dataPage`, and `updateStrategy` decides when that data page is fetched. + +| `updateStrategy` | When the data page is fetched | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `messaging` (default) | On mount, then each time the PCore messaging service reports a matching update. Suited to jobs that push rapid updates, such as once a second. | +| `interval` | On mount, then every `refreshIntervalSeconds` seconds (default 30, minimum 10), whether or not anything changed. | +| `onLoad` | Once on mount. The value changes only when the page is reloaded. | + +Until the first fetch succeeds, the widget shows an indeterminate state. If a fetch fails, the widget keeps its last known value. + +The data page can also drive the visual tone. If the record has a valid `toneProperty` value (`accent`, `success`, `warning`, or `danger`), it overrides the `tone` prop. This lets a job switch the bar to `danger` as soon as it reports a failure. + +Set `indeterminateOnly` to skip the data page and every refresh strategy and show a continuous progress animation. Use it when work is in progress but there is nothing to measure it against. + +## Update flow + +### Push (`messaging`) + +The runtime messaging service owns the WebSocket connection. On mount, the progress bar fetches the data page and subscribes through PCore. After that, it fetches the data page again each time the socket delivers a matching update, and it unsubscribes on unmount. If the messaging service is unavailable, the widget falls back to polling every `refreshIntervalSeconds`. + +In a case context, the subscription matches the `CASE` event for the current case. On a page without a case context, it matches the `DATAPAGE_UPDATED` event for the configured data page. + +### Polling (`interval`) + +A timer fetches the data page every `refreshIntervalSeconds` and never subscribes to messaging. Use it when the backend cannot push updates or a slower refresh is acceptable. The timer is cleared on unmount. + +### Load once (`onLoad`) + +The data page is fetched once on mount, with no timer and no messaging subscription. Use it for values that rarely change while the page is open. + + + +## Props + + + +## Examples + + + + + + + + + + diff --git a/src/components/Pega_Extensions_ProgressBar/config.json b/src/components/Pega_Extensions_ProgressBar/config.json new file mode 100644 index 0000000..3c6d5e5 --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/config.json @@ -0,0 +1,214 @@ +{ + "name": "Pega_Extensions_ProgressBar", + "label": "Progress Bar", + "description": "Accessible progress widget for jobs that report progress from a data page. The data page is fetched on each PCore messaging service update (default), on a polling interval, or once on page load.", + "organization": "Pega", + "version": "5.0.6", + "library": "Extensions", + "allowedApplications": [], + "componentKey": "Pega_Extensions_ProgressBar", + "type": "Widget", + "subtype": [ + "PAGE", + "CASE" + ], + "properties": [ + { + "name": "label", + "label": "Label", + "format": "TEXT", + "required": true + }, + { + "name": "indeterminateOnly", + "label": "Indeterminate only", + "format": "BOOLEAN", + "defaultValue": false + }, + { + "name": "dataPage", + "label": "Data page name", + "format": "TEXT", + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "updateStrategy", + "label": "Update strategy", + "format": "SELECT", + "defaultValue": "messaging", + "source": [ + { + "key": "messaging", + "value": "Push (PCore messaging service)" + }, + { + "key": "interval", + "value": "Poll on an interval" + }, + { + "key": "onLoad", + "value": "Load once on page load" + } + ], + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "refreshIntervalSeconds", + "label": "Refresh interval (seconds, minimum 10)", + "format": "NUMBER", + "defaultValue": 30, + "visibility": "$this.updateStrategy == 'interval' && $this.indeterminateOnly == false" + }, + { + "name": "valueProperty", + "label": "Value property name", + "format": "TEXT", + "defaultValue": "Value", + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "minProperty", + "label": "Minimum property name", + "format": "TEXT", + "defaultValue": "Min", + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "maxProperty", + "label": "Maximum property name", + "format": "TEXT", + "defaultValue": "Max", + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "toneProperty", + "label": "Tone property name", + "format": "TEXT", + "defaultValue": "Tone", + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "tone", + "label": "Default progress tone", + "format": "SELECT", + "defaultValue": "accent", + "source": [ + { + "key": "accent", + "value": "Accent" + }, + { + "key": "success", + "value": "Success" + }, + { + "key": "warning", + "value": "Warning" + }, + { + "key": "danger", + "value": "Danger" + } + ] + }, + { + "name": "size", + "label": "Bar size", + "format": "SELECT", + "defaultValue": "regular", + "source": [ + { + "key": "compact", + "value": "Compact" + }, + { + "key": "regular", + "value": "Regular" + }, + { + "key": "large", + "value": "Large" + } + ] + }, + { + "name": "showValue", + "label": "Show status and percentage", + "format": "BOOLEAN", + "defaultValue": true, + "visibility": "($this.indeterminateOnly = false)" + }, + { + "name": "showFraction", + "label": "Show completed fraction", + "format": "BOOLEAN", + "defaultValue": true, + "visibility": "($this.indeterminateOnly = false)" + }, + { + "label": "Input settings", + "format": "GROUP", + "properties": [ + { + "name": "helperText", + "label": "Helper text", + "format": "TEXT" + } + ] + }, + { + "label": "Conditions", + "format": "GROUP", + "properties": [ + { + "name": "visibility", + "label": "Visibility", + "format": "VISIBILITY" + } + ] + }, + { + "label": "Advanced", + "format": "GROUP", + "collapsible": true, + "properties": [ + { + "name": "testId", + "label": "Test ID", + "format": "TEXT", + "ignorePattern": "[^-_\\p{N}\\p{L}]", + "includeAnnotations": false + } + ] + }, + { + "format": "LABEL", + "label": "About", + "name": "PegaAboutLabel", + "variant": "h3" + }, + { + "format": "LABEL", + "label": "Pega_Extensions 5.0.6, Wed Oct 21 2026, 27.1.0-SNAPSHOT", + "name": "PegaAboutData", + "variant": "primary" + } + ], + "defaultConfig": { + "label": "@L $this.label", + "indeterminateOnly": false, + "updateStrategy": "messaging", + "refreshIntervalSeconds": 30, + "valueProperty": "Value", + "minProperty": "Min", + "maxProperty": "Max", + "toneProperty": "Tone", + "tone": "accent", + "size": "regular", + "showValue": true, + "showFraction": true + }, + "infinityVersion": "27.1.0-SNAPSHOT", + "packageCosmosVersion": "9.25.1-11", + "buildDate": "2026-10-21T11:42:21.953Z" +} \ No newline at end of file diff --git a/src/components/Pega_Extensions_ProgressBar/demo.stories.tsx b/src/components/Pega_Extensions_ProgressBar/demo.stories.tsx new file mode 100644 index 0000000..9d019f1 --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/demo.stories.tsx @@ -0,0 +1,342 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; + +import { configProps, createProgressDataPageResponse, rapidProgressSteps } from './mock'; +import { PegaExtensionsProgressBar, type PegaExtensionsProgressBarProps } from './index'; + +type StoryArgs = Omit; + +const renderProgressBar = (args: StoryArgs) => { + const props = { + ...args, + getPConnect: () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect, + }; + + return ; +}; + +/* Fake PCore so the widget can fetch and subscribe via the messaging service in Storybook */ +const stubPCore = (getData: () => Promise) => { + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ getData }), + getMessagingServiceManager: () => ({ + subscribe: () => 'subscription-id', + unsubscribe: () => {}, + }), + } as unknown as typeof PCore; +}; + +type ProgressFilter = { + matcher: string; + criteria: Record; +}; + +type ProgressSubscription = { + filter: ProgressFilter; + handler: () => void; +}; + +/* Simulates a backend socket updating the data page before notifying the messaging service. */ +const stubPCoreWithRapidUpdates = (steps: number[], args: StoryArgs, intervalMs = 1000) => { + let step = 0; + let nextSubscriptionId = 0; + let socketIntervalId: number | undefined; + const subscriptions = new Map(); + + const routeSocketMessage = () => { + const socketMessage = { + filter: { + matcher: 'CASE', + criteria: { caseId: 'WORK-1' }, + }, + message: { + datapage: 'D_ExportJobProgress', + }, + }; + + subscriptions.forEach(({ filter, handler }) => { + if ( + filter.matcher === socketMessage.filter.matcher && + filter.criteria.caseId === socketMessage.filter.criteria.caseId + ) { + handler(); + } + }); + }; + + const startSocket = () => { + socketIntervalId = window.setInterval(() => { + if (step < steps.length - 1) step += 1; + routeSocketMessage(); + }, intervalMs); + }; + + const stopSocket = () => { + if (socketIntervalId !== undefined) { + window.clearInterval(socketIntervalId); + socketIntervalId = undefined; + } + }; + + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve(createProgressDataPageResponse(args, steps[step], 0, 100)), + }), + getMessagingServiceManager: () => ({ + subscribe: (filter: ProgressFilter, handler: () => void) => { + const subscriptionId = `subscription-${nextSubscriptionId++}`; + subscriptions.set(subscriptionId, { filter, handler }); + if (subscriptions.size === 1) startSocket(); + return subscriptionId; + }, + unsubscribe: (subscriptionId: string) => { + subscriptions.delete(subscriptionId); + if (subscriptions.size === 0) stopSocket(); + }, + }), + } as unknown as typeof PCore; +}; + +/* Serves the next value on every fetch, as a data page polled on a timer would */ +const stubPCoreWithPolling = (steps: number[], args: StoryArgs) => { + let step = 0; + stubPCore(() => { + const value = steps[Math.min(step, steps.length - 1)]; + step += 1; + return Promise.resolve(createProgressDataPageResponse(args, value, 0, 100)); + }); +}; + +const StoryComponent = (args: StoryArgs) => { + stubPCoreWithRapidUpdates(rapidProgressSteps, args); + return renderProgressBar(args); +}; + +const meta = { + title: 'Widgets/Progress Bar', + component: StoryComponent, + args: { + indeterminateOnly: false, + }, + argTypes: { + label: { + control: { + type: 'text', + }, + }, + indeterminateOnly: { + control: { + type: 'boolean', + }, + }, + dataPage: { + control: { + type: 'text', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + updateStrategy: { + options: ['messaging', 'interval', 'onLoad'], + labels: { + messaging: 'Push (PCore messaging service)', + interval: 'Poll on an interval', + onLoad: 'Load once on page load', + }, + control: { + type: 'select', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + refreshIntervalSeconds: { + control: { + type: 'number', + min: 10, + }, + if: { + arg: 'updateStrategy', + eq: 'interval', + }, + }, + valueProperty: { + control: { + type: 'text', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + minProperty: { + control: { + type: 'text', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + maxProperty: { + control: { + type: 'text', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + toneProperty: { + control: { + type: 'text', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + helperText: { + control: { + type: 'text', + }, + }, + testId: { + control: { + type: 'text', + }, + }, + tone: { + options: ['accent', 'success', 'warning', 'danger'], + labels: { + accent: 'Accent', + success: 'Success', + warning: 'Warning', + danger: 'Danger', + }, + control: { + type: 'select', + }, + }, + size: { + options: ['compact', 'regular', 'large'], + labels: { + compact: 'Compact', + regular: 'Regular', + large: 'Large', + }, + control: { + type: 'inline-radio', + }, + }, + showValue: { + control: { + type: 'boolean', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + showFraction: { + control: { + type: 'boolean', + }, + if: { + arg: 'indeterminateOnly', + eq: false, + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + stubPCoreWithRapidUpdates(rapidProgressSteps, args); + return renderProgressBar(args); + }, + args: configProps, +}; + +export const PollingInterval: Story = { + render: (args) => { + stubPCoreWithPolling(rapidProgressSteps, args); + return renderProgressBar(args); + }, + args: { + ...configProps, + label: 'Polled export progress', + updateStrategy: 'interval', + refreshIntervalSeconds: 10, + helperText: 'The data page is fetched every few seconds, whether or not anything changed.', + }, +}; + +export const LoadOnce: Story = { + render: (args) => { + stubPCore(() => Promise.resolve(createProgressDataPageResponse(args, 64, 0, 100))); + return renderProgressBar(args); + }, + args: { + ...configProps, + label: 'Report progress', + updateStrategy: 'onLoad', + helperText: 'Fetched once when the page opens; reload the page to see newer progress.', + }, +}; + +export const Complete: Story = { + render: (args) => { + stubPCore(() => Promise.resolve(createProgressDataPageResponse(args, 100, 0, 100))); + return renderProgressBar(args); + }, + args: { + ...configProps, + label: 'Backup job progress', + tone: 'success', + helperText: 'The backup job finished processing all records.', + }, +}; + +export const AtRisk: Story = { + render: (args) => { + stubPCore(() => Promise.resolve(createProgressDataPageResponse(args, 42, 20, 100, 'warning'))); + return renderProgressBar(args); + }, + args: { + ...configProps, + label: 'Sync job progress', + valueProperty: 'Completed', + minProperty: 'Start', + maxProperty: 'Total', + toneProperty: 'Status', + tone: 'accent', + size: 'large', + helperText: 'Updates have slowed; the sync job may be stalled.', + }, +}; + +export const Continuous: Story = { + /* indeterminateOnly mode never fetches or subscribes */ + render: (args) => renderProgressBar(args), + args: { + ...configProps, + label: 'Indexing job', + indeterminateOnly: true, + dataPage: undefined, + helperText: 'Runs continuously while the indexing job is in progress.', + }, +}; diff --git a/src/components/Pega_Extensions_ProgressBar/demo.test.tsx b/src/components/Pega_Extensions_ProgressBar/demo.test.tsx new file mode 100644 index 0000000..e926019 --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/demo.test.tsx @@ -0,0 +1,430 @@ +import { act, render, screen } from '@testing-library/react'; +import { composeStories } from '@storybook/react-webpack5'; +import '@testing-library/jest-dom'; + +import * as DemoStories from './demo.stories'; +import { PegaExtensionsProgressBar } from './index'; + +const { Default, Complete, AtRisk, Continuous, PollingInterval } = composeStories(DemoStories); + +test('loads its first value from the data page and renders accessible values', async () => { + render(); + + const progress = await screen.findByRole('progressbar', { name: 'Export job progress' }); + expect(progress).toHaveAttribute('aria-valuenow', '12'); + expect(progress).toHaveAttribute('aria-valuemax', '100'); + expect(screen.getByText('12%')).toBeVisible(); + expect(screen.getByText('On track')).toBeVisible(); + expect(screen.getByText('12 of 100 completed')).toBeVisible(); +}); + +test('refreshes the data page after a socket message reaches the messaging service', async () => { + jest.useFakeTimers(); + try { + render(); + + const progress = await screen.findByRole('progressbar', { name: 'Export job progress' }); + expect(await screen.findByText('12%')).toBeVisible(); + expect(progress).toHaveAttribute('aria-valuenow', '12'); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(progress).toHaveAttribute('aria-valuenow', '27'); + expect(screen.getByText('27%')).toBeVisible(); + } finally { + jest.useRealTimers(); + } +}); + +test('can hide the completed fraction', async () => { + render(); + + await screen.findByRole('progressbar', { name: 'Export job progress' }); + expect(await screen.findByText('12%')).toBeVisible(); + expect(screen.queryByText('12 of 100 completed')).not.toBeInTheDocument(); +}); + +test('renders completion state', async () => { + render(); + + const progress = await screen.findByRole('progressbar', { name: 'Backup job progress' }); + expect(progress).toHaveAttribute('aria-valuenow', '100'); + expect(progress).toHaveAttribute('aria-valuetext', '100% complete'); + expect(screen.getByText('100 of 100 completed')).toBeVisible(); +}); + +test('renders an at-risk state', async () => { + render(); + + const progress = await screen.findByRole('progressbar', { name: 'Sync job progress' }); + expect(progress).toHaveAttribute('aria-valuemin', '20'); + expect(progress).toHaveAttribute('aria-valuemax', '100'); + expect(progress).toHaveAttribute('aria-valuenow', '42'); + expect(screen.getByText('22 of 80 completed')).toBeVisible(); + expect(screen.getByText('At risk')).toBeVisible(); +}); + +test('stays indeterminate until the data page resolves', () => { + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ getData: () => new Promise(() => {}) }), + getMessagingServiceManager: () => ({ + subscribe: () => 'subscription-id', + unsubscribe: () => {}, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + render( + , + ); + + const progress = screen.getByRole('progressbar', { name: 'Import job progress' }); + expect(progress).not.toHaveAttribute('aria-valuenow'); + expect(progress).toHaveAttribute('aria-valuetext', 'In progress'); + expect(screen.queryByText(/completed/)).not.toBeInTheDocument(); +}); + +test('runs continuously with no data page or PCore involved', () => { + delete (window as { PCore?: typeof PCore }).PCore; + + render(); + + const progress = screen.getByRole('progressbar', { name: 'Indexing job' }); + expect(progress).not.toHaveAttribute('aria-valuenow'); + expect(progress).toHaveAttribute('aria-valuetext', 'In progress'); +}); + +test('offsets progress from a non-zero minimum reported by the data page', async () => { + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve({ data: { data: [{ Value: 50, Min: 20, Max: 100 }] } }), + }), + getMessagingServiceManager: () => ({ + subscribe: () => 'subscription-id', + unsubscribe: () => {}, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + render(); + + const progress = await screen.findByRole('progressbar', { name: 'Gauge progress' }); + expect(progress).toHaveAttribute('aria-valuemin', '20'); + expect(progress).toHaveAttribute('aria-valuemax', '100'); + expect(progress).toHaveAttribute('aria-valuenow', '50'); + expect(await screen.findByText('38%')).toBeVisible(); +}); + +test('lets the data page override the static tone', async () => { + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve({ data: { data: [{ Value: 30, Max: 100, Tone: 'danger' }] } }), + }), + getMessagingServiceManager: () => ({ + subscribe: () => 'subscription-id', + unsubscribe: () => {}, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + render( + , + ); + + expect(await screen.findByText('Needs attention')).toBeVisible(); +}); + +test('ignores an invalid tone reported by the data page', async () => { + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve({ data: { data: [{ Value: 30, Max: 100, Tone: 'not-a-tone' }] } }), + }), + getMessagingServiceManager: () => ({ + subscribe: () => 'subscription-id', + unsubscribe: () => {}, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + render( + , + ); + + expect(await screen.findByText('On track')).toBeVisible(); +}); + +test('falls back to the static tone when the tone property no longer resolves', async () => { + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve({ data: { data: [{ Value: 30, Max: 100, Tone: 'danger' }] } }), + }), + getMessagingServiceManager: () => ({ + subscribe: () => 'subscription-id', + unsubscribe: () => {}, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + const { rerender } = render( + , + ); + expect(await screen.findByText('Needs attention')).toBeVisible(); + + rerender( + , + ); + expect(await screen.findByText('On track')).toBeVisible(); +}); + +test('subscribes to and unsubscribes from the PCore messaging service', () => { + const subscribe = jest.fn(() => 'subscription-id'); + const unsubscribe = jest.fn(); + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve({ data: { data: [{ Value: 10, Max: 100 }] } }), + }), + getMessagingServiceManager: () => ({ + subscribe, + unsubscribe, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + const { unmount } = render( + , + ); + + expect(subscribe).toHaveBeenCalledWith( + { matcher: 'CASE', criteria: { caseId: 'WORK-1' } }, + expect.any(Function), + 'primary', + ); + + unmount(); + + expect(unsubscribe).toHaveBeenCalledWith('subscription-id'); +}); + +test('falls back to polling when the messaging service is unavailable', () => { + jest.useFakeTimers(); + try { + const getData = jest.fn(() => Promise.resolve({ data: { data: [{ Value: 10, Max: 100 }] } })); + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ getData }), + getMessagingServiceManager: () => { + throw new Error('Messaging service not initialized'); + }, + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + const { unmount } = render( + , + ); + + expect(getData).toHaveBeenCalledTimes(1); + act(() => { + jest.advanceTimersByTime(30000); + }); + expect(getData).toHaveBeenCalledTimes(2); + + unmount(); + act(() => { + jest.advanceTimersByTime(60000); + }); + expect(getData).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } +}); + +test('loads once and never subscribes when the update strategy is onLoad', async () => { + jest.useFakeTimers(); + try { + const subscribe = jest.fn(() => 'subscription-id'); + const getData = jest.fn(() => Promise.resolve({ data: { data: [{ Value: 35, Max: 100 }] } })); + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ getData }), + getMessagingServiceManager: () => ({ subscribe, unsubscribe: () => {} }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + render( + , + ); + + expect(await screen.findByText('35%')).toBeVisible(); + await act(async () => { + jest.advanceTimersByTime(60000); + }); + + expect(getData).toHaveBeenCalledTimes(1); + expect(subscribe).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } +}); + +test('polls the data page on the configured interval and stops when unmounted', async () => { + jest.useFakeTimers(); + try { + const subscribe = jest.fn(() => 'subscription-id'); + const getData = jest.fn(() => Promise.resolve({ data: { data: [{ Value: 10, Max: 100 }] } })); + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ getData }), + getMessagingServiceManager: () => ({ subscribe, unsubscribe: () => {} }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => 'WORK-1', + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + const { unmount } = render( + , + ); + + expect(getData).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(20000); + }); + expect(getData).toHaveBeenCalledTimes(3); + + unmount(); + await act(async () => { + jest.advanceTimersByTime(10000); + }); + expect(getData).toHaveBeenCalledTimes(3); + expect(subscribe).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } +}); + +test('shows each value a polling interval delivers', async () => { + jest.useFakeTimers(); + try { + render(); + + const progress = await screen.findByRole('progressbar', { name: 'Polled export progress' }); + expect(await screen.findByText('12%')).toBeVisible(); + expect(progress).toHaveAttribute('aria-valuenow', '12'); + + await act(async () => { + jest.advanceTimersByTime(10000); + }); + expect(progress).toHaveAttribute('aria-valuenow', '27'); + } finally { + jest.useRealTimers(); + } +}); + +test('subscribes to data page updates when used on a page without a case context', () => { + const subscribe = jest.fn(() => 'subscription-id'); + const unsubscribe = jest.fn(); + window.PCore = { + getConstants: () => ({ CASE_INFO: { CASE_INFO_ID: 'caseInfoID' } }), + getDataApiUtils: () => ({ + getData: () => Promise.resolve({ data: { data: [{ Value: 10, Max: 100 }] } }), + }), + getMessagingServiceManager: () => ({ + subscribe, + unsubscribe, + }), + } as unknown as typeof PCore; + + const getPConnect = () => + ({ + getValue: () => undefined, + getLocalizedValue: (text: string) => text, + getContextName: () => 'primary', + }) as unknown as typeof PConnect; + + render( + , + ); + + expect(subscribe).toHaveBeenCalledWith( + { matcher: 'DATAPAGE_UPDATED', criteria: { datapage: 'D_ExportJobProgress' } }, + expect.any(Function), + 'primary', + ); +}); diff --git a/src/components/Pega_Extensions_ProgressBar/index.tsx b/src/components/Pega_Extensions_ProgressBar/index.tsx new file mode 100644 index 0000000..58c2509 --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/index.tsx @@ -0,0 +1,218 @@ +import { createUID, Text, withConfiguration } from '@pega/cosmos-react-core'; +import { useCallback, useEffect, useState } from 'react'; + +import '../shared/create-nonce'; + +import { + StyledProgressBar, + StyledProgressFill, + StyledProgressHeader, + StyledProgressFraction, + StyledProgressRail, + StyledProgressStatus, + StyledProgressTrack, + StyledProgressValue, +} from './styles'; +import { + DEFAULT_REFRESH_INTERVAL_SECONDS, + getPercentage, + getProgressStatus, + getRefreshIntervalMs, + isProgressTone, + isProgressUpdateStrategy, + normalizeProgress, + type ProgressSize, + type ProgressTone, + type ProgressUpdateStrategy, +} from './utils'; + +export interface PegaExtensionsProgressBarProps { + /** Label shown above the progress bar. */ + label: string; + /** Shows a never-ending progress animation and skips the data page and all refresh strategies. */ + indeterminateOnly?: boolean; + /** Name of the data page that returns the progress record (the first record if it returns a list). Fetched according to `updateStrategy`. Ignored when `indeterminateOnly` is set. */ + dataPage?: string; + /** When the data page is fetched: `messaging` on each PCore messaging service update, `interval` on a timer, or `onLoad` once on mount. */ + updateStrategy?: ProgressUpdateStrategy; + /** Seconds between data page fetches when `updateStrategy` is `interval`. Defaults to 30; values below 10 are raised to 10. */ + refreshIntervalSeconds?: number; + /** Data page property holding the current progress value. */ + valueProperty?: string; + /** Data page property holding the minimum progress value. Defaults to 0 when missing. */ + minProperty?: string; + /** Data page property holding the maximum progress value. Defaults to the minimum plus 100 when missing or not greater than the minimum. */ + maxProperty?: string; + /** Data page property holding the progress tone (`accent`, `success`, `warning` or `danger`). A valid value overrides the `tone` prop. */ + toneProperty?: string; + /** Supporting text shown below the label. */ + helperText?: string; + /** Test identifier. */ + testId?: string; + /** Default tone of the progress fill, used until or unless the data page provides one. */ + tone?: ProgressTone; + /** Height of the progress track. */ + size?: ProgressSize; + /** Shows the status label and the percentage above the bar. Hidden while the progress is indeterminate. */ + showValue?: boolean; + /** Shows the completed fraction (for example `20 of 80 completed`) below the bar. Hidden while the progress is indeterminate. */ + showFraction?: boolean; + getPConnect: () => typeof PConnect; +} + +export function PegaExtensionsProgressBar(props: PegaExtensionsProgressBarProps) { + const { + label, + indeterminateOnly = false, + dataPage, + updateStrategy: updateStrategyProp = 'messaging', + refreshIntervalSeconds = DEFAULT_REFRESH_INTERVAL_SECONDS, + valueProperty = 'Value', + minProperty = 'Min', + maxProperty = 'Max', + toneProperty = 'Tone', + helperText, + testId, + tone = 'accent', + size = 'regular', + showValue = true, + showFraction = true, + getPConnect, + } = props; + + const pConn = getPConnect(); + const localize = (text: string) => pConn.getLocalizedValue?.(text) || text; + const [id] = useState(() => createUID()); + const [value, setValue] = useState(undefined); + const [min, setMin] = useState(undefined); + const [max, setMax] = useState(undefined); + const [dataPageTone, setDataPageTone] = useState(undefined); + + const loadFromDataPage = useCallback(() => { + if (indeterminateOnly || !dataPage) return; + PCore.getDataApiUtils() + .getData(dataPage, {}, getPConnect().getContextName()) + .then((response: any) => { + const data = response?.data?.data; + const record = Array.isArray(data) ? data[0] : data; + if (record) { + if (record[valueProperty] !== undefined) setValue(Number(record[valueProperty])); + if (record[minProperty] !== undefined) setMin(Number(record[minProperty])); + if (record[maxProperty] !== undefined) setMax(Number(record[maxProperty])); + setDataPageTone(isProgressTone(record[toneProperty]) ? record[toneProperty] : undefined); + } + }) + .catch(() => {}); + }, [indeterminateOnly, dataPage, valueProperty, minProperty, maxProperty, toneProperty, getPConnect]); + + useEffect(() => { + setDataPageTone(undefined); + }, [dataPage, toneProperty, indeterminateOnly]); + + const updateStrategy = isProgressUpdateStrategy(updateStrategyProp) ? updateStrategyProp : 'messaging'; + + useEffect(() => { + if (indeterminateOnly || !dataPage) return undefined; + + if (updateStrategy === 'onLoad') { + loadFromDataPage(); + return undefined; + } + + const startPolling = () => { + loadFromDataPage(); + const intervalId = window.setInterval(loadFromDataPage, getRefreshIntervalMs(refreshIntervalSeconds)); + return () => { + window.clearInterval(intervalId); + }; + }; + + if (updateStrategy === 'interval') return startPolling(); + + /* Messaging: on a case, listen for that case's updates; on a page (no case context), listen for the data page itself */ + let caseID: string | undefined; + try { + caseID = getPConnect().getValue(PCore.getConstants().CASE_INFO.CASE_INFO_ID); + } catch { + caseID = undefined; + } + const filter: { matcher: string; criteria: Record } = caseID + ? { matcher: 'CASE', criteria: { caseId: caseID } } + : { matcher: 'DATAPAGE_UPDATED', criteria: { datapage: dataPage } }; + + let subscriptionId: string; + try { + subscriptionId = PCore.getMessagingServiceManager().subscribe( + filter, + loadFromDataPage, + getPConnect().getContextName(), + ); + } catch { + /* Messaging service unavailable: fall back to polling */ + return startPolling(); + } + loadFromDataPage(); + return () => { + try { + PCore.getMessagingServiceManager().unsubscribe(subscriptionId); + } catch { + /* Messaging service already gone */ + } + }; + }, [indeterminateOnly, dataPage, updateStrategy, refreshIntervalSeconds, loadFromDataPage, getPConnect]); + + const indeterminate = indeterminateOnly || value === undefined; + const effectiveTone = dataPageTone ?? tone; + const normalizedMin = Number.isFinite(min) ? (min as number) : 0; + const normalizedMax = Number.isFinite(max) && (max as number) > normalizedMin ? (max as number) : normalizedMin + 100; + const normalizedValue = normalizeProgress(value ?? normalizedMin, normalizedMin, normalizedMax); + const percentage = getPercentage(normalizedValue, normalizedMin, normalizedMax); + const completedValue = normalizedValue - normalizedMin; + const totalValue = normalizedMax - normalizedMin; + const progressText = percentage === 100 ? localize('Complete') : `${percentage}%`; + const fractionText = `${completedValue} ${localize('of')} ${totalValue} ${localize('completed')}`; + const statusText = localize(getProgressStatus(percentage, effectiveTone)); + const ariaValueText = indeterminate ? localize('In progress') : `${percentage}% ${localize('complete')}`; + const progressId = `${id}-progress`; + const helperId = `${id}-helper`; + + return ( + + {label} + {helperText && ( + + {helperText} + + )} + {showValue && !indeterminate && ( + + {statusText} + {progressText} + + )} + + + + + {showFraction && !indeterminate && {fractionText}} + + + ); +} + +export default withConfiguration(PegaExtensionsProgressBar); diff --git a/src/components/Pega_Extensions_ProgressBar/mock.ts b/src/components/Pega_Extensions_ProgressBar/mock.ts new file mode 100644 index 0000000..2d2dea0 --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/mock.ts @@ -0,0 +1,50 @@ +import type { ProgressTone } from './utils'; + +export interface ProgressDataPageProps { + valueProperty?: string; + minProperty?: string; + maxProperty?: string; + toneProperty?: string; +} + +export const configProps = { + label: 'Export job progress', + dataPage: 'D_ExportJobProgress', + valueProperty: 'Value', + minProperty: 'Min', + maxProperty: 'Max', + toneProperty: 'Tone', + helperText: 'Progress is streamed by the export job several times a second.', + testId: 'ProgressBar-12345678', + tone: 'accent' as const, + size: 'regular' as const, + showValue: true, + showFraction: true, +}; + +export const createProgressDataPageResponse = ( + props: ProgressDataPageProps, + value: number, + min: number, + max: number, + tone?: ProgressTone, +) => { + const record: Record = { + [props.valueProperty ?? 'Value']: value, + [props.minProperty ?? 'Min']: min, + [props.maxProperty ?? 'Max']: max, + }; + + if (tone) { + record[props.toneProperty ?? 'Tone'] = tone; + } + + return { + data: { + data: [record], + }, + }; +}; + +/* Successive values the export job reports as it streams rapid, roughly once-a-second progress updates */ +export const rapidProgressSteps = [12, 27, 41, 58, 73, 89, 100]; diff --git a/src/components/Pega_Extensions_ProgressBar/styles.ts b/src/components/Pega_Extensions_ProgressBar/styles.ts new file mode 100644 index 0000000..d78b0bc --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/styles.ts @@ -0,0 +1,249 @@ +import styled, { css, keyframes } from 'styled-components'; +import type { DefaultTheme } from 'styled-components'; +import { defaultThemeProp } from '@pega/cosmos-react-core'; +import type { ProgressSize, ProgressTone } from './utils'; + +const lightSweep = keyframes` + from { + transform: translateX(-140%); + } + + to { + transform: translateX(520%); + } +`; + +const glowBreath = keyframes` + 0%, + 100% { + filter: brightness(1); + } + + 50% { + filter: brightness(1.12); + } +`; + +const completionFlash = keyframes` + 0% { + filter: brightness(1); + transform: scaleY(1); + } + + 35% { + filter: brightness(1.3); + transform: scaleY(1.35); + } + + 100% { + filter: brightness(1); + transform: scaleY(1); + } +`; + +const stripeDrift = keyframes` + from { + background-position: 0 0; + } + + to { + background-position: 1rem 1rem; + } +`; + +const getTrackHeight = (size: ProgressSize, spacing: string): string => { + if (size === 'compact') { + return `calc(${spacing} * 0.5)`; + } + + if (size === 'large') { + return `calc(${spacing} * 1.5)`; + } + + return spacing; +}; + +const getToneColor = (tone: ProgressTone, theme: DefaultTheme): string => { + const colors: Record = { + accent: theme.base.palette.interactive, + success: theme.base.palette.success, + warning: theme.base.palette.warn, + danger: theme.base.palette.urgent, + }; + + return colors[tone]; +}; + +export const StyledProgressBar = styled.div(({ theme }) => { + return css` + display: flex; + flex-direction: column; + gap: ${theme.base.spacing}; + width: 100%; + `; +}); + +StyledProgressBar.defaultProps = defaultThemeProp; + +export const StyledProgressHeader = styled.div(({ theme }) => { + return css` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${theme.base.spacing}; + min-height: calc(${theme.base.spacing} * 1.25); + `; +}); + +StyledProgressHeader.defaultProps = defaultThemeProp; + +export const StyledProgressValue = styled.span(({ theme }) => { + return css` + min-width: 3.5rem; + padding: calc(${theme.base.spacing} * 0.375) calc(${theme.base.spacing} * 0.75); + border: 0.0625rem solid ${theme.base.palette['border-line']}; + border-radius: ${theme.base['border-radius']}; + background: ${theme.base.palette['secondary-background']}; + color: ${theme.base.palette['foreground-color']}; + font-size: ${theme.base['font-size']}; + font-weight: ${theme.base['font-weight']['semi-bold']}; + text-align: center; + `; +}); + +StyledProgressValue.defaultProps = defaultThemeProp; + +export const StyledProgressStatus = styled.span<{ $tone: ProgressTone }>(({ theme, $tone }) => { + const toneColor = getToneColor($tone, theme); + + return css` + display: inline-flex; + align-items: center; + gap: calc(${theme.base.spacing} * 0.5); + color: ${theme.base.palette['foreground-color']}; + font-size: ${theme.base['font-size']}; + + &::before { + width: calc(${theme.base.spacing} * 0.625); + height: calc(${theme.base.spacing} * 0.625); + border-radius: 50%; + background: ${toneColor}; + box-shadow: 0 0 calc(${theme.base.spacing} * 0.5) ${toneColor}; + content: ''; + } + `; +}); + +StyledProgressStatus.defaultProps = defaultThemeProp; + +export const StyledProgressRail = styled.div(({ theme }) => { + return css` + display: flex; + flex-direction: column; + gap: calc(${theme.base.spacing} * 0.5); + width: 100%; + `; +}); + +StyledProgressRail.defaultProps = defaultThemeProp; + +export const StyledProgressTrack = styled.div<{ $size: ProgressSize }>(({ theme, $size }) => { + return css` + position: relative; + overflow: hidden; + width: 100%; + height: ${getTrackHeight($size, theme.base.spacing)}; + border: 0.0625rem solid ${theme.base.palette['border-line']}; + border-radius: ${theme.base['border-radius']}; + background: ${theme.base.palette['secondary-background']}; + box-shadow: inset 0 0.125rem 0.25rem rgb(0 0 0 / 0.08); + `; +}); + +StyledProgressTrack.defaultProps = defaultThemeProp; + +export const StyledProgressFraction = styled.div(({ theme }) => { + return css` + display: flex; + justify-content: flex-end; + color: ${theme.base.palette['foreground-color']}; + font-size: calc(${theme.base['font-size']} * 0.875); + opacity: ${theme.base.transparency['transparent-2']}; + `; +}); + +StyledProgressFraction.defaultProps = defaultThemeProp; + +export const StyledProgressFill = styled.div<{ + $complete: boolean; + $indeterminate: boolean; + $percentage: number; + $tone: ProgressTone; +}>(({ theme, $complete, $indeterminate, $percentage, $tone }) => { + return css` + position: relative; + overflow: hidden; + width: ${$indeterminate ? '100%' : `${$percentage}%`}; + height: 100%; + border-radius: inherit; + background: ${getToneColor($tone, theme)}; + box-shadow: 0 0 calc(${theme.base.spacing} * 0.75) ${getToneColor($tone, theme)}; + transition: width 420ms cubic-bezier(0.22, 1, 0.36, 1); + + &::after { + position: absolute; + inset-block: 0; + left: 0; + width: 28%; + background: ${theme.base.palette['primary-background']}; + content: ''; + opacity: 0.24; + transform: translateX(-120%); + } + + @media (prefers-reduced-motion: no-preference) { + animation: ${$complete ? completionFlash : glowBreath} ${$complete ? '900ms' : '2.8s'} ease-in-out + ${$complete ? '1' : 'infinite'}; + + &::after { + animation: ${lightSweep} ${$indeterminate ? '1.6s' : '2.6s'} cubic-bezier(0.4, 0, 0.2, 1) infinite; + } + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + + /* translucent texture on the same tone color (not a moving sweep or a contrasting stripe) keeps this readable and consistent with the solid fill look */ + background: ${ + $indeterminate + ? css`repeating-linear-gradient( + 135deg, + rgb(255 255 255 / 18%) 25%, + transparent 25%, + transparent 50%, + rgb(255 255 255 / 18%) 50%, + rgb(255 255 255 / 18%) 75%, + transparent 75%, + transparent + ), + ${getToneColor($tone, theme)}` + : getToneColor($tone, theme) + }; + background-size: ${$indeterminate ? '1rem 1rem' : 'auto'}; + animation: ${ + $indeterminate + ? css` + ${stripeDrift} 900ms linear infinite + ` + : 'none' + }; + + &::after { + animation: none; + opacity: 0; + } + } + `; +}); + +StyledProgressFill.defaultProps = defaultThemeProp; diff --git a/src/components/Pega_Extensions_ProgressBar/utils.ts b/src/components/Pega_Extensions_ProgressBar/utils.ts new file mode 100644 index 0000000..5ecfb60 --- /dev/null +++ b/src/components/Pega_Extensions_ProgressBar/utils.ts @@ -0,0 +1,52 @@ +export type ProgressTone = 'accent' | 'success' | 'warning' | 'danger'; +export type ProgressSize = 'compact' | 'regular' | 'large'; +/** `messaging` pushes via the PCore messaging service, `interval` polls the data page on a timer, `onLoad` fetches once on mount. */ +export type ProgressUpdateStrategy = 'messaging' | 'interval' | 'onLoad'; + +const progressTones: ProgressTone[] = ['accent', 'success', 'warning', 'danger']; +const progressUpdateStrategies: ProgressUpdateStrategy[] = ['messaging', 'interval', 'onLoad']; + +export const DEFAULT_REFRESH_INTERVAL_SECONDS = 30; +const MIN_REFRESH_INTERVAL_SECONDS = 10; + +export const isProgressTone = (value: unknown): value is ProgressTone => progressTones.includes(value as ProgressTone); + +export const isProgressUpdateStrategy = (value: unknown): value is ProgressUpdateStrategy => + progressUpdateStrategies.includes(value as ProgressUpdateStrategy); + +export const getRefreshIntervalMs = (seconds: number | undefined): number => { + const safeSeconds = Number.isFinite(seconds) ? (seconds as number) : DEFAULT_REFRESH_INTERVAL_SECONDS; + return Math.max(MIN_REFRESH_INTERVAL_SECONDS, safeSeconds) * 1000; +}; + +export const normalizeProgress = (value: number, min: number, max: number): number => { + if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || max <= min) { + return min; + } + + return Math.min(max, Math.max(min, value)); +}; + +export const getPercentage = (value: number, min: number, max: number): number => { + if (max <= min) { + return 0; + } + + return Math.round(((value - min) / (max - min)) * 100); +}; + +export const getProgressStatus = (percentage: number, tone: ProgressTone): string => { + if (percentage === 100) { + return 'Complete'; + } + + if (tone === 'danger') { + return 'Needs attention'; + } + + if (tone === 'warning') { + return 'At risk'; + } + + return 'On track'; +};