├── .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 && {title}}
9 |
10 |
11 |
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 |
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 |
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 | 
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 |
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 |
--------------------------------------------------------------------------------