Skip to content

Commit

Permalink
feat(components): JOB-78452 Select DataList row (#1529)
Browse files Browse the repository at this point in the history
* test change for checkbox stuff

* Undo moving generateDataListItems to a hook

* Initial Checkbox code

* Added Tests and ability to unselect checkbox

* Make checkbox always visible on small breakpoint

* Fix styling on smaller screens

* Address storybook comments

* Address type related comments

* Added opacity animation

* Allow focus on checkbox

* Adjust animation speed

* Address DataListItemInternal comments

* Update types for DataList Select

* Add padding to checkboxes on smaller screens

* Small changes addressing review comments

* Set props to readonly
Revert renaming

* Add whitespace

* Remove selection example temporarily
  • Loading branch information
MichaelParadis committed Sep 14, 2023
1 parent 0ec05cc commit 3b503b6
Show file tree
Hide file tree
Showing 11 changed files with 237 additions and 4 deletions.
2 changes: 1 addition & 1 deletion docs/components/DataList/Web.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const Template: ComponentStory<typeof DataList> = args => {
{...args}
loadingState={getLoadingState()}
totalCount={totalCount}
data={(args.data as typeof mappedData) || mappedData}
data={args.data || mappedData}
headers={{
label: "Name",
home: "Home world",
Expand Down
29 changes: 29 additions & 0 deletions packages/components/src/DataList/DataList.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,35 @@
border-bottom: var(--border-base) solid var(--color-border);
}

.selectable {
display: grid;
column-gap: var(--space-small);
align-items: flex-start;
grid-template-columns: max-content minmax(0px, auto);

@media (--medium-screens-and-up) {
align-items: center;
}
}

.listItem .selectable > :first-child {
padding-top: var(--space-smaller);
opacity: 1;
transition: opacity var(--transition-properties);
--transition-properties: var(--timing-quick) ease-in-out;

@media (--medium-screens-and-up) {
padding-top: 0;
opacity: 0;
}
}

.listItem:hover .selectable > :first-child,
.listItem:focus-within .selectable > :first-child,
.listItem .selectable.selected > :first-child {
opacity: 1;
}

.filtering {
display: flex;
position: absolute;
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/DataList/DataList.css.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ declare const styles: {
readonly "headerTitles": string;
readonly "headerLabel": string;
readonly "listItem": string;
readonly "selectable": string;
readonly "selected": string;
readonly "filtering": string;
readonly "filteringSpinner": string;
};
Expand Down
6 changes: 4 additions & 2 deletions packages/components/src/DataList/DataList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,13 @@ describe("DataList", () => {
});

it("should not have the trigger element", () => {
function getElement(props?: Partial<Parameters<typeof DataList>[0]>) {
function getElement(
props?: Partial<DataListProps<(typeof mockData)[0]>>,
) {
return (
<DataList
{...props}
data={(props?.data as typeof mockData) || mockData}
data={props?.data || mockData}
headers={mockHeaders}
>
<></>
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/DataList/DataList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function DataList<T extends DataListObject>(props: DataListProps<T>) {
layoutComponents,
emptyStateComponents,
...props,
selected: props.selected ?? [],
}}
>
<InternalDataList />
Expand Down
10 changes: 10 additions & 0 deletions packages/components/src/DataList/DataList.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ export interface DataListProps<T extends DataListObject> {
* The callback function when the user scrolls to the bottom of the list.
*/
readonly onLoadMore?: () => void;

/**
* Callback when an item checkbox is clicked.
*/
readonly onSelect?: (items: T["id"][]) => void;

/**
* The list of Selected Item ids
*/
readonly selected?: T["id"][];
}

export interface DataListLayoutProps<T extends DataListObject> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import classNames from "classnames";
import React from "react";
import { Checkbox } from "../../../Checkbox";
import { useDataListContext } from "../../context/DataListContext";
import styles from "../../DataList.css";
import { DataListObject } from "../../DataList.types";

interface ListItemInternalProps<T extends DataListObject> {
readonly children: JSX.Element;
readonly item: T;
}

export function DataListItemInternal<T extends DataListObject>({
children,
item,
}: ListItemInternalProps<T>) {
const { selected, onSelect } = useDataListContext();

if (selected !== undefined && onSelect) {
return (
<div
className={classNames(styles.selectable, {
[styles.selected]: selected?.length,
})}
>
<Checkbox
checked={selected?.includes(item.id)}
onChange={handleChange}
/>
{children}
</div>
);
}

return children;

function handleChange() {
if (selected?.includes(item.id)) {
onSelect?.(selected?.filter(id => id !== item.id));
} else if (selected) {
onSelect?.([...selected, item.id]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import omit from "lodash/omit";
import userEvent from "@testing-library/user-event";
import { DataListItems } from "./DataListItems";
import { defaultValues } from "../../context/DataListContext";
import * as dataListContext from "../../context/DataListContext/DataListContext";
import { DataListLayout } from "../DataListLayout";
import { DataListItemType } from "../../DataList.types";

const spy = jest.spyOn(dataListContext, "useDataListContext");
const mockData = [
{ id: 1, label: "Luke Skywalker" },
{ id: 2, label: "Anakin Skywalker" },
];
const mockHeader = { label: "Name" };
const contextValueWithRenderableChildren = {
...defaultValues,
data: mockData,
onSelect: jest.fn(),
selected: [],
headers: mockHeader,
children: (
<DataListLayout key="layout1">
{(item: DataListItemType<typeof mockData>) => (
<div>
<div>{item.label}</div>
</div>
)}
</DataListLayout>
),
};

afterEach(() => {
cleanup();
spy.mockReset();
});

describe("DataListItems", () => {
describe("selectable", () => {
it("should render list items with checkboxes when onSelect and selected provided", () => {
spy.mockReturnValue(contextValueWithRenderableChildren);

renderItems();

expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});

it("should not render list items with checkboxes when selected is not provided", async () => {
spy.mockReturnValue(omit(contextValueWithRenderableChildren, "selected"));

renderItems();
await waitFor(() => {
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
});
});

it("should not render list items with checkboxes when onSelect is not provided", async () => {
spy.mockReturnValue(omit(contextValueWithRenderableChildren, "onSelect"));

renderItems();
await waitFor(() => {
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
});
});
});

describe("onSelect", () => {
it("should call onSelect when a single checkbox is selected", async () => {
const onSelectMock = jest.fn();
spy.mockReturnValue({
...contextValueWithRenderableChildren,
onSelect: onSelectMock,
});
renderItems();

const checkbox = screen.getAllByRole("checkbox")[0];

await userEvent.click(checkbox);

expect(onSelectMock).toHaveBeenCalledTimes(1);
expect(onSelectMock).toHaveBeenCalledWith([mockData[0].id]);
});

it("should call onSelect with multiple checkboxes selected", async () => {
const onSelectMock = jest.fn();
spy.mockReturnValue({
...contextValueWithRenderableChildren,
selected: [mockData[0].id],
onSelect: onSelectMock,
});
renderItems();

const checkbox = screen.getAllByRole("checkbox")[1];

userEvent.click(checkbox);
await waitFor(() => {
expect(onSelectMock).toHaveBeenCalledTimes(1);
expect(onSelectMock).toHaveBeenCalledWith([
mockData[0].id,
mockData[1].id,
]);
});
});

it("should call onSelect when a signle checkbox is un-selected", async () => {
const onSelectMock = jest.fn();
spy.mockReturnValue({
...contextValueWithRenderableChildren,
onSelect: onSelectMock,
selected: [mockData[0].id, mockData[1].id],
});
renderItems();
const checkbox = screen.getAllByRole("checkbox")[0];

await userEvent.click(checkbox);

expect(onSelectMock).toHaveBeenCalledTimes(1);
expect(onSelectMock).toHaveBeenCalledWith([mockData[1].id]);
});
});
});

function renderItems() {
render(
<DataListItems
layouts={[
<DataListLayout key={1}>
{(item: DataListItemType<typeof mockData>) => (
<div>
<div>{item.label}</div>
</div>
)}
</DataListLayout>,
]}
mediaMatches={{ xs: true, sm: true, md: true, lg: true, xl: true }}
data={mockData}
/>,
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { DataListLayoutInternal } from "./DataListLayoutInternal";
import { DataListItemInternal } from "./DataListItemInternal";
import { Breakpoints } from "../../DataList.const";
import styles from "../../DataList.css";
import { DataListLayoutProps, DataListObject } from "../../DataList.types";
Expand Down Expand Up @@ -28,7 +29,9 @@ export function DataListItems<T extends DataListObject>({
{elementData.map((child, i) => {
return (
<div className={styles.listItem} key={`${data[i].id}`}>
{layout.props.children(child)}
<DataListItemInternal item={data[i]}>
{layout.props.children(child)}
</DataListItemInternal>
</div>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const defaultValues = {
data: [],
headers: {},
children: [],
selected: [],
};

export const DataListContext =
Expand Down
1 change: 1 addition & 0 deletions packages/hooks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./useAssert";
export * from "./useCollectionQuery";
export * from "./useFocusTrap";
export * from "./useFormState";
export * from "./useInView";
export * from "./useIsMounted";
export * from "./useLiveAnnounce";
export * from "./useOnKeyDown";
Expand Down

0 comments on commit 3b503b6

Please sign in to comment.