Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use global state #1

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,77 @@
# Rapid Checkbox List

## Getting started
This site was created using Create-React-App so you can start it with:

```shell
npm i
npm start
```

You can also view it [deployed on Netlify](https://cerulean-mooncake-cf81d5.netlify.app/).
You can also view it [deployed on Netlify](https://cerulean-mooncake-cf81d5.netlify.app/).

## Project walkthrough
### Understanding of the task
- Create an encapsulated widget such as might be used on a config dashboard where user choices can be made and displayed
- The widget should fetch a list via a passed in function which fetches and normalises data.
- The list should be displayed as checkbox items.
- The user's choices (selecting checkbox items) should be exposed so they can be displayed elsewhere (here at the top of the page as indexes).
- The list should work smoothly when rendering 1000 entries to the page.

### High-level overview of the solution
#### Wireframe
```
+-------------------------------------------------------------------------+
| +-----------------------------+ |
| | Checked item indexes: 1 | |
| +-----------------------------+ |
| |
| +-----------------------------+ |
| | | |
| | Checkbox List | |
| | | |
| +-----------------------------+ |
| | | |
| | Info | |
| | | |
| | +-+ Some item 1 text | |
| | | | Other item 1 text | |
| | +-+ More item 1 text | |
| | | |
| | +-+ Some item 2 text | |
| | |x| Other item 2 text | |
| | +-+ | |
| | | |
| | +-+ Some item 3 text | |
+-------------------------------------------------------------------------+
```

#### Component spec
A single component with two callback props:
- The first fetches and normalises the list so it can be consumed by the component.
- The second is a form change event handler for exposing the user's interactions with the list.

The component is responsible for calling the list-getting function and setting its initial state, managing its own state internally (including the checked status of the checkboxes), and displaying the UI.

N.B. The checkboxes are uncontrolled which means that their state is not managed in Javascript.

#### Step-by-step description of functionality
0. The checkbox list widget component is rendered on the page in a "loading" state, displaying a simple "loading..." messsage.
0. The component calls the async data-fetching-and-normalising function (passed in to the component as a prop). On failure the component would be set to an "error" state, displaying a simple "Something went wrong" messsage. On success the list is set in the component's "local state" and the "loading" state is removed.
0. When the state is neither "error" nor "loading" the content of the local list state is displayed (a checkbox with a label which could be multiple items).
0. When the user changes the status of a checkbox, by clicking on it, it triggers the form change handler prop passed in to the component (with the form change event as an argument).

#### Pros and Cons
+ a11y is out-of-the-box
+ easy to test (simple i/o with encapsulated logic)
+ performance (load and responsivness) is good dispite large number of items.
+ answers understanding of brief minimally
+ low complexity

- too many elements rendered to the page
- somewhat inflexible as stands as answers quite tighty to the brief
- somewhat limiting as stands in terms of changing scope
- ux is likely subpar (can't filter, can't sort, can't search, no pagination)

#### Suggestions for improvements
- Add pagination to improve performance and useability
- Switch to a 'more powerful' state management solution and data-flow architecture => see reducer + context api [pull request](https://github.com/AlexJeffcott/rapid-checkbox-list/pull/1) and [deployed pr preview](https://deploy-preview-1--cerulean-mooncake-cf81d5.netlify.app/)
5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { ThemeProvider } from "@emotion/react";

import theme from "./theme";
import {DashboardPage} from "./pages";
import { ListProvider } from "./components/ListState";

export default function App() {
return (
<ThemeProvider theme={theme}>
<DashboardPage></DashboardPage>
<ListProvider>
<DashboardPage></DashboardPage>
</ListProvider>
</ThemeProvider>
);
}
13 changes: 8 additions & 5 deletions src/components/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, memo } from "react";
import styled from "@emotion/styled";
import type { CheckboxProps } from "../types";

Expand Down Expand Up @@ -65,24 +65,27 @@ const StyledCheckbox = styled.div(({ theme }) => ({
}
}));

export const Checkbox: FC<CheckboxProps> = ({ item, name }) => (
export const Checkbox: FC<CheckboxProps> = memo(({ item, handleCheckboxChange }) => (
<Label>
<Input
name={item.id}
checked={item.status}
aria-checked={item.status}
type="checkbox"
role="checkbox"
name={name}
onChange={handleCheckboxChange}
/>
<StyledCheckbox>
<svg viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12" />
</svg>
</StyledCheckbox>
<InfoContainer>
{item.map((Info, i) =>
{item.infos.map((Info, i) =>
!!Info ? (
<div key={i}>{typeof Info !== "string" ? <Info /> : Info}</div>
) : null
)}
</InfoContainer>
</Label>
)
), (p, n) => p.item.status === n.item.status)
86 changes: 44 additions & 42 deletions src/components/List.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useState, useEffect, FC, memo } from "react";
import { useState, useEffect, FC, memo, ChangeEvent } from "react";
import { Checkbox, Card, CardTitle, CardBody } from "./";
import type { Item, ListProps } from "../types";
import styled from "@emotion/styled";
import {useListReducer} from './ListState'

const ListHeader = styled.div(({ theme }) => ({
fontFamily: theme.other.fontFamily.lato,
Expand All @@ -14,46 +15,47 @@ const ListHeader = styled.div(({ theme }) => ({
left: "38px"
}));

export const List: FC<ListProps> = memo(
({ getList, handleFormChange }) => {
const [apiState, setApiState] = useState<"loading" | "success" | "failure">(
"loading"
);
const [items, setItems] = useState<Item[]>([]);
export const List: FC<ListProps> = ({ getList }) => {
const [apiState, setApiState] = useState<"loading" | "success" | "failure">(
"loading"
);
const [{ items }, { addItems, updateItemStatus }] = useListReducer();

useEffect(() => {
getList()
.then((data) => {
setTimeout(() => {
setItems(data);
setApiState("success");
}, 500);
})
.catch((error: Error) => {
setApiState("failure");
console.error(error);
});
}, [getList]);
useEffect(() => {
getList()
.then((data) => {
setTimeout(() => {
addItems(data);
setApiState("success");
}, 500);
})
.catch((error: Error) => {
setApiState("failure");
console.error(error);
});
}, [getList]);

return (
<Card>
<CardTitle>Super Special Checkbox list</CardTitle>
<CardBody>
{apiState === "loading" ? <div>loading...</div> : <></>}
{apiState === "failure" ? <div>Something went wrong!</div> : <></>}
{apiState === "success" ? (
<form onChange={handleFormChange}>
<ListHeader>Info</ListHeader>
{items.map((item, i) => (
<Checkbox key={i} name={i.toString()} item={item}></Checkbox>
))}
</form>
) : (
<></>
)}
</CardBody>
</Card>
);
},
(p, n) => true
);
const handleCheckboxChange = (event: ChangeEvent<HTMLInputElement>) => {
updateItemStatus(event.currentTarget.name as `${number}`, event.currentTarget.checked)
}

return (
<Card>
<CardTitle>Super Special Checkbox list</CardTitle>
<CardBody>
{apiState === "loading" ? <div>loading...</div> : <></>}
{apiState === "failure" ? <div>Something went wrong!</div> : <></>}
{apiState === "success" ? (
<>
<ListHeader>Info</ListHeader>
{items.map((item, i) => (
<Checkbox key={i} item={item} handleCheckboxChange={handleCheckboxChange}></Checkbox>
))}
</>
) : (
<></>
)}
</CardBody>
</Card>
);
};
75 changes: 75 additions & 0 deletions src/components/ListState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import React, {
createContext,
FC,
useContext,
useReducer
} from 'react'
import type {
State,
ContextType,
ActionHandlers,
Actions,
Item,
Items,
ProviderType
} from '../types'

const initialState: State = {
items: []
}

const stateInitializer = (
_initialState: State
): State => {
return {
..._initialState
}
}

const Context = createContext<ContextType>([
stateInitializer(initialState),
{} as ActionHandlers
])

const useListReducer = (): ContextType => useContext(Context)

const reducer = (state: State, action: Actions) => {
switch (action.type) {
case 'addItems': {
return {
...state,
items: action.items
}
}
case 'updateItemStatus': {
return {
...state,
items: state.items.reduce((items: Items, item: Item): Items => {
if (item.id === action.itemId) return [...items, {...item, status: action.newStatus}]
return [...items, item]
}, [])
}
}
}
}

const ListProvider: FC<ProviderType> = ({ children }) => {
const [state, dispatch]: [
State,
React.Dispatch<Actions>
] = useReducer(reducer, initialState, stateInitializer)

const actionHandlers: ActionHandlers = {
addItems: (items) => dispatch({ type: 'addItems', items }),
updateItemStatus: (itemId, newStatus) => dispatch({ type: 'updateItemStatus', itemId, newStatus })
}

return (
<Context.Provider value={[state, actionHandlers]}>
{children}
</Context.Provider>
)
}

export { useListReducer, ListProvider }

17 changes: 6 additions & 11 deletions src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,28 @@ import { Layout, List } from "../components";
import { H2 } from "../components";
import { fetchItemsA, fetchItemsB } from "../api";
import type { Item, MockA, MockB } from "../types";
import {useListReducer} from '../components/ListState'

const getNormalisedMockListA = async () =>
fetchItemsA().then((data: MockA[]): Item[] =>
data.map((d) => ([d.title]))
data.map((d, i) => ({id: `${i}`, status: false, infos: [d.title]}))
);

const getNormalisedMockListB = async () =>
fetchItemsB().then((data: MockB[]): Item[] =>
data.map((d) => ([d.name, d.description, d.link]))
data.map((d, i) => ({id: `${i}`, status: false, infos: [d.name, d.description, d.link]}))
);

export const DashboardPage = () => {
const [checkedItems, setCheckedItems] = useState<string[]>([]);

const handleFormChange = (event: ChangeEvent<HTMLFormElement>) => {
const inputElements = Array.from(event.target.form) as HTMLInputElement[]
const checkedInputs = inputElements.map((input: HTMLInputElement) => input.checked ? input.name : '').filter(Boolean)
setCheckedItems(checkedInputs)
}
const [{ items }] = useListReducer();

return (
<Layout>
<H2>
Selected indexes:{" "}
{checkedItems.join(", ") || "none"}
{items.map((item: Item) => item.status ? item.id : '').filter(Boolean).join(", ") || "none"}
</H2>
<List getList={getNormalisedMockListB} handleFormChange={handleFormChange}></List>
<List getList={getNormalisedMockListB}></List>
</Layout>
);
}
Loading