diff --git a/README.md b/README.md index 9a4abe1..743b406 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ An example for React Semantic UI sortable table. ![Build and Deploy](https://github.com/gges5110/React-Semantic-UI-Sortable-Table-Example/workflows/Test%20and%20Deploy/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/gges5110/React-Semantic-UI-Sortable-Table-Example/badge.svg?branch=master&service=github)](https://coveralls.io/github/gges5110/React-Semantic-UI-Sortable-Table-Example?branch=master) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/a5f2bc2a9a8944549c95a17de5d863e9)](https://www.codacy.com/app/gges5110/React-Semantic-UI-Sortable-Table-Example?utm_source=github.com&utm_medium=referral&utm_content=gges5110/React-Semantic-UI-Sortable-Table-Example&utm_campaign=Badge_Grade) +[![CodeFactor](https://www.codefactor.io/repository/github/gges5110/react-semantic-ui-sortable-table-example/badge)](https://www.codefactor.io/repository/github/gges5110/react-semantic-ui-sortable-table-example) ## Prerequisite Node.js runtime environment of 10.16.0. diff --git a/src/VehicleFilter.spec.tsx b/src/VehicleFilter.spec.tsx index 83ce9e4..2cfd48e 100644 --- a/src/VehicleFilter.spec.tsx +++ b/src/VehicleFilter.spec.tsx @@ -3,6 +3,8 @@ import { VehicleFilter } from "./VehicleFilter"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +jest.mock("lodash.debounce", () => jest.fn(fn => fn)); + describe("VehicleFilter", () => { it("renders", () => { const filter = ""; @@ -57,4 +59,23 @@ describe("VehicleFilter", () => { expect(onSubmitFilter).toHaveBeenCalled(); expect(onSubmitFilter.mock.calls).toHaveLength(5); }); + + it("submits invalid", () => { + const filter = ""; + const totalCount = 10; + const onSubmitFilter = jest.fn(); + + render( + + ); + + userEvent.type(screen.getByRole("textbox"), "#"); + + expect(onSubmitFilter).not.toHaveBeenCalled(); + waitFor(() => expect(screen.getByText("Invalid")).toBeInTheDocument()); + }); }); diff --git a/src/VehicleFilter.tsx b/src/VehicleFilter.tsx index 82658d8..542cfd6 100644 --- a/src/VehicleFilter.tsx +++ b/src/VehicleFilter.tsx @@ -1,82 +1,78 @@ -import React from "react"; -import PropTypes from "prop-types"; +import React, { useState } from "react"; import { Form, Popup } from "semantic-ui-react"; +import { InputOnChangeData } from "semantic-ui-react/dist/commonjs/elements/Input/Input"; +import debounce from "lodash.debounce"; const regex = new RegExp("^[a-zA-Z0-9 ]+$"); interface VehicleFilterProps { + filter: string; totalCount: number; loading?: boolean; onSubmitFilter(value: string): void; } interface VehicleFilterState { - [index: string]: any; filter: string; filterValid: boolean; } -export class VehicleFilter extends React.Component< - VehicleFilterProps, - VehicleFilterState -> { - static propTypes = { - onSubmitFilter: PropTypes.func.isRequired, - filter: PropTypes.string.isRequired, - totalCount: PropTypes.number.isRequired - }; +export const VehicleFilter: React.FC = ({ + totalCount, + loading, + onSubmitFilter +}) => { + const [state, setState] = useState({ + filter: "", + filterValid: true + }); - constructor(props: VehicleFilterProps) { - super(props); - this.state = { - filter: "", - filterValid: true - }; - } - - handleOnChange = (event: any, { name, value }: any) => { + const f = debounce((value: string) => { if (value !== "" && !regex.test(value)) { - this.setState({ [name]: value, filterValid: false }); + setState({ filter: value, filterValid: false }); } else { - this.setState({ [name]: value, filterValid: true }); - this.props.onSubmitFilter(value); + setState({ filter: value, filterValid: true }); + onSubmitFilter(value); } - }; + }, 500); - render() { - const { filter } = this.state; - let popupMessage = ""; - if (!this.state.filterValid) { - popupMessage = "Invalid character."; - } else if (this.props.totalCount === 0) { - popupMessage = "No results found."; - } + const handleOnChange = ( + event: React.ChangeEvent, + { value }: InputOnChangeData + ) => { + f(value); + }; - return ( -
- - - - } - content={popupMessage} - on="click" - open={!this.state.filterValid || this.props.totalCount === 0} - position="right center" - /> - - -
- ); + let popupMessage = ""; + if (!state.filterValid) { + popupMessage = "Invalid character."; + } else if (totalCount === 0) { + popupMessage = "No results found."; } -} + + return ( +
+ + + + } + content={popupMessage} + on={"click"} + open={!state.filterValid || totalCount === 0} + position={"right center"} + /> + + +
+ ); +}; diff --git a/src/VehicleList.spec.tsx b/src/VehicleList.spec.tsx index 114ffa5..ff09465 100644 --- a/src/VehicleList.spec.tsx +++ b/src/VehicleList.spec.tsx @@ -5,6 +5,8 @@ import { VehicleList } from "./VehicleList"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +jest.mock("lodash.debounce", () => jest.fn(fn => fn)); + describe("VehicleList", () => { beforeEach(() => { fetchMock.mock("*", [ @@ -46,7 +48,7 @@ describe("VehicleList", () => { render(); }); - it("handles sort", async () => { + it("handles changes", async () => { render(); await waitFor(() => expect(screen.getByText("Mazda")).toBeInTheDocument()); @@ -97,6 +99,24 @@ describe("VehicleList", () => { } ); userEvent.click(screen.getByRole("columnheader", { name: "Make" })); + + // change filter + await userEvent.type(screen.getByRole("textbox"), "Volvo"); + + // change limit + userEvent.click(screen.getByRole("listbox")); + + const options = await screen.findAllByRole("option"); + const option = options.find(ele => ele.textContent === "25"); + expect(option).toBeDefined(); + if (option) { + userEvent.click(option); // verify your onChange event + } + + // change page + expect(screen.getByRole("navigation")).toBeInTheDocument(); + expect(screen.getByRole("navigation").childElementCount).toEqual(5); + userEvent.click(screen.getByRole("navigation").children[3]); }); it("add favorite", async () => { diff --git a/src/VehicleList.tsx b/src/VehicleList.tsx index 1eef194..2b9f768 100644 --- a/src/VehicleList.tsx +++ b/src/VehicleList.tsx @@ -1,19 +1,16 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Divider, Segment } from "semantic-ui-react"; -import debounce from "lodash.debounce"; + import { VehicleTable } from "./VehicleTable"; import { VehicleFilter } from "./VehicleFilter"; -const queryParams = ["_limit", "_order", "_sort", "q", "_page"]; +interface Pagination { + limit: number; + page: number; +} interface VehicleListState { - _sort: string; - _order: "desc" | "asc" | null; - _limit: number; - _page: number; - q: string; totalCount: number; - loading: boolean; vehicles: Vehicle[]; } @@ -28,80 +25,95 @@ export interface Vehicle { transmission: string; } -interface Params { - [index: string]: any; - _sort: string; - _order: "desc" | "asc"; - _limit: number; - _page: number; - q: string; +interface SortField { + sortColumn: string; + sortOrder?: "descending" | "ascending"; } -export class VehicleList extends React.Component<{}, VehicleListState> { - constructor(props: {}) { - super(props); - this.state = { - vehicles: [], - _sort: "id", - _page: 1, - _order: null, - _limit: 10, - q: "", - totalCount: 0, - loading: false - } as VehicleListState; - this.onSubmitFilter = debounce(this.onSubmitFilter, 800); - } - - componentDidMount() { - this.loadData({}); - } - - static directionConverter(order: "asc" | "desc" | null) { - if (order === "asc") { - return "ascending"; - } else if (order === "desc") { - return "descending"; - } else { - return undefined; +export const VehicleList: React.FC = () => { + const [state, setState] = useState({ + vehicles: [], + totalCount: 0 + }); + + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ + page: 1, + limit: 10 + }); + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState({ sortColumn: "id" }); + + const loadData = () => { + setLoading(true); + + const query = constructQuery(); + + // Make a request without limit first to get the total number of data. + let totalCountQuery = ""; + if (filter !== "") { + totalCountQuery = `q=${encodeURIComponent(filter)}`; } - } - handleSort = (clickedColumn: string) => { - const { _sort, _order } = this.state; + Promise.all([ + fetch(`/api/v1/vehicles?${totalCountQuery}`), + fetch(`/api/v1/vehicles?${query}`) + ]) + .then(values => { + Promise.all([values[0].json(), values[1].json()]).then(data => { + setState({ totalCount: data[0].length, vehicles: data[1] }); + setLoading(false); + }); + }) + .catch(error => { + console.log(`Failed to load data: ${error.message}`); + }); + }; - let newOrder: "desc" | "asc" = _order === "asc" ? "desc" : "asc"; - if (_sort !== clickedColumn) { - newOrder = "asc"; + useEffect(() => { + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + filter, + sort.sortOrder, + sort.sortColumn, + pagination.page, + pagination.limit + ]); + + const handleSort = (clickedColumn: string) => { + const { sortColumn, sortOrder } = sort; + + let newOrder: "ascending" | "descending" = + sortOrder === "ascending" ? "descending" : "ascending"; + if (sortColumn !== clickedColumn) { + newOrder = "ascending"; } - this.loadData({ - _sort: clickedColumn, - _page: 1, - _order: newOrder - }); + setPagination({ ...pagination, page: 1 }); + setSort({ sortColumn: clickedColumn, sortOrder: newOrder }); }; - onChangeLimit = (event: any, data: any) => { - if (data.value !== this.state._limit) { - this.loadData({ _limit: data.value, _page: 1 }); + const onChangeLimit = (limit: number) => { + if (limit !== pagination.limit) { + setPagination({ limit, page: 1 }); } }; - onSubmitFilter = (filter: string) => { - if (filter !== this.state.q) { - this.loadData({ q: filter, _page: 1 }); + const onSubmitFilter = (value: string) => { + if (value !== filter) { + setFilter(value); + setPagination({ ...pagination, page: 1 }); } }; - onChangePage = (event: any, data: any) => { - const { activePage } = data; - if (activePage !== this.state._page) { - this.loadData({ _page: activePage }); + const onChangePage = (page: number) => { + if (page !== pagination.page) { + setPagination({ ...pagination, page: page as number }); } }; - addFavorite = (vehicle: Vehicle) => { + const addFavorite = (vehicle: Vehicle) => { vehicle.favorite = !vehicle.favorite; fetch(`/api/v1/vehicles/${vehicle.id}`, { method: "PUT", @@ -110,11 +122,12 @@ export class VehicleList extends React.Component<{}, VehicleListState> { }).then(response => { if (response.ok) { response.json().then(data => { - const index = this.state.vehicles.findIndex( + const index = state.vehicles.findIndex( vehicle => vehicle.id === data.id ); - this.setState({ - vehicles: Object.assign([...this.state.vehicles], { + setState({ + ...state, + vehicles: Object.assign([...state.vehicles], { [index]: data }) }); @@ -127,81 +140,41 @@ export class VehicleList extends React.Component<{}, VehicleListState> { }); }; - loadData = (params: Partial) => { - const newState = Object.assign({}, this.state, params, { loading: false }); - this.setState({ loading: true }); - - queryParams.forEach(function(element) { - if (!(element in params)) { - params[element] = newState[element]; - } - }); - - const esc = encodeURIComponent; - const query = Object.keys(params) - .map(k => esc(k) + "=" + esc(params[k])) - .join("&"); - - // Make a request without limit first to get the total number of data. - let totalCountQuery = ""; - if (params.q !== "") { - totalCountQuery = `q=${params.q}`; + const constructQuery = (): string => { + const params = []; + params.push(`_limit=${pagination.limit}`); + params.push(`_page=${pagination.page}`); + params.push(`q=${encodeURIComponent(filter)}`); + params.push(`_sort=${sort.sortColumn}`); + if (sort.sortOrder) { + params.push(`_order=${sort.sortOrder === "ascending" ? "asc" : "desc"}`); } - fetch(`/api/v1/vehicles?${totalCountQuery}`).then(response => { - if (response.ok) { - response.json().then(data => { - this.setState({ totalCount: data.length }); - }); - } else { - response.json().then(error => { - console.log(`Failed to load data: ${error.message}`); - }); - } - this.setState(newState, () => { - fetch("/api/v1/vehicles?" + query).then(response => { - if (response.ok) { - response.json().then(data => { - this.setState({ vehicles: data }); - }); - } else { - response.json().then(error => { - console.log(`Failed to load data: ${error.message}`); - }); - } - const newState = Object.assign({}, this.state, params, { - loading: false - }); - this.setState(newState); - }); - }); - }); + return params.join("&"); }; - render() { - return ( - - - - - - ); - } -} + return ( + + + + + + ); +}; diff --git a/src/VehiclePageSizeSelect.spec.tsx b/src/VehiclePageSizeSelect.spec.tsx index a8b608d..278b6f9 100644 --- a/src/VehiclePageSizeSelect.spec.tsx +++ b/src/VehiclePageSizeSelect.spec.tsx @@ -4,11 +4,24 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; describe("VehiclePageSizeSelect", () => { - it("should render correctly", () => { - render(); + it("should render correctly", async () => { + const onChangeLimitMock = jest.fn(); + render( + + ); userEvent.click(screen.getByRole("listbox")); expect(screen.getByRole("alert")).toBeInTheDocument(); expect(screen.getByRole("alert").innerHTML).toEqual("10"); + + const options = await screen.findAllByRole("option"); + const option = options.find(ele => ele.textContent === "25"); + expect(option).toBeDefined(); + if (option) { + userEvent.click(option); // verify your onChange event + } + + expect(onChangeLimitMock).toHaveBeenCalledTimes(1); + expect(onChangeLimitMock.mock.calls[0]).toEqual(["25"]); }); }); diff --git a/src/VehiclePageSizeSelect.tsx b/src/VehiclePageSizeSelect.tsx index 78bc4e6..9f2300d 100644 --- a/src/VehiclePageSizeSelect.tsx +++ b/src/VehiclePageSizeSelect.tsx @@ -1,8 +1,9 @@ import React from "react"; import { Dropdown } from "semantic-ui-react"; import { DropdownProps } from "semantic-ui-react/dist/commonjs/modules/Dropdown/Dropdown"; +import { DropdownItemProps } from "semantic-ui-react/dist/commonjs/modules/Dropdown/DropdownItem"; -const limitOptions = [ +const limitOptions: DropdownItemProps[] = [ { key: "0", value: "10", text: "10" }, { key: "1", value: "25", text: "25" }, { key: "2", value: "50", text: "50" }, @@ -11,23 +12,28 @@ const limitOptions = [ interface VehiclePageSizeSelectProps { limit: number; - onChangeLimit( - event: React.SyntheticEvent, - data: DropdownProps - ): void; + onChangeLimit(limit: number): void; } export const VehiclePageSizeSelect: React.FC = ({ limit, onChangeLimit -}) => ( - - Records per page:{" "} - - -); +}) => { + const handleChangeLimit = ( + event: React.SyntheticEvent, + { value }: DropdownProps + ) => { + onChangeLimit(value as number); + }; + return ( + + Records per page:{" "} + + + ); +}; diff --git a/src/VehicleTable.spec.tsx b/src/VehicleTable.spec.tsx index f9e58a9..ce30126 100644 --- a/src/VehicleTable.spec.tsx +++ b/src/VehicleTable.spec.tsx @@ -1,6 +1,7 @@ import React from "react"; import { VehicleTable } from "./VehicleTable"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; describe("VehicleTable", () => { it("should render correctly", () => { @@ -67,4 +68,28 @@ describe("VehicleTable", () => { /> ); }); + + it("should change page", () => { + const onChangePageMock = jest.fn(); + + render( + + ); + + expect(screen.getByRole("navigation")).toBeInTheDocument(); + expect(screen.getByRole("navigation").childElementCount).toEqual(11); + userEvent.click(screen.getByRole("navigation").children[1]); + + expect(onChangePageMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/VehicleTable.tsx b/src/VehicleTable.tsx index ff8731c..d62e67a 100644 --- a/src/VehicleTable.tsx +++ b/src/VehicleTable.tsx @@ -5,19 +5,20 @@ import { VehiclePageSizeSelect } from "./VehiclePageSizeSelect"; import { VehicleTableHeader } from "./VehicleTableHeader"; import { VehicleRow } from "./VehicleRow"; import { Vehicle } from "./VehicleList"; +import { PaginationProps } from "semantic-ui-react/dist/commonjs/addons/Pagination/Pagination"; interface VehicleTableProps { vehicles: Vehicle[]; totalCount: number; totalPages: number; currentPage: number; - onChangePage(event: any, data: any): void; - addFavorite(vehicle: Vehicle): void; column?: string; + limit: number; direction?: "ascending" | "descending"; + onChangePage(page: number): void; + addFavorite(vehicle: Vehicle): void; handleSort(clickedColumn: string): void; - onChangeLimit(event: any, data: any): void; - limit: number; + onChangeLimit(limit: number): void; } export const VehicleTable: React.FC = ({ @@ -36,6 +37,13 @@ export const VehicleTable: React.FC = ({ const vehicleRows = vehicles.map((vehicle, index) => ( )); + const handleChangePage = ( + event: React.MouseEvent, + { activePage }: PaginationProps + ) => { + onChangePage(activePage as number); + }; + return ( @@ -55,7 +63,7 @@ export const VehicleTable: React.FC = ({ diff --git a/src/__snapshots__/VehicleFilter.spec.tsx.snap b/src/__snapshots__/VehicleFilter.spec.tsx.snap index 896a5f3..b611646 100644 --- a/src/__snapshots__/VehicleFilter.spec.tsx.snap +++ b/src/__snapshots__/VehicleFilter.spec.tsx.snap @@ -22,9 +22,8 @@ exports[`VehicleFilter renders 1`] = ` >