import { Accessibility, AriaRole, IS_FOCUSABLE_ATTRIBUTE } from '@fluentui/accessibility'; import { compose, ComposedComponent, FocusZone, Telemetry } from '@fluentui/react-bindings'; import { Ref, RefFindNode } from '@fluentui/react-component-ref'; import { Renderer } from '@fluentui/react-northstar-styles-renderer'; import { ComponentSlotStylesPrepared, emptyTheme } from '@fluentui/styles'; import * as faker from 'faker'; import * as _ from 'lodash'; import * as React from 'react'; import * as ReactIs from 'react-is'; import { ComponentType, ReactWrapper } from 'enzyme'; import * as ReactDOMServer from 'react-dom/server'; import { act } from 'react-dom/test-utils'; import { isExportedAtTopLevel } from './isExportedAtTopLevel'; import { assertBodyContains, consoleUtil, getDisplayName, mountWithProvider as mount, syntheticEvent, } from 'test/utils'; import { commonHelpers } from './commonHelpers'; import * as FluentUI from 'src/index'; import { getEventTargetComponent, EVENT_TARGET_ATTRIBUTE } from './eventTarget'; export interface Conformant { constructorName?: string; /** Map of events and the child component to target. */ eventTargets?: object; hasAccessibilityProp?: boolean; /** Props required to render Component without errors or warnings. */ requiredProps?: object; /** Is this component exported as top level API? */ exportedAtTopLevel?: boolean; /** Does this component render a Portal powered component? */ rendersPortal?: boolean; /** This component uses wrapper slot to wrap the 'meaningful' element. */ wrapperComponent?: React.ElementType; handlesAsProp?: boolean; /** List of autocontrolled props for this component. */ autoControlledProps?: string[]; /** Child component that will receive unhandledProps. */ passesUnhandledPropsTo?: ComponentType; /** Child component that will receive ref. */ forwardsRefTo?: string | false; } /** * Assert Component conforms to guidelines that are applicable to all components. * @param Component - A component that should conform. */ export function isConformant( Component: React.ComponentType & { handledProps?: string[]; autoControlledProps?: string[]; deprecated_className?: string; }, options: Conformant = {}, ) { const { constructorName = Component.prototype.constructor.name, eventTargets = {}, exportedAtTopLevel = true, hasAccessibilityProp = true, requiredProps = {}, rendersPortal = false, wrapperComponent = null, handlesAsProp = true, autoControlledProps = [], passesUnhandledPropsTo, forwardsRefTo, } = options; const { throwError } = commonHelpers('isConformant', Component); const componentType = typeof Component; // composed components store `handledProps` under config const isComposedComponent: boolean = !!(Component as ComposedComponent).fluentComposeConfig; const handledProps = isComposedComponent ? (Component as ComposedComponent).fluentComposeConfig?.handledProps : Component.handledProps; const helperComponentNames = [...[Ref, RefFindNode], ...(wrapperComponent ? [wrapperComponent] : [])].map( getDisplayName, ); const toNextNonTrivialChild = (from: ReactWrapper) => { const current = from.childAt(0); if (!current) return current; return helperComponentNames.indexOf(current.name()) === -1 ? current : toNextNonTrivialChild(current); }; const getComponent = (wrapper: ReactWrapper) => { let componentElement = toNextNonTrivialChild(wrapper); // passing through Focus Zone wrappers if (componentElement.type() === FocusZone) { // another HOC component is added: FocusZone componentElement = componentElement.childAt(0); // skip through } // in that case 'topLevelChildElement' we've found so far is a wrapper's topmost child // thus, we should continue search return wrapperComponent ? toNextNonTrivialChild(componentElement) : componentElement; }; // make sure components are properly exported if (!ReactIs.isValidElementType(Component)) { throwError(`Components should export a valid React element type, got: ${componentType}.`); } // tests depend on Component constructor names, enforce them if (!constructorName) { throwError( [ 'Component is not a named function. This should help identify it:\n\n', `${ReactDOMServer.renderToStaticMarkup()}`, ].join(''), ); } // ---------------------------------------- // Component info // ---------------------------------------- // This is pretty ugly because: // - jest doesn't support custom error messages // - jest will run all test const infoJSONPath = `@fluentui/docs/src/componentInfo/${constructorName}.info.json`; let info; try { info = require(infoJSONPath); } catch (err) { // handled in the test() below test('component info file exists', () => { throw new Error( [ '!! ==========================================================', `!! Missing ${infoJSONPath}.`, '!! Run `yarn test` or `yarn test:watch` again to generate one.', '!! ==========================================================', ].join('\n'), ); }); return null; } // ---------------------------------------- // Docblock description // ---------------------------------------- const hasDocblockDescription = info.docblock.description.trim().length > 0; test('has a docblock description', () => { expect(hasDocblockDescription).toEqual(true); }); if (hasDocblockDescription) { const minWords = 5; const maxWords = 25; test(`docblock description is long enough to be meaningful (>${minWords} words)`, () => { expect(_.words(info.docblock.description).length).toBeGreaterThanOrEqual(minWords); }); test(`docblock description is short enough to be quickly understood (<${maxWords} words)`, () => { expect(_.words(info.docblock.description).length).toBeLessThan(maxWords); }); } // ---------------------------------------- // Class and file name // ---------------------------------------- test(`constructor name matches filename "${constructorName}"`, () => { expect(constructorName).toEqual(info.filenameWithoutExt); }); // find the apiPath in the top level API const foundAsSubcomponent = ReactIs.isValidElementType(_.get(FluentUI, info.apiPath)); exportedAtTopLevel && isExportedAtTopLevel(constructorName, info.displayName); if (info.isChild) { test('is a static component on its parent', () => { const message = `'${info.displayName}' is a child component (is in ${info.repoPath}).` + ` It must be a static prop of its parent '${info.parentDisplayName}'`; expect({ foundAsSubcomponent, message }).toEqual({ message, foundAsSubcomponent: true, }); }); } // ---------------------------------------- // Props // ---------------------------------------- test('spreads user props', () => { const propName = 'data-is-conformant-spread-props'; const props = { [propName]: true }; const component = mount(); // The component already has the prop, so we are testing if it's children also have the props, // that is why we are testing if it is greater then 1 expect(component.find(props).length).toBeGreaterThan(1); }); if (!rendersPortal && handlesAsProp) { describe('"as" prop (common)', () => { test('renders the component as HTML tags or passes "as" to the next component', () => { // silence element nesting warnings consoleUtil.disableOnce(); const tags = ['a', 'em', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'p', 'span', 'strong']; tags.forEach(tag => { const wrapper = mount(); const component = getComponent(wrapper); try { expect(component.is(tag)).toEqual(true); } catch (err) { expect(component.type()).not.toEqual(Component); expect(component.prop('as')).toEqual(tag); } }); }); test('renders as a functional component or passes "as" to the next component', () => { const MyComponent = () => null; const wrapper = mount(); const component = getComponent(wrapper); try { expect(component.type()).toEqual(MyComponent); } catch (err) { expect(component.type()).not.toEqual(Component); expect( component .find('[as]') .last() .prop('as'), ).toEqual(MyComponent); } }); test('renders as a ReactClass or passes "as" to the next component', () => { class MyComponent extends React.Component { render() { return
; } } const wrapper = mount(); const component = getComponent(wrapper); try { expect(component.type()).toEqual(MyComponent); } catch (err) { expect(component.type()).not.toEqual(Component); expect(component.prop('as')).toEqual(MyComponent); } }); test('passes extra props to the component it is renders as', () => { if (passesUnhandledPropsTo) { const el = mount().find(passesUnhandledPropsTo); expect(el.prop('data-extra-prop')).toBe('foo'); } else { const MyComponent = () => null; const el = mount().find(MyComponent); expect(el.prop('data-extra-prop')).toBe('foo'); } }); }); } // --------------------------------------- // Autocontrolled props // --------------------------------------- test('autoControlled props should have prop, default prop and on change handler in handled props', () => { autoControlledProps.forEach(propName => { const capitalisedPropName = `${propName.slice(0, 1).toUpperCase()}${propName.slice(1)}`; const expectedDefaultProp = `default${capitalisedPropName}`; const expectedChangeHandler = propName === 'value' || propName === 'checked' ? 'onChange' : `on${capitalisedPropName}Change`; expect(handledProps).toContain(propName); expect(handledProps).toContain(expectedDefaultProp); expect(handledProps).toContain(expectedChangeHandler); }); }); // --------------------------------------- // Handled props // --------------------------------------- describe('handles props', () => { test('defines handled props in handledProps', () => { expect(handledProps).toBeDefined(); expect(Array.isArray(handledProps)).toEqual(true); }); test(`has 'styles' as handled prop`, () => { expect(handledProps).toContain('styles'); }); test(`has 'variables' as handled prop`, () => { expect(handledProps).toContain('variables'); }); test('handledProps includes props defined in autoControlledProps, defaultProps or propTypes', () => { const computedProps = _.union( Component.autoControlledProps, _.keys(Component.defaultProps), _.keys(Component.propTypes), ); const expectedProps = _.uniq(computedProps).sort(); const message = 'Not all handled props were defined correctly. All props defined in handled props, must be defined' + 'either in static autoControlledProps, static defaultProps or static propTypes.'; expect({ message, handledProps: handledProps.sort(), }).toEqual( expect.objectContaining({ message, handledProps: expect.arrayContaining(expectedProps), }), ); }); const isClassComponent = !!Component.prototype?.isReactComponent; if (!isClassComponent) { test('uses "useUnhandledProps" hook', () => { const wrapper = passesUnhandledPropsTo ? mount().find(passesUnhandledPropsTo) : mount(); const element = getComponent(wrapper); expect(element.prop('data-uses-unhanded-props')).toBeTruthy(); }); } }); if (hasAccessibilityProp) { const role = faker.lorem.word() as AriaRole; const noopBehavior: Accessibility = () => ({ attributes: { root: { [IS_FOCUSABLE_ATTRIBUTE]: true, role, }, }, }); test('defines an "accessibility" prop in handledProps', () => { expect(handledProps).toContain('accessibility'); }); test('spreads "attributes" on root', () => { const wrapper = mount(); const element = getComponent(wrapper); expect(element.prop(IS_FOCUSABLE_ATTRIBUTE)).toBe(true); expect(element.prop('role')).toBe(role); }); test("client's attributes override the ones provided by Fluent UI", () => { const wrapperProps = { ...requiredProps, [IS_FOCUSABLE_ATTRIBUTE]: false }; const wrapper = passesUnhandledPropsTo ? mount().find(passesUnhandledPropsTo) : mount(); const element = getComponent(wrapper); expect(element.prop(IS_FOCUSABLE_ATTRIBUTE)).toBe(false); }); _.forEach(['onKeyDown', 'onKeyPress', 'onKeyUp'], listenerName => { test(`handles ${listenerName} transparently`, () => { // onKeyDown => keyDown const eventName = _.camelCase(listenerName.replace('on', '')); const handler = jest.fn(); const wrapperProps = { ...requiredProps, [EVENT_TARGET_ATTRIBUTE]: true, [listenerName]: handler, }; const wrapper = mount(); getEventTargetComponent(wrapper, listenerName, eventTargets).simulate(eventName); expect(handler).toBeCalledTimes(1); }); }); } // ---------------------------------------- // Events // ---------------------------------------- test('handles events transparently', () => { // Events should be handled transparently, working just as they would in vanilla React. // Example, both of these handler()s should be called with the same event: // //