Add test suite for useDefinition hook in @backstage/ui (#34042)
* Add test infrastructure to @backstage/ui Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add resolveResponsiveValue tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add useDefinition prop resolution and classes tests Fixed a bug in useDefinition where passing null for classNameTarget or utilityTarget was incorrectly defaulted to 'root' due to the nullish coalescing operator treating null as falsy. Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add useDefinition data attributes tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add resolveDefinitionProps tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add processUtilityProps tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add useDefinition bg system tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add useDefinition utility style and analytics tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add useDefinition href resolution tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Add useDefinition options tests Signed-off-by: Johan Persson <johanopersson@gmail.com> * Wrap all useDefinition tests with BUIProvider Signed-off-by: Johan Persson <johanopersson@gmail.com> * Fix type errors in useDefinition tests Use `as const satisfies ComponentConfig<any, any>` instead of `as ComponentConfig<any, any>` to preserve literal types needed by the conditional type machinery (DataAttributes, ResolveBgProps, analytics intersection). Signed-off-by: Johan Persson <johanopersson@gmail.com> * Address review feedback in useDefinition tests - Rename shadowed Wrapper to RouterWrapper in createRouterWrapper - Fix inaccurate comment about splat vs non-splat routes - Clarify misleading test name for provider data-bg behavior Signed-off-by: Johan Persson <johanopersson@gmail.com> --------- Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -59,6 +59,8 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@storybook/react-vite": "^10.3.3",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/use-sync-external-store": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
resolveResponsiveValue,
|
||||
resolveDefinitionProps,
|
||||
processUtilityProps,
|
||||
} from './helpers';
|
||||
import type { ComponentConfig } from './types';
|
||||
|
||||
describe('resolveResponsiveValue', () => {
|
||||
it('returns a plain string unchanged', () => {
|
||||
expect(resolveResponsiveValue('hello', 'md')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns a plain number unchanged', () => {
|
||||
expect(resolveResponsiveValue(42, 'md')).toBe(42);
|
||||
});
|
||||
|
||||
it('returns undefined unchanged', () => {
|
||||
expect(resolveResponsiveValue(undefined, 'md')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null unchanged', () => {
|
||||
expect(resolveResponsiveValue(null, 'md')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a non-breakpoint object unchanged', () => {
|
||||
const obj = { foo: 'bar' };
|
||||
expect(resolveResponsiveValue(obj, 'md')).toBe(obj);
|
||||
});
|
||||
|
||||
it('returns an object with only an initial key unchanged (not detected as responsive)', () => {
|
||||
const obj = { initial: 'base' };
|
||||
expect(resolveResponsiveValue(obj, 'md')).toBe(obj);
|
||||
});
|
||||
|
||||
it('resolves exact breakpoint match', () => {
|
||||
expect(resolveResponsiveValue({ xs: 'small', md: 'medium' }, 'md')).toBe(
|
||||
'medium',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the nearest smaller breakpoint', () => {
|
||||
expect(resolveResponsiveValue({ xs: 'small', md: 'medium' }, 'sm')).toBe(
|
||||
'small',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back across multiple missing breakpoints', () => {
|
||||
expect(resolveResponsiveValue({ xs: 'small', xl: 'xlarge' }, 'lg')).toBe(
|
||||
'small',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to initial when no named breakpoint matches at or below current', () => {
|
||||
expect(
|
||||
resolveResponsiveValue({ initial: 'base', md: 'medium' }, 'sm'),
|
||||
).toBe('base');
|
||||
});
|
||||
|
||||
it('falls forward to the smallest available breakpoint when nothing is at or below current', () => {
|
||||
expect(
|
||||
resolveResponsiveValue({ md: 'medium', xl: 'xlarge' }, 'initial'),
|
||||
).toBe('medium');
|
||||
});
|
||||
|
||||
it('resolves initial breakpoint from a responsive object that includes initial', () => {
|
||||
expect(
|
||||
resolveResponsiveValue({ initial: 'base', xs: 'small' }, 'initial'),
|
||||
).toBe('base');
|
||||
});
|
||||
|
||||
it('skips undefined values during fallback', () => {
|
||||
expect(
|
||||
resolveResponsiveValue(
|
||||
{ xs: undefined, sm: 'small', md: undefined },
|
||||
'md',
|
||||
),
|
||||
).toBe('small');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDefinitionProps', () => {
|
||||
it('separates own props from rest props based on propDefs keys', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {}, size: {} },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved, restProps } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ variant: 'primary', size: 'large', 'aria-label': 'test' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({ variant: 'primary', size: 'large' });
|
||||
expect(restProps).toEqual({ 'aria-label': 'test' });
|
||||
});
|
||||
|
||||
it('excludes utility props from rest props', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {} },
|
||||
utilityProps: ['m', 'p'] as const,
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { restProps } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ variant: 'primary', m: '2', p: '4', 'aria-label': 'test' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(restProps).toEqual({ 'aria-label': 'test' });
|
||||
});
|
||||
|
||||
it('does not exclude utility props that are also in propDefs', () => {
|
||||
const definition = {
|
||||
propDefs: { gap: {} },
|
||||
utilityProps: ['gap'] as const,
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ gap: '4' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({ gap: '4' });
|
||||
});
|
||||
|
||||
it('applies default values from propDefs when prop is not provided', () => {
|
||||
const definition = {
|
||||
propDefs: { size: { default: 'medium' } },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{},
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({ size: 'medium' });
|
||||
});
|
||||
|
||||
it('does not apply default when prop is explicitly provided', () => {
|
||||
const definition = {
|
||||
propDefs: { size: { default: 'medium' } },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ size: 'large' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({ size: 'large' });
|
||||
});
|
||||
|
||||
it('preserves falsy prop values (false, 0, empty string) over defaults', () => {
|
||||
const definition = {
|
||||
propDefs: {
|
||||
disabled: { default: true },
|
||||
count: { default: 10 },
|
||||
label: { default: 'default' },
|
||||
},
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ disabled: false, count: 0, label: '' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({
|
||||
disabled: false,
|
||||
count: 0,
|
||||
label: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves responsive own prop values at the given breakpoint', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {} },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ variant: { xs: 'small', md: 'large' } },
|
||||
'md',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({ variant: 'large' });
|
||||
});
|
||||
|
||||
it('omits own props that are undefined and have no default', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {}, size: {} },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ variant: 'primary' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({ variant: 'primary' });
|
||||
expect('size' in ownPropsResolved).toBe(false);
|
||||
});
|
||||
|
||||
it('passes through rest props without responsive resolution', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {} },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const responsiveObj = { xs: 'small', md: 'large' };
|
||||
const { restProps } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ variant: 'primary', 'data-value': responsiveObj },
|
||||
'md',
|
||||
);
|
||||
|
||||
expect(restProps['data-value']).toBe(responsiveObj);
|
||||
});
|
||||
|
||||
it('returns empty ownPropsResolved when no props match propDefs', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {} },
|
||||
utilityProps: [],
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { ownPropsResolved } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ 'aria-label': 'test' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(ownPropsResolved).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty restProps when all props are own or utility props', () => {
|
||||
const definition = {
|
||||
propDefs: { variant: {} },
|
||||
utilityProps: ['m'] as const,
|
||||
styles: {},
|
||||
classNames: {},
|
||||
} as ComponentConfig<any, any>;
|
||||
|
||||
const { restProps } = resolveDefinitionProps(
|
||||
definition,
|
||||
{ variant: 'primary', m: '2' },
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(restProps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('processUtilityProps', () => {
|
||||
it('returns empty classes and style when no utility props are provided', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps({}, [
|
||||
'm',
|
||||
'p',
|
||||
]);
|
||||
expect(utilityClasses).toBe('');
|
||||
expect(utilityStyle).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty classes and style when utility values are undefined', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps(
|
||||
{ m: undefined },
|
||||
['m'],
|
||||
);
|
||||
expect(utilityClasses).toBe('');
|
||||
expect(utilityStyle).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty classes and style when utility values are null', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps({ m: null }, [
|
||||
'm',
|
||||
]);
|
||||
expect(utilityClasses).toBe('');
|
||||
expect(utilityStyle).toEqual({});
|
||||
});
|
||||
|
||||
it('generates a utility class for a predefined spacing value', () => {
|
||||
const { utilityClasses } = processUtilityProps({ m: '2' }, ['m']);
|
||||
expect(utilityClasses).toBe('bui-m-2');
|
||||
});
|
||||
|
||||
it('generates a CSS custom property for a custom value', () => {
|
||||
const { utilityStyle } = processUtilityProps({ width: '100px' }, ['width']);
|
||||
expect(utilityStyle).toEqual({ '--width': '100px' });
|
||||
});
|
||||
|
||||
it('generates both the class name and CSS var for custom values', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps(
|
||||
{ width: '100px' },
|
||||
['width'],
|
||||
);
|
||||
expect(utilityClasses).toBe('bui-w');
|
||||
expect(utilityStyle).toEqual({ '--width': '100px' });
|
||||
});
|
||||
|
||||
it('handles responsive utility values with breakpoint prefixes', () => {
|
||||
const { utilityClasses } = processUtilityProps(
|
||||
{ m: { initial: '2', md: '4' } },
|
||||
['m'],
|
||||
);
|
||||
expect(utilityClasses).toContain('bui-m-2');
|
||||
expect(utilityClasses).toContain('md:bui-m-4');
|
||||
});
|
||||
|
||||
it('uses no prefix for the initial breakpoint in responsive values', () => {
|
||||
const { utilityClasses } = processUtilityProps({ m: { initial: '2' } }, [
|
||||
'm',
|
||||
]);
|
||||
expect(utilityClasses).toBe('bui-m-2');
|
||||
});
|
||||
|
||||
it('applies the transform function (grow: true becomes 1)', () => {
|
||||
const { utilityStyle } = processUtilityProps({ grow: true }, ['grow']);
|
||||
expect(utilityStyle).toEqual({ '--grow': 1 });
|
||||
});
|
||||
|
||||
it('applies the transform function (basis: 42 becomes "42px")', () => {
|
||||
const { utilityStyle } = processUtilityProps({ basis: 42 }, ['basis']);
|
||||
expect(utilityStyle).toEqual({ '--basis': '42px' });
|
||||
});
|
||||
|
||||
it('ignores values not in the valid list for fixed-value props', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps(
|
||||
{ position: 'invalid' },
|
||||
['position'],
|
||||
);
|
||||
expect(utilityClasses).toBe('');
|
||||
expect(utilityStyle).toEqual({});
|
||||
});
|
||||
|
||||
it('combines classes and styles from multiple utility props', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps(
|
||||
{ m: '2', p: '4', width: '100px' },
|
||||
['m', 'p', 'width'],
|
||||
);
|
||||
expect(utilityClasses).toContain('bui-m-2');
|
||||
expect(utilityClasses).toContain('bui-p-4');
|
||||
expect(utilityClasses).toContain('bui-w');
|
||||
expect(utilityStyle).toEqual({ '--width': '100px' });
|
||||
});
|
||||
|
||||
it('handles a mix of predefined and custom values across different props', () => {
|
||||
const { utilityClasses, utilityStyle } = processUtilityProps(
|
||||
{ m: '2', width: '100px', position: 'relative' },
|
||||
['m', 'width', 'position'],
|
||||
);
|
||||
expect(utilityClasses).toContain('bui-m-2');
|
||||
expect(utilityClasses).toContain('bui-w');
|
||||
expect(utilityClasses).toContain('bui-position-relative');
|
||||
expect(utilityStyle).toEqual({ '--width': '100px' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,753 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { type PropsWithChildren, type ReactNode } from 'react';
|
||||
import { renderHook, render } from '@testing-library/react';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { useDefinition } from './useDefinition';
|
||||
import type { ComponentConfig } from './types';
|
||||
import { BgProvider, useBgConsumer } from '../useBg';
|
||||
import { noopTracker } from '../../analytics/useAnalytics';
|
||||
import { BUIProvider } from '../../provider';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BgReader() {
|
||||
const { bg } = useBgConsumer();
|
||||
return <span data-testid="bg">{bg ?? 'none'}</span>;
|
||||
}
|
||||
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return <BUIProvider>{children}</BUIProvider>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const basicDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
variant: { dataAttribute: true } as const,
|
||||
size: { dataAttribute: true, default: 'medium' } as const,
|
||||
className: {},
|
||||
},
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const multiSlotDef = {
|
||||
styles: { root: 'css-root', content: 'css-content' },
|
||||
classNames: { root: 'root', content: 'content' },
|
||||
propDefs: {
|
||||
variant: { dataAttribute: true } as const,
|
||||
className: {},
|
||||
},
|
||||
utilityProps: ['m'] as const,
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const utilityDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
variant: {},
|
||||
className: {},
|
||||
},
|
||||
utilityProps: ['m', 'p', 'width'] as const,
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const providerDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
bg: { dataAttribute: true } as const,
|
||||
children: {},
|
||||
className: {},
|
||||
},
|
||||
bg: 'provider' as const,
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const consumerDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
variant: {},
|
||||
className: {},
|
||||
},
|
||||
bg: 'consumer' as const,
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const analyticsDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
noTrack: {},
|
||||
className: {},
|
||||
},
|
||||
analytics: true,
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const hrefDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
href: {},
|
||||
className: {},
|
||||
},
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRouterWrapper({
|
||||
basename,
|
||||
currentRoute,
|
||||
}: {
|
||||
basename?: string;
|
||||
currentRoute: string;
|
||||
}) {
|
||||
return function RouterWrapper({ children }: PropsWithChildren) {
|
||||
const entry = basename ? `${basename}${currentRoute}` : currentRoute;
|
||||
// Build nested Routes one level per path segment. The leaf route uses a
|
||||
// non-splat path so that `..` resolution works correctly.
|
||||
const segments =
|
||||
currentRoute === '/'
|
||||
? []
|
||||
: currentRoute.replace(/^\//, '').split('/').filter(Boolean);
|
||||
|
||||
const buildRoutes = (segs: string[], el: ReactNode): ReactNode => {
|
||||
if (segs.length === 0) return <Route index element={el} />;
|
||||
const [head, ...tail] = segs;
|
||||
if (tail.length === 0) {
|
||||
return <Route path={head} element={el} />;
|
||||
}
|
||||
return <Route path={`${head}/*`}>{buildRoutes(tail, el)}</Route>;
|
||||
};
|
||||
|
||||
return (
|
||||
<MemoryRouter basename={basename} initialEntries={[entry]}>
|
||||
<BUIProvider>
|
||||
<Routes>
|
||||
{segments.length === 0 ? (
|
||||
<Route path="*" element={children} />
|
||||
) : (
|
||||
buildRoutes(segments, children)
|
||||
)}
|
||||
</Routes>
|
||||
</BUIProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('useDefinition', () => {
|
||||
describe('prop resolution', () => {
|
||||
it('returns resolved own props from propDefs', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(basicDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.variant).toBe('primary');
|
||||
});
|
||||
|
||||
it('applies default values for missing own props', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(basicDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect((result.current.ownProps as any).size).toBe('medium');
|
||||
});
|
||||
|
||||
it('returns rest props for props not in propDefs or utilityProps', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(basicDef, {
|
||||
variant: 'primary',
|
||||
'aria-label': 'test',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.restProps).toEqual({ 'aria-label': 'test' });
|
||||
});
|
||||
|
||||
it('excludes utility props from both ownProps and restProps', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(utilityDef, {
|
||||
variant: 'primary',
|
||||
m: '2',
|
||||
'aria-label': 'test',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps).not.toHaveProperty('m');
|
||||
expect(result.current.restProps).not.toHaveProperty('m');
|
||||
expect(result.current.restProps).toEqual({ 'aria-label': 'test' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('classes', () => {
|
||||
it('builds a classes object with keys matching definition.classNames', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(multiSlotDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes).toHaveProperty('root');
|
||||
expect(result.current.ownProps.classes).toHaveProperty('content');
|
||||
});
|
||||
|
||||
it('includes the base CSS class from definition.styles', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(basicDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).toContain('css-root');
|
||||
});
|
||||
|
||||
it('appends user className to the root slot by default', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(basicDef, {
|
||||
variant: 'primary',
|
||||
className: 'custom',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).toContain('custom');
|
||||
});
|
||||
|
||||
it('appends utility classes to the root slot by default', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(utilityDef, { variant: 'primary', m: '2' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).toContain('bui-m-2');
|
||||
});
|
||||
|
||||
it('appends user className to a custom classNameTarget slot', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(
|
||||
multiSlotDef,
|
||||
{ variant: 'primary', className: 'custom' },
|
||||
{
|
||||
classNameTarget: 'content',
|
||||
},
|
||||
),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.content).toContain('custom');
|
||||
expect(result.current.ownProps.classes.root).not.toContain('custom');
|
||||
});
|
||||
|
||||
it('appends utility classes to a custom utilityTarget slot', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(
|
||||
multiSlotDef,
|
||||
{ variant: 'primary', m: '2' },
|
||||
{
|
||||
utilityTarget: 'content',
|
||||
},
|
||||
),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.content).toContain('bui-m-2');
|
||||
expect(result.current.ownProps.classes.root).not.toContain('bui-m-2');
|
||||
});
|
||||
|
||||
it('does not append user className when classNameTarget is null', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(
|
||||
basicDef,
|
||||
{ variant: 'primary', className: 'custom' },
|
||||
{
|
||||
classNameTarget: null,
|
||||
},
|
||||
),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).not.toContain('custom');
|
||||
});
|
||||
|
||||
it('does not append utility classes when utilityTarget is null', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(
|
||||
utilityDef,
|
||||
{ variant: 'primary', m: '2' },
|
||||
{
|
||||
utilityTarget: null,
|
||||
},
|
||||
),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).not.toContain('bui-m-2');
|
||||
});
|
||||
|
||||
it('keeps non-targeted slots free of utility classes and user className', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(multiSlotDef, {
|
||||
variant: 'primary',
|
||||
m: '2',
|
||||
className: 'custom',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// Defaults target root — content should be clean
|
||||
expect(result.current.ownProps.classes.content).not.toContain('bui-m-2');
|
||||
expect(result.current.ownProps.classes.content).not.toContain('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data attributes', () => {
|
||||
it('generates data-* attributes for props with dataAttribute: true', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(basicDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes['data-variant']).toBe('primary');
|
||||
});
|
||||
|
||||
it('does not generate data-* for props without dataAttribute', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(basicDef, {
|
||||
variant: 'primary',
|
||||
className: 'custom',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes).not.toHaveProperty(
|
||||
'data-classname',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits data-* when the prop value is undefined', () => {
|
||||
const { result } = renderHook(() => useDefinition(basicDef, {}), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.dataAttributes).not.toHaveProperty('data-variant');
|
||||
});
|
||||
|
||||
it('stringifies non-string prop values in data attributes', () => {
|
||||
const numericDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
count: { dataAttribute: true } as const,
|
||||
className: {},
|
||||
},
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(numericDef, { count: 42 }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes['data-count']).toBe('42');
|
||||
});
|
||||
|
||||
it('does not generate data-bg from propDefs when bg=provider (uses provider path instead)', () => {
|
||||
const localProviderDef = {
|
||||
styles: { root: 'css-root' },
|
||||
classNames: { root: 'root' },
|
||||
propDefs: {
|
||||
bg: { dataAttribute: true } as const,
|
||||
children: {},
|
||||
className: {},
|
||||
},
|
||||
bg: 'provider' as const,
|
||||
} as const satisfies ComponentConfig<any, any>;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(localProviderDef, {
|
||||
bg: 'danger',
|
||||
children: null,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// data-bg comes from the provider resolution path, not the propDef
|
||||
expect(result.current.dataAttributes['data-bg']).toBe('danger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bg: provider', () => {
|
||||
it('sets data-bg from the resolved provider bg value', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(providerDef, {
|
||||
bg: 'danger',
|
||||
children: null,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes['data-bg']).toBe('danger');
|
||||
});
|
||||
|
||||
it('does not set data-bg when bg prop is undefined', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(providerDef, {
|
||||
children: null,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes).not.toHaveProperty('data-bg');
|
||||
});
|
||||
|
||||
it('adds childrenWithBgProvider to ownProps', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(providerDef, {
|
||||
bg: 'neutral',
|
||||
children: 'hello',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps).toHaveProperty('childrenWithBgProvider');
|
||||
});
|
||||
|
||||
it('wraps children in BgProvider when bg resolves to a value', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(providerDef, {
|
||||
bg: 'neutral',
|
||||
children: <BgReader />,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>{result.current.ownProps.childrenWithBgProvider}</>,
|
||||
);
|
||||
|
||||
expect(getByTestId('bg')).toHaveTextContent('neutral-1');
|
||||
});
|
||||
|
||||
it('returns raw children as childrenWithBgProvider when bg is undefined', () => {
|
||||
const childContent = <span>hello</span>;
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(providerDef, {
|
||||
children: childContent,
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.childrenWithBgProvider).toBe(childContent);
|
||||
});
|
||||
|
||||
it('increments neutral bg level from parent context', () => {
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<BUIProvider>
|
||||
<BgProvider bg="neutral-1">{children}</BgProvider>
|
||||
</BUIProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(providerDef, {
|
||||
bg: 'neutral',
|
||||
children: <BgReader />,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes['data-bg']).toBe('neutral-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bg: consumer', () => {
|
||||
it('sets data-on-bg from parent BgProvider context', () => {
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<BUIProvider>
|
||||
<BgProvider bg="neutral-1">{children}</BgProvider>
|
||||
</BUIProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(consumerDef, { variant: 'primary' }),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes['data-on-bg']).toBe('neutral-1');
|
||||
});
|
||||
|
||||
it('does not set data-on-bg when no parent BgProvider exists', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(consumerDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes).not.toHaveProperty('data-on-bg');
|
||||
});
|
||||
|
||||
it('returns children (not childrenWithBgProvider) in ownProps', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(consumerDef, { variant: 'primary', children: 'hello' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps).toHaveProperty('children', 'hello');
|
||||
expect(result.current.ownProps).not.toHaveProperty(
|
||||
'childrenWithBgProvider',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no bg config', () => {
|
||||
it('does not set data-bg or data-on-bg', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(basicDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.dataAttributes).not.toHaveProperty('data-bg');
|
||||
expect(result.current.dataAttributes).not.toHaveProperty('data-on-bg');
|
||||
});
|
||||
|
||||
it('returns children in ownProps', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(basicDef, { variant: 'primary', children: 'hello' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps).toHaveProperty('children', 'hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility style', () => {
|
||||
it('returns utilityStyle with CSS custom properties from utility props', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(utilityDef, {
|
||||
variant: 'primary',
|
||||
width: '100px',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.utilityStyle).toEqual({ '--width': '100px' });
|
||||
});
|
||||
|
||||
it('returns empty utilityStyle when no utility props are provided', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(utilityDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.utilityStyle).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('analytics', () => {
|
||||
it('returns analytics tracker when definition.analytics is true', () => {
|
||||
const { result } = renderHook(() => useDefinition(analyticsDef, {}), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toHaveProperty('analytics');
|
||||
expect(result.current.analytics).toHaveProperty('captureEvent');
|
||||
});
|
||||
|
||||
it('does not include analytics key when definition.analytics is not set', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(basicDef, { variant: 'primary' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current).not.toHaveProperty('analytics');
|
||||
});
|
||||
|
||||
it('returns noopTracker when noTrack is true', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(analyticsDef, { noTrack: true }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.analytics).toBe(noopTracker);
|
||||
});
|
||||
});
|
||||
|
||||
describe('href resolution', () => {
|
||||
describe('inside router context', () => {
|
||||
describe.each([
|
||||
['no basename', undefined],
|
||||
['with basename /app', '/app'],
|
||||
] as const)('%s', (_label, basename) => {
|
||||
it.each`
|
||||
description | href | currentRoute | expected
|
||||
${'absolute path'} | ${'/foo'} | ${'/catalog'} | ${'/foo'}
|
||||
${'root /'} | ${'/'} | ${'/catalog'} | ${'/'}
|
||||
${'relative path "foo"'} | ${'foo'} | ${'/catalog'} | ${'/catalog/foo'}
|
||||
${'relative path "./foo"'} | ${'./foo'} | ${'/catalog'} | ${'/catalog/foo'}
|
||||
${'relative path "../foo"'} | ${'../foo'} | ${'/catalog/items'} | ${'/catalog/foo'}
|
||||
${'empty string'} | ${''} | ${'/catalog'} | ${'/catalog'}
|
||||
${'absolute with query params'} | ${'/foo?q=1'} | ${'/catalog'} | ${'/foo?q=1'}
|
||||
${'absolute with hash'} | ${'/foo#section'} | ${'/catalog'} | ${'/foo#section'}
|
||||
${'absolute with query and hash'} | ${'/foo?q=1#section'} | ${'/catalog'} | ${'/foo?q=1#section'}
|
||||
${'relative with query params'} | ${'foo?q=1'} | ${'/catalog'} | ${'/catalog/foo?q=1'}
|
||||
`(
|
||||
'resolves $description — returns $expected',
|
||||
({
|
||||
href,
|
||||
currentRoute,
|
||||
expected,
|
||||
}: {
|
||||
href: string;
|
||||
currentRoute: string;
|
||||
expected: string;
|
||||
}) => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(hrefDef, { href }),
|
||||
{
|
||||
wrapper: createRouterWrapper({ basename, currentRoute }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.href).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each`
|
||||
description | href
|
||||
${'https:// URL'} | ${'https://example.com'}
|
||||
${'http:// URL'} | ${'http://example.com'}
|
||||
${'mailto: link'} | ${'mailto:a@b.com'}
|
||||
${'tel: link'} | ${'tel:123'}
|
||||
${'protocol-relative URL'} | ${'//example.com'}
|
||||
`('leaves $description unchanged', ({ href }: { href: string }) => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(hrefDef, { href }),
|
||||
{
|
||||
wrapper: createRouterWrapper({
|
||||
basename,
|
||||
currentRoute: '/catalog',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.href).toBe(href);
|
||||
});
|
||||
|
||||
it('does not modify props when href is undefined', () => {
|
||||
const { result } = renderHook(() => useDefinition(hrefDef, {}), {
|
||||
wrapper: createRouterWrapper({
|
||||
basename,
|
||||
currentRoute: '/catalog',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.current.ownProps).not.toHaveProperty('href');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('outside router context', () => {
|
||||
it('passes all href values through unchanged', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(hrefDef, { href: '/foo' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.href).toBe('/foo');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
it('utilityTarget defaults to root', () => {
|
||||
const { result } = renderHook(
|
||||
() => useDefinition(multiSlotDef, { variant: 'primary', m: '2' }),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).toContain('bui-m-2');
|
||||
expect(result.current.ownProps.classes.content).not.toContain('bui-m-2');
|
||||
});
|
||||
|
||||
it('classNameTarget defaults to root', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(multiSlotDef, {
|
||||
variant: 'primary',
|
||||
className: 'custom',
|
||||
}),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.root).toContain('custom');
|
||||
expect(result.current.ownProps.classes.content).not.toContain('custom');
|
||||
});
|
||||
|
||||
it('custom utilityTarget applies utility classes to that slot', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(
|
||||
multiSlotDef,
|
||||
{ variant: 'primary', m: '2' },
|
||||
{ utilityTarget: 'content' },
|
||||
),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.content).toContain('bui-m-2');
|
||||
expect(result.current.ownProps.classes.root).not.toContain('bui-m-2');
|
||||
});
|
||||
|
||||
it('custom classNameTarget applies className to that slot', () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useDefinition(
|
||||
multiSlotDef,
|
||||
{ variant: 'primary', className: 'custom' },
|
||||
{ classNameTarget: 'content' },
|
||||
),
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
expect(result.current.ownProps.classes.content).toContain('custom');
|
||||
expect(result.current.ownProps.classes.root).not.toContain('custom');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -122,8 +122,10 @@ export function useDefinition<
|
||||
analytics = ownPropsResolved.noTrack ? noopTracker : tracker;
|
||||
}
|
||||
|
||||
const utilityTarget = options?.utilityTarget ?? 'root';
|
||||
const classNameTarget = options?.classNameTarget ?? 'root';
|
||||
const utilityTarget =
|
||||
options?.utilityTarget !== undefined ? options.utilityTarget : 'root';
|
||||
const classNameTarget =
|
||||
options?.classNameTarget !== undefined ? options.classNameTarget : 'root';
|
||||
|
||||
const classes: Record<string, string> = {};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -7949,6 +7949,8 @@ __metadata:
|
||||
"@remixicon/react": "npm:^4.6.0"
|
||||
"@storybook/react-vite": "npm:^10.3.3"
|
||||
"@tanstack/react-table": "npm:^8.21.3"
|
||||
"@testing-library/jest-dom": "npm:^6.0.0"
|
||||
"@testing-library/react": "npm:^16.0.0"
|
||||
"@types/react": "npm:^18.0.0"
|
||||
"@types/react-dom": "npm:^18.0.0"
|
||||
"@types/use-sync-external-store": "npm:^1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user