├── .env.example ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── craco.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── components │ ├── card │ │ └── index.tsx │ ├── input │ │ └── index.tsx │ └── loader │ │ └── index.tsx ├── constants │ ├── keyboardKeys.ts │ └── redux.ts ├── index.css ├── index.tsx ├── logo.svg ├── pages │ ├── login │ │ └── index.tsx │ └── main │ │ └── index.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts ├── setupTests.ts ├── store │ ├── auth │ │ ├── index.ts │ │ ├── selectors.ts │ │ └── types.ts │ ├── dashboard │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── selectors.ts │ │ └── types.ts │ └── index.ts └── types │ └── index.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_API_KEY = -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | .env 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "airtable" 4 | ] 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | - Use React 2 | - Use Typescript 3 | - The entire app's state should be in Redux 4 | - Every single call to Airtable API should only fetch the required data needed by the app at that time 5 | - No Airtable API call that filters using "Students" in the "Classes" table 6 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | // craco.config.js 2 | module.exports = { 3 | style: { 4 | postcss: { 5 | plugins: [ 6 | require('tailwindcss'), 7 | require('autoprefixer'), 8 | ], 9 | }, 10 | }, 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-extension", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^6.3.0", 7 | "@reduxjs/toolkit": "^1.6.1", 8 | "@testing-library/jest-dom": "^5.11.4", 9 | "@testing-library/react": "^11.1.0", 10 | "@testing-library/user-event": "^12.1.10", 11 | "@types/jest": "^26.0.15", 12 | "@types/node": "^12.0.0", 13 | "@types/react": "^17.0.0", 14 | "@types/react-dom": "^17.0.0", 15 | "airtable": "^0.11.1", 16 | "axios": "^0.21.4", 17 | "react": "^17.0.2", 18 | "react-dom": "^17.0.2", 19 | "react-hook-form": "^7.15.4", 20 | "react-redux": "^7.2.5", 21 | "react-scripts": "4.0.3", 22 | "typescript": "^4.1.2", 23 | "web-vitals": "^1.0.1" 24 | }, 25 | "scripts": { 26 | "start": "craco start", 27 | "build": "craco build", 28 | "test": "craco test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": [ 33 | "react-app", 34 | "react-app/jest" 35 | ] 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "devDependencies": { 50 | "autoprefixer": "^9.8.6", 51 | "postcss": "^7.0.36", 52 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.14" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-free89/Airtable-with-React-Redux-Typescript/2ac265b7c7a08e672176a7a71847d0bce9f97270/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-free89/Airtable-with-React-Redux-Typescript/2ac265b7c7a08e672176a7a71847d0bce9f97270/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-free89/Airtable-with-React-Redux-Typescript/2ac265b7c7a08e672176a7a71847d0bce9f97270/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import './App.css'; 4 | import LoginPage from './pages/login'; 5 | import MainPage from './pages/main'; 6 | import { selectLoginStatus } from './store/auth/selectors'; 7 | 8 | function App() { 9 | const logInStatus = useSelector(selectLoginStatus); 10 | return ( 11 |
12 | {!logInStatus ? : } 13 |
14 | ); 15 | } 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/components/card/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ClassInfo } from '../../store/dashboard/types'; 3 | 4 | export type Props = { 5 | data: ClassInfo 6 | } 7 | 8 | const Card: React.FC = ({ data }) => { 9 | return ( 10 |
11 | Name 12 | {data.name} 13 | Students 14 | {data.students} 15 |
16 | ) 17 | }; 18 | 19 | export default Card; 20 | -------------------------------------------------------------------------------- /src/components/input/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | import { FieldError } from "react-hook-form"; 3 | import keyboardKeys from "../../constants/keyboardKeys"; 4 | 5 | type Props = { 6 | label?: string; 7 | name: string; 8 | type: string; 9 | className?: string; 10 | required?: boolean; 11 | placeholder?: string; 12 | register: any; 13 | error?: FieldError; 14 | }; 15 | 16 | const Input = forwardRef( 17 | ( 18 | { 19 | className = "", 20 | name, 21 | type, 22 | required = false, 23 | placeholder = "", 24 | label, 25 | register = {}, 26 | error, 27 | }: Props, 28 | ref 29 | ) => { 30 | const keyDownHandler = (e: React.KeyboardEvent) => { 31 | const value = e.currentTarget.value; 32 | if (!value.trim()) { 33 | if (e.code === keyboardKeys.space) { 34 | e.preventDefault(); 35 | } 36 | } 37 | }; 38 | 39 | return ( 40 |
41 | {label ? ( 42 | 45 | ) : null} 46 |
47 | 60 |
61 | {error ? ( 62 |
63 |

{error.message}

64 |
65 | ) : null} 66 |
67 | ); 68 | } 69 | ); 70 | 71 | export default Input; 72 | -------------------------------------------------------------------------------- /src/components/loader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Loader: React.FC = () => { 4 | return ( 5 |
6 |
Loading...
7 |
8 | ) 9 | }; 10 | 11 | export default Loader; 12 | -------------------------------------------------------------------------------- /src/constants/keyboardKeys.ts: -------------------------------------------------------------------------------- 1 | const keyboardKeys = { 2 | space: "Space", 3 | }; 4 | 5 | export default keyboardKeys; 6 | -------------------------------------------------------------------------------- /src/constants/redux.ts: -------------------------------------------------------------------------------- 1 | export const STATUSES = { 2 | INITIAL: 0, 3 | PENDING: 1, 4 | FULFILLED: 2, 5 | REJECTED: 3, 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* ./src/index.css */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | body { 7 | margin: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 17 | monospace; 18 | } 19 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import { Provider } from 'react-redux' 7 | import store from './store'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | 18 | // If you want to start measuring performance in your app, pass a function 19 | // to log results (for example: reportWebVitals(console.log)) 20 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 21 | reportWebVitals(); 22 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useForm } from "react-hook-form"; 3 | import { useDispatch } from "react-redux"; 4 | import Input from '../../components/input'; 5 | import { setLogin } from '../../store/auth'; 6 | import { getData } from '../../store/dashboard/actions'; 7 | import { ILogin } from '../../types'; 8 | 9 | const LoginPage: React.FC = () => { 10 | const dispatch = useDispatch(); 11 | const { 12 | register, 13 | handleSubmit, 14 | formState: { errors }, 15 | } = useForm(); 16 | 17 | const onSubmit = async (data: ILogin) => { 18 | dispatch(setLogin(true)); 19 | dispatch(getData(data.name)); 20 | }; 21 | 22 | return ( 23 |
24 |
25 | Students Name: 26 | { 31 | if (!value.trim()) { 32 | return "Please enter student's name"; 33 | } 34 | }, 35 | })} 36 | error={errors.name} 37 | /> 38 |
39 |
40 | 43 |
44 |
45 | ) 46 | } 47 | 48 | export default LoginPage 49 | -------------------------------------------------------------------------------- /src/pages/main/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import Card from '../../components/card'; 4 | import { STATUSES } from '../../constants/redux'; 5 | import { setLogin } from '../../store/auth'; 6 | import { selectDashboardData, selectDashboardStatus } from '../../store/dashboard/selectors'; 7 | import { ClassInfo } from '../../store/dashboard/types'; 8 | 9 | const MainPage: React.FC = () => { 10 | const dispatch = useDispatch(); 11 | const isPending = useSelector(selectDashboardStatus) !== STATUSES.FULFILLED; 12 | const classesData = useSelector(selectDashboardData); 13 | const logOut = () => { 14 | dispatch(setLogin(false)); 15 | }; 16 | 17 | return ( 18 |
19 | { 20 | isPending ? ( 21 |
Loading...
22 | ) : ( 23 | <> 24 |
25 | 28 |
29 |
30 | { 31 | classesData.map((classData: ClassInfo, index: number) => ()) 32 | } 33 |
34 | 35 | ) 36 | } 37 |
38 | ) 39 | }; 40 | 41 | export default MainPage; 42 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/store/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { STATUSES } from "../../constants/redux"; 3 | import { AuthState } from "./types"; 4 | 5 | const initialState: AuthState = { 6 | status: STATUSES.INITIAL, 7 | loggedIn: false, 8 | }; 9 | 10 | export const userSlice = createSlice({ 11 | name: "auth", 12 | initialState: initialState, 13 | reducers: { 14 | setLogin: (state, action: PayloadAction) => { 15 | state.loggedIn = action.payload; 16 | } 17 | }, 18 | }); 19 | 20 | export const { setLogin } = userSlice.actions; 21 | 22 | export default userSlice.reducer; -------------------------------------------------------------------------------- /src/store/auth/selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from ".."; 2 | 3 | export const selectLoginStatus = (state: RootState) => state.auth.loggedIn; -------------------------------------------------------------------------------- /src/store/auth/types.ts: -------------------------------------------------------------------------------- 1 | export type AuthState = { 2 | status: number; 3 | loggedIn: boolean; 4 | } -------------------------------------------------------------------------------- /src/store/dashboard/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import Airtable from "airtable"; 3 | import { Student, ClassInfo } from "./types"; 4 | 5 | const base = new Airtable({apiKey: process.env.REACT_APP_API_KEY}).base('app8ZbcPx7dkpOnP0'); 6 | 7 | const getData = createAsyncThunk( 8 | "getData", 9 | async (name: string, thinkAPI) => { 10 | let classes: string[] = []; 11 | let students: Student[] = []; 12 | const studentRecords = await base("Students").select().all(); 13 | studentRecords!.forEach((studentRecord) => { 14 | students.push({ 15 | id: studentRecord.id, 16 | name: studentRecord.get("Name")?.toString() ?? "", 17 | }); 18 | if (studentRecord.get("Name")?.toString() === name) { 19 | classes = studentRecord.get("Classes") as string[]; 20 | } 21 | }); 22 | const classRecords = await base("Classes").select().all(); 23 | const classInfos: ClassInfo[] = []; 24 | classRecords.forEach((classRecord) => { 25 | if (classes.includes(classRecord.id)) { 26 | const classInfo: ClassInfo = { 27 | name: classRecord.get("Name")?.toString() ?? "", 28 | students: "", 29 | }; 30 | const classStudents: string[] = classRecord.get("Students") as string[]; 31 | let studentNames = ""; 32 | classStudents.forEach((classStudent) => { 33 | const studentName = students.find((student) => student.id === classStudent)?.name; 34 | if (studentNames !== "") studentNames += ", "; 35 | studentNames += studentName; 36 | }); 37 | classInfo.students = studentNames; 38 | classInfos.push(classInfo); 39 | } 40 | }); 41 | return classInfos; 42 | } 43 | ); 44 | 45 | export { 46 | getData 47 | }; -------------------------------------------------------------------------------- /src/store/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | import { Action, createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { STATUSES } from "../../constants/redux"; 3 | import { getData } from "./actions"; 4 | import { ClassInfo, DashboardState } from "./types"; 5 | 6 | const initialState: DashboardState = { 7 | status: STATUSES.INITIAL, 8 | classInfos: [], 9 | }; 10 | 11 | const isDashboardPendingAction = (action: Action) => 12 | action.type.startsWith("getData") && action.type.endsWith("pending"); 13 | const isDashboardRejectAction = (action: Action) => 14 | action.type.startsWith(`getData`) && action.type.endsWith("rejected"); 15 | 16 | export const dashboardSlice = createSlice({ 17 | name: "dashboard", 18 | initialState: initialState, 19 | reducers: {}, 20 | extraReducers: (builder) => { 21 | builder 22 | .addCase(getData.fulfilled.type, (state: DashboardState, action: PayloadAction>) => { 23 | state.status = STATUSES.FULFILLED; 24 | state.classInfos = action.payload; 25 | }) 26 | .addMatcher(isDashboardPendingAction, (state: DashboardState) => { 27 | state.status = STATUSES.PENDING; 28 | }) 29 | .addMatcher(isDashboardRejectAction, (state: DashboardState) => { 30 | state.status = STATUSES.REJECTED; 31 | }) 32 | } 33 | }); 34 | 35 | export default dashboardSlice.reducer; -------------------------------------------------------------------------------- /src/store/dashboard/selectors.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from ".."; 2 | 3 | export const selectDashboardStatus = (state: RootState) => state.dashboard.status; 4 | 5 | export const selectDashboardData = (state: RootState) => state.dashboard.classInfos; -------------------------------------------------------------------------------- /src/store/dashboard/types.ts: -------------------------------------------------------------------------------- 1 | export type Student = { 2 | id: string; 3 | name: string; 4 | } 5 | 6 | export type ClassInfo = { 7 | name: string; 8 | students: string; 9 | } 10 | 11 | export type DashboardState = { 12 | status: number; 13 | classInfos: ClassInfo[]; 14 | } -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import authReducer from "./auth"; 3 | import dashboardReducer from "./dashboard"; 4 | const store = configureStore({ 5 | reducer: { 6 | auth: authReducer, 7 | dashboard: dashboardReducer, 8 | } 9 | }); 10 | 11 | // Infer the `RootState` and `AppDispatch` types from the store itself 12 | export type RootState = ReturnType; 13 | 14 | export default store; -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface ILogin { 2 | name: string; 3 | }; -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------