diff --git a/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx b/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx index 156a75f580e5ca..6a91df47757b2d 100644 --- a/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx +++ b/apps/fabric-website/src/SiteDefinition/SiteDefinition.pages/Controls/web.tsx @@ -58,6 +58,8 @@ export const categories: { Other?: ICategory; [name: string]: ICategory } = { Compact: {}, Grouped: {}, LargeGrouped: { title: 'Large Grouped' }, + GroupedV2: { title: 'Grouped V2' }, + LargeGroupedV2: { title: 'Large Grouped V2' }, CustomColumns: { title: 'Custom Item Columns', url: 'customitemcolumns' }, CustomRows: { title: 'Custom Item Rows', url: 'customitemrows' }, CustomFooter: { title: 'Custom Footer' }, diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts new file mode 100644 index 00000000000000..faff72c426efe7 --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.doc.ts @@ -0,0 +1,10 @@ +import { TFabricPlatformPageProps } from '../../../interfaces/Platforms'; +import { DetailsListSimpleGroupedV2PageProps as ExternalProps } from '@fluentui/react-examples/lib/office-ui-fabric-react/DetailsList/DetailsList.doc'; + +export const DetailsListGroupedV2PageProps: TFabricPlatformPageProps = { + web: { + ...(ExternalProps as any), + title: 'DetailsList - Grouped V2', + isFeedbackVisible: false, + }, +}; diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx new file mode 100644 index 00000000000000..dac663349f9fe6 --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListGroupedV2Page.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { ControlsAreaPage, IControlsPageProps } from '../ControlsAreaPage'; +import { DetailsListGroupedV2PageProps } from './DetailsListGroupedV2Page.doc'; + +export const DetailsListGroupedV2Page: React.FunctionComponent = props => { + return ; +}; diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts new file mode 100644 index 00000000000000..4bba8a375dca28 --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.doc.ts @@ -0,0 +1,10 @@ +import { TFabricPlatformPageProps } from '../../../interfaces/Platforms'; +import { DetailsListLargeGroupedPageProps as ExternalProps } from '@fluentui/react-examples/lib/office-ui-fabric-react/DetailsList/DetailsList.doc'; + +export const DetailsListLargeGroupedV2PageProps: TFabricPlatformPageProps = { + web: { + ...(ExternalProps as any), + title: 'DetailsList - Large Grouped V2', + isFeedbackVisible: false, + }, +}; diff --git a/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx new file mode 100644 index 00000000000000..0c55806c77397e --- /dev/null +++ b/apps/fabric-website/src/pages/Controls/DetailsListPage/DetailsListLargeGroupedV2Page.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { ControlsAreaPage, IControlsPageProps } from '../ControlsAreaPage'; +import { DetailsListLargeGroupedV2PageProps } from './DetailsListLargeGroupedV2Page.doc'; + +export const DetailsListLargeGroupedV2Page: React.FunctionComponent = props => { + return ; +}; diff --git a/apps/perf-test/package.json b/apps/perf-test/package.json index 0e002802617082..ce401ad45b2f0a 100644 --- a/apps/perf-test/package.json +++ b/apps/perf-test/package.json @@ -26,6 +26,7 @@ "dependencies": { "@uifabric/set-version": "^7.0.24", "@uifabric/example-app-base": "^7.22.30", + "@uifabric/example-data": "^7.1.6", "@uifabric/experiments": "^7.45.18", "@microsoft/load-themed-styles": "^1.10.26", "office-ui-fabric-react": "^7.201.1", diff --git a/apps/perf-test/src/scenarioIterations.js b/apps/perf-test/src/scenarioIterations.js index 62cd6cbc3cf4aa..0c9f14cc90651e 100644 --- a/apps/perf-test/src/scenarioIterations.js +++ b/apps/perf-test/src/scenarioIterations.js @@ -10,6 +10,11 @@ const scenarioIterations = { ComboBox: 1000, Persona: 1000, ContextualMenu: 1000, + /* List performance is generally more influenced by the size + * of the list rather than the number of lists on a page. + */ + GroupedList: 2, + GroupedListV2: 2, }; module.exports = scenarioIterations; diff --git a/apps/perf-test/src/scenarios/GroupedList.tsx b/apps/perf-test/src/scenarios/GroupedList.tsx new file mode 100644 index 00000000000000..426b6004e7cd07 --- /dev/null +++ b/apps/perf-test/src/scenarios/GroupedList.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { createListItems, createGroups, IExampleItem } from '@uifabric/example-data'; +import { GroupedList, Selection, SelectionMode, DetailsRow, IGroup, IColumn } from 'office-ui-fabric-react'; + +const groupCount = 5; +const groupDepth = 5; +const items = createListItems(Math.pow(groupCount, groupDepth + 1)); +const groups = createGroups(groupCount, groupDepth, 0, groupCount); + +const columns = Object.keys(items[0]) + .slice(0, 3) + .map( + (key: string): IColumn => ({ + key: key, + name: key, + fieldName: key, + minWidth: 300, + }), + ); + +const selection = new Selection(); +selection.setItems(items); + +const onRenderCell = ( + nestingDepth?: number, + item?: IExampleItem, + itemIndex?: number, + group?: IGroup, +): React.ReactNode => { + return item && typeof itemIndex === 'number' && itemIndex > -1 ? ( + + ) : null; +}; + +const Scenario = () => { + return ( + + ); +}; + +export default Scenario; diff --git a/apps/perf-test/src/scenarios/GroupedListV2.tsx b/apps/perf-test/src/scenarios/GroupedListV2.tsx new file mode 100644 index 00000000000000..00f9dd3bffd462 --- /dev/null +++ b/apps/perf-test/src/scenarios/GroupedListV2.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { createListItems, createGroups, IExampleItem } from '@uifabric/example-data'; +import { + GroupedListV2_unstable as GroupedListV2, + Selection, + SelectionMode, + DetailsRow, + IGroup, + IColumn, +} from 'office-ui-fabric-react'; + +const groupCount = 5; +const groupDepth = 5; +const items = createListItems(Math.pow(groupCount, groupDepth + 1)); +const groups = createGroups(groupCount, groupDepth, 0, groupCount); + +const columns = Object.keys(items[0]) + .slice(0, 3) + .map( + (key: string): IColumn => ({ + key: key, + name: key, + fieldName: key, + minWidth: 300, + }), + ); + +const selection = new Selection(); +selection.setItems(items); + +const onRenderCell = ( + nestingDepth?: number, + item?: IExampleItem, + itemIndex?: number, + group?: IGroup, +): React.ReactNode => { + return item && typeof itemIndex === 'number' && itemIndex > -1 ? ( + + ) : null; +}; + +const Scenario = () => { + return ( + + ); +}; + +export default Scenario; diff --git a/change/@fluentui-react-9a7c1082-8e48-4512-8a9d-54462ef4a32b.json b/change/@fluentui-react-9a7c1082-8e48-4512-8a9d-54462ef4a32b.json new file mode 100644 index 00000000000000..6b037b5c73061c --- /dev/null +++ b/change/@fluentui-react-9a7c1082-8e48-4512-8a9d-54462ef4a32b.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "add GroupedListV2 export", + "packageName": "@fluentui/react", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/office-ui-fabric-react-d8bae40a-a915-41d0-82e6-26e408cd1fff.json b/change/office-ui-fabric-react-d8bae40a-a915-41d0-82e6-26e408cd1fff.json new file mode 100644 index 00000000000000..ce7ef6ac6b565a --- /dev/null +++ b/change/office-ui-fabric-react-d8bae40a-a915-41d0-82e6-26e408cd1fff.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "cherry-pick: GroupedList: fix virtualization (unstable preview) (#24460)", + "packageName": "office-ui-fabric-react", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md b/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md index 8c23e373e4e517..e1763ba242beb4 100644 --- a/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md +++ b/packages/office-ui-fabric-react/etc/office-ui-fabric-react.api.md @@ -1563,6 +1563,11 @@ export class GroupedListSection extends React.Component; + // @public (undocumented) export const GroupFooter: React.FunctionComponent; @@ -3788,6 +3793,8 @@ export interface IDetailsGroupDividerProps extends IGroupDividerProps, IDetailsI // @public (undocumented) export interface IDetailsGroupRenderProps extends IGroupRenderProps { + // (undocumented) + groupedListAs?: IComponentAs; // (undocumented) onRenderFooter?: IRenderFunction; // (undocumented) @@ -4065,6 +4072,7 @@ export interface IDetailsRowBaseProps extends Pick string; getRowAriaDescription?: (item: any) => string; getRowAriaLabel?: (item: any) => string; + group?: IGroup; id?: string; item: any; itemIndex: number; @@ -5968,6 +5976,7 @@ export interface IListProps extends React.HTMLAttributes | HTML onPageRemoved?: (page: IPage) => void; onPagesUpdated?: (pages: IPage[]) => void; onRenderCell?: (item?: T, index?: number, isScrolling?: boolean) => React.ReactNode; + onRenderCellConditional?: (item?: T, index?: number, isScrolling?: boolean) => React.ReactNode | null; onRenderPage?: IRenderFunction>; onRenderRoot?: IRenderFunction>; onRenderSurface?: IRenderFunction>; @@ -5986,6 +5995,8 @@ export interface IListState { // (undocumented) getDerivedStateFromProps(nextProps: IListProps, previousState: IListState): IListState; // (undocumented) + hasMounted: boolean; + // (undocumented) isScrolling?: boolean; measureVersion?: number; // (undocumented) @@ -8964,13 +8975,14 @@ export class List extends React.Component, IListState> static defaultProps: { startIndex: number; onRenderCell: (item: any, index: number, containsFocus: boolean) => JSX.Element; + onRenderCellConditional: undefined; renderedWindowsAhead: number; renderedWindowsBehind: number; }; // (undocumented) forceUpdate(): void; // (undocumented) - static getDerivedStateFromProps(nextProps: IListProps, previousState: IListState): IListState; + static getDerivedStateFromProps(nextProps: IListProps, previousState: IListState): IListState; // (undocumented) getStartItemIndexInView(measureItem?: (itemIndex: number) => number): number; getTotalListHeight(): number; diff --git a/packages/office-ui-fabric-react/src/GroupedListV2.ts b/packages/office-ui-fabric-react/src/GroupedListV2.ts new file mode 100644 index 00000000000000..6c257b794af247 --- /dev/null +++ b/packages/office-ui-fabric-react/src/GroupedListV2.ts @@ -0,0 +1 @@ +export * from './components/GroupedList/GroupedListV2'; diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx index 450d20af79741e..27569eb0eab330 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.base.tsx @@ -10,6 +10,9 @@ import { IRenderFunction, classNamesFunction, memoizeFunction, + getId, + composeRenderFunction, + composeComponentAs, } from '../../Utilities'; import { CheckboxVisibility, @@ -38,7 +41,7 @@ import { IFocusZone, FocusZone, FocusZoneDirection, IFocusZoneProps } from '../. import { IObjectWithKey, ISelection, Selection, SelectionMode, SelectionZone } from '../../utilities/selection/index'; import { DragDropHelper } from '../../utilities/dragdrop/DragDropHelper'; -import { IGroupedList, GroupedList, IGroupDividerProps, IGroupRenderProps } from '../../GroupedList'; +import { IGroup, IGroupedList, GroupedList, IGroupDividerProps, IGroupRenderProps } from '../../GroupedList'; import { List, IListProps, ScrollToMode } from '../../List'; import { withViewport } from '../../utilities/decorators/withViewport'; import { GetGroupCount } from '../../utilities/groupedList/GroupedListUtility'; @@ -46,7 +49,6 @@ import { DEFAULT_CELL_STYLE_PROPS } from './DetailsRow.styles'; import { CHECK_CELL_WIDTH as CHECKBOX_WIDTH } from './DetailsRowCheck.styles'; // For every group level there is a GroupSpacer added. Importing this const to have the source value in one place. import { SPACER_WIDTH as GROUP_EXPAND_WIDTH } from '../GroupedList/GroupSpacer'; -import { composeRenderFunction, getId } from '@uifabric/utilities'; import { useConst } from '@uifabric/react-hooks'; const getClassNames = classNamesFunction(); @@ -187,6 +189,7 @@ const DetailsListInner: React.ComponentType = ( const rowId = getId('row'); const groupNestingDepth = getGroupNestingDepth(groups); + const groupedDetailsListIndexMap = useGroupedDetailsListIndexMap(groups); const additionalListProps = React.useMemo((): IListProps => { return { @@ -361,7 +364,13 @@ const DetailsListInner: React.ComponentType = ( const finalOnRenderDetailsGroupHeader = React.useMemo(() => { return onRenderDetailsGroupHeader ? (groupHeaderProps: IGroupDividerProps, defaultRender?: IRenderFunction) => { - const { ariaPosInSet, ariaSetSize } = groupHeaderProps; + const { groupIndex } = groupHeaderProps; + const groupKey: string | undefined = + groups && groupIndex !== undefined && groups[groupIndex] !== undefined ? groups[groupIndex].key : undefined; + const totalRowCount: number = + groupKey !== undefined && groupedDetailsListIndexMap[groupKey] + ? groupedDetailsListIndexMap[groupKey].totalRowCount + : 0; return onRenderDetailsGroupHeader( { @@ -377,27 +386,34 @@ const DetailsListInner: React.ComponentType = ( ariaColSpan: adjustedColumns.length, ariaPosInSet: undefined, ariaSetSize: undefined, - ariaRowCount: ariaSetSize ? ariaSetSize + (isHeaderVisible ? 1 : 0) : undefined, - ariaRowIndex: ariaPosInSet ? ariaPosInSet + (isHeaderVisible ? 1 : 0) : undefined, + ariaRowCount: undefined, + ariaRowIndex: groupIndex !== undefined ? totalRowCount + (isHeaderVisible ? 1 : 0) : undefined, }, defaultRender, ); } : (groupHeaderProps: IGroupDividerProps, defaultRender: IRenderFunction) => { - const { ariaPosInSet, ariaSetSize } = groupHeaderProps; + const { groupIndex } = groupHeaderProps; + const groupKey: string | undefined = + groups && groupIndex !== undefined && groups[groupIndex] !== undefined ? groups[groupIndex].key : undefined; + const totalRowCount: number = + groupKey !== undefined && groupedDetailsListIndexMap[groupKey] + ? groupedDetailsListIndexMap[groupKey].totalRowCount + : 0; return defaultRender({ ...groupHeaderProps, ariaColSpan: adjustedColumns.length, ariaPosInSet: undefined, ariaSetSize: undefined, - ariaRowCount: ariaSetSize ? ariaSetSize + (isHeaderVisible ? 1 : 0) : undefined, - ariaRowIndex: ariaPosInSet ? ariaPosInSet + (isHeaderVisible ? 1 : 0) : undefined, + ariaRowCount: undefined, + ariaRowIndex: groupIndex !== undefined ? totalRowCount + (isHeaderVisible ? 1 : 0) : undefined, }); }; }, [ onRenderDetailsGroupHeader, adjustedColumns, + groups, groupNestingDepth, indentWidth, isHeaderVisible, @@ -406,6 +422,7 @@ const DetailsListInner: React.ComponentType = ( viewport, checkboxVisibility, cellStyleProps, + groupedDetailsListIndexMap, ]); const finalGroupProps = React.useMemo((): IGroupRenderProps | undefined => { @@ -442,17 +459,23 @@ const DetailsListInner: React.ComponentType = ( }, [adjustedColumns, sumColumnWidths]); const onRenderCell = React.useCallback( - (nestingDepth: number, item: any, index: number): React.ReactNode => { + (nestingDepth: number, item: any, index: number, group?: IGroup): React.ReactNode => { const finalOnRenderRow = props.onRenderRow ? composeRenderFunction(props.onRenderRow, onRenderDefaultRow) : onRenderDefaultRow; + const groupKey: string | undefined = group ? group.key : undefined; + const numOfGroupHeadersBeforeItem: number = + groupKey && groupedDetailsListIndexMap[groupKey] + ? groupedDetailsListIndexMap[groupKey].numOfGroupHeadersBeforeItem + : 0; + const rowRole = role === defaultRole ? undefined : 'presentation'; const rowProps: IDetailsRowProps = { item: item, itemIndex: index, - flatIndexOffset: isHeaderVisible ? 2 : 1, + flatIndexOffset: (isHeaderVisible ? 2 : 1) + numOfGroupHeadersBeforeItem, compact, columns: adjustedColumns, groupNestingDepth: nestingDepth, @@ -525,6 +548,7 @@ const DetailsListInner: React.ComponentType = ( props.onRenderRow, rowWidth, role, + groupedDetailsListIndexMap, ], ); @@ -554,8 +578,11 @@ const DetailsListInner: React.ComponentType = ( onBlur: onBlur, }; + const FinalGroupedList = + groups && groupProps?.groupedListAs ? composeComponentAs(groupProps.groupedListAs, GroupedList) : GroupedList; + const list = groups ? ( - { + const indexMap: IGroupedDetailsListIndexMap = {}; + if (groups) { + let rowCount = 1; + let numGroupHeaders = 1; + for (const group of groups) { + const { key } = group; + indexMap[key] = { numOfGroupHeadersBeforeItem: numGroupHeaders, totalRowCount: rowCount }; + numGroupHeaders++; + rowCount += group.count + 1; + } + } + return indexMap; + }, [groups]); +} diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.test.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.test.tsx index dba83c79d43325..addc7ef381bd87 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.test.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsList.test.tsx @@ -15,7 +15,7 @@ import { } from './DetailsList.types'; import { IDetailsColumnProps } from './DetailsColumn'; import { IDetailsHeaderProps, DetailsHeader } from './DetailsHeader'; -import { EventGroup, IRenderFunction } from '../../Utilities'; +import { EventGroup, IRenderFunction, resetIds } from '../../Utilities'; import { IDragDropEvents } from './../../utilities/dragdrop/index'; import { SelectionMode, Selection, SelectionZone } from '../../utilities/selection/index'; import { getTheme } from '../../Styling'; @@ -70,7 +70,15 @@ function customColumnDivider( ); } +Object.defineProperty(window, 'scrollTo', { + value: jest.fn(), +}); + describe('DetailsList', () => { + beforeEach(() => { + resetIds(); + }); + it('renders List correctly with onRenderDivider props', () => { const component = renderer.create( ; onRenderHeader?: IRenderFunction; + groupedListAs?: IComponentAs; } /** diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx new file mode 100644 index 00000000000000..9920663809a68f --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsListV2.test.tsx @@ -0,0 +1,1136 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as renderer from 'react-test-renderer'; +import { ReactWrapper } from 'enzyme'; +import { safeMount } from '@uifabric/test-utilities'; +import { EventGroup, KeyCodes, resetIds, IRenderFunction } from '../../Utilities'; +import { SelectionMode, Selection, SelectionZone } from '../../Selection'; +import { getTheme } from '../../Styling'; +import { DetailsHeader } from './DetailsHeader'; +import { DetailsList } from './DetailsList'; +import { DetailsListBase } from './DetailsList.base'; +import { CheckboxVisibility, DetailsListLayoutMode, IDetailsGroupRenderProps } from './DetailsList.types'; +import { DetailsRow } from './DetailsRow'; +import { DetailsRowCheck } from './DetailsRowCheck'; +import { IDragDropEvents } from '../../DragDrop'; +import { IGroup } from '../../GroupedList'; +import { IDetailsColumnProps } from './DetailsColumn'; +import { IDetailsHeaderProps } from './DetailsHeader'; +import { IColumn, IDetailsGroupDividerProps, IDetailsList } from './DetailsList.types'; +import { IDetailsRowProps } from './DetailsRow'; +import { GroupedListV2_unstable as GroupedListV2 } from '../GroupedList/GroupedListV2'; + +// Populate mock data for testing +function mockData(count: number, isColumn: boolean = false, customDivider: boolean = false): any { + const data = []; + let _data = {}; + + for (let i = 0; i < count; i++) { + _data = { + key: i, + name: 'Item ' + i, + value: i, + }; + if (isColumn) { + _data = { + ..._data, + key: `column_key_${i}`, + ariaLabel: `column_${i}`, + onRenderDivider: customDivider ? customColumnDivider : columnDividerWrapper, + }; + } + data.push(_data); + } + + return data; +} + +// Wrapper function which calls the defaultRenderer with the corresponding params +function columnDividerWrapper( + iDetailsColumnProps: IDetailsColumnProps, + defaultRenderer: (props?: IDetailsColumnProps) => JSX.Element | null, +): any { + return defaultRenderer(iDetailsColumnProps); +} + +// Using a bar sign as a custom divider along with the default divider +function customColumnDivider( + iDetailsColumnProps: IDetailsColumnProps, + defaultRenderer: (props?: IDetailsColumnProps) => JSX.Element | null, +): any { + return ( + + | + {defaultRenderer(iDetailsColumnProps)} + + ); +} + +const groupProps: IDetailsGroupRenderProps = { + groupedListAs: GroupedListV2, +}; + +/** + * NOTE: There isn't actually a DetailsListV2 control, rather + * this control uses GroupedListV2 for rendering. + */ +describe('DetailsListV2', () => { + let spy: jest.SpyInstance; + beforeAll(() => { + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + spy = jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); + }); + + afterAll(() => { + spy.mockRestore(); + }); + + beforeEach(() => { + resetIds(); + }); + + afterEach(() => { + if (((setTimeout as unknown) as jest.Mock).mock) { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }); + + it('renders List correctly', () => { + const component = renderer.create( + null} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List correctly with onRenderDivider props', () => { + const component = renderer.create( + null} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List with custom icon as column divider', () => { + const component = renderer.create( + null} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List in fixed constrained layout correctly', () => { + const component = renderer.create( + null} + layoutMode={DetailsListLayoutMode.fixedColumns} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders a single proportional column with correct width', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + layoutMode={DetailsListLayoutMode.fixedColumns} + flexMargin={-640} + skipViewportMeasures={false} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + setTimeout(() => { + const elements = (document.activeElement as HTMLElement).querySelectorAll('div[role=columnheader]'); + elements.forEach((element: Element, index: number) => { + if (index === 0) { + return; + } + + const style = element.getAttribute('style')!; + expect(style).toBeDefined(); + + const width = style.match(/(?<=width: )\d+/g)!; + expect(width).toBeDefined(); + expect(width[0]).toBeDefined(); + + if (index === 1) { + expect(width[0]).toBe('121'); + } else if (index === 2) { + expect(width[0]).toBe('348'); + } else if (index === 3) { + expect(width[0]).toBe('123'); + } else { + fail('Unexpected index.'); + } + }); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('renders proportional columns with proper width ratios', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + layoutMode={DetailsListLayoutMode.fixedColumns} + flexMargin={-640} + skipViewportMeasures={false} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + setTimeout(() => { + const elements = (document.activeElement as HTMLElement).querySelectorAll('div[role=columnheader]'); + elements.forEach((element: Element, index: number) => { + if (index === 0) { + return; + } + + const style = element.getAttribute('style')!; + expect(style).toBeDefined(); + + const width = style.match(/(?<=width: )\d+/g)!; + expect(width).toBeDefined(); + expect(width[0]).toBeDefined(); + + if (index === 1) { + expect(width[0]).toBe('336'); + } else if (index === 2) { + expect(width[0]).toBe('255'); + } else { + fail('Unexpected index.'); + } + }); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('renders proportional columns with proper width ratios when delayFirstMeasure', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + layoutMode={DetailsListLayoutMode.fixedColumns} + flexMargin={-640} + skipViewportMeasures={false} + onShouldVirtualize={() => false} + delayFirstMeasure + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + setTimeout(() => { + const elements = (document.activeElement as HTMLElement).querySelectorAll('div[role=columnheader]'); + elements.forEach((element: Element, index: number) => { + if (index === 0) { + return; + } + + const style = element.getAttribute('style')!; + expect(style).toBeDefined(); + + const width = style.match(/(?<=width: )\d+/g)!; + expect(width).toBeDefined(); + expect(width[0]).toBeDefined(); + + if (index === 1) { + expect(width[0]).toBe('336'); + } else if (index === 2) { + expect(width[0]).toBe('255'); + } else { + fail('Unexpected index.'); + } + }); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('renders List in compact mode correctly', () => { + const component = renderer.create( + null} + compact={true} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders List with hidden checkboxes correctly', () => { + const component = renderer.create( + false} + groups={[ + { + key: 'group0', + name: 'Group 0', + startIndex: 0, + count: 2, + }, + { + key: 'group1', + name: 'Group 1', + startIndex: 2, + count: 3, + }, + ]} + checkboxVisibility={CheckboxVisibility.hidden} + groupProps={groupProps} + />, + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('focuses row by index', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + safeMount( + (component = ref)} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(2); + jest.runAllTimers(); + expect( + (document.activeElement as HTMLElement).querySelector('[data-automationid=DetailsRowCell]')!.textContent, + ).toEqual('2'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); + }, + ); + }); + + it('focuses row by arrow key', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + component!.focusIndex(0); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-DetailsList-headerWrapper').simulate('keyDown', { which: KeyCodes.down }); + expect(onSelectionChanged).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not focus by arrow key when isSelectedOnFocus is `false`', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + isSelectedOnFocus={false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + component!.focusIndex(0); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-DetailsList-headerWrapper').simulate('keyDown', { which: KeyCodes.down }); + expect(onSelectionChanged).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('clears selection when escape key is pressed and isSelectedOnFocus is `true`', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + selection.setAllSelected(true); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-SelectionZone').simulate('keyDown', { which: KeyCodes.escape }); + expect(onSelectionChanged).toHaveBeenCalledTimes(1); + expect(selection.getSelectedCount()).toEqual(0); + }, + ); + }); + + it('does not clear selection when escape key is pressed and isSelectedOnFocus is `false`', () => { + jest.useFakeTimers(); + + let component: IDetailsList | null; + const onSelectionChanged = jest.fn(); + const selection = new Selection({ + onSelectionChanged, + }); + safeMount( + (component = ref)} + items={mockData(5)} + selection={selection} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + isSelectedOnFocus={false} + groupProps={groupProps} + />, + wrapper => { + expect(component).toBeTruthy(); + selection.setAllSelected(true); + jest.runAllTimers(); + + onSelectionChanged.mockClear(); + wrapper.find('.ms-SelectionZone').simulate('keyDown', { which: KeyCodes.escape }); + expect(onSelectionChanged).toHaveBeenCalledTimes(0); + expect(selection.getSelectedCount()).toEqual(5); + }, + ); + }); + + it('invokes optional onRenderMissingItem prop once per missing item rendered', () => { + const onRenderMissingItem = jest.fn(); + const items = [...mockData(5), null, null]; + + safeMount( + , + () => { + expect(onRenderMissingItem).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('does not invoke optional onRenderMissingItem prop if no missing items are rendered', () => { + const onRenderMissingItem = jest.fn(); + const items = mockData(5); + + safeMount( + , + () => { + expect(onRenderMissingItem).toHaveBeenCalledTimes(0); + }, + ); + }); + + it('executes onItemInvoked when double click is pressed', () => { + const items = mockData(5); + const onItemInvoked = jest.fn(); + + safeMount( + , + (wrapper: ReactWrapper) => { + wrapper + .find('.ms-DetailsRow') + .first() + .simulate('dblclick'); + + expect(onItemInvoked).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('executes onItemInvoked when enter is pressed', () => { + const items = mockData(5); + const onItemInvoked = jest.fn(); + + safeMount( + , + (wrapper: ReactWrapper) => { + wrapper + .find('.ms-DetailsRow') + .first() + .simulate('keydown', { which: KeyCodes.enter }); + + expect(onItemInvoked).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('respects changed dragDropEvents prop on re-renders.', () => { + const _dragDropEvents: IDragDropEvents = { + canDrag: jest.fn().mockReturnValue(true), + canDrop: jest.fn().mockReturnValue(true), + onDragEnd: jest.fn(), + onDragEnter: jest.fn(), + onDragLeave: jest.fn(), + onDragStart: jest.fn(), + onDrop: jest.fn(), + }; + + const _dragDropEvents2: IDragDropEvents = { + canDrag: jest.fn().mockReturnValue(true), + canDrop: jest.fn().mockReturnValue(true), + onDragEnd: jest.fn(), + onDragEnter: jest.fn(), + onDragLeave: jest.fn(), + onDragStart: jest.fn(), + onDrop: jest.fn(), + }; + + const _RaiseEvent = (target: any, _eventName: string, _clientX: number) => { + EventGroup.raise( + target, + _eventName, + { + clientX: _clientX, + button: 0, + } as DragEvent, + true, + ); + }; + + const container = document.createElement('div'); + const items = mockData(5); + const columns = mockData(5, true); + + ReactDOM.render( + , + container, + ); + + let detailsRowSource = container.querySelector('div[aria-rowindex="2"][role="row"]') as HTMLDivElement; + + _RaiseEvent(detailsRowSource, 'mousedown', 270); + _RaiseEvent(detailsRowSource, 'dragstart', 270); + + // original eventhandler should be fired + expect(_dragDropEvents.onDragStart).toHaveBeenCalledTimes(1); + expect(_dragDropEvents2.onDragStart).toHaveBeenCalledTimes(0); + + ReactDOM.render( + , + container, + ); + + detailsRowSource = container.querySelector('div[aria-rowindex="2"][role="row"]') as HTMLDivElement; + + _RaiseEvent(detailsRowSource, 'mousedown', 270); + _RaiseEvent(detailsRowSource, 'dragstart', 270); + + expect(_dragDropEvents.onDragStart).toHaveBeenCalledTimes(2); + expect(_dragDropEvents2.onDragStart).toHaveBeenCalledTimes(0); + + ReactDOM.render( + , + container, + ); + + detailsRowSource = container.querySelector('div[aria-rowindex="2"][role="row"]') as HTMLDivElement; + + _RaiseEvent(detailsRowSource, 'mousedown', 270); + _RaiseEvent(detailsRowSource, 'dragstart', 270); + + expect(_dragDropEvents.onDragStart).toHaveBeenCalledTimes(2); + expect(_dragDropEvents2.onDragStart).toHaveBeenCalledTimes(1); + }); + + it('focuses into row element', () => { + jest.useFakeTimers(); + + const onRenderColumn = (item: any, index: number, column: IColumn) => { + let value = item && column && column.fieldName ? item[column.fieldName] : ''; + if (value === null || value === undefined) { + value = ''; + } + return ( +
+ {value} +
+ ); + }; + + const getCellValueKey = (item: any, index: number, column: IColumn) => { + const valueKey = item && column && column.fieldName ? item[column.fieldName] : column.key + index; + return valueKey; + }; + + let component: IDetailsList | null; + safeMount( + (component = ref)} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + onRenderItemColumn={onRenderColumn} + getCellValueKey={getCellValueKey} + groupProps={groupProps} + />, + () => { + expect(component).toBeTruthy(); + component!.focusIndex(3); + jest.runOnlyPendingTimers(); + expect( + (document.activeElement as HTMLElement).querySelector('[data-automationid=DetailsRowCell]')!.textContent, + ).toEqual('3'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); + + // Set element visibility manually as a test workaround + component!.focusIndex(4); + jest.runOnlyPendingTimers(); + ((document.activeElement as HTMLElement).children[1] as any).isVisible = true; + ((document.activeElement as HTMLElement).children[1].children[0] as any).isVisible = true; + ((document.activeElement as HTMLElement).children[1].children[0].children[0] as any).isVisible = true; + + component!.focusIndex(4, true); + jest.runOnlyPendingTimers(); + expect((document.activeElement as HTMLElement).textContent).toEqual('4'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('test-column'); + }, + ); + }); + + it('reset focusedItemIndex when setKey updates', () => { + jest.useFakeTimers(); + + let component: any; + + safeMount( + (component = ref)} + skipViewportMeasures={true} + onShouldVirtualize={() => false} + />, + (wrapper: ReactWrapper) => { + expect(component).toBeDefined(); + component.setState({ focusedItemIndex: 3 }); + setTimeout(() => { + expect(component.state.focusedItemIndex).toEqual(3); + }, 0); + jest.runOnlyPendingTimers(); + + // update props to new setKey + const newProps = { items: mockData(7), setKey: 'set2', initialFocusedIndex: 0 }; + wrapper.setProps(newProps); + wrapper.update(); + + // verify that focusedItemIndex is reset to 0 and 0th row is focused + setTimeout(() => { + expect(component.state.focusedItemIndex).toEqual(0); + expect( + (document.activeElement as HTMLElement).querySelector('[data-automationid=DetailsRowCell]')!.textContent, + ).toEqual('0'); + expect((document.activeElement as HTMLElement).className.split(' ')).toContain('ms-DetailsRow'); + }, 0); + jest.runOnlyPendingTimers(); + }, + ); + }); + + it('invokes optional onColumnResize callback per IColumn if defined when columns are adjusted', () => { + jest.useFakeTimers(); + + const columns: IColumn[] = mockData(2, true); + columns[0].onColumnResize = jest.fn(); + columns[1].onColumnResize = jest.fn(); + + safeMount( + false} groupProps={groupProps} />, + () => { + jest.runOnlyPendingTimers(); + expect(columns[0].onColumnResize).toHaveBeenCalledTimes(1); + expect(columns[1].onColumnResize).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('invokes optional onRenderDetailsHeader prop to customize DetailsHeader rendering when provided', () => { + const onRenderDetailsHeaderMock = jest.fn(); + + safeMount( + false} + onRenderDetailsHeader={onRenderDetailsHeaderMock} + groupProps={groupProps} + />, + () => { + expect(onRenderDetailsHeaderMock).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('invokes onRenderColumnHeaderTooltip to customize DetailsColumn tooltip rendering when provided', () => { + const NUM_COLUMNS = 2; + const onRenderColumnHeaderTooltipMock = jest.fn(); + const onRenderDetailsHeader = ( + props: IDetailsHeaderProps, + defaultRenderer?: IRenderFunction, + ) => { + return ; + }; + + safeMount( + false} + onRenderDetailsHeader={onRenderDetailsHeader} + groupProps={groupProps} + />, + () => { + expect(onRenderColumnHeaderTooltipMock).toHaveBeenCalledTimes(4); + }, + ); + }); + + it('invokes optional onRenderCheckbox callback to customize checkbox rendering when provided', () => { + const onRenderCheckboxMock = jest.fn(); + const selection = new Selection(); + const theme = getTheme(); + + safeMount( + false} + onRenderCheckbox={onRenderCheckboxMock} + checkboxVisibility={CheckboxVisibility.always} + selectionMode={SelectionMode.multiple} + selection={selection} + groupProps={groupProps} + />, + () => { + expect(onRenderCheckboxMock).toHaveBeenCalledTimes(3); + expect(onRenderCheckboxMock.mock.calls[2][0]).toEqual({ checked: false, theme }); + + selection.setAllSelected(true); + + expect(onRenderCheckboxMock).toHaveBeenCalledTimes(6); + expect(onRenderCheckboxMock.mock.calls[5][0]).toEqual({ checked: true, theme }); + }, + ); + }); + + it('initializes the selection mode object with the selectionMode prop', () => { + safeMount( + , + (wrapper: ReactWrapper) => { + const selectionZone = wrapper.find(SelectionZone); + + expect(selectionZone.props().selection.mode).toEqual(SelectionMode.none); + }, + ); + }); + + it('handles updates to items and groups', () => { + const tableOneItems = [ + { f1: 'A1', f2: 'B1', f3: 'C1' }, + { f1: 'A2', f2: 'B2', f3: 'C2' }, + { f1: 'A3', f2: 'B3', f3: 'C3' }, + { f1: 'A4', f2: 'B4', f3: 'C4' }, + ]; + const tableTwoItems = [ + { f1: 'D1', f2: 'E1', f3: 'F1' }, + { f1: 'D2', f2: 'E2', f3: 'F2' }, + { f1: 'D3', f2: 'E3', f3: 'F3' }, + { f1: 'D4', f2: 'E4', f3: 'F4' }, + ]; + + const groupOneGroups: IGroup[] = [ + { key: 'one-1', name: 'one 1', count: 1, startIndex: 0 }, + { key: 'one-2', name: 'one 2', count: 1, startIndex: 1 }, + { key: 'one-3', name: 'one 3', count: 1, startIndex: 2 }, + { key: 'one-4', name: 'one 4', count: 1, startIndex: 3 }, + ]; + + const groupTwoGroups: IGroup[] = [ + { key: 'two-1', name: 'two 1', count: 2, startIndex: 0 }, + { key: 'two-2', name: 'two 2', count: 2, startIndex: 2 }, + ]; + + const onRenderDetailsHeader: IRenderFunction = (headerProps: IDetailsHeaderProps) => { + return ( +
+ {headerProps.columns.map((column: IColumn) => { + return
{column.name}
; + })} +
+ ); + }; + + const onRenderRow = (rowProps: IDetailsRowProps) => { + return ( +
+ {rowProps.columns.map((column: IColumn) => { + return
{rowProps.item[column.key]}
; + })} +
+ ); + }; + + const onRenderGroupHeader: IRenderFunction = ( + groupDividerProps: IDetailsGroupDividerProps, + ) => { + return
{groupDividerProps.group?.name}
; + }; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // New items, same groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // Same items, new groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // New items, same groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + }); + + it('handles paged updates to items within groups', () => { + const roundOneItems = [{ f1: 'A1', f2: 'B1', f3: 'C1' }, undefined, { f1: 'A3', f2: 'B3', f3: 'C3' }, undefined]; + const roundTwoItems = [ + { f1: 'A1', f2: 'B1', f3: 'C1' }, + { f1: 'A2', f2: 'B2', f3: 'C2' }, + { f1: 'A3', f2: 'B3', f3: 'C3' }, + { f1: 'A4', f2: 'B4', f3: 'C4' }, + ]; + + const groups: IGroup[] = [ + { key: 'two-1', name: 'two 1', count: 2, startIndex: 0 }, + { key: 'two-2', name: 'two 2', count: 2, startIndex: 2 }, + ]; + + const onRenderDetailsHeader: IRenderFunction = (headerProps: IDetailsHeaderProps) => { + return ( +
+ {headerProps.columns.map((column: IColumn) => { + return
{column.name}
; + })} +
+ ); + }; + + const onRenderRow = (rowProps: IDetailsRowProps) => { + return ( +
+ {rowProps.columns.map((column: IColumn) => { + return
{rowProps.item[column.key]}
; + })} +
+ ); + }; + + const onRenderMissingItem = () => { + return
Placeholder
; + }; + + const onRenderGroupHeader: IRenderFunction = ( + groupDividerProps: IDetailsGroupDividerProps, + ) => { + return
{groupDividerProps.group?.name}
; + }; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + + // New items, same groups + + component.update( + , + ); + + expect(component.toJSON()).toMatchSnapshot(); + }); + + it('returns an element with the correct text based on the second id passed in aria-labelledby', () => { + const container = document.createElement('div'); + const columns = [ + { + key: 'column1', + name: 'Name', + fieldName: 'name', + minWidth: 100, + maxWidth: 200, + isResizable: true, + isRowHeader: true, + }, + { key: 'column2', name: 'Value', fieldName: 'value', minWidth: 100, maxWidth: 200, isResizable: true }, + ]; + const items = mockData(1); + + ReactDOM.render( + , + container, + ); + + const checkbox = container.querySelector('div[role="checkbox"][aria-label="select row"]') as HTMLElement; + const rowHeaderId = checkbox?.getAttribute('aria-labelledby')?.split(' ')[1]; + + expect(container.querySelector(`#${rowHeaderId}`)!.textContent).toEqual(items[0].name); + }); + + it('has an aria-labelledby checkboxId that matches the id of the checkbox', () => { + const component = renderer.create( + , + ); + + const detailsRowSource = component.root.findAllByType(DetailsRow)[0]; + const detailsRowCheckSource = detailsRowSource.findByType(DetailsRowCheck).props; + const checkboxId = detailsRowCheckSource.id; + + expect(checkboxId).toEqual(detailsRowCheckSource[`aria-labelledby`].split(' ')[0]); + }); + + it('names group header checkboxes based on checkButtonGroupAriaLabel', () => { + const container = document.createElement('div'); + ReactDOM.render( + , + container, + ); + + const checkbox = container.querySelector('[role="checkbox"][aria-label="select section"]') as HTMLElement; + expect(checkbox).not.toBeNull(); + + const groupNameId = checkbox.getAttribute('aria-labelledby')?.split(' ')[1]; + expect(container.querySelector(`#${groupNameId} span`)!.textContent).toEqual('Group 0'); + }); +}); diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsRow.base.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsRow.base.tsx index a7599f5b6ed1fd..44b591dd56b62b 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/DetailsRow.base.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/DetailsRow.base.tsx @@ -206,6 +206,7 @@ export class DetailsRowBase extends React.Component