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:
Johan Persson
2026-04-23 16:00:09 +02:00
committed by GitHub
parent 564697a8dd
commit 1a8de99d1d
6 changed files with 1175 additions and 2 deletions
+2
View File
@@ -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> = {};
+16
View File
@@ -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';
+2
View File
@@ -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"