mirror of
https://github.com/storybookjs/storybook.git
synced 2025-04-08 11:11:53 +08:00
Merge pull request #8883 from donaldpipowitch/8126-addon-a11y--allow-manual-run
Addon-a11y: Support manual run
This commit is contained in:
commit
f486faaceb
@ -69,6 +69,8 @@ export default {
|
||||
config: {},
|
||||
// axe-core optionsParameter (https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter)
|
||||
options: {},
|
||||
// optional flag to prevent the automatic check
|
||||
manual: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -1,19 +1,18 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { ThemeProvider, themes, convert } from '@storybook/theming';
|
||||
import { STORY_RENDERED } from '@storybook/core-events';
|
||||
import { ScrollArea } from '@storybook/components';
|
||||
|
||||
import { A11YPanel } from './A11YPanel';
|
||||
import { EVENTS } from '../constants';
|
||||
|
||||
function createApi() {
|
||||
return {
|
||||
emit: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
};
|
||||
const emitter = new EventEmitter();
|
||||
jest.spyOn(emitter, 'emit');
|
||||
jest.spyOn(emitter, 'on');
|
||||
jest.spyOn(emitter, 'off');
|
||||
return emitter;
|
||||
}
|
||||
|
||||
const axeResult = {
|
||||
@ -63,7 +62,7 @@ function ThemedA11YPanel(props) {
|
||||
}
|
||||
|
||||
describe('A11YPanel', () => {
|
||||
it('should register STORY_RENDERED, RESULT and ERROR updater on mount', () => {
|
||||
it('should register event listener on mount', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
expect(api.on).not.toHaveBeenCalled();
|
||||
@ -73,59 +72,75 @@ describe('A11YPanel', () => {
|
||||
|
||||
// then
|
||||
expect(api.on.mock.calls.length).toBe(3);
|
||||
expect(api.on.mock.calls[0][0]).toBe(STORY_RENDERED);
|
||||
expect(api.on.mock.calls[1][0]).toBe(EVENTS.RESULT);
|
||||
expect(api.on.mock.calls[2][0]).toBe(EVENTS.ERROR);
|
||||
expect(api.on.mock.calls[0][0]).toBe(EVENTS.RESULT);
|
||||
expect(api.on.mock.calls[1][0]).toBe(EVENTS.ERROR);
|
||||
expect(api.on.mock.calls[2][0]).toBe(EVENTS.MANUAL);
|
||||
});
|
||||
|
||||
it('should request a run on tab activation', () => {
|
||||
it('should deregister event listener on unmount', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} />);
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
|
||||
// when
|
||||
wrapper.setProps({ active: true });
|
||||
wrapper.update();
|
||||
|
||||
// then
|
||||
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
|
||||
expect(wrapper.find(ScrollArea).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should deregister STORY_RENDERED, RESULT and ERROR updater on unmount', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} />);
|
||||
expect(api.off).not.toHaveBeenCalled();
|
||||
|
||||
// when
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} />);
|
||||
wrapper.unmount();
|
||||
|
||||
// then
|
||||
expect(api.off.mock.calls.length).toBe(3);
|
||||
expect(api.off.mock.calls[0][0]).toBe(STORY_RENDERED);
|
||||
expect(api.off.mock.calls[1][0]).toBe(EVENTS.RESULT);
|
||||
expect(api.off.mock.calls[2][0]).toBe(EVENTS.ERROR);
|
||||
expect(api.off.mock.calls[0][0]).toBe(EVENTS.RESULT);
|
||||
expect(api.off.mock.calls[1][0]).toBe(EVENTS.ERROR);
|
||||
expect(api.off.mock.calls[2][0]).toBe(EVENTS.MANUAL);
|
||||
});
|
||||
|
||||
it('should update run result', () => {
|
||||
it('should handle "initial" status', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
|
||||
// when
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
|
||||
// then
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
expect(wrapper.text()).toMatch(/Initializing/);
|
||||
});
|
||||
|
||||
it('should handle "manual" status', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
const onUpdate = api.on.mock.calls.find(([event]) => event === EVENTS.RESULT)[1];
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('button')
|
||||
.last()
|
||||
.text()
|
||||
.trim()
|
||||
).toBe('Rerun tests');
|
||||
|
||||
// when
|
||||
onUpdate(axeResult);
|
||||
api.emit(EVENTS.MANUAL, true);
|
||||
wrapper.update();
|
||||
|
||||
// then
|
||||
expect(wrapper.text()).toMatch(/Manually run the accessibility scan/);
|
||||
expect(api.emit).not.toHaveBeenCalledWith(EVENTS.REQUEST);
|
||||
});
|
||||
|
||||
it('should handle "running" status', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
|
||||
// when
|
||||
api.emit(EVENTS.MANUAL, false);
|
||||
wrapper.update();
|
||||
|
||||
// then
|
||||
expect(wrapper.text()).toMatch(/Please wait while the accessibility scan is running/);
|
||||
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
|
||||
});
|
||||
|
||||
it('should handle "ran" status', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
|
||||
// when
|
||||
api.emit(EVENTS.RESULT, axeResult);
|
||||
wrapper.update();
|
||||
|
||||
// then
|
||||
expect(
|
||||
@ -135,82 +150,13 @@ describe('A11YPanel', () => {
|
||||
.text()
|
||||
.trim()
|
||||
).toBe('Tests completed');
|
||||
expect(wrapper.find('Tabs').prop('tabs').length).toBe(3);
|
||||
expect(wrapper.find('Tabs').prop('tabs')[0].label.props.children).toEqual([1, ' Violations']);
|
||||
expect(wrapper.find('Tabs').prop('tabs')[1].label.props.children).toEqual([1, ' Passes']);
|
||||
expect(wrapper.find('Tabs').prop('tabs')[2].label.props.children).toEqual([1, ' Incomplete']);
|
||||
});
|
||||
|
||||
it('should request run', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('button')
|
||||
.last()
|
||||
.text()
|
||||
.trim()
|
||||
).toBe('Rerun tests');
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
|
||||
// when
|
||||
request();
|
||||
|
||||
// then
|
||||
expect(
|
||||
wrapper
|
||||
.find('button')
|
||||
.last()
|
||||
.text()
|
||||
.trim()
|
||||
).toBe('Running test');
|
||||
expect(api.emit).toHaveBeenCalledWith(EVENTS.REQUEST);
|
||||
});
|
||||
|
||||
it('should NOT request run on inactive tab', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
mount(<ThemedA11YPanel api={api} active={false} />);
|
||||
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
|
||||
// when
|
||||
request();
|
||||
|
||||
// then
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render report', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
const onUpdate = api.on.mock.calls.find(([event]) => event === EVENTS.RESULT)[1];
|
||||
|
||||
// when
|
||||
onUpdate(axeResult);
|
||||
|
||||
// then
|
||||
expect(wrapper.find(A11YPanel)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should render loader when it's running", () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active />);
|
||||
const request = api.on.mock.calls.find(([event]) => event === STORY_RENDERED)[1];
|
||||
|
||||
// when
|
||||
request();
|
||||
wrapper.update();
|
||||
|
||||
// then
|
||||
expect(wrapper.find('ScrollArea').length).toBe(0);
|
||||
expect(wrapper.find('Loader').length).toBe(1);
|
||||
expect(wrapper.find('ActionBar').length).toBe(1);
|
||||
expect(wrapper.find('Loader')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should NOT anything when tab is not active', () => {
|
||||
it('should handle inactive state', () => {
|
||||
// given
|
||||
const api = createApi();
|
||||
|
||||
@ -218,7 +164,7 @@ describe('A11YPanel', () => {
|
||||
const wrapper = mount(<ThemedA11YPanel api={api} active={false} />);
|
||||
|
||||
// then
|
||||
expect(wrapper.find('ScrollArea').length).toBe(0);
|
||||
expect(wrapper.find('ActionBar').length).toBe(0);
|
||||
expect(wrapper.text()).toBe('');
|
||||
expect(api.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { Component, Fragment, ComponentProps } from 'react';
|
||||
/* eslint-disable react/destructuring-assignment,default-case,consistent-return,no-case-declarations */
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import { styled } from '@storybook/theming';
|
||||
|
||||
import { STORY_RENDERED } from '@storybook/core-events';
|
||||
import { ActionBar, Icons, ScrollArea } from '@storybook/components';
|
||||
|
||||
import { AxeResults, Result } from 'axe-core';
|
||||
@ -20,21 +20,15 @@ export enum RuleType {
|
||||
INCOMPLETION,
|
||||
}
|
||||
|
||||
type IconProps = ComponentProps<typeof Icons> & { status?: string; inline?: boolean };
|
||||
const Icon = styled(Icons)({
|
||||
height: 12,
|
||||
width: 12,
|
||||
marginRight: 4,
|
||||
});
|
||||
|
||||
const Icon = styled(Icons)<IconProps>(
|
||||
{
|
||||
height: 12,
|
||||
width: 12,
|
||||
marginRight: 4,
|
||||
},
|
||||
({ status, theme }) =>
|
||||
status === 'running'
|
||||
? {
|
||||
animation: `${theme.animation.rotate360} 1s linear infinite;`,
|
||||
}
|
||||
: {}
|
||||
);
|
||||
const RotatingIcon = styled(Icon)(({ theme }) => ({
|
||||
animation: `${theme.animation.rotate360} 1s linear infinite;`,
|
||||
}));
|
||||
|
||||
const Passes = styled.span<{}>(({ theme }) => ({
|
||||
color: theme.color.positive,
|
||||
@ -48,34 +42,51 @@ const Incomplete = styled.span<{}>(({ theme }) => ({
|
||||
color: theme.color.warning,
|
||||
}));
|
||||
|
||||
const centeredStyle = {
|
||||
const Centered = styled.span<{}>({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
};
|
||||
});
|
||||
|
||||
const Loader = styled(({ className }) => (
|
||||
<div className={className}>
|
||||
<Icon inline icon="sync" status="running" /> Please wait while the accessibility scan is running
|
||||
...
|
||||
</div>
|
||||
))(centeredStyle);
|
||||
Loader.displayName = 'Loader';
|
||||
interface InitialState {
|
||||
status: 'initial';
|
||||
}
|
||||
|
||||
interface A11YPanelNormalState {
|
||||
status: 'ready' | 'ran' | 'running';
|
||||
interface ManualState {
|
||||
status: 'manual';
|
||||
}
|
||||
|
||||
interface RunningState {
|
||||
status: 'running';
|
||||
}
|
||||
|
||||
interface RanState {
|
||||
status: 'ran';
|
||||
passes: Result[];
|
||||
violations: Result[];
|
||||
incomplete: Result[];
|
||||
}
|
||||
|
||||
interface A11YPanelErrorState {
|
||||
interface ReadyState {
|
||||
status: 'ready';
|
||||
passes: Result[];
|
||||
violations: Result[];
|
||||
incomplete: Result[];
|
||||
}
|
||||
|
||||
interface ErrorState {
|
||||
status: 'error';
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
type A11YPanelState = A11YPanelNormalState | A11YPanelErrorState;
|
||||
type A11YPanelState =
|
||||
| InitialState
|
||||
| ManualState
|
||||
| RunningState
|
||||
| RanState
|
||||
| ReadyState
|
||||
| ErrorState;
|
||||
|
||||
interface A11YPanelProps {
|
||||
active: boolean;
|
||||
@ -84,18 +95,15 @@ interface A11YPanelProps {
|
||||
|
||||
export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
|
||||
state: A11YPanelState = {
|
||||
status: 'ready',
|
||||
passes: [],
|
||||
violations: [],
|
||||
incomplete: [],
|
||||
status: 'initial',
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { api } = this.props;
|
||||
|
||||
api.on(STORY_RENDERED, this.request);
|
||||
api.on(EVENTS.RESULT, this.onUpdate);
|
||||
api.on(EVENTS.RESULT, this.onResult);
|
||||
api.on(EVENTS.ERROR, this.onError);
|
||||
api.on(EVENTS.MANUAL, this.onManual);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: A11YPanelProps) {
|
||||
@ -105,18 +113,18 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
|
||||
if (!prevProps.active && active) {
|
||||
// removes all elements from the redux map in store from the previous panel
|
||||
store.dispatch(clearElements());
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
const { api } = this.props;
|
||||
api.off(STORY_RENDERED, this.request);
|
||||
api.off(EVENTS.RESULT, this.onUpdate);
|
||||
|
||||
api.off(EVENTS.RESULT, this.onResult);
|
||||
api.off(EVENTS.ERROR, this.onError);
|
||||
api.off(EVENTS.MANUAL, this.onManual);
|
||||
}
|
||||
|
||||
onUpdate = ({ passes, violations, incomplete }: AxeResults) => {
|
||||
onResult = ({ passes, violations, incomplete }: AxeResults) => {
|
||||
this.setState(
|
||||
{
|
||||
status: 'ran',
|
||||
@ -144,64 +152,67 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
|
||||
});
|
||||
};
|
||||
|
||||
request = () => {
|
||||
const { api, active } = this.props;
|
||||
|
||||
if (active) {
|
||||
this.setState(
|
||||
{
|
||||
status: 'running',
|
||||
},
|
||||
() => {
|
||||
api.emit(EVENTS.REQUEST);
|
||||
// removes all elements from the redux map in store from the previous panel
|
||||
store.dispatch(clearElements());
|
||||
}
|
||||
);
|
||||
onManual = (manual: boolean) => {
|
||||
if (manual) {
|
||||
this.setState({
|
||||
status: 'manual',
|
||||
});
|
||||
} else {
|
||||
this.request();
|
||||
}
|
||||
};
|
||||
|
||||
request = () => {
|
||||
const { api } = this.props;
|
||||
this.setState(
|
||||
{
|
||||
status: 'running',
|
||||
},
|
||||
() => {
|
||||
api.emit(EVENTS.REQUEST);
|
||||
// removes all elements from the redux map in store from the previous panel
|
||||
store.dispatch(clearElements());
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { active } = this.props;
|
||||
if (!active) return null;
|
||||
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
if (this.state.status === 'error') {
|
||||
const { error } = this.state;
|
||||
return (
|
||||
<div style={centeredStyle}>
|
||||
The accessibility scan encountered an error.
|
||||
<br />
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { passes, violations, incomplete, status } = this.state;
|
||||
|
||||
let actionTitle: string | JSX.Element = 'Rerun tests';
|
||||
if (status === 'ready') {
|
||||
actionTitle = 'Rerun tests';
|
||||
} else if (status === 'running') {
|
||||
actionTitle = (
|
||||
<Fragment>
|
||||
<Icon inline icon="sync" status={status} /> Running test
|
||||
</Fragment>
|
||||
);
|
||||
} else if (status === 'ran') {
|
||||
actionTitle = (
|
||||
<Fragment>
|
||||
<Icon inline icon="check" /> Tests completed
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Provider store={store}>
|
||||
{status === 'running' ? (
|
||||
<Loader />
|
||||
switch (this.state.status) {
|
||||
case 'initial':
|
||||
return <Centered>Initializing...</Centered>;
|
||||
case 'manual':
|
||||
return (
|
||||
<Fragment>
|
||||
<Centered>Manually run the accessibility scan.</Centered>
|
||||
<ActionBar
|
||||
key="actionbar"
|
||||
actionItems={[{ title: 'Run test', onClick: this.request }]}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Centered>
|
||||
<RotatingIcon inline icon="sync" /> Please wait while the accessibility scan is running
|
||||
...
|
||||
</Centered>
|
||||
);
|
||||
case 'ready':
|
||||
case 'ran':
|
||||
const { passes, violations, incomplete, status } = this.state;
|
||||
const actionTitle =
|
||||
status === 'ready' ? (
|
||||
'Rerun tests'
|
||||
) : (
|
||||
<Fragment>
|
||||
<Icon inline icon="check" /> Tests completed
|
||||
</Fragment>
|
||||
);
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ScrollArea vertical horizontal>
|
||||
<Tabs
|
||||
key="tabs"
|
||||
@ -245,13 +256,21 @@ export class A11YPanel extends Component<A11YPanelProps, A11YPanelState> {
|
||||
]}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<ActionBar
|
||||
key="actionbar"
|
||||
actionItems={[{ title: actionTitle, onClick: this.request }]}
|
||||
/>
|
||||
</Provider>
|
||||
</Fragment>
|
||||
);
|
||||
<ActionBar
|
||||
key="actionbar"
|
||||
actionItems={[{ title: actionTitle, onClick: this.request }]}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
case 'error':
|
||||
const { error } = this.state;
|
||||
return (
|
||||
<Centered>
|
||||
The accessibility scan encountered an error.
|
||||
<br />
|
||||
{error}
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,5 +7,6 @@ export const CLEAR_ELEMENTS = 'CLEAR_ELEMENTS';
|
||||
const RESULT = `${ADDON_ID}/result`;
|
||||
const REQUEST = `${ADDON_ID}/request`;
|
||||
const ERROR = `${ADDON_ID}/error`;
|
||||
const MANUAL = `${ADDON_ID}/manual`;
|
||||
|
||||
export const EVENTS = { RESULT, REQUEST, ERROR };
|
||||
export const EVENTS = { RESULT, REQUEST, ERROR, MANUAL };
|
||||
|
@ -11,8 +11,10 @@ interface Setup {
|
||||
element?: ElementContext;
|
||||
config: Spec;
|
||||
options: RunOptions;
|
||||
manual: boolean;
|
||||
}
|
||||
let setup: Setup = { element: undefined, config: {}, options: {} };
|
||||
|
||||
let setup: Setup = { element: undefined, config: {}, options: {}, manual: false };
|
||||
|
||||
const getElement = () => {
|
||||
const storyRoot = document.getElementById('story-root');
|
||||
@ -58,14 +60,16 @@ export const withA11y = makeDecorator({
|
||||
if (storedDefaultSetup === null) {
|
||||
storedDefaultSetup = { ...setup };
|
||||
}
|
||||
Object.assign(setup, parameters as Setup);
|
||||
Object.assign(setup, parameters as Partial<Setup>);
|
||||
} else if (storedDefaultSetup !== null) {
|
||||
Object.assign(setup, storedDefaultSetup);
|
||||
storedDefaultSetup = null;
|
||||
}
|
||||
|
||||
addons
|
||||
.getChannel()
|
||||
.on(EVENTS.REQUEST, () => run(setup.element as ElementContext, setup.config, setup.options));
|
||||
addons.getChannel().emit(EVENTS.MANUAL, setup.manual);
|
||||
|
||||
return getStory(context);
|
||||
},
|
||||
|
@ -2,13 +2,13 @@ import React, { FunctionComponent } from 'react';
|
||||
import { styled } from '@storybook/theming';
|
||||
import icons, { IconKey } from './icons';
|
||||
|
||||
import Svg from './svg';
|
||||
import Svg, { SvgProps } from './svg';
|
||||
|
||||
const Path = styled.path({
|
||||
fill: 'currentColor',
|
||||
});
|
||||
|
||||
export interface IconsProps {
|
||||
export interface IconsProps extends SvgProps {
|
||||
icon: IconKey;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user