├── .gitignore ├── src ├── global.d.ts ├── modules │ ├── options │ │ ├── components │ │ │ ├── ItemDisplay.module.scss │ │ │ ├── Options.module.scss │ │ │ ├── ItemDisplay.tsx │ │ │ └── Options.tsx │ │ ├── index.tsx │ │ └── index.html │ └── newTab │ │ ├── index.tsx │ │ ├── components │ │ ├── Tabs.module.css │ │ ├── Equivalence.tsx │ │ ├── Controls │ │ │ ├── Controls.module.scss │ │ │ └── index.tsx │ │ ├── Table │ │ │ ├── index.tsx │ │ │ ├── Cell.tsx │ │ │ ├── Table.module.scss │ │ │ ├── Body.test.tsx │ │ │ └── Body.tsx │ │ ├── Tabs.tsx │ │ ├── Tabs.test.tsx │ │ ├── useDataSources.ts │ │ └── useDataSources.test.ts │ │ └── index.html ├── assets │ ├── sources.ts │ └── de-en │ │ ├── sources.ts │ │ └── tables.json ├── getHashFromItem.ts ├── components │ ├── IconButton │ │ ├── IconButton.module.scss │ │ └── index.tsx │ ├── useSettings.ts │ └── useSettings.test.ts ├── types.ts ├── global.scss └── getDeterministicPallette.ts ├── docs └── loadingExample.png ├── tsconfig.node.json ├── jest.config.js ├── public └── manifest.json ├── tsconfig.json ├── vite.config.ts ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .parcel-cache -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css"; 2 | declare module "*.module.scss"; 3 | -------------------------------------------------------------------------------- /src/modules/options/components/ItemDisplay.module.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | margin-left: 0.5em; 3 | } 4 | -------------------------------------------------------------------------------- /docs/loadingExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drFabio/loading-screen-extension/main/docs/loadingExample.png -------------------------------------------------------------------------------- /src/assets/sources.ts: -------------------------------------------------------------------------------- 1 | import { deEnGrammar, deEnVocubalary } from "./de-en/sources"; 2 | 3 | export const sources = [deEnVocubalary, deEnGrammar]; 4 | -------------------------------------------------------------------------------- /src/modules/newTab/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { Tabs } from "./components/Tabs"; 3 | 4 | const app = document.getElementById("app"); 5 | ReactDOM.render(, app); 6 | -------------------------------------------------------------------------------- /src/modules/options/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { Options } from "./components/Options"; 3 | 4 | const app = document.getElementById("app"); 5 | ReactDOM.render(, app); 6 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Tabs.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background-color: black; 3 | color: white; 4 | justify-content: space-around; 5 | align-items: center; 6 | font-size: 2rem; 7 | position: relative; 8 | } 9 | -------------------------------------------------------------------------------- /src/getHashFromItem.ts: -------------------------------------------------------------------------------- 1 | import { v5 as uuidv5 } from "uuid"; 2 | 3 | export function getHashFromItem(item: any) { 4 | const ITEM_NS = "915b67d4-725c-4145-b254-36117f5f79a1"; 5 | return uuidv5(JSON.stringify(item), ITEM_NS); 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Equivalence.tsx: -------------------------------------------------------------------------------- 1 | export const Equivalence = ({ 2 | term, 3 | definition, 4 | }: { 5 | term: string; 6 | definition: string; 7 | }) => { 8 | return ( 9 |
10 |
{term}
11 |
{definition}
12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/newTab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tabs 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/modules/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/modules/options/components/Options.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | justify-content: flex-start; 3 | align-items: flex-start; 4 | display: flex; 5 | align-content: flex-start; 6 | h2 { 7 | color: var(--primary-color); 8 | } 9 | } 10 | .termsContainer { 11 | height: 10rem; 12 | overflow-y: scroll; 13 | scrollbar-color: dark; 14 | } 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": ["ts-jest"], 5 | }, 6 | 7 | testEnvironment: "jsdom", 8 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"], 10 | moduleNameMapper: { 11 | "\\.(css|less|scss|sass)$": "identity-obj-proxy", 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Controls/Controls.module.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | width: auto; 3 | height: auto; 4 | position: absolute; 5 | left: 0; 6 | bottom: 0; 7 | display: block; 8 | opacity: 0; 9 | padding: 0.5em 2em; 10 | color: black; 11 | background-color: white; 12 | border-top-right-radius: 5px; 13 | font-size: 1.5rem; 14 | 15 | &:hover { 16 | opacity: 1; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Loading screen", 3 | "description": "Learn stuff while browsing!", 4 | "version": "1.0", 5 | "manifest_version": 3, 6 | "background": {}, 7 | "chrome_url_overrides": { 8 | "newtab": "src/modules/newTab/index.html" 9 | }, 10 | "options_page": "src/modules/options/index.html", 11 | "permissions": ["storage", "scripting", "tabs"], 12 | "action": { 13 | "default_icon": {} 14 | }, 15 | "icons": {} 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Table/index.tsx: -------------------------------------------------------------------------------- 1 | import { TableSource } from "../../../../types"; 2 | import { Body } from "./Body"; 3 | import * as classes from "./Table.module.scss"; 4 | 5 | export const Table = ({ header, rows, title }: TableSource) => { 6 | return ( 7 | 8 | {title && } 9 | 10 | 11 |
{title}
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Table/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export const Cell = ({ 4 | isRow, 5 | children, 6 | rowSpan, 7 | colSpan, 8 | }: { 9 | isRow?: boolean; 10 | colSpan?: number; 11 | rowSpan?: number; 12 | children: ReactNode; 13 | }) => { 14 | return isRow ? ( 15 | 16 | {children} 17 | 18 | ) : ( 19 | 20 | {children} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Table/Table.module.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | border-collapse: collapse; 3 | caption { 4 | font-size: 1.25em; 5 | } 6 | tr { 7 | td { 8 | border-top: 1px solid; 9 | padding: 0.25rem 0; 10 | } 11 | &:last-child td { 12 | border-bottom: 1px solid; 13 | } 14 | &:first-child th { 15 | padding-top: 1rem; 16 | } 17 | &:last-child th { 18 | padding-bottom: 1rem; 19 | } 20 | td, 21 | th { 22 | padding: 0 1rem; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.module.scss: -------------------------------------------------------------------------------- 1 | .iconButton { 2 | background: none; 3 | border: 0; 4 | color: inherit; 5 | font-size: inherit; 6 | padding: 0; 7 | &:hover { 8 | cursor: pointer; 9 | } 10 | &:disabled { 11 | opacity: 0.5; 12 | &:hover { 13 | cursor: not-allowed; 14 | } 15 | [data-tooltip] { 16 | opacity: 1; 17 | } 18 | } 19 | & + & { 20 | margin-left: 0.5em; 21 | } 22 | svg { 23 | color: inherit; 24 | width: 1em; 25 | height: 1em; 26 | } 27 | [data-tooltip] { 28 | font-size: 1rem; 29 | margin-right: 0.5em; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx" 17 | }, 18 | "include": ["src"], 19 | "references": [{ "path": "./tsconfig.node.json" }] 20 | } 21 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { fileURLToPath } from "url"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | // base: "https://drfabio.github.io/loading-screen-extension-gh-pages", 8 | plugins: [react()], 9 | build: { 10 | rollupOptions: { 11 | input: { 12 | newTab: fileURLToPath( 13 | new URL("./src/modules/newTab/index.html", import.meta.url) 14 | ), 15 | options: fileURLToPath( 16 | new URL("./src/modules/options/index.html", import.meta.url) 17 | ), 18 | }, 19 | }, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/assets/de-en/sources.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EquivalenceInputSource, 3 | InputSource, 4 | SourceTypes, 5 | TableInputSource, 6 | TableSource, 7 | } from "../../types"; 8 | import tables from "./tables.json"; 9 | import words from "./words.json"; 10 | 11 | const tableData: TableInputSource[] = (tables as TableSource[]).map( 12 | (value) => ({ 13 | type: SourceTypes.TABLE, 14 | value, 15 | }) 16 | ); 17 | const dictionaryData: EquivalenceInputSource[] = Object.keys(words).map( 18 | (key) => ({ 19 | type: SourceTypes.EQUIVALENCE, 20 | value: { [key]: words[key] }, 21 | }) 22 | ); 23 | 24 | export const deEnVocubalary: InputSource = { 25 | id: "de-en-vocabulary", 26 | title: "German to english - Vocubalary", 27 | data: dictionaryData, 28 | }; 29 | export const deEnGrammar: InputSource = { 30 | id: "de-en-grammar", 31 | title: "German to english - grammar", 32 | data: tableData, 33 | }; 34 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Table/Body.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import "@testing-library/jest-dom"; 3 | import { Body } from "./Body"; 4 | 5 | describe(`Body`, () => { 6 | test(`Renders header`, () => { 7 | render( 8 | 9 | 13 |
14 | ); 15 | 16 | screen.getByText("Simple"); 17 | const cell = screen.getByText("Complex"); 18 | expect(cell.rowSpan).toBe(3); 19 | expect(cell.colSpan).toBe(7); 20 | }); 21 | test(`Renders body`, () => { 22 | render( 23 | 24 | 27 |
28 | ); 29 | 30 | screen.getByText("Simple"); 31 | const cell = screen.getByText("Complex"); 32 | expect(cell.rowSpan).toBe(3); 33 | expect(cell.colSpan).toBe(7); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export enum SourceTypes { 2 | EQUIVALENCE, 3 | STATEMENT, 4 | TABLE, 5 | } 6 | 7 | export type Source = { 8 | value: SourceType; 9 | type: type; 10 | }; 11 | 12 | export type EquivalenceInputSource = Source< 13 | SourceTypes.EQUIVALENCE, 14 | Record 15 | >; 16 | 17 | export type StatementInputSource = Source; 18 | 19 | export type CellSource = 20 | | string 21 | | { rowSpan?: number; colSpan?: number; text?: String }; 22 | export type RowSource = CellSource[]; 23 | 24 | export type TableSource = { 25 | header: RowSource[]; 26 | rows: RowSource[]; 27 | title: string; 28 | }; 29 | export type TableInputSource = Source; 30 | 31 | export type DataSource = 32 | | EquivalenceInputSource 33 | | StatementInputSource 34 | | TableInputSource; 35 | export type InputSource = { 36 | id: string; 37 | title?: string; 38 | data: DataSource[]; 39 | }; 40 | 41 | export type SourceConfiguration = { 42 | deactivatedMap: Record; 43 | initialized: boolean; 44 | hideMap: Record>; 45 | weightMap: Record>; 46 | }; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loading-screen-ext-react", 3 | "version": "0.0.1", 4 | "description": "", 5 | "scripts": { 6 | "test": "jest", 7 | "prebuild": "rm -rf dist/*", 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "preview": "vite preview" 11 | }, 12 | "author": "", 13 | "license": "UNLICENSED", 14 | "dependencies": { 15 | "@iconscout/react-unicons": "^1.1.6", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "uuid": "^9.0.0" 19 | }, 20 | "devDependencies": { 21 | "@parcel/transformer-css": "^2.8.0", 22 | "@parcel/transformer-html": "^2.8.0", 23 | "@parcel/transformer-postcss": "^2.8.0", 24 | "@parcel/transformer-posthtml": "^2.8.0", 25 | "@parcel/transformer-sass": "^2.8.0", 26 | "@testing-library/jest-dom": "^5.16.5", 27 | "@testing-library/react": "^13.4.0", 28 | "@types/jest": "^29.2.3", 29 | "@types/react": "^18.0.25", 30 | "@types/react-dom": "^18.0.9", 31 | "@vitejs/plugin-react": "^2.2.0", 32 | "global": "^4.4.0", 33 | "identity-obj-proxy": "^3.0.0", 34 | "jest": "^29.3.1", 35 | "jest-environment-jsdom": "^29.3.1", 36 | "ts-jest": "^29.0.3", 37 | "typescript": "^4.9.3", 38 | "vite": "^3.2.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Table/Body.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import type { RowSource } from "../../../../types"; 3 | import { Cell } from "./Cell"; 4 | 5 | export const Body = ({ 6 | rows, 7 | isRow, 8 | }: { 9 | rows?: RowSource[]; 10 | isRow?: boolean; 11 | }) => { 12 | if (!rows) return null; 13 | const Wrapper = ({ children }: { children: ReactNode }) => { 14 | if (isRow) return {children}; 15 | return {children}; 16 | }; 17 | 18 | return ( 19 | 20 | {rows.map((cells, index) => ( 21 | 22 | {cells.map((cell, index) => { 23 | if (typeof cell === "string") { 24 | return ( 25 | 26 | {cell} 27 | 28 | ); 29 | } 30 | const { rowSpan, colSpan, text } = cell; 31 | return ( 32 | 38 | {text} 39 | 40 | ); 41 | })} 42 | 43 | ))} 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/assets/de-en/tables.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Personal Pronoun", 4 | "header": [["case", { "text": "pronouns", "colSpan": 9 }]], 5 | "rows": [ 6 | [ 7 | "nominative", 8 | "ich", 9 | "du", 10 | "er", 11 | "sie", 12 | "es", 13 | "wir", 14 | "ihr", 15 | "sie", 16 | "Sie" 17 | ], 18 | [ 19 | "accusative", 20 | "mich", 21 | "dich", 22 | "ihn", 23 | "sie", 24 | "es", 25 | "uns", 26 | "euch", 27 | "sie", 28 | "Sie" 29 | ], 30 | [ 31 | "dative", 32 | "mir", 33 | "dir", 34 | "ihm", 35 | "ihr", 36 | "ihm", 37 | "uns", 38 | "euch", 39 | "ihnen", 40 | "Ihnen" 41 | ] 42 | ] 43 | }, 44 | { 45 | "title": "Possessiv artikel", 46 | "header": [ 47 | [ 48 | "", 49 | { "text": "Maskulin", "rowSpan": 2 }, 50 | "feminin", 51 | { "text": "neutral", "rowSpan": 2 } 52 | ], 53 | ["", "Plural"] 54 | ], 55 | "rows": [ 56 | ["ich", "mein", "meine", "mein"], 57 | ["du", "dein", "deine", "dein"], 58 | ["er", "sein", "seine", "sein"], 59 | ["sie", "ihr", "ihre", "ihr"] 60 | ] 61 | } 62 | ] 63 | -------------------------------------------------------------------------------- /src/components/IconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import * as Unicons from "@iconscout/react-unicons"; 2 | import { ReactNode } from "react"; 3 | import * as classes from "./IconButton.module.scss"; 4 | 5 | export type IconButtonProps = { 6 | icon: 7 | | "hide" 8 | | "plus" 9 | | "minus" 10 | | "show" 11 | | "settings" 12 | | "folder" 13 | | "folderOpen"; 14 | tooltip?: string; 15 | hasTooltip?: boolean; 16 | } & React.DetailedHTMLProps< 17 | React.ButtonHTMLAttributes, 18 | HTMLButtonElement 19 | >; 20 | 21 | export const IconButton = ({ icon, tooltip, ...props }: IconButtonProps) => { 22 | let iconComponent: ReactNode; 23 | switch (icon) { 24 | case "hide": 25 | iconComponent = ; 26 | break; 27 | case "show": 28 | iconComponent = ; 29 | break; 30 | case "plus": 31 | iconComponent = ; 32 | break; 33 | case "minus": 34 | iconComponent = ; 35 | break; 36 | case "settings": 37 | iconComponent = ; 38 | break; 39 | case "folder": 40 | iconComponent = ; 41 | break; 42 | case "folderOpen": 43 | iconComponent = ; 44 | break; 45 | } 46 | return ( 47 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # loading screen 2 | 3 | A chrome extension that display new information on every tab! 4 | 5 | So far it only has the fixed german dataSet but the next goal is probably do some notion integration. 6 | 7 | Think of this as the loading screens that appears before a game starts, only it is information you want to learn while you open your 10th mdn tab for the day. 8 | 9 | ![loading example](./docs/loadingExample.png) 10 | 11 | ## Preview 12 | 13 | The extension is unpublished, but you can see it as a page here: 14 | 15 | [Main](https://drfabio.github.io/loading-screen-extension-gh-pages/src/modules/newTab/) - Refresh for new words 16 | [Options](https://drfabio.github.io/loading-screen-extension-gh-pages/src/modules/options/) - Configure options 17 | 18 | "" 19 | 20 | ## Development 21 | 22 | Run 23 | 24 | ```sh 25 | npm run dev 26 | ``` 27 | 28 | Then go to http://127.0.0.1:5173/src/modules/newTab/index.html to check a new tab or http://127.0.0.1:5173/src/modules/options/index.html to check options. 29 | 30 | ## Loading as unpublished extension 31 | 32 | Build 33 | 34 | ```sh 35 | npm run build 36 | ``` 37 | 38 | Then go to chrome extensions [chrome://extensions/](chrome://extensions/) and open on developer mode. 39 | 40 | For more info check the [tutorial](https://developer.chrome.com/docs/extensions/mv2/getstarted/#manifest). 41 | 42 | ## Tech aspects 43 | 44 | This is a series of react apps that compile to a chrome extension through [vite](https://vitejs.dev/). 45 | It has Typescript, Jest and react testing library for testing 46 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Controls/index.tsx: -------------------------------------------------------------------------------- 1 | import * as classes from "./Controls.module.scss"; 2 | import { IconButton } from "../../../../components/IconButton"; 3 | 4 | export type ControlProps = { 5 | onHide: () => void; 6 | onShow: () => void; 7 | onIncrease: () => void; 8 | onDecrease: () => void; 9 | isHidden?: boolean; 10 | weight?: number; 11 | }; 12 | export const Controls = ({ 13 | onHide, 14 | onShow, 15 | onIncrease, 16 | onDecrease, 17 | isHidden, 18 | weight = 1, 19 | }: ControlProps) => { 20 | return ( 21 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #f9ba00; 3 | --primary-bg: #000; 4 | } 5 | body { 6 | margin: 0; 7 | overflow: hidden; 8 | } 9 | *, 10 | *::before, 11 | *::after { 12 | box-sizing: border-box; 13 | } 14 | html, 15 | body { 16 | height: 100%; 17 | } 18 | dl, 19 | dd { 20 | margin: 0; 21 | } 22 | #app { 23 | width: 100%; 24 | height: 100%; 25 | } 26 | 27 | section { 28 | width: 100%; 29 | padding-left: 1rem; 30 | } 31 | main { 32 | width: 100%; 33 | height: 100%; 34 | display: flex; 35 | } 36 | ul { 37 | padding: 0; 38 | margin: 0; 39 | } 40 | ul, 41 | li { 42 | list-style-type: none; 43 | } 44 | 45 | input[type="checkbox"] { 46 | margin: 0; 47 | margin-right: 0.5rem; 48 | } 49 | 50 | [data-tooltip] { 51 | position: relative; 52 | border-bottom: 1px dashed #000; 53 | cursor: help; 54 | pointer-events: none; 55 | } 56 | 57 | [data-tooltip]::after { 58 | position: absolute; 59 | opacity: 0; 60 | pointer-events: none; 61 | content: attr(data-tooltip); 62 | left: 0; 63 | top: 0; 64 | border-radius: 3px; 65 | box-shadow: 0 0 5px 2px rgba(100, 100, 100, 0.6); 66 | background-color: wheat; 67 | z-index: 10; 68 | width: auto; 69 | min-width: 5rem; 70 | transition: all 150ms cubic-bezier(0.25, 0.8, 0.25, 1); 71 | opacity: 0; 72 | width: fit-content; 73 | block-size: fit-content; 74 | } 75 | 76 | [data-tooltip]:hover::after { 77 | opacity: 1; 78 | transform: translateY(-100%) translateX(-50%); 79 | transition-duration: 300ms; 80 | } 81 | 82 | label:hover, 83 | input:hover { 84 | cursor: pointer; 85 | } 86 | 87 | h1, 88 | h2, 89 | h3 { 90 | color: var(--primary-color); 91 | background-color: var(--primary-bg); 92 | padding-left: 1em; 93 | } 94 | -------------------------------------------------------------------------------- /src/getDeterministicPallette.ts: -------------------------------------------------------------------------------- 1 | import { getHashFromItem } from "./getHashFromItem"; 2 | 3 | const ALLOWED_COLORS = [ 4 | "#001f3f", 5 | "#0074d9", 6 | "#7fdbff", 7 | "#39cccc", 8 | "#3d9970", 9 | "#2ecc40", 10 | "#01ff70", 11 | "#ffdc00", 12 | "#ff851b", 13 | "#ff4136", 14 | "#85144b", 15 | "#f012be", 16 | "#b10dc9", 17 | ]; 18 | 19 | const TOTAL_COLORS = ALLOWED_COLORS.length; 20 | 21 | /** 22 | * Given any serializable input returns a legible fg and bg. Same input = same color 23 | * @param input 24 | * @returns a deterministic color for that input 25 | */ 26 | export function getDeterministicPallette(hash: string) { 27 | const colorIndex = hash 28 | .split("") 29 | .reduce((acc, char) => (acc + char.charCodeAt(0)) % TOTAL_COLORS, 0); 30 | const deterministicColor = ALLOWED_COLORS[colorIndex]; 31 | const color = getForegroundColor(deterministicColor); 32 | return { color, backgroundColor: deterministicColor }; 33 | } 34 | 35 | /** 36 | * (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com 37 | * Derived from work by https://gomakethings.com/dynamically-changing-the-text-color-based-on-background-color-contrast-with-vanilla-js/ 38 | *@see {https://gomakethings.com/dynamically-changing-the-text-color-based-on-background-color-contrast-with-vanilla-js/} 39 | */ 40 | const getForegroundColor = (hexInput: string) => { 41 | let hex = hexInput.replace("#", ""); 42 | if (hex.length == 3) { 43 | hex = hex.split("").reduce((acc, char) => `${acc}${char}${char}`, ""); 44 | } 45 | const [r, g, b] = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i 46 | .exec(hex) 47 | .slice(1) 48 | .map((color) => parseInt(color, 16)); 49 | /** 50 | * @see {@link https://en.wikipedia.org/wiki/YIQ} 51 | */ 52 | const yiq = (r * 299 + g * 587 + b * 114) / 1000; 53 | /** 54 | * @todo it would be nice to get a complementary foreground that was high contrast 55 | * alas, I am no designer so this will do for now 56 | */ 57 | return yiq >= 128 ? "#000000" : "#ffffff"; 58 | }; 59 | -------------------------------------------------------------------------------- /src/modules/options/components/ItemDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from "../../../components/IconButton"; 2 | import { 3 | EquivalenceInputSource, 4 | InputSource, 5 | SourceTypes, 6 | StatementInputSource, 7 | TableInputSource, 8 | } from "../../../types"; 9 | import * as classes from "./ItemDisplay.module.scss"; 10 | 11 | export type ItemDisplayProps = { 12 | source: EquivalenceInputSource | StatementInputSource | TableInputSource; 13 | isHidden?: boolean; 14 | weight?: number; 15 | onShow: () => void; 16 | onHide: () => void; 17 | onIncrease: () => void; 18 | onDecrease: () => void; 19 | }; 20 | export const ItemDisplay = ({ 21 | source, 22 | isHidden, 23 | onHide, 24 | onShow, 25 | onIncrease, 26 | onDecrease, 27 | weight, 28 | }: ItemDisplayProps) => { 29 | let displayText: string; 30 | switch (source.type) { 31 | case SourceTypes.EQUIVALENCE: 32 | displayText = Object.keys(source.value)[0]; 33 | break; 34 | case SourceTypes.STATEMENT: 35 | displayText = source.value; 36 | 37 | break; 38 | case SourceTypes.TABLE: 39 | displayText = source.value.title; 40 | break; 41 | } 42 | return ( 43 | <> 44 | {!isHidden && ( 45 | { 48 | e.preventDefault(); 49 | onHide(); 50 | }} 51 | /> 52 | )} 53 | {isHidden && ( 54 | { 57 | e.preventDefault(); 58 | onShow(); 59 | }} 60 | /> 61 | )} 62 | { 63 | { 66 | e.preventDefault(); 67 | onIncrease(); 68 | }} 69 | /> 70 | } 71 | { 72 | { 75 | e.preventDefault(); 76 | onDecrease(); 77 | }} 78 | /> 79 | } 80 | {displayText} ({weight || 1}) 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { sources } from "../../../assets/sources"; 2 | import { useSettings } from "../../../components/useSettings"; 3 | import { getDeterministicPallette } from "../../../getDeterministicPallette"; 4 | import { SourceTypes, TableSource } from "../../../types"; 5 | import { Controls } from "./Controls"; 6 | import { Equivalence } from "./Equivalence"; 7 | import { Table } from "./Table"; 8 | import * as classes from "./Tabs.module.css"; 9 | import { useDataSources } from "./useDataSources"; 10 | 11 | export function Tabs() { 12 | const { 13 | deactivatedMap, 14 | increaseWeight, 15 | decreaseWeight, 16 | hideItem, 17 | hideMap, 18 | initialized, 19 | showItem, 20 | weightMap, 21 | } = useSettings(); 22 | 23 | const { type, choice, hash, id } = useDataSources(sources, { 24 | deactivatedMap, 25 | initialized, 26 | hideMap, 27 | weightMap, 28 | }); 29 | 30 | if (!initialized) return null; 31 | let container: JSX.Element; 32 | /** 33 | * We want to have the same color for the same input 34 | * So people can associate them better in case they show up more than once 35 | */ 36 | const { color, backgroundColor } = getDeterministicPallette(hash); 37 | switch (type) { 38 | case SourceTypes.EQUIVALENCE: { 39 | const [term, definition] = choice as [string, string]; 40 | container = ; 41 | break; 42 | } 43 | case SourceTypes.TABLE: { 44 | container =
; 45 | break; 46 | } 47 | case SourceTypes.STATEMENT: { 48 | container = {choice as string}; 49 | break; 50 | } 51 | } 52 | 53 | return ( 54 |
55 | {container} 56 | { 59 | decreaseWeight(id, hash); 60 | }} 61 | onHide={() => { 62 | hideItem(id, hash); 63 | }} 64 | onShow={() => { 65 | showItem(id, hash); 66 | }} 67 | onIncrease={() => { 68 | increaseWeight(id, hash); 69 | }} 70 | isHidden={hideMap?.[id]?.[hash]} 71 | /> 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/modules/newTab/components/Tabs.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { SourceTypes } from "../../../types"; 4 | import { Equivalence as MockedEquivalence } from "./Equivalence"; 5 | import { Table as MockedTable } from "./Table"; 6 | import { Tabs } from "./Tabs"; 7 | import { useDataSources as mockedUseDataSources } from "./useDataSources"; 8 | import { useSettings } from "../../../components/useSettings"; 9 | 10 | jest.mock("../../../getDeterministicPallette", () => ({ 11 | getDeterministicPallette: jest.fn(() => ({ 12 | color: "mockColor", 13 | backgroundColor: "mockBackgroundColor", 14 | })), 15 | })); 16 | jest.mock("../../../components/useSettings", () => ({ 17 | useSettings: jest.fn(() => ({ 18 | deactivatedMap: { someId: true }, 19 | initialized: true, 20 | })), 21 | })); 22 | jest.mock("./useDataSources", () => ({ 23 | useDataSources: jest.fn(), 24 | })); 25 | 26 | jest.mock("./Equivalence", () => ({ Equivalence: jest.fn(() =>
) })); 27 | jest.mock("./Table", () => ({ Table: jest.fn(() =>
) })); 28 | 29 | describe(`Tabs`, () => { 30 | test(`Renders equivalence`, () => { 31 | const term = "term"; 32 | const definition = "definition"; 33 | (mockedUseDataSources as jest.Mock).mockImplementation(() => ({ 34 | type: SourceTypes.EQUIVALENCE, 35 | choice: [term, definition], 36 | })); 37 | render(); 38 | expect((MockedEquivalence as jest.Mock).mock.calls[0][0]).toEqual({ 39 | term, 40 | definition, 41 | }); 42 | }); 43 | test(`Renders statement`, () => { 44 | const statement = "statement"; 45 | (mockedUseDataSources as jest.Mock).mockImplementation(() => ({ 46 | type: SourceTypes.STATEMENT, 47 | choice: statement, 48 | })); 49 | render(); 50 | screen.getByText(statement); 51 | }); 52 | test(`Renders table`, () => { 53 | const header = [["header1"]]; 54 | const rows = [["cell1"]]; 55 | const title = "title"; 56 | 57 | (mockedUseDataSources as jest.Mock).mockImplementation(() => ({ 58 | type: SourceTypes.TABLE, 59 | choice: { 60 | header, 61 | rows, 62 | title, 63 | }, 64 | })); 65 | render(); 66 | 67 | expect((MockedTable as jest.Mock).mock.calls[0][0]).toEqual({ 68 | header, 69 | rows, 70 | title, 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/modules/newTab/components/useDataSources.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useRef } from "react"; 2 | import { getHashFromItem } from "../../../getHashFromItem"; 3 | import { 4 | DataSource, 5 | EquivalenceInputSource, 6 | InputSource, 7 | SourceConfiguration, 8 | SourceTypes, 9 | TableSource, 10 | } from "../../../types"; 11 | 12 | /**^ 13 | * Loads the desired data sources returning a "randomized" source to be displayed 14 | * The sources will respect configuration if given 15 | * @param sources 16 | */ 17 | export function useDataSources( 18 | sources: InputSource[], 19 | configuration: SourceConfiguration = { 20 | initialized: true, 21 | deactivatedMap: {}, 22 | hideMap: {}, 23 | weightMap: {}, 24 | } 25 | ) { 26 | const choiceRef = useRef<{ 27 | type: SourceTypes; 28 | hash: string; 29 | id: string; 30 | choice: string | TableSource | [string, string]; 31 | }>(); 32 | 33 | const data = useMemo(() => { 34 | if (!configuration?.initialized) return null; 35 | 36 | if (choiceRef.current) { 37 | return choiceRef.current; 38 | } 39 | const validSources = sources.filter( 40 | ({ id }) => !configuration?.deactivatedMap?.[id] 41 | ); 42 | if (!validSources.length) return null; 43 | const index = Math.floor(Math.random() * validSources.length); 44 | const chosenSource = validSources[index]; 45 | 46 | const id = chosenSource.id; 47 | 48 | const hashDataMap: Record = {}; 49 | 50 | let weightedHashes: string[] = chosenSource.data.reduce((acc, data) => { 51 | const hash = getHashFromItem(data.value); 52 | const shown = !configuration?.hideMap?.[id]?.[hash]; 53 | if (!shown) return acc; 54 | const weight = configuration?.weightMap?.[id]?.[hash] ?? 1; 55 | hashDataMap[hash] = data; 56 | 57 | return acc.concat(new Array(weight).fill(hash)); 58 | }, []); 59 | 60 | const dataIndex = Math.floor(Math.random() * weightedHashes.length); 61 | const { type, value } = hashDataMap[weightedHashes[dataIndex]]; 62 | const hash = getHashFromItem(value); 63 | 64 | const choice = 65 | type === SourceTypes.EQUIVALENCE 66 | ? Object.entries(value as EquivalenceInputSource["value"])[0] 67 | : value; 68 | choiceRef.current = { 69 | hash, 70 | choice, 71 | type, 72 | id, 73 | }; 74 | return choiceRef.current; 75 | }, [sources, configuration]); 76 | 77 | if (!data) { 78 | const choice = "No sources, go to options to select them"; 79 | return { 80 | type: SourceTypes.STATEMENT, 81 | choice, 82 | id: "__NO_CHOICE__", 83 | hash: getHashFromItem(choice), 84 | }; 85 | } 86 | 87 | return data; 88 | } 89 | -------------------------------------------------------------------------------- /src/modules/options/components/Options.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { IconButton } from "../../../components/IconButton"; 3 | import { useSettings } from "../../../components/useSettings"; 4 | import { getHashFromItem } from "../../../getHashFromItem"; 5 | import { ItemDisplay } from "./ItemDisplay"; 6 | import * as classes from "./Options.module.scss"; 7 | 8 | export function Options() { 9 | const { 10 | sources, 11 | toogleActivation, 12 | hideMap, 13 | weightMap, 14 | initialized, 15 | hideItem, 16 | showItem, 17 | increaseWeight, 18 | decreaseWeight, 19 | } = useSettings(); 20 | 21 | const [openSetting, setOpenSetting] = useState(); 22 | if (!initialized) return; 23 | 24 | return ( 25 |
26 |
27 |

Available Sources

28 |
    29 | {sources.map(({ id, title, deactivated, data }) => { 30 | const isOpen = openSetting === id; 31 | return ( 32 |
  • 33 | { 38 | toogleActivation(id); 39 | }} 40 | checked={!deactivated} 41 | /> 42 | 43 | { 46 | isOpen ? setOpenSetting(undefined) : setOpenSetting(id); 47 | }} 48 | /> 49 | {isOpen && ( 50 | <> 51 |

    Terms

    52 |
      53 | {data.map((source, index) => { 54 | const hash = getHashFromItem(source.value); 55 | return ( 56 |
    • 57 | { 58 | { 63 | hideItem(id, hash); 64 | }} 65 | onShow={() => { 66 | showItem(id, hash); 67 | }} 68 | onIncrease={() => { 69 | increaseWeight(id, hash); 70 | }} 71 | onDecrease={() => { 72 | decreaseWeight(id, hash); 73 | }} 74 | /> 75 | } 76 |
    • 77 | ); 78 | })} 79 |
    80 | 81 | )} 82 |
  • 83 | ); 84 | })} 85 |
86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/components/useSettings.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { InputSource } from "../types"; 3 | import { sources as staticSources } from "../assets/sources"; 4 | 5 | /** 6 | * List the available data sources to consider 7 | */ 8 | export const useSettings = () => { 9 | const [deactivatedMap, setDeactivatedMap] = useState>( 10 | {} 11 | ); 12 | const [hideMap, setHideMap] = useState< 13 | Record> 14 | >({}); 15 | 16 | const [weightMap, setWeightMap] = useState< 17 | Record> 18 | >({}); 19 | 20 | const [initialized, setInitialized] = useState(false); 21 | 22 | useEffect(() => { 23 | const savedDeactivatedMap: Record = JSON.parse( 24 | localStorage.getItem("deactivatedMap") || `{}` 25 | ); 26 | const savedHideMap: Record> = JSON.parse( 27 | localStorage.getItem("hideMap") || `{}` 28 | ); 29 | const savedWeightMap: Record> = JSON.parse( 30 | localStorage.getItem("weightMap") || `{}` 31 | ); 32 | setDeactivatedMap(savedDeactivatedMap); 33 | setHideMap(savedHideMap); 34 | setWeightMap(savedWeightMap); 35 | setInitialized(true); 36 | }, [setDeactivatedMap]); 37 | /** 38 | * @todo implement adding different sources 39 | */ 40 | const dynamicSources: InputSource[] = []; 41 | 42 | const sources = useMemo(() => { 43 | return staticSources.concat(dynamicSources).map(({ title, id, data }) => ({ 44 | title: title || id, 45 | id, 46 | deactivated: !!deactivatedMap[id], 47 | data, 48 | })); 49 | }, [deactivatedMap, staticSources, dynamicSources]); 50 | 51 | useEffect(() => { 52 | const storeData = () => { 53 | localStorage.setItem("deactivatedMap", JSON.stringify(deactivatedMap)); 54 | localStorage.setItem("hideMap", JSON.stringify(hideMap)); 55 | localStorage.setItem("weightMap", JSON.stringify(weightMap)); 56 | }; 57 | storeData(); 58 | }, [deactivatedMap, hideMap, weightMap]); 59 | 60 | const toogleActivation = (id: string) => { 61 | setDeactivatedMap((oldMap) => ({ 62 | ...oldMap, 63 | [id]: !oldMap[id], 64 | })); 65 | }; 66 | 67 | const increaseWeight = (sourceId: string, itemHash: string) => { 68 | setWeightMap((previousWeightMap) => ({ 69 | ...previousWeightMap, 70 | [sourceId]: { 71 | ...previousWeightMap?.[sourceId], 72 | [itemHash]: (previousWeightMap?.[sourceId]?.[itemHash] || 0) + 1, 73 | }, 74 | })); 75 | }; 76 | const decreaseWeight = (sourceId: string, itemHash: string) => { 77 | setWeightMap((previousWeightMap) => ({ 78 | ...previousWeightMap, 79 | [sourceId]: { 80 | ...previousWeightMap?.[sourceId], 81 | [itemHash]: Math.max( 82 | 1, 83 | (previousWeightMap?.[sourceId]?.[itemHash] || 0) - 1 84 | ), 85 | }, 86 | })); 87 | }; 88 | const hideItem = (sourceId: string, itemHash: string) => { 89 | setHideMap((previousHideMap) => ({ 90 | ...previousHideMap, 91 | [sourceId]: { 92 | ...previousHideMap?.[sourceId], 93 | [itemHash]: true, 94 | }, 95 | })); 96 | }; 97 | const showItem = (sourceId: string, itemHash: string) => { 98 | setHideMap((previousHideMap) => ({ 99 | ...previousHideMap, 100 | [sourceId]: { 101 | ...previousHideMap?.[sourceId], 102 | [itemHash]: false, 103 | }, 104 | })); 105 | }; 106 | 107 | return { 108 | sources, 109 | toogleActivation, 110 | deactivatedMap, 111 | hideMap, 112 | weightMap, 113 | increaseWeight, 114 | decreaseWeight, 115 | hideItem, 116 | initialized, 117 | showItem, 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /src/components/useSettings.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { useSettings } from "./useSettings"; 3 | import { sources as mockSources } from "../assets/sources"; 4 | import { act } from "react-dom/test-utils"; 5 | jest.mock("../assets/sources", () => ({ 6 | sources: [{ id: "source 1", title: "title 1" }, { id: "source 2" }], 7 | })); 8 | 9 | describe(`useSettings`, () => { 10 | let mockGetItem: jest.SpyInstance; 11 | let mockSetItem: jest.SpyInstance; 12 | const savedDeactivatedMap = { 13 | [mockSources[1].id]: true, 14 | }; 15 | const savedHideMap = { 16 | [mockSources[1].id]: { 17 | hash1: true, 18 | }, 19 | }; 20 | 21 | const savedWeightMap = { 22 | [mockSources[1].id]: { 23 | hash2: 5, 24 | }, 25 | }; 26 | const savedLocalStorage = { 27 | deactivatedMap: JSON.stringify(savedDeactivatedMap), 28 | hideMap: JSON.stringify(savedHideMap), 29 | weightMap: JSON.stringify(savedWeightMap), 30 | }; 31 | beforeEach(() => { 32 | /** 33 | * @see {@link https://github.com/jsdom/jsdom/issues/2318} 34 | */ 35 | mockGetItem = jest.spyOn(Storage.prototype, "getItem"); 36 | mockGetItem.mockImplementation((key) => { 37 | return savedLocalStorage[key]; 38 | }); 39 | mockSetItem = jest.spyOn(Storage.prototype, "setItem"); 40 | }); 41 | it(`renders listing sources`, () => { 42 | mockGetItem.mockImplementation(() => 43 | JSON.stringify({ 44 | [mockSources[1].id]: true, 45 | }) 46 | ); 47 | 48 | const expectedResult = [ 49 | { 50 | title: mockSources[0].title, 51 | deactivated: false, 52 | id: mockSources[0].id, 53 | }, 54 | { 55 | title: mockSources[1].id, 56 | deactivated: true, 57 | id: mockSources[1].id, 58 | }, 59 | ]; 60 | const { result } = renderHook(() => useSettings()); 61 | 62 | expect(result.current.sources).toEqual(expectedResult); 63 | }); 64 | it(`Toogles activation saving`, () => { 65 | const { result } = renderHook(() => useSettings()); 66 | 67 | act(() => { 68 | result.current.toogleActivation(mockSources[1].id); 69 | }); 70 | expect(mockSetItem).toHaveBeenCalledWith( 71 | "deactivatedMap", 72 | JSON.stringify({ 73 | [mockSources[1].id]: false, 74 | }) 75 | ); 76 | }); 77 | it(`Hides item`, () => { 78 | const { result } = renderHook(() => useSettings()); 79 | const hash = `newHash`; 80 | act(() => { 81 | result.current.hideItem(mockSources[1].id, hash); 82 | }); 83 | expect(mockSetItem).toHaveBeenCalledWith( 84 | "hideMap", 85 | JSON.stringify({ 86 | ...savedHideMap, 87 | [mockSources[1].id]: { 88 | ...savedHideMap[mockSources[1].id], 89 | [hash]: true, 90 | }, 91 | }) 92 | ); 93 | }); 94 | it(`Show item`, () => { 95 | const { result } = renderHook(() => useSettings()); 96 | const hash = `hash1`; 97 | act(() => { 98 | result.current.showItem(mockSources[1].id, hash); 99 | }); 100 | expect(mockSetItem).toHaveBeenCalledWith( 101 | "hideMap", 102 | JSON.stringify({ 103 | ...savedHideMap, 104 | [mockSources[1].id]: { 105 | ...savedHideMap[mockSources[1].id], 106 | [hash]: false, 107 | }, 108 | }) 109 | ); 110 | }); 111 | it(`Decreases weight`, () => { 112 | const { result } = renderHook(() => useSettings()); 113 | const hash = `hash2`; 114 | act(() => { 115 | result.current.decreaseWeight(mockSources[1].id, hash); 116 | }); 117 | expect(mockSetItem).toHaveBeenCalledWith( 118 | "weightMap", 119 | JSON.stringify({ 120 | ...savedWeightMap, 121 | [mockSources[1].id]: { 122 | ...savedWeightMap[mockSources[1].id], 123 | [hash]: savedWeightMap[mockSources[1].id][hash] - 1, 124 | }, 125 | }) 126 | ); 127 | }); 128 | it(`increases weight`, () => { 129 | const { result } = renderHook(() => useSettings()); 130 | const hash = `hash2`; 131 | act(() => { 132 | result.current.increaseWeight(mockSources[1].id, hash); 133 | }); 134 | expect(mockSetItem).toHaveBeenCalledWith( 135 | "weightMap", 136 | JSON.stringify({ 137 | ...savedWeightMap, 138 | [mockSources[1].id]: { 139 | ...savedWeightMap[mockSources[1].id], 140 | [hash]: savedWeightMap[mockSources[1].id][hash] + 1, 141 | }, 142 | }) 143 | ); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/modules/newTab/components/useDataSources.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { getHashFromItem } from "../../../getHashFromItem"; 3 | import { 4 | EquivalenceInputSource, 5 | SourceConfiguration, 6 | SourceTypes, 7 | } from "../../../types"; 8 | import { useDataSources } from "./useDataSources"; 9 | 10 | describe(`useDataSources`, () => { 11 | const mockSources = new Array(13).fill({}).map((_, index) => ({ 12 | data: new Array(17).fill("").map( 13 | (_, index) => 14 | ({ 15 | type: SourceTypes.EQUIVALENCE, 16 | value: { 17 | [`data ${index}`]: `data ${index}`, 18 | }, 19 | } as EquivalenceInputSource) 20 | ), 21 | id: `source ${index}`, 22 | })); 23 | 24 | it(`renders without configuration`, () => { 25 | jest.spyOn(global.Math, "random").mockReturnValue(0.5); 26 | const expectedSource = mockSources[Math.floor(0.5 * 13)]; 27 | const resultIndex = Math.floor(0.5 * 17); 28 | 29 | const { result } = renderHook(() => useDataSources(mockSources)); 30 | expect(result.current).toEqual( 31 | expect.objectContaining({ 32 | id: expectedSource.id, 33 | type: expectedSource.data[resultIndex].type, 34 | choice: Object.entries(expectedSource.data[resultIndex].value)[0], 35 | }) 36 | ); 37 | }); 38 | it(`Renders no choice if there is no availabel choice`, () => { 39 | const configuration: SourceConfiguration = { 40 | initialized: true, 41 | hideMap: {}, 42 | weightMap: {}, 43 | deactivatedMap: mockSources.reduce( 44 | (acc, { id }) => ({ ...acc, [id]: true }), 45 | {} 46 | ), 47 | }; 48 | const { result } = renderHook(() => 49 | useDataSources(mockSources, configuration) 50 | ); 51 | expect(result.current).toEqual( 52 | expect.objectContaining({ 53 | type: SourceTypes.STATEMENT, 54 | choice: "No sources, go to options to select them", 55 | id: "__NO_CHOICE__", 56 | }) 57 | ); 58 | }); 59 | describe(`with configuration`, () => { 60 | it(`hides source`, () => { 61 | jest.spyOn(global.Math, "random").mockReturnValue(0.5); 62 | 63 | const activeSourceId = mockSources[0].id; 64 | const configuration: SourceConfiguration = { 65 | initialized: true, 66 | hideMap: {}, 67 | weightMap: {}, 68 | deactivatedMap: mockSources.reduce( 69 | (acc, { id }) => ({ ...acc, [id]: id !== activeSourceId }), 70 | {} 71 | ), 72 | }; 73 | const expectedSource = mockSources[0]; 74 | const resultIndex = Math.floor(0.5 * 17); 75 | 76 | const { result } = renderHook(() => 77 | useDataSources(mockSources, configuration) 78 | ); 79 | 80 | expect(result.current).toEqual( 81 | expect.objectContaining({ 82 | id: expectedSource.id, 83 | type: expectedSource.data[resultIndex].type, 84 | choice: Object.entries(expectedSource.data[resultIndex].value)[0], 85 | }) 86 | ); 87 | }); 88 | it(`respects hidden data`, () => { 89 | const resultIndex = Math.floor(0.2 * 17); 90 | 91 | jest 92 | .spyOn(global.Math, "random") 93 | .mockReturnValueOnce(0) 94 | .mockReturnValueOnce(0.2); 95 | 96 | const expectedSource = mockSources[0]; 97 | 98 | const configuration: SourceConfiguration = { 99 | initialized: true, 100 | weightMap: {}, 101 | hideMap: { 102 | [expectedSource.id]: { 103 | [getHashFromItem(expectedSource.data[resultIndex].value)]: true, 104 | }, 105 | }, 106 | deactivatedMap: {}, 107 | }; 108 | 109 | const { result } = renderHook(() => 110 | useDataSources(mockSources, configuration) 111 | ); 112 | 113 | expect(result.current).toEqual( 114 | expect.objectContaining({ 115 | id: expectedSource.id, 116 | type: expectedSource.data[resultIndex].type, 117 | /** 118 | * actual index was hidden, it would land on the next one 119 | */ 120 | choice: Object.entries(expectedSource.data[resultIndex + 1].value)[0], 121 | }) 122 | ); 123 | }); 124 | it(`respects weighted data`, () => { 125 | const resultIndex = 1; 126 | 127 | jest 128 | .spyOn(global.Math, "random") 129 | .mockReturnValueOnce(0) 130 | // the new number is 1+ 6+ 15 due to the weight, we want the top weighted element 131 | .mockReturnValue(0.32); 132 | const expectedSource = mockSources[0]; 133 | const hash = getHashFromItem(expectedSource.data[1].value); 134 | const configuration: SourceConfiguration = { 135 | initialized: true, 136 | hideMap: {}, 137 | weightMap: { 138 | [expectedSource.id]: { 139 | [hash]: 6, 140 | }, 141 | }, 142 | deactivatedMap: {}, 143 | }; 144 | 145 | const { result } = renderHook(() => 146 | useDataSources(mockSources, configuration) 147 | ); 148 | /** 149 | * The next item after weights, so the index 1 is from 1-6, 0.32 on random 150 | * would land into 7 given the element on index 2 151 | */ 152 | const expectedData = expectedSource.data[2]; 153 | 154 | expect(result.current).toEqual( 155 | expect.objectContaining({ 156 | id: expectedSource.id, 157 | type: expectedData.type, 158 | choice: Object.entries(expectedData.value)[0], 159 | }) 160 | ); 161 | }); 162 | }); 163 | }); 164 | --------------------------------------------------------------------------------