diff --git a/fixtures/webpack-stats.baseline.json b/fixtures/webpack-stats.baseline.json index 4d32dba876..245e4b72aa 100644 --- a/fixtures/webpack-stats.baseline.json +++ b/fixtures/webpack-stats.baseline.json @@ -94,10 +94,10 @@ "entry": true, "initial": true, "files": [ - "vendors.8b6bb4f.css", - "vendors.176a5f6.js", - "vendors.8b6bb4f.css.map", - "vendors.176a5f6.js.map" + "assets/css/vendors.8b6bb4f.css", + "assets/js/vendors.176a5f6.js", + "assets/css/vendors.8b6bb4f.css.map", + "assets/js/vendors.176a5f6.js.map" ], "names": [ "vendors" diff --git a/fixtures/webpack-stats.current.json b/fixtures/webpack-stats.current.json index 48d4104738..2c27c83cee 100644 --- a/fixtures/webpack-stats.current.json +++ b/fixtures/webpack-stats.current.json @@ -182,10 +182,10 @@ "entry": true, "initial": true, "files": [ - "vendors.5024abb.css", - "vendors.278dc2f.js", - "vendors.5024abb.css.map", - "vendors.278dc2f.js.map" + "assets/css/vendors.5024abb.css", + "assets/js/vendors.278dc2f.js", + "assets/css/vendors.5024abb.css.map", + "assets/js/vendors.278dc2f.js.map" ], "names": [ "vendors" diff --git a/packages/ui/src/app/app-gladys.stories.jsx b/packages/ui/src/app/app-gladys.stories.tsx similarity index 69% rename from packages/ui/src/app/app-gladys.stories.jsx rename to packages/ui/src/app/app-gladys.stories.tsx index 4ba11124ff..ad80541f2f 100644 --- a/packages/ui/src/app/app-gladys.stories.jsx +++ b/packages/ui/src/app/app-gladys.stories.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; import { createJobs } from '@bundle-stats/utils'; /* eslint-disable import/no-unresolved, import/no-relative-packages */ @@ -20,16 +21,21 @@ const BASELINE_SOURCE = { const JOBS = createJobs([CURRENT_SOURCE, BASELINE_SOURCE]); -export default { +const meta: Meta = { title: 'App/Gladys', component: App, - decorators: [ - (Story) => ( -
- -
- ), - ], + parameters: { + layout: 'fullscreen', + }, + args: { + version: '1.0', + }, }; -export const Default = () => ; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; diff --git a/packages/ui/src/app/app-home-assistant.stories.jsx b/packages/ui/src/app/app-home-assistant.stories.tsx similarity index 69% rename from packages/ui/src/app/app-home-assistant.stories.jsx rename to packages/ui/src/app/app-home-assistant.stories.tsx index 9b342460fb..fda012c68f 100644 --- a/packages/ui/src/app/app-home-assistant.stories.jsx +++ b/packages/ui/src/app/app-home-assistant.stories.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; import { createJobs } from '@bundle-stats/utils'; /* eslint-disable import/no-unresolved, import/no-relative-packages */ @@ -20,16 +21,21 @@ const BASELINE_SOURCE = { const JOBS = createJobs([CURRENT_SOURCE, BASELINE_SOURCE]); -export default { +const meta: Meta = { title: 'App/HomeAssistant', component: App, - decorators: [ - (Story) => ( -
- -
- ), - ], + parameters: { + layout: 'fullscreen', + }, + args: { + version: '1.0', + }, }; -export const Default = () => ; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; diff --git a/packages/ui/src/app/app-outline.stories.jsx b/packages/ui/src/app/app-outline.stories.tsx similarity index 69% rename from packages/ui/src/app/app-outline.stories.jsx rename to packages/ui/src/app/app-outline.stories.tsx index 250532001e..20bfd3b721 100644 --- a/packages/ui/src/app/app-outline.stories.jsx +++ b/packages/ui/src/app/app-outline.stories.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; import { createJobs } from '@bundle-stats/utils'; /* eslint-disable import/no-unresolved, import/no-relative-packages */ @@ -20,16 +21,21 @@ const BASELINE_SOURCE = { const JOBS = createJobs([CURRENT_SOURCE, BASELINE_SOURCE]); -export default { +const meta: Meta = { title: 'App/Outline', component: App, - decorators: [ - (Story) => ( -
- -
- ), - ], + parameters: { + layout: 'fullscreen', + }, + args: { + version: '1.0', + }, }; -export const Default = () => ; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; diff --git a/packages/ui/src/app/app.stories.jsx b/packages/ui/src/app/app.stories.jsx deleted file mode 100644 index 89f8f88751..0000000000 --- a/packages/ui/src/app/app.stories.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { createJobs } from '@bundle-stats/utils'; - -/* eslint-disable import/no-unresolved, import/no-relative-packages */ -import currentData from '../../../../fixtures/job.current'; -import baselineData from '../../../../fixtures/job.baseline'; -/* eslint-enable import/no-unresolved, import/no-relative-packages */ -import { App } from '.'; - -const CURRENT_SOURCE = { - webpack: { - ...currentData.rawData.webpack, - builtAt: currentData.createdAt, - hash: currentData.commit, - }, -}; - -const BASELINE_SOURCE = { - webpack: { - ...baselineData.rawData.webpack, - builtAt: baselineData.createdAt, - hash: baselineData.commit, - }, -}; - -const JOBS = createJobs([CURRENT_SOURCE, BASELINE_SOURCE], { - webpack: { - budgets: [ - { - metric: 'totalSizeByTypeALL', - value: 1024 * 1024, - }, - ], - }, -}); -const NO_BASELINE_JOBS = createJobs([CURRENT_SOURCE]); - -const MULTIPLE_JOBS = createJobs([ - CURRENT_SOURCE, - BASELINE_SOURCE, - { - webpack: { - ...baselineData.rawData.webpack, - builtAt: baselineData.createdAt, - hash: 'aaaa1111', - assets: baselineData.rawData.webpack.assets.filter((asset) => asset.name.match(/.(css|js)$/)), - modules: baselineData.rawData.webpack.modules.slice(0, 100), - }, - }, -]); - -const [CURRENT_JOB, BASELINE_JOB] = JOBS; - -const EMPTY_BASELINE = createJobs([CURRENT_SOURCE, { webpack: null }]); - -export default { - title: 'App', - component: App, - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export const Default = () => ; - -export const NoInsights = () => ( - -); - -export const NoBaseline = () => ; - -export const EmptyBaseline = () => ; - -export const MultipleBaselines = () => ; - -export const Empty = () => ; diff --git a/packages/ui/src/app/app.stories.tsx b/packages/ui/src/app/app.stories.tsx new file mode 100644 index 0000000000..a816744d11 --- /dev/null +++ b/packages/ui/src/app/app.stories.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { createJobs } from '@bundle-stats/utils'; +import merge from 'lodash/merge'; + +/* eslint-disable import/no-unresolved, import/no-relative-packages */ +import currentData from '../../../../fixtures/job.current'; +import baselineData from '../../../../fixtures/job.baseline'; +/* eslint-enable import/no-unresolved, import/no-relative-packages */ +import { App } from '.'; + +const CURRENT_SOURCE = { + webpack: { + ...currentData.rawData.webpack, + builtAt: currentData.createdAt, + hash: currentData.commit, + }, +}; + +const BASELINE_SOURCE = { + webpack: { + ...baselineData.rawData.webpack, + builtAt: baselineData.createdAt, + hash: baselineData.commit, + }, +}; + +const JOBS = createJobs([CURRENT_SOURCE, BASELINE_SOURCE]); + +const MULTIPLE_JOBS = createJobs([ + CURRENT_SOURCE, + BASELINE_SOURCE, + { + webpack: { + ...baselineData.rawData.webpack, + builtAt: baselineData.createdAt, + hash: 'aaaa1111', + assets: baselineData.rawData.webpack.assets.filter((asset) => asset.name.match(/.(css|js)$/)), + modules: baselineData.rawData.webpack.modules.slice(0, 100), + }, + }, +]); + +const [CURRENT_JOB, BASELINE_JOB] = JOBS; + +const meta: Meta = { + title: 'App', + component: App, + parameters: { + layout: 'fullscreen', + }, + args: { + version: '1.0', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , +}; + +export const NoInsights: Story = { + render: (args) => ( + + ), +}; + +export const AssetMetaChanges: Story = { + render: (args) => { + const current = merge({}, CURRENT_SOURCE); + const baseline = merge({}, CURRENT_SOURCE); + + current.webpack.assets.push({ + name: 'assets/js/vendors-auth.1a278dc.js', + size: 26364, + }); + current.webpack.chunks.push({ + id: 100, + entry: false, + initial: false, + files: ['assets/js/vendors-auth.1a278dc.js'], + names: ['vendors-auth'], + }); + + baseline.webpack.assets.push({ + name: 'assets/js/vendors-auth.1a278dc.js', + size: 26364, + }); + baseline.webpack.chunks.push({ + id: 100, + entry: false, + initial: true, + files: ['assets/js/vendors-auth.1a278dc.js'], + names: ['vendors-auth'], + }); + + return ; + }, +}; + +export const NoBaseline: Story = { + render: (args) => , +}; + +export const EmptyBaseline: Story = { + render: (args) => , +}; + +export const MultipleBaselines: Story = { + render: (args) => , +}; + +export const Empty: Story = { + render: (args) => , +}; diff --git a/packages/ui/src/components/asset-info/asset-info.module.css b/packages/ui/src/components/asset-info/asset-info.module.css index baa8af4122..57a859417c 100644 --- a/packages/ui/src/components/asset-info/asset-info.module.css +++ b/packages/ui/src/components/asset-info/asset-info.module.css @@ -29,3 +29,12 @@ content: ' '; } +.runNameTags { + display: flex; + align-items: center; + gap: calc(var(--space-xxxsmall) / 2); +} + +.runNameTags:empty { + display: block; +} diff --git a/packages/ui/src/components/asset-info/asset-info.tsx b/packages/ui/src/components/asset-info/asset-info.tsx index 59c13d551f..db33d5e303 100644 --- a/packages/ui/src/components/asset-info/asset-info.tsx +++ b/packages/ui/src/components/asset-info/asset-info.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps, ElementType, ReactNode } from 'react'; import React, { useMemo } from 'react'; import cx from 'classnames'; import noop from 'lodash/noop'; @@ -9,16 +10,17 @@ import { getBundleAssetsFileTypeComponentLink, getModuleFileType, } from '@bundle-stats/utils'; -import { Asset, MetaChunk } from '@bundle-stats/utils/types/webpack'; +import type { AssetMetricRun, MetaChunk } from '@bundle-stats/utils/types/webpack'; +import type { ReportMetricAssetRow } from '../../types'; import { FlexStack } from '../../layout/flex-stack'; -import { Tag } from '../../ui/tag'; +import { AssetMetaTag } from '../asset-meta-tag'; import { ComponentLink } from '../component-link'; import { EntryInfo, EntryInfoMetaLink } from '../entry-info'; import css from './asset-info.module.css'; interface ChunkModulesLinkProps { - as: React.ElementType; + as: ElementType; chunks: Array; chunkId: string; name: string; @@ -30,7 +32,7 @@ const ChunkModulesLink = ({ chunkId, name, onClick, -}: ChunkModulesLinkProps & React.ComponentProps<'a'>) => { +}: ChunkModulesLinkProps & ComponentProps<'a'>) => { const chunk = chunks?.find(({ id }) => id === chunkId); if (!chunk) { @@ -51,6 +53,26 @@ const ChunkModulesLink = ({ ); }; +type AssetRunNameProps = { + run: ReportMetricAssetRow; + children: ReactNode; +}; + +const AssetRunName = (props: AssetRunNameProps) => { + const { run, children } = props; + + return ( + <> + + {run.isEntry && } + {run.isInitial && } + {run.isChunk && } + + {children} + + ); +}; + interface AssetInfoProps { item: { label: string; @@ -60,15 +82,15 @@ interface AssetInfoProps { isInitial?: boolean; isNotPredictive?: boolean; fileType?: string; - runs: Array; + runs: Array; }; chunks?: Array; labels: Array; - customComponentLink?: React.ElementType; + customComponentLink?: ElementType; onClose: () => void; } -export const AssetInfo = (props: AssetInfoProps & React.ComponentProps<'div'>) => { +export const AssetInfo = (props: AssetInfoProps & ComponentProps<'div'>) => { const { className = '', chunks = null, @@ -89,34 +111,43 @@ export const AssetInfo = (props: AssetInfoProps & React.ComponentProps<'div'>) = return ( {item.isEntry && ( - Entrypoint - + )} {item.isInitial && ( - Initial - + )} {item.isChunk && ( - Chunk - + )} ); @@ -130,6 +161,7 @@ export const AssetInfo = (props: AssetInfoProps & React.ComponentProps<'div'>) = labels={labels} tags={tags} onClose={onClose} + RunName={AssetRunName} className={cx(css.root, className)} > {item.fileType && ( diff --git a/packages/ui/src/components/asset-meta-tag/asset-meta-tag.module.css b/packages/ui/src/components/asset-meta-tag/asset-meta-tag.module.css new file mode 100644 index 0000000000..f8fb49d437 --- /dev/null +++ b/packages/ui/src/components/asset-meta-tag/asset-meta-tag.module.css @@ -0,0 +1,56 @@ +.root { + --background-color: var(--color-info); + background: var(--background-color); + overflow: hidden; + position: relative; + vertical-align: middle; +} + +.root::before { + display: block; + content: ' '; + padding: 2px; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + font-size: 8px; + line-height: 1; + vertical-align: middle; +} + +/* tag */ +.entry { + --background-color: var(--color-info-dark); +} + +.entry:empty::before { + content: 'e'; +} + +.initial { + --background-color: var(--color-info); +} + +.initial:empty::before { + content: 'i'; +} + +.chunk { + --background-color: var(--color-info-light); +} + +.chunk:empty::before { + content: 'c'; +} + +/* status */ +.added { + background: linear-gradient(-45deg, var(--color-danger-light) 50%, var(--background-color) 50%) !important; +} + +.removed { + background: linear-gradient(135deg, var(--color-danger-light) 50%, var(--background-color) 50%) !important; +} + diff --git a/packages/ui/src/components/asset-meta-tag/asset-meta-tag.tsx b/packages/ui/src/components/asset-meta-tag/asset-meta-tag.tsx new file mode 100644 index 0000000000..487092ca2c --- /dev/null +++ b/packages/ui/src/components/asset-meta-tag/asset-meta-tag.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import cx from 'classnames'; + +import type { TagProps } from '../../ui/tag'; +import { Tag } from '../../ui/tag'; +import css from './asset-meta-tag.module.css'; +import type { ReportMetricAssetRowMetaStatus } from '../../types'; + +export type AssetMetaTagProps = { + tag: 'entry' | 'initial' | 'chunk'; + status?: ReportMetricAssetRowMetaStatus | boolean; +} & TagProps; + +export const AssetMetaTag = (props: AssetMetaTagProps) => { + const { className = '', title, tag, status, ...restProps } = props; + + const rootClassName = cx( + css.root, + status === 'added' && css.added, + status === 'removed' && css.removed, + css[tag], + className, + ); + + return ( + + ); +}; diff --git a/packages/ui/src/components/asset-meta-tag/index.ts b/packages/ui/src/components/asset-meta-tag/index.ts new file mode 100644 index 0000000000..8a9abc4325 --- /dev/null +++ b/packages/ui/src/components/asset-meta-tag/index.ts @@ -0,0 +1 @@ +export * from './asset-meta-tag'; diff --git a/packages/ui/src/components/asset-name/asset-name.module.css b/packages/ui/src/components/asset-name/asset-name.module.css new file mode 100644 index 0000000000..a7a983123c --- /dev/null +++ b/packages/ui/src/components/asset-name/asset-name.module.css @@ -0,0 +1,32 @@ +.notPredictive { + margin-right: var(--space-xxsmall); + display: inline-block; + width: var(--space-small); + height: var(--space-small); +} + +.notPredictiveIcon { + color: var(--color-warning-dark); +} + +.notPredictiveHoverCard { + max-width: 480px; +} + +.metaTags { + display: inline; + margin-right: var(--space-xxxsmall); +} + +.metaTag + .metaTag { + margin-left: calc(var(--space-xxxsmall) / 2); +} + +.metaTags:empty { + display: none; +} + +.nameText { + display: inline-block; + vertical-align: middle; +} diff --git a/packages/ui/src/components/asset-name/asset-name.stories.tsx b/packages/ui/src/components/asset-name/asset-name.stories.tsx new file mode 100644 index 0000000000..d8af6be3d8 --- /dev/null +++ b/packages/ui/src/components/asset-name/asset-name.stories.tsx @@ -0,0 +1,85 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AssetName } from '.'; + +const ROW = { + key: 'asset/main.js', + label: 'asset/main.js', + changed: true, + isEntry: false, + isChunk: false, + isInitial: false, + isAsset: true, + isNotPredictive: false, + fileType: 'JS', + biggerIsBetter: false, + runs: [ + { name: 'asset/main.abcd1234.js', value: 1024 * 10 }, + { name: 'asset/main.abcd1234.js', value: 1024 * 11 }, + ], +}; + +const meta = { + title: 'Components/AssetName', + component: AssetName, + args: { + customComponentLink: 'span', + filters: {}, + search: '', + }, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + row: ROW, + }, +}; + +export const MetaDefault: Story = { + args: { + row: { + ...ROW, + isEntry: true, + isChunk: true, + isInitial: true, + isAsset: false, + }, + }, +}; + +export const MetaAdded: Story = { + args: { + row: { + ...ROW, + isEntry: 'added', + isChunk: 'added', + isInitial: 'added', + isAsset: false, + }, + }, +}; + +export const MetaRemoved: Story = { + args: { + row: { + ...ROW, + isEntry: 'removed', + isChunk: 'removed', + isInitial: 'removed', + isAsset: false, + }, + }, +}; + +export const NotPredictive: Story = { + args: { + row: { + ...ROW, + isNotPredictive: true, + }, + }, +}; diff --git a/packages/ui/src/components/asset-name/asset-name.tsx b/packages/ui/src/components/asset-name/asset-name.tsx new file mode 100644 index 0000000000..c005798ab1 --- /dev/null +++ b/packages/ui/src/components/asset-name/asset-name.tsx @@ -0,0 +1,59 @@ +import React, { ElementType } from 'react'; + +import { Icon } from '../../ui/icon'; +import { FileName } from '../../ui/file-name'; +import { HoverCard } from '../../ui/hover-card'; +import { AssetNotPredictive } from '../asset-not-predictive'; + +import type { ReportMetricAssetRow } from '../../types'; +import { AssetMetaTag } from '../asset-meta-tag'; +import css from './asset-name.module.css'; + +const RUN_TITLE_CURRENT = 'Current'; +const RUN_TITLE_BASELINE = 'Baseline'; +const RUNS_LABELS = [RUN_TITLE_CURRENT, RUN_TITLE_BASELINE]; + +export type AssetNameProps = { + className?: string; + row: ReportMetricAssetRow; + customComponentLink: ElementType; +}; + +export const AssetName = (props: AssetNameProps) => { + const { className = '', customComponentLink: CustomComponentLink, row } = props; + const { label, isNotPredictive, runs, isChunk, isEntry, isInitial } = row; + + return ( + + {isNotPredictive && ( + } + className={css.notPredictive} + hoverCardClassName={css.notPredictiveHoverCard} + > + + + )} + + + + {isEntry && ( + + )} + {isInitial && ( + + )} + {isChunk && ( + + )} + + + + + ); +}; diff --git a/packages/ui/src/components/asset-name/index.ts b/packages/ui/src/components/asset-name/index.ts new file mode 100644 index 0000000000..e42ac8c381 --- /dev/null +++ b/packages/ui/src/components/asset-name/index.ts @@ -0,0 +1 @@ +export * from './asset-name'; diff --git a/packages/ui/src/components/asset-not-predictive/asset-not-predictive.module.css b/packages/ui/src/components/asset-not-predictive/asset-not-predictive.module.css index 46bc5db490..bace631b9b 100644 --- a/packages/ui/src/components/asset-not-predictive/asset-not-predictive.module.css +++ b/packages/ui/src/components/asset-not-predictive/asset-not-predictive.module.css @@ -1,3 +1,8 @@ -.tableValue { +.colName { + width: 100%; + overflow: hidden; +} + +.colValue { text-align: right; } diff --git a/packages/ui/src/components/asset-not-predictive/asset-not-predictive.tsx b/packages/ui/src/components/asset-not-predictive/asset-not-predictive.tsx index 7e0c729a6e..ada855b372 100644 --- a/packages/ui/src/components/asset-not-predictive/asset-not-predictive.tsx +++ b/packages/ui/src/components/asset-not-predictive/asset-not-predictive.tsx @@ -1,24 +1,28 @@ +import type { ComponentProps } from 'react'; import React from 'react'; import cx from 'classnames'; -import type { Asset } from '@bundle-stats/utils/types/webpack'; +import type { AssetMetricRun } from '@bundle-stats/utils/types/webpack'; import { Stack } from '../../layout/stack'; import { Table } from '../../ui/table'; import css from './asset-not-predictive.module.css'; +import { Alert } from '../../ui'; -interface AssetNotPredictiveProps { - runs?: Array; - labels?: Array -} +export type AssetNotPredictiveProps = { + runs?: Array; + labels?: Array; +} & ComponentProps<'div'>; -export const AssetNotPredictive = (props: AssetNotPredictiveProps & React.ComponentProps<'div'>) => { +export const AssetNotPredictive = (props: AssetNotPredictiveProps) => { const { className = '', runs = null, labels = null, ...restProps } = props; return ( - -

- Asset file name is the same, but the size has changed. -

+ +

Asset hash is not predictive

+ + File names are identical, but the size is different. Content changes without a hash change + can cause runtime errors. + {runs && ( @@ -32,8 +36,8 @@ export const AssetNotPredictive = (props: AssetNotPredictiveProps & React.Compon {runs.map(({ name, value }, index) => ( {labels?.[index]} - {name} - {value} + {name} + {value} ))} diff --git a/packages/ui/src/components/bundle-assets/__tests__/add-metric-report-asset-row-data.ts b/packages/ui/src/components/bundle-assets/__tests__/add-metric-report-asset-row-data.ts new file mode 100644 index 0000000000..cb409f7f72 --- /dev/null +++ b/packages/ui/src/components/bundle-assets/__tests__/add-metric-report-asset-row-data.ts @@ -0,0 +1,235 @@ +import { addMetricReportAssetRowData } from '../bundle-assets.utils'; + +describe('BundleAssets / addMetricReportAssetRowData', () => { + test('should add data to a single run', () => { + expect( + addMetricReportAssetRowData({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toEqual({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + fileType: 'JS', + isAsset: false, + isChunk: true, + isEntry: true, + isInitial: true, + isNotPredictive: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }); + }); + + test('should add add data when baseline run is null', () => { + expect( + addMetricReportAssetRowData({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + null, + ], + }), + ).toEqual({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + fileType: 'JS', + isAsset: false, + isChunk: true, + isEntry: true, + isInitial: true, + isNotPredictive: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + null, + ], + }); + }); + + test('should add add data when curent run is null', () => { + expect( + addMetricReportAssetRowData({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: true, + runs: [ + null, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toEqual({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: true, + fileType: 'JS', + isAsset: false, + isChunk: true, + isEntry: true, + isInitial: true, + isNotPredictive: false, + runs: [ + null, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }); + }); + + test('should add data when baseline is not null', () => { + expect( + addMetricReportAssetRowData({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toEqual({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + fileType: 'JS', + isAsset: false, + isChunk: true, + isEntry: true, + isInitial: true, + isNotPredictive: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }); + }); + + test('should flag as changed when only meta changes', () => { + expect( + addMetricReportAssetRowData({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: false, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toEqual({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: true, + fileType: 'JS', + isAsset: false, + isChunk: true, + isEntry: true, + isInitial: 'removed', + isNotPredictive: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: false, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }); + }); +}); diff --git a/packages/ui/src/components/bundle-assets/__tests__/get-asset-meta-status.ts b/packages/ui/src/components/bundle-assets/__tests__/get-asset-meta-status.ts new file mode 100644 index 0000000000..14c492a4e1 --- /dev/null +++ b/packages/ui/src/components/bundle-assets/__tests__/get-asset-meta-status.ts @@ -0,0 +1,21 @@ +import { getAssetMetaStatus } from '../bundle-assets.utils'; + +describe('BundleAssets / getAssetMetaStatus', () => { + test('should return true when all runs meta values are truthy', () => { + expect(getAssetMetaStatus([true])).toBeTruthy(); + expect(getAssetMetaStatus([true, true])).toBeTruthy(); + }); + + test('should return false when all runs meta values are falsy', () => { + expect(getAssetMetaStatus([false])).toBeFalsy(); + expect(getAssetMetaStatus([false, false])).toBeFalsy(); + }); + + test('should return "added" when current is truthy and baseline is falsy', () => { + expect(getAssetMetaStatus([true, false])).toEqual('added'); + }); + + test('should return "removed" when current is false and baseline is truthy', () => { + expect(getAssetMetaStatus([false, true])).toEqual('removed'); + }); +}); diff --git a/packages/ui/src/components/bundle-assets/__tests__/get-is-not-predictive.ts b/packages/ui/src/components/bundle-assets/__tests__/get-is-not-predictive.ts new file mode 100644 index 0000000000..b390a43d0c --- /dev/null +++ b/packages/ui/src/components/bundle-assets/__tests__/get-is-not-predictive.ts @@ -0,0 +1,119 @@ +import { getIsNotPredictive } from '../bundle-assets.utils'; + +describe('BundleAssets / getIsNotPredictive', () => { + test('should return false by default', () => { + expect( + getIsNotPredictive({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toBeFalsy(); + + expect( + getIsNotPredictive({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + null, + ], + }), + ).toBeFalsy(); + + expect( + getIsNotPredictive({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toBeFalsy(); + + expect( + getIsNotPredictive({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.def456.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 11, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toBeFalsy(); + }); + + test('should return true when not predictive', () => { + expect( + getIsNotPredictive({ + key: 'assets/main.js', + label: 'assets/main.js', + biggerIsBetter: false, + changed: false, + runs: [ + { + name: 'assets/main.abc123.js', + value: 1024 * 10, + isEntry: true, + isChunk: true, + isInitial: true, + }, + { + name: 'assets/main.abc123.js', + value: 1024 * 11, + isEntry: true, + isChunk: true, + isInitial: true, + }, + ], + }), + ).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/components/bundle-assets/bundle-assets.jsx b/packages/ui/src/components/bundle-assets/bundle-assets.jsx index 28cd768b27..633bccb0b4 100644 --- a/packages/ui/src/components/bundle-assets/bundle-assets.jsx +++ b/packages/ui/src/components/bundle-assets/bundle-assets.jsx @@ -20,17 +20,12 @@ import { Box } from '../../layout/box'; import { FlexStack } from '../../layout/flex-stack'; import { Stack } from '../../layout/stack'; import { Dialog, useDialogState } from '../../ui/dialog'; -import { Icon } from '../../ui/icon'; import { InputSearch } from '../../ui/input-search'; -import { FileName } from '../../ui/file-name'; -import { HoverCard } from '../../ui/hover-card'; -import { Tag } from '../../ui/tag'; import { Table } from '../../ui/table'; import { Filters } from '../../ui/filters'; import { EmptySet } from '../../ui/empty-set'; import { Toolbar } from '../../ui/toolbar'; import { AssetInfo } from '../asset-info'; -import { AssetNotPredictive } from '../asset-not-predictive'; import { ComponentLink } from '../component-link'; import { MetricsTable } from '../metrics-table'; import { MetricsTableExport } from '../metrics-table-export'; @@ -41,10 +36,7 @@ import { MetricsTableHeader } from '../metrics-table-header'; import { MetricsTreemap, getTreemapNodes, getTreemapNodesGroupedByPath } from '../metrics-treemap'; import { SEARCH_PLACEHOLDER } from './bundle-assets.i18n'; import css from './bundle-assets.module.css'; - -const RUN_TITLE_CURRENT = 'Current'; -const RUN_TITLE_BASELINE = 'Baseline'; -const RUNS_LABELS = [RUN_TITLE_CURRENT, RUN_TITLE_BASELINE]; +import { AssetName } from '../asset-name'; const DISPLAY_TYPE_GROUPS = { [MetricsDisplayType.TREEMAP]: ['folder'], @@ -94,77 +86,6 @@ const getFilters = ({ compareMode, filters }) => ({ }, }); -const RowHeader = ({ row, customComponentLink: CustomComponentLink, filters, search }) => { - const { label, isNotPredictive, runs, isChunk, isEntry, isInitial } = row; - - return ( - - {isNotPredictive && ( - } - className={css.notPredictive} - anchorClassName={css.notPredictiveAnchor} - > - - - )} - - - - {isEntry && ( - - )} - {isInitial && ( - - )} - {isChunk && ( - - )} - - - - - ); -}; - -RowHeader.propTypes = { - row: PropTypes.shape({ - key: PropTypes.string, - label: PropTypes.string, - isNotPredictive: PropTypes.bool, - isChunk: PropTypes.bool, - isInitial: PropTypes.bool, - isEntry: PropTypes.bool, - runs: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types - }).isRequired, - customComponentLink: PropTypes.elementType.isRequired, - filters: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types - search: PropTypes.string, -}; - -RowHeader.defaultProps = { - search: '', -}; - const ViewMetricsTreemap = (props) => { const { metricsTableTitle, @@ -283,13 +204,23 @@ export const BundleAssets = (props) => { [items, totalRowCount], ); + const assetNameCustomComponentLink = useCallback( + ({ entryId: assetEntryId, ...assetNameRestProps }) => ( + + ), + [CustomComponentLink, filters, search], + ); + const renderRowHeader = useCallback( (row) => ( - ), [CustomComponentLink, filters, search], diff --git a/packages/ui/src/components/bundle-assets/bundle-assets.module.css b/packages/ui/src/components/bundle-assets/bundle-assets.module.css index d6ad564fff..bc5f90a022 100644 --- a/packages/ui/src/components/bundle-assets/bundle-assets.module.css +++ b/packages/ui/src/components/bundle-assets/bundle-assets.module.css @@ -3,70 +3,6 @@ max-width: 240px; } -.assetNameWrapper { -} - -.assetName {} - -.notPredictive { - margin-right: var(--space-xxsmall); - display: inline-block; - width: var(--space-small); - height: var(--space-small); -} - -.notPredictiveAnchor {} - -.notPredictiveIcon { - color: var(--color-warning-dark); -} - -.assetNameTags { - display: inline; - margin-right: var(--space-xxxsmall); -} - -.assetNameTags:empty { - display: none; -} - -.assetNameTag { - vertical-align: middle; -} - -.assetNameTag + .assetNameTag { - margin-left: 2px; -} - -.assetNameTagEntry { - background: var(--color-info-dark); -} - -.assetNameTagEntry::before { - content: 'e'; -} - -.assetNameTagInitial { - background: var(--color-info); -} - -.assetNameTagInitial::before { - content: 'i'; -} - -.assetNameTagChunk { - background: var(--color-info-light); -} - -.assetNameTagChunk::before { - content: 'c'; -} - -.assetNameText { - display: inline-block; - vertical-align: middle; -} - .root .assetName:hover, .root .assetName:active, .root .assetName:focus { diff --git a/packages/ui/src/components/bundle-assets/bundle-assets.utils.js b/packages/ui/src/components/bundle-assets/bundle-assets.utils.js index 41d1fd4690..185e2d5d5d 100644 --- a/packages/ui/src/components/bundle-assets/bundle-assets.utils.js +++ b/packages/ui/src/components/bundle-assets/bundle-assets.utils.js @@ -1,26 +1,21 @@ -import { ASSET_ENTRY_TYPE, ASSET_FILE_TYPE, ASSET_FILTERS, getFileType } from '@bundle-stats/utils'; +/** + * @type {import('@bundle-stats/utils').ReportMetricRow} ReportMetricRow + * @type {import('../../types').ReportMetricAssetRow} ReportMetricAssetRow + * @type {import('../../types').ReportMetricAssetRowMetaStatus} ReportMetricAssetRowFlagStatus + */ -export const addRowAssetFlags = (row) => { - const { runs } = row; +import { ASSET_ENTRY_TYPE, ASSET_FILE_TYPE, ASSET_FILTERS, getFileType } from '@bundle-stats/utils'; - const isEntry = runs.map((run) => run?.isEntry).includes(true); - const isInitial = runs.map((run) => run?.isInitial).includes(true); - const isChunk = runs.map((run) => run?.isChunk).includes(true); - const isAsset = !(isEntry || isInitial || isChunk); - const fileType = getFileType(row.key); +/** + * Check if the asset cache is not predictive + * + * @param {ReportMetricRow} row + * @returns {boolean} + */ +export const getIsNotPredictive = (row) => { + const { key, runs } = row; - return { - ...row, - isEntry, - isInitial, - isChunk, - isAsset, - fileType, - }; -}; - -export const getIsNotPredictive = (key, runs) => - runs.reduce((agg, current, index) => { + return runs.reduce((agg, current, index) => { if (agg) { return agg; } @@ -32,20 +27,94 @@ export const getIsNotPredictive = (key, runs) => if ( current && runs[index + 1] && - current.delta !== 0 && key !== current.name && - current.name === runs[index + 1].name + current.name === runs[index + 1].name && + current.value !== runs[index + 1].value ) { return true; } return agg; }, false); +}; + +/** + * @param {Array} + * @returns {ReportMetricAssetRowFlagStatus | boolean} + */ +export const getAssetMetaStatus = (values) => { + if (!values.includes(true)) { + return false; + } + + // filter empty runs + const metaValues = values.filter((value) => typeof value !== 'undefined'); + + const current = metaValues[0]; + const metaValuesLength = metaValues.length; + + if (metaValuesLength === 1) { + return Boolean(current); + } + + const baseline = metaValues[metaValuesLength - 1]; -export const addRowIsNotPredictive = (row) => ({ - ...row, - isNotPredictive: getIsNotPredictive(row.key, row.runs), -}); + if (current && !baseline) { + return 'added'; + } + + if (!current && baseline) { + return 'removed'; + } + + return true; +}; + +/** + * Add asset row flags + * + * @param {ReportMetricRow} row + * @returns {ReportMetricAssetRow} + */ +export const addMetricReportAssetRowData = (row) => { + const { changed, runs } = row; + + // Collect meta for each run + const runsEntry = []; + const runsInitial = []; + const runsChunk = []; + + runs.forEach((run) => { + runsEntry.push(run?.isEntry); + runsInitial.push(run?.isInitial); + runsChunk.push(run?.isChunk); + }); + + const isEntry = getAssetMetaStatus(runsEntry); + const isInitial = getAssetMetaStatus(runsInitial); + const isChunk = getAssetMetaStatus(runsChunk); + const isAsset = !(isEntry || isInitial || isChunk); + const isNotPredictive = getIsNotPredictive(row); + const fileType = getFileType(row.key); + + // Flag asset as changed if name and value are identical, if one of the meta tags is changed + const assetChanged = + changed || + typeof isEntry !== 'boolean' || + typeof isInitial !== 'boolean' || + typeof isChunk !== 'boolean'; + + return { + ...row, + changed: assetChanged, + isEntry, + isInitial, + isChunk, + isAsset, + isNotPredictive, + fileType, + }; +}; export const getRowFilter = (filters) => (item) => { if (filters[ASSET_FILTERS.CHANGED] && !item.changed) { diff --git a/packages/ui/src/components/bundle-assets/index.jsx b/packages/ui/src/components/bundle-assets/index.jsx index 751d9a79ef..ae7f875681 100644 --- a/packages/ui/src/components/bundle-assets/index.jsx +++ b/packages/ui/src/components/bundle-assets/index.jsx @@ -13,12 +13,7 @@ import { useSearchParams } from '../../hooks/search-params'; import { useEntryInfo } from '../../hooks/entry-info'; import { getJobsChunksData } from '../../utils/jobs'; import { BundleAssets as BundleAssetsComponent } from './bundle-assets'; -import { - addRowAssetFlags, - addRowIsNotPredictive, - getRowFilter, - getCustomSort, -} from './bundle-assets.utils'; +import { addMetricReportAssetRowData, getRowFilter, getCustomSort } from './bundle-assets.utils'; export const BundleAssets = (props) => { const { jobs, filters, search, setState, sortBy, direction, ...restProps } = props; @@ -50,7 +45,7 @@ export const BundleAssets = (props) => { }); const { rows, totalRowCount } = useMemo(() => { - const result = webpack.compareBySection.assets(jobs, [addRowAssetFlags, addRowIsNotPredictive]); + const result = webpack.compareBySection.assets(jobs, [addMetricReportAssetRowData]); return { rows: result, totalRowCount: result.length }; }, [jobs]); diff --git a/packages/ui/src/components/entry-info/entry-info.tsx b/packages/ui/src/components/entry-info/entry-info.tsx index efdb524373..eb9ec010f3 100644 --- a/packages/ui/src/components/entry-info/entry-info.tsx +++ b/packages/ui/src/components/entry-info/entry-info.tsx @@ -2,7 +2,7 @@ import type { ElementType, ReactNode } from 'react'; import React from 'react'; import cx from 'classnames'; import { Portal } from 'ariakit/portal'; -import type { MetricRunInfo, ReportMetricRow } from '@bundle-stats/utils'; +import type { MetricRunInfo, ReportMetricRow, ReportMetricRun } from '@bundle-stats/utils'; import { METRIC_TYPE_CONFIGS, getMetricRunInfo } from '@bundle-stats/utils'; import { Box } from '../../layout/box'; @@ -72,6 +72,12 @@ function defaultRenderRunInfo(item: ReportMetricRow) { ); } +export type RenderRunNameProps = { + className?: string; + run: T; + runSelector: string; +}; + interface EntryInfoProps { itemTitle?: React.ReactNode; item: ReportMetricRow; @@ -82,6 +88,7 @@ interface EntryInfoProps { tags?: React.ReactNode; onClose: () => void; renderRunInfo?: (item: ReportMetricRow) => React.ReactNode; + RunName?: React.ElementType; } export const EntryInfo = (props: EntryInfoProps & React.ComponentProps<'div'>) => { @@ -93,10 +100,11 @@ export const EntryInfo = (props: EntryInfoProps & React.ComponentProps<'div'>) = runNameSelector = 'name', runNameLabel = I18N.PATH, runSizeLabel = I18N.SIZE, - children, tags = null, onClose, renderRunInfo = defaultRenderRunInfo, + RunName = React.Fragment, + children, } = props; return ( @@ -145,17 +153,14 @@ export const EntryInfo = (props: EntryInfoProps & React.ComponentProps<'div'>) = {rowRun?.[runNameSelector] ? ( - - + + + + ) : ( diff --git a/packages/ui/src/components/metrics-treemap/metrics-treemap.utils.ts b/packages/ui/src/components/metrics-treemap/metrics-treemap.utils.ts index da7c7121d5..5ae402baaf 100644 --- a/packages/ui/src/components/metrics-treemap/metrics-treemap.utils.ts +++ b/packages/ui/src/components/metrics-treemap/metrics-treemap.utils.ts @@ -141,7 +141,6 @@ export function getTreemapNodesGroupedByPath(items: Array): Tre }; const childrenTotal = setTreeNode(treeNodes, baseSlugs, 0, treeNode); - console.log(childrenTotal); total.current += childrenTotal.current; total.baseline = diff --git a/packages/ui/src/stories/wrapper.jsx b/packages/ui/src/stories/wrapper.jsx index e9b76eba16..964fa63f24 100644 --- a/packages/ui/src/stories/wrapper.jsx +++ b/packages/ui/src/stories/wrapper.jsx @@ -1,19 +1,25 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -export const getWrapperDecorator = (customWrapperStyles = {}) => (storyFn) => { - const wrapperStyles = { - width: '100%', - height: '100%', - padding: '24px', - ...customWrapperStyles, - }; +import { QueryStateProvider } from '../query-state'; + +export const getWrapperDecorator = + (customWrapperStyles = {}) => + (Story) => { + const wrapperStyles = { + width: '100%', + height: '100%', + padding: '24px', + ...customWrapperStyles, + }; - return ( - -
- {storyFn()} -
-
- ); -}; + return ( + + +
+ +
+
+
+ ); + }; diff --git a/packages/ui/src/types.d.ts b/packages/ui/src/types.d.ts index 884f20dc74..2a1b3a644f 100644 --- a/packages/ui/src/types.d.ts +++ b/packages/ui/src/types.d.ts @@ -1,5 +1,5 @@ import type { ReportMetricRow } from '@bundle-stats/utils'; -import type { Module } from '@bundle-stats/utils/types/webpack/types'; +import type { Asset, Module } from '@bundle-stats/utils/types/webpack/types'; export interface SortAction { field: string; @@ -19,6 +19,35 @@ type FilterGroupFieldData = { type FilterFieldsData = Record; +export type ReportMetricAssetRowMetaStatus = 'added' | 'removed'; + +export type ReportMetricAssetRow = { + /** + * Asset isEntry - at least one run has isEntry truthy + */ + isEntry: ReportMetricAssetRowMetaStatus | boolean; + /** + * Asset isInitial - at least one run has isInitial truthy + */ + isInitial: ReportMetricAssetRowMetaStatus | boolean; + /** + * Asset isChunk - at least one run has isChunk truthy + */ + isChunk: ReportMetricAssetRowMetaStatus | boolean; + /** + * Asset isAsset - at least one run has isAsset truthy + */ + isAsset: boolean; + /** + * Asset name is not predictive + */ + isNotPredictive: boolean; + /** + * Report asset row isEntry - at least one run has isEntry + */ + fileType: string; +} & Omit & { runs: Array<(Asset & ReportMetricRun) | null> }; + export type ReportMetricModuleRow = { thirdParty: boolean; duplicated: boolean; diff --git a/packages/ui/src/ui/tag/tag.tsx b/packages/ui/src/ui/tag/tag.tsx index c5fc4c4aa4..e9d5c13269 100644 --- a/packages/ui/src/ui/tag/tag.tsx +++ b/packages/ui/src/ui/tag/tag.tsx @@ -1,16 +1,17 @@ +import type { ComponentProps } from 'react'; import React from 'react'; import cx from 'classnames'; import { KIND, SIZE } from '../../tokens'; import css from './tag.module.css'; -interface TagProps { +export type TagProps = { as?: React.ElementType; kind?: (typeof KIND)[keyof typeof KIND]; size?: (typeof SIZE)[keyof typeof SIZE]; -} +} & ComponentProps<'span'>; -export const Tag = (props: TagProps & React.ComponentProps<'span'>) => { +export const Tag = (props: TagProps) => { const { className = '', as: Component = 'span', diff --git a/packages/utils/src/webpack/types.ts b/packages/utils/src/webpack/types.ts index 4ba4aa0466..0bee3a9a09 100644 --- a/packages/utils/src/webpack/types.ts +++ b/packages/utils/src/webpack/types.ts @@ -29,7 +29,7 @@ export interface MetaChunk { name: string; } -export interface Asset extends MetricRun { +export interface AssetMetricRun extends MetricRun { name: string; isEntry: boolean; isInitial: boolean; @@ -37,7 +37,7 @@ export interface Asset extends MetricRun { chunkId?: string; } -export type Assets = Record; +export type Assets = Record; export interface MetricsAssets { metrics: {