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.

[](https://coveralls.io/github/gges5110/React-Semantic-UI-Sortable-Table-Example?branch=master)
[](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)
+[](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`] = `
>