├── babel.config.js
├── postcss.config.js
├── tsconfig.node.json
├── README.md
├── src
├── env.d.ts
├── api
│ ├── __mocks__
│ │ ├── wrong-menu.md
│ │ └── correct-menu.md
│ ├── linkedin-practices.spec.ts
│ └── linkedin-practices.ts
├── practice-parser
│ ├── __mocks__
│ │ ├── lexer-tree.md
│ │ ├── mock-markdown.ts
│ │ └── lexer-mock.md
│ ├── lexer-tree.ts
│ ├── lexer-tree.spec.ts
│ ├── lexer.spec.ts
│ └── lexer.ts
├── main.tsx
├── components
│ ├── common
│ │ ├── __mocks__
│ │ │ └── markdown-content.tsx
│ │ ├── error-fallback.tsx
│ │ ├── modal.tsx
│ │ ├── viewport.hook.ts
│ │ ├── local-storage.hook.ts
│ │ └── markdown-content.tsx
│ ├── practices
│ │ ├── __mocks__
│ │ │ ├── practices.mock.ts
│ │ │ └── local-storage.mock.ts
│ │ ├── piechart.tsx
│ │ ├── practices.tsx
│ │ ├── practices.hook.spec.ts
│ │ ├── practices.hook.ts
│ │ ├── information-panel.tsx
│ │ └── practices.spec.tsx
│ └── practice
│ │ ├── __mocks__
│ │ └── data.mock.ts
│ │ ├── practice.tsx
│ │ └── practice.spec.tsx
├── pages
│ ├── linkedin-practices.tsx
│ ├── github-practices
│ │ ├── utils.spec.ts
│ │ ├── utils.ts
│ │ └── github-practices.tsx
│ ├── live-preview.tsx
│ └── linkedin-practices-menu.tsx
├── index.css
├── app.tsx
└── assets
│ └── md2practice-logo.svg
├── vite.config.ts
├── .gitignore
├── index.html
├── .github
├── dependabot.yml
└── workflows
│ ├── ci.yml
│ └── deploy-ghpages.yml
├── jest.config.ts
├── tailwind.config.js
├── tsconfig.json
├── .eslintrc.js
├── package.json
└── public
└── favicon.svg
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }],
3 | ],
4 | };
5 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "esnext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MD2Practice
2 | Transform markdown to practice for exercise.
3 | ## Features
4 | - LinkedIn Practices from https://github.com/Ebazhanov/linkedin-skill-assessments-quizzes
5 | - Save progress to local storage
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | interface ImportMetaEnv {
3 | readonly VITE_GA_MEASUREMENT_ID: string
4 | // more env variables...
5 | }
6 |
7 | interface ImportMeta {
8 | readonly env: ImportMetaEnv
9 | }
10 |
--------------------------------------------------------------------------------
/src/api/__mocks__/wrong-menu.md:
--------------------------------------------------------------------------------
1 | ### Some random table
2 | | Tables | Are | Cool |
3 | |----------|:-------------:|------:|
4 | | col 1 is | left-aligned | $1600 |
5 | | col 2 is | centered | $12 |
6 | | col 3 is | right-aligned | $1 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | });
9 |
--------------------------------------------------------------------------------
/src/practice-parser/__mocks__/lexer-tree.md:
--------------------------------------------------------------------------------
1 | #### Q1. Question with codespan?
2 |
3 | `with-codespan`
4 | `with-codespan 1`
5 | `with-codespan 2`
6 | `with-codespan 3`
7 |
8 | - [ ] selection 1
9 | - [ ] selection 2
10 | - [x] selection 3
11 | - [ ] selection 4
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './app';
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById('root'),
11 | );
12 |
--------------------------------------------------------------------------------
/src/components/common/__mocks__/markdown-content.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface MarkdownContentProps {
4 | raw: string;
5 | }
6 |
7 | const MarkdownContent = jest.fn().mockImplementation((
8 | { raw }: MarkdownContentProps,
9 | ) =>
{raw}
);
10 |
11 | export default MarkdownContent;
12 |
--------------------------------------------------------------------------------
/src/components/practices/__mocks__/practices.mock.ts:
--------------------------------------------------------------------------------
1 | import { withId } from '../../../practice-parser/lexer';
2 | import { mockedPractice, mockedSelection } from '../../practice/__mocks__/data.mock';
3 |
4 | export const mockedPractices = (length: number) => {
5 | const practices = Array.from({ length }, () => mockedPractice(mockedSelection(4)));
6 | return withId(practices);
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/common/error-fallback.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { FallbackProps } from 'react-error-boundary';
3 |
4 | export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
5 |
6 |
Something went wrong:
7 |
{error.message}
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | coverage
12 | dist
13 | dist-ssr
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
27 | # ENVIRONMENT
28 | .env
29 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | MD2Practice
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/pages/linkedin-practices.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useParams } from 'react-router-dom';
3 | import GithubPractices from './github-practices/github-practices';
4 |
5 | export const LinkedInPracticesPage: React.FC = () => {
6 | const { encodedUrl } = useParams<{ encodedUrl: string }>();
7 | const url = atob(encodedUrl || '');
8 |
9 | return (
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/components/practices/__mocks__/local-storage.mock.ts:
--------------------------------------------------------------------------------
1 | export const mockedLocalStorage = (() => {
2 | let store: Record = {};
3 | return {
4 | getItem(key: string) {
5 | return store[key];
6 | },
7 | setItem(key: string, value: string) {
8 | store[key] = value.toString();
9 | },
10 | clear() {
11 | store = {};
12 | },
13 | removeItem(key: string) {
14 | delete store[key];
15 | },
16 | };
17 | })();
18 |
--------------------------------------------------------------------------------
/src/pages/github-practices/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import { convertGithubLink } from './utils';
2 |
3 | describe('github practices utilities', () => {
4 | it('should convert the github link to raw file link correctly', () => {
5 | const githubLink = 'https://github.com/Ebazhanov/linkedin-skill-assessments-quizzes/blob/master/git/git-quiz.md';
6 | const rawLink = 'https://raw.githubusercontent.com/Ebazhanov/linkedin-skill-assessments-quizzes/master/git/git-quiz.md';
7 | expect(convertGithubLink(githubLink)).toEqual(rawLink);
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Basic dependabot.yml file with
2 | # minimum configuration for two package managers
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: 'npm'
7 | directory: '/'
8 | schedule:
9 | interval: 'weekly'
10 | day: 'saturday'
11 | versioning-strategy: 'increase'
12 | labels:
13 | - 'dependencies'
14 | open-pull-requests-limit: 5
15 | commit-message:
16 | # cause a release for non-dev-deps
17 | prefix: fix(deps)
18 | # no release for dev-deps
19 | prefix-development: chore(dev-deps)
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer utilities {
6 | /* Chrome, Safari and Opera */
7 | .no-scrollbar::-webkit-scrollbar {
8 | display: none;
9 | }
10 |
11 | .no-scrollbar {
12 | -ms-overflow-style: none; /* IE and Edge */
13 | scrollbar-width: none; /* Firefox */
14 | }
15 | }
16 |
17 | @layer base {
18 | html {
19 | @apply text-neutral-300 antialiased leading-normal font-mono
20 | }
21 | body {
22 | @apply min-h-screen bg-primary-dark-900
23 | }
24 | }
--------------------------------------------------------------------------------
/jest.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 | /*
3 | * For a detailed explanation regarding each configuration property and type check, visit:
4 | * https://jestjs.io/docs/configuration
5 | */
6 |
7 | export default {
8 | preset: 'ts-jest',
9 | transform: {
10 | '^.+\\.(ts|tsx)?$': 'ts-jest',
11 | '^.+\\.(js|jsx)$': 'babel-jest',
12 | },
13 | collectCoverage: true,
14 | coverageDirectory: 'coverage',
15 | testEnvironment: 'jsdom',
16 | moduleNameMapper: {
17 | '.*/markdown-content': '/src/components/common/__mocks__/markdown-content.tsx',
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/common/modal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface ModalProps {
4 | show: boolean;
5 | children: React.ReactNode;
6 | onClick: React.MouseEventHandler;
7 | }
8 |
9 | const Modal: React.FC = ({ show, children, onClick }) => {
10 | if (show) {
11 | return (
12 |
13 |
14 | {children}
15 |
16 |
17 | );
18 | }
19 | return null;
20 | };
21 |
22 | export default Modal;
23 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | const colors = require('tailwindcss/colors');
3 |
4 | module.exports = {
5 | content: [
6 | './index.html',
7 | './src/**/*.{vue,js,ts,jsx,tsx}',
8 | ],
9 | theme: {
10 | screens: {
11 | tablet: '640px',
12 | desktop: '1024px',
13 | },
14 | extend: {
15 | colors: {
16 | primary: {
17 | ...colors.emerald,
18 | dark: { ...colors.slate },
19 | },
20 | secondary: {
21 | ...colors.blue,
22 | dark: {
23 | ...colors.violet,
24 | },
25 | },
26 | },
27 | },
28 | },
29 | plugins: [],
30 | };
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": false,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | },
19 | "include": ["src"],
20 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx"],
21 | "references": [{ "path": "./tsconfig.node.json" }]
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/common/viewport.hook.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export const useViewport = () => {
4 | const [width, setWidth] = useState(window.innerWidth);
5 | // Add a second state variable "height" and default it to the current window height
6 | const [height, setHeight] = useState(window.innerHeight);
7 |
8 | useEffect(() => {
9 | const handleWindowResize = () => {
10 | setWidth(window.innerWidth);
11 | // Set the height in state as well as the width
12 | setHeight(window.innerHeight);
13 | };
14 |
15 | window.addEventListener('resize', handleWindowResize);
16 | return () => window.removeEventListener('resize', handleWindowResize);
17 | }, []);
18 |
19 | // Return both the height and width
20 | return { width, height };
21 | };
22 |
--------------------------------------------------------------------------------
/src/pages/github-practices/utils.ts:
--------------------------------------------------------------------------------
1 | import { PracticeParams } from '../../components/practice/practice';
2 |
3 | export const convertGithubLink = (githubLink: string) => {
4 | const url = new URL(githubLink);
5 | const tokens = url.pathname.split('/');
6 | const user = tokens[1];
7 | const repository = tokens[2];
8 | const branch = tokens[4];
9 | const path = tokens.slice(5).join('/');
10 | return `https://raw.githubusercontent.com/${user}/${repository}/${branch}/${path}`;
11 | };
12 |
13 | // for linkedin assessment incorrect question index,
14 | // because some question still doesn't have answer yet.
15 | export const recorrectQuestionIndex = (data: PracticeParams[]) => data.map((item, idx) => ({
16 | ...item,
17 | rawQuestion: item.rawQuestion.replace(/#### Q{0,1}\d+/g, `#### Q${idx + 1}`),
18 | }));
19 |
--------------------------------------------------------------------------------
/src/practice-parser/__mocks__/mock-markdown.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from 'fs';
2 | import { marked } from 'marked';
3 | import { resolve } from 'path';
4 |
5 | export const mockMarkdown = (filename: string) => readFileSync(resolve(__dirname, filename), 'utf-8');
6 |
7 | export const getPracticeByIndex = (lexer: marked.TokensList, index: number) => {
8 | let chunk: marked.Token[] = [];
9 | const practices = lexer.reduce((result:marked.Token[][], token, idx) => {
10 | if (chunk.length > 0 && token.type === 'heading' && token.depth === 4) {
11 | result.push(chunk);
12 | chunk = [];
13 | }
14 | chunk.push(token);
15 |
16 | if (idx + 1 === lexer.length) {
17 | result.push(chunk);
18 | }
19 | return result;
20 | }, []);
21 | return practices[index] as marked.TokensList;
22 | };
23 |
--------------------------------------------------------------------------------
/src/components/practice/__mocks__/data.mock.ts:
--------------------------------------------------------------------------------
1 | import { Chance } from 'chance';
2 | import {
3 | OptionStatus, PracticeParams, PracticeStatus, SelectionOption,
4 | } from '../practice';
5 |
6 | const chance = new Chance();
7 |
8 | export const mockedQuestion = () => chance.string();
9 |
10 | export const mockedSelection = (
11 | totalOptions: number = 4,
12 | ): SelectionOption[] => Array.from({ length: totalOptions }, () => ({
13 | rawOption: chance.string(),
14 | status: OptionStatus.IDLE,
15 | }));
16 |
17 | export const mockedPractice = (
18 | selection: SelectionOption[],
19 | isMultiple: boolean = false,
20 | ): PracticeParams => {
21 | const range = { min: 0, max: selection.length - 1 };
22 | return {
23 | rawQuestion: mockedQuestion(),
24 | selection,
25 | answers: isMultiple
26 | ? chance.unique(chance.natural, 2, range)
27 | : chance.unique(chance.natural, 1, range),
28 | status: PracticeStatus.IDLE,
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/src/practice-parser/__mocks__/lexer-mock.md:
--------------------------------------------------------------------------------
1 | #### Q0. Question with different content?
2 |
3 | 
4 | 
5 | 
6 | `with-codespan 1`
7 | `with-codespan 2`
8 | `with-codespan 3`
9 | ```javascript
10 | const data = null
11 | ```
12 | ```javascript
13 | const data = null
14 | ```
15 |
16 | - [ ] selection 1
17 | - [ ] selection 2
18 | - [x] selection 3
19 | - [ ] selection 4
20 |
21 | #### Q1. Normal Selection
22 |
23 | - [ ] selection 1
24 | - [ ] selection 2
25 | - [x] selection 3
26 | - [ ] selection 4
27 |
28 | #### Q2. Normal and Code Selection
29 |
30 | - [x]
31 | ```javascript
32 | const data = null
33 | ```
34 | - [ ] normal selection 1
35 | - [ ] normal selection 2
36 | - [ ]
37 | ```javascript
38 | const data = null
39 | ```
40 |
41 | #### Q3. Question without selection available
42 |
43 | #### Q4. Question without answer
44 | - [ ] selection 1
45 | - [ ] selection 2
46 | - [ ] selection 3
47 | - [ ] selection 4
--------------------------------------------------------------------------------
/src/practice-parser/lexer-tree.ts:
--------------------------------------------------------------------------------
1 | import { marked } from 'marked';
2 | import { curry } from 'ramda';
3 |
4 | // Reference: https://jrsinclair.com/articles/2019/functional-js-traversing-trees-with-recursive-reduce/
5 |
6 | type Generic = marked.Tokens.Generic
7 |
8 | function hasChildren(node: Generic) {
9 | return (typeof node === 'object')
10 | && (typeof node.tokens !== 'undefined')
11 | && (node.tokens.length > 0);
12 | }
13 |
14 | export function wrapLexerTokens(tokens: marked.TokensList) {
15 | return {
16 | type: 'root',
17 | tokens: [...tokens],
18 | } as marked.Tokens.Generic;
19 | }
20 |
21 | export const LexerTree = {
22 | reduce: curry((reducerFn: Function, init: unknown, node: Generic): unknown => {
23 | const acc = reducerFn(init, node);
24 | if (!hasChildren(node)) {
25 | return acc;
26 | }
27 | return node.tokens?.reduce(LexerTree.reduce(reducerFn), acc);
28 | }),
29 | map: curry((mapFn: Function, node: Generic): Generic => {
30 | const newNode = mapFn(node);
31 | if (!hasChildren(node)) {
32 | return newNode;
33 | }
34 | newNode.tokens = node.tokens?.map((item) => LexerTree.map(mapFn)(item));
35 | return newNode;
36 | }),
37 | };
38 |
--------------------------------------------------------------------------------
/src/practice-parser/lexer-tree.spec.ts:
--------------------------------------------------------------------------------
1 | import { marked } from 'marked';
2 | import { LexerTree, wrapLexerTokens } from './lexer-tree';
3 | import { getPracticeByIndex, mockMarkdown } from './__mocks__/mock-markdown';
4 |
5 | describe('marked parser', () => {
6 | const lexer = marked.lexer(mockMarkdown('lexer-tree.md'));
7 |
8 | it('reduce function is working, add all codespan count.', () => {
9 | const data = wrapLexerTokens(getPracticeByIndex(lexer, 0));
10 |
11 | function sumCodespan(result: number, item: marked.Token) {
12 | return result + ((item.type === 'codespan') ? 1 : 0);
13 | }
14 | const total = LexerTree.reduce(sumCodespan, 0, data);
15 | expect(total).toBe(4);
16 | });
17 | it('map function is working', () => {
18 | const data = wrapLexerTokens(getPracticeByIndex(lexer, 0));
19 | function addChildCount(node: marked.Tokens.Generic) {
20 | const count = node.tokens?.length || 0;
21 | return {
22 | ...node,
23 | childCount: count,
24 | };
25 | }
26 | const tree = LexerTree.map(addChildCount, data);
27 | const childNode = (tree.tokens ? tree.tokens[0] : {}) as {childCount: number};
28 | expect(childNode.childCount).toBe(1);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Continuous Integration
2 |
3 | on:
4 | pull_request
5 |
6 | jobs:
7 | tests:
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | node-version: [17.x]
12 | steps:
13 | - uses: actions/checkout@v3
14 | - name: Use Node.js ${{ matrix.node-version }}
15 | uses: actions/setup-node@v3
16 | with:
17 | node-version: ${{ matrix.node-version }}
18 | - name: Cache dependencies
19 | id: cache-dependencies
20 | uses: actions/cache@v3
21 | with:
22 | path: '**/node_modules'
23 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
24 | - name: Install dependencies
25 | if: steps.cache-dependencies.outputs.cache-hit != 'true'
26 | run: yarn --frozen-lockfile
27 | - name: Testing 🧪
28 | run: yarn run test
29 | - name: Build 👷
30 | run: yarn run build
31 | auto-merge:
32 | needs: ['tests']
33 | runs-on: ubuntu-latest
34 | if: github.actor == 'dependabot[bot]'
35 | permissions:
36 | pull-requests: write
37 | contents: write
38 | steps:
39 | - uses: fastify/github-action-merge-dependabot@v3
40 | with:
41 | github-token: ${{ secrets.GITHUB_TOKEN }}
42 | target: minor
43 |
--------------------------------------------------------------------------------
/src/pages/live-preview.tsx:
--------------------------------------------------------------------------------
1 | import { marked } from 'marked';
2 | import React, { ChangeEventHandler } from 'react';
3 | import { parsePractices, withId } from '../practice-parser/lexer';
4 | import { Practices } from '../components/practices/practices';
5 | import { usePractices } from '../components/practices/practices.hook';
6 |
7 | const sample = '#### Q1. Normal Selection\n- [x] selection 1\n- [ ] selection 2';
8 |
9 | const LivePreviewPractices: React.FC = () => {
10 | const sampleLexer = marked.lexer(sample);
11 | const initPractices = withId(parsePractices(sampleLexer));
12 |
13 | const [practices, {
14 | handleSubmit,
15 | handleSelectionChange,
16 | setPractices,
17 | }] = usePractices(initPractices);
18 |
19 | const handleChange: ChangeEventHandler = (e) => {
20 | const lexer = marked.lexer(e.target.value);
21 | setPractices(withId(parsePractices(lexer)));
22 | };
23 |
24 | return (
25 |
35 | );
36 | };
37 |
38 | export default LivePreviewPractices;
39 |
--------------------------------------------------------------------------------
/src/components/common/local-storage.hook.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | /**
4 | *
5 | * @param {String} key The key to set in localStorage for this value
6 | * @param {Object} defaultValue The value to use if it is not already in localStorage
7 | * @param {{serialize: Function, deserialize: Function}} options The serialize and deserialize
8 | * functions to use (defaults to JSON.stringify and JSON.parse respectively)
9 | */
10 |
11 | /* istanbul ignore file */
12 | export function useLocalStorageState(
13 | key: string,
14 | defaultValue: T,
15 | overwritePrev: boolean = false,
16 | { serialize = JSON.stringify, deserialize = JSON.parse } = {},
17 | ) {
18 | const [state, setState] = React.useState(() => {
19 | const valueInLocalStorage = window.localStorage.getItem(key);
20 | if (valueInLocalStorage) {
21 | return deserialize(valueInLocalStorage);
22 | }
23 | return typeof defaultValue === 'function' ? defaultValue() : defaultValue;
24 | });
25 |
26 | const prevKeyRef = React.useRef(key);
27 |
28 | React.useEffect(() => {
29 | const prevKey = prevKeyRef.current;
30 | if (prevKey !== key && overwritePrev) {
31 | window.localStorage.removeItem(prevKey);
32 | }
33 | prevKeyRef.current = key;
34 | window.localStorage.setItem(key, serialize(state));
35 | }, [key, state, serialize]);
36 |
37 | return [state, setState] as const;
38 | }
39 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-ghpages.yml:
--------------------------------------------------------------------------------
1 | name: Deployment
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | repository_name:
7 | description: 'The repository name to correct gh-page base URL'
8 | required: true
9 | default: 'md2practice'
10 |
11 | env:
12 | VITE_GA_MEASUREMENT_ID: ${{ secrets.VITE_GA_MEASUREMENT_ID }}
13 |
14 | jobs:
15 | deploy:
16 | environment: production
17 | if: github.ref == 'refs/heads/main'
18 | runs-on: ubuntu-latest
19 | strategy:
20 | matrix:
21 | node-version: [17.x]
22 | steps:
23 | - uses: actions/checkout@v3
24 | - name: Use Node.js ${{ matrix.node-version }}
25 | uses: actions/setup-node@v3
26 | with:
27 | node-version: ${{ matrix.node-version }}
28 | - name: Cache dependencies
29 | id: cache-dependencies
30 | uses: actions/cache@v3
31 | with:
32 | path: '**/node_modules'
33 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}
34 | - name: Install dependencies
35 | if: steps.cache-dependencies.outputs.cache-hit != 'true'
36 | run: yarn --frozen-lockfile
37 | - name: Testing 🧪
38 | run: yarn run test
39 | - name: Build 👷
40 | run: yarn run build --base=${{ github.event.inputs.repository_name }}
41 | - name: Deploy 🚀
42 | uses: JamesIves/github-pages-deploy-action@v4.4.0
43 | with:
44 | branch: gh-pages
45 | folder: dist
46 | token: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/src/api/linkedin-practices.spec.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from 'fs';
2 | import { resolve } from 'path';
3 | import { fetchLinkedInPracticeInfos, getLinkedInPracticeInfos } from './linkedin-practices';
4 |
5 | describe('LinkedIn Practices API', () => {
6 | const mockedData = readFileSync(resolve(__dirname, './__mocks__/correct-menu.md'), 'utf-8');
7 |
8 | it('should be able get a link of LinkedIn info', () => {
9 | const data = getLinkedInPracticeInfos(mockedData, 'https://www.base.com');
10 | expect(data).toHaveLength(3);
11 | expect(data[0]).toMatchInlineSnapshot(`
12 | Object {
13 | "title": "Accounting",
14 | "url": "https://www.base.com/accounting/accounting-quiz.md",
15 | }
16 | `);
17 | expect(data[1]).toMatchInlineSnapshot(`
18 | Object {
19 | "title": "Adobe-Acrobat",
20 | "url": "https://www.base-2.com/adobe-acrobat/adobe-acrobat-quiz.md",
21 | }
22 | `);
23 | });
24 |
25 | it('should throw error if no LinkedIn table data found', () => {
26 | const mockedWrongData = readFileSync(resolve(__dirname, './__mocks__/wrong-menu.md'), 'utf-8');
27 | expect(() => getLinkedInPracticeInfos(mockedWrongData, 'https://www.base.com')).toThrowError(new Error('No LinkedIn Data Found.'));
28 | });
29 |
30 | // Not unit test, fetch directly from remote source, check whether got update.
31 | it('should be able fetch from LinkedIn info from repository', async () => {
32 | const data = await fetchLinkedInPracticeInfos();
33 | expect(data.length).toBeGreaterThan(70);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/pages/github-practices/github-practices.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { marked } from 'marked';
3 | import React, { useEffect } from 'react';
4 | import { parsePractices, withId } from '../../practice-parser/lexer';
5 | import { Practices } from '../../components/practices/practices';
6 | import { isEmptyPractices, usePracticesWithLocalStorage } from '../../components/practices/practices.hook';
7 | import { convertGithubLink, recorrectQuestionIndex } from './utils';
8 |
9 | interface GithubPracticesProps {
10 | githubLink: string;
11 | }
12 |
13 | const GithubPractices: React.FC = ({ githubLink }) => {
14 | const link = convertGithubLink(githubLink);
15 | const getPractices = () => axios.get(link)
16 | .then((response) => {
17 | const lexer = marked.lexer(response.data);
18 | const practices = recorrectQuestionIndex(parsePractices(lexer));
19 | return withId(practices);
20 | });
21 |
22 | const [practices, {
23 | handleSubmit,
24 | handleSelectionChange,
25 | setPractices,
26 | resetStorage,
27 | }] = usePracticesWithLocalStorage(link, {});
28 |
29 | useEffect(() => {
30 | if (isEmptyPractices(practices)) {
31 | getPractices().then((data) => {
32 | setPractices(data);
33 | });
34 | }
35 | }, [practices]);
36 |
37 | return (
38 |
45 | );
46 | };
47 |
48 | export default GithubPractices;
49 |
--------------------------------------------------------------------------------
/src/components/common/markdown-content.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import React from 'react';
3 | import ReactMarkdown from 'react-markdown';
4 | import { CodeProps, TransformImage } from 'react-markdown/lib/ast-to-react';
5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
6 | import { dracula, materialLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
7 |
8 | interface MarkdownContentProps {
9 | raw: string;
10 | baseImageURL?: string;
11 | }
12 |
13 | const components = (theme: string) => ({
14 | code({
15 | inline, className, children, ...props
16 | }: CodeProps) {
17 | const match = /language-(\w+)/.exec(className || '');
18 | return !inline && match ? (
19 |
26 | {String(children).replace(/\n$/, '')}
27 |
28 | ) : (
29 | {children}
30 | );
31 | },
32 | });
33 |
34 | const MarkdownContent = (
35 | { raw, baseImageURL }: MarkdownContentProps,
36 | ) => {
37 | const transformImageUri: TransformImage = (src) => {
38 | if (!src.startsWith('http') && baseImageURL) {
39 | return new URL(src, baseImageURL).toString();
40 | }
41 | return src;
42 | };
43 |
44 | return ({raw});
45 | };
46 |
47 | export default MarkdownContent;
48 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: [
7 | 'plugin:react/recommended',
8 | 'airbnb',
9 | 'plugin:jest/recommended',
10 | ],
11 | parser: '@typescript-eslint/parser',
12 | parserOptions: {
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | ecmaVersion: 'latest',
17 | sourceType: 'module',
18 | },
19 | plugins: [
20 | 'react',
21 | '@typescript-eslint',
22 | ],
23 | rules: {
24 | 'react/jsx-filename-extension': ['warn', { extensions: ['.tsx'] }],
25 | 'react/function-component-definition': [1, { namedComponents: 'arrow-function' }],
26 | 'react/require-default-props': 'off',
27 | 'import/extensions': ['error', 'ignorePackages', { ts: 'never', tsx: 'never' }],
28 | 'import/prefer-default-export': 'off',
29 | 'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.spec.ts', '**/*.spec.tsx', '**/*.mock.ts'] }],
30 | 'jest/no-mocks-import': 'off',
31 | '@typescript-eslint/no-unused-vars': ['error'],
32 | 'no-unused-vars': 'off',
33 | 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
34 | 'jsx-a11y/label-has-associated-control': [2, {
35 | labelComponents: ['CustomInputLabel'],
36 | labelAttributes: ['label'],
37 | controlComponents: ['CustomInput'],
38 | depth: 3,
39 | }],
40 | },
41 | overrides: [
42 | {
43 | files: ['*.ts', '*.tsx'],
44 | rules: {
45 | 'no-undef': 'off',
46 | },
47 | },
48 | ],
49 | settings: {
50 | 'import/resolver': {
51 | typescript: {
52 | project: './tsconfig.json',
53 | },
54 | },
55 | },
56 | };
57 |
--------------------------------------------------------------------------------
/src/api/linkedin-practices.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { marked } from 'marked';
3 |
4 | export interface LinkedInPracticeInfo {
5 | title: string;
6 | url: string;
7 | }
8 |
9 | type Table = marked.Tokens.Table
10 | type Link = marked.Tokens.Link
11 |
12 | export const getLinkedInPracticeInfos = (
13 | data: string,
14 | baseURL?: string,
15 | ): LinkedInPracticeInfo[] => {
16 | const tableData = marked.lexer(data).find((token) => {
17 | const isTable = token.type === 'table';
18 | if (isTable) {
19 | const table = token as Table;
20 | const isLinkedInTable = table.header[0].text === 'Linkedin-quiz-questions';
21 | return isLinkedInTable;
22 | }
23 | return isTable;
24 | }) as Table | undefined;
25 |
26 | if (!tableData) {
27 | throw new Error('No LinkedIn Data Found.');
28 | }
29 |
30 | const infos = tableData.rows.map((row) => {
31 | const link = row[0].tokens.find((token) => token.type === 'link') as Link | undefined;
32 | if (!link) {
33 | return { title: 'No Title', url: 'No URL' };
34 | }
35 | return { title: link.text, url: new URL(link.href, baseURL).toString() };
36 | }).filter((item) => item.title !== 'No Title');
37 |
38 | return infos;
39 | };
40 |
41 | export const fetchLinkedInPracticeInfos = async (): Promise => {
42 | // const BASE_URL = 'https://raw.githubusercontent.com/Ebazhanov/linkedin-skill-assessments-quizzes/master/';
43 | const MENU = 'https://raw.githubusercontent.com/Ebazhanov/linkedin-skill-assessments-quizzes/master/README.md';
44 | const response = await axios.get(MENU);
45 | return getLinkedInPracticeInfos(response.data, 'https://github.com/Ebazhanov/linkedin-skill-assessments-quizzes/blob/master/');
46 | };
47 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "md2practice",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "dev": "vite",
7 | "build": "tsc && vite build",
8 | "preview": "vite preview",
9 | "test": "eslint && jest"
10 | },
11 | "dependencies": {
12 | "@types/ramda": "^0.28.15",
13 | "@use-gesture/react": "^10.2.20",
14 | "axios": "^0.27.2",
15 | "marked": "^4.0.14",
16 | "ramda": "^0.28.0",
17 | "react": "^17.0.2",
18 | "react-cookie-consent": "^7.4.1",
19 | "react-dom": "^17.0.2",
20 | "react-error-boundary": "^3.1.4",
21 | "react-ga4": "^1.4.1",
22 | "react-highlight": "^0.14.0",
23 | "react-markdown": "^8.0.3",
24 | "react-router-dom": "6",
25 | "react-spring": "^9.4.4",
26 | "react-syntax-highlighter": "^15.5.0",
27 | "recharts": "^2.1.9"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "^7.19.3",
31 | "@babel/preset-env": "^7.19.3",
32 | "@testing-library/jest-dom": "^5.16.5",
33 | "@testing-library/react": "12.1.4",
34 | "@testing-library/react-hooks": "^8.0.1",
35 | "@testing-library/user-event": "^14.4.3",
36 | "@types/chance": "^1.1.3",
37 | "@types/jest": "^27.4.1",
38 | "@types/js-cookie": "^3.0.2",
39 | "@types/marked": "^4.0.3",
40 | "@types/react": "^17.0.43",
41 | "@types/react-dom": "^17.0.10",
42 | "@types/react-syntax-highlighter": "^13.5.2",
43 | "@typescript-eslint/eslint-plugin": "^5.40.1",
44 | "@typescript-eslint/parser": "^5.39.0",
45 | "@vitejs/plugin-react": "^1.3.1",
46 | "autoprefixer": "^10.4.5",
47 | "babel-jest": "^28.0.3",
48 | "chance": "^1.1.9",
49 | "eslint": "^8.14.0",
50 | "eslint-config-airbnb": "^19.0.4",
51 | "eslint-import-resolver-typescript": "^2.7.1",
52 | "eslint-plugin-import": "^2.26.0",
53 | "eslint-plugin-jest": "^26.1.5",
54 | "eslint-plugin-jsx-a11y": "^6.6.1",
55 | "eslint-plugin-react": "^7.29.4",
56 | "eslint-plugin-react-hooks": "^4.6.0",
57 | "jest": "^27.5.1",
58 | "postcss": "^8.4.12",
59 | "tailwindcss": "^3.1.8",
60 | "ts-jest": "^27.1.4",
61 | "ts-node": "^10.7.0",
62 | "typescript": "^4.6.3",
63 | "vite": "^2.9.6"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { withErrorBoundary } from 'react-error-boundary';
3 | import { HashRouter, Routes, Route } from 'react-router-dom';
4 | import CookieConsent, {
5 | getCookieConsentValue,
6 | Cookies,
7 | } from 'react-cookie-consent';
8 | import ReactGA from 'react-ga4';
9 | import { ErrorFallback } from './components/common/error-fallback';
10 | import { LinkedInPracticesPage } from './pages/linkedin-practices';
11 | import LinkedInPracticesMenuPage from './pages/linkedin-practices-menu';
12 | import LivePreviewPractices from './pages/live-preview';
13 |
14 | const GA_MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID;
15 | const { DEV } = import.meta.env;
16 |
17 | const App = () => {
18 | const [isConsent, setIsConsent] = useState(() => getCookieConsentValue());
19 |
20 | const handleAcceptCookie = () => {
21 | setIsConsent(getCookieConsentValue());
22 | };
23 |
24 | const handleDeclineCookie = () => {
25 | // remove google analytics cookies
26 | Cookies.remove('_ga');
27 | Cookies.remove('_gat');
28 | Cookies.remove('_gid');
29 | };
30 |
31 | useEffect(() => {
32 | if (isConsent) {
33 | if (DEV && GA_MEASUREMENT_ID) {
34 | ReactGA.initialize(GA_MEASUREMENT_ID, { gtagOptions: { debug_mode: true } });
35 | } else if (!DEV) {
36 | ReactGA.initialize(GA_MEASUREMENT_ID);
37 | }
38 | }
39 | }, [isConsent]);
40 |
41 | return (
42 |
43 |
44 |
45 | } />
46 | } />
47 | } />
48 |
49 |
50 |
55 | This website use cookies to know is there even people using this app.
56 |
57 |
58 | );
59 | };
60 |
61 | const AppWithErrorBoundary = withErrorBoundary(App, {
62 | FallbackComponent: ErrorFallback,
63 | });
64 |
65 | export default AppWithErrorBoundary;
66 |
--------------------------------------------------------------------------------
/src/components/practices/piechart.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | PieChart,
4 | Pie,
5 | Cell,
6 | ResponsiveContainer,
7 | PieLabelRenderProps,
8 | } from 'recharts';
9 |
10 | interface PieChartProps {
11 | data: { name: string; value: number }[];
12 | }
13 |
14 | const COLORS = ['#00C49F', '#B91C1C'];
15 | const RADIAN = Math.PI / 180;
16 |
17 | interface LabelProps extends PieLabelRenderProps {
18 | cx: number;
19 | cy: number;
20 | midAngle: number;
21 | innerRadius: number;
22 | outerRadius: number;
23 | percent: number;
24 | index: number;
25 | }
26 |
27 | const CustomPieChart = (props: PieChartProps) => {
28 | const { data } = props;
29 |
30 | // const data = [
31 | // { name: 'Group A', value: 400 },
32 | // { name: 'Group B', value: 300 },
33 | // { name: 'Group C', value: 300 },
34 | // { name: 'Group D', value: 200 },
35 | // ];
36 |
37 | /* istanbul ignore next */
38 | const renderCustomizedLabel = ({
39 | cx,
40 | cy,
41 | midAngle,
42 | innerRadius,
43 | outerRadius,
44 | percent,
45 | index,
46 | }: LabelProps) => {
47 | const radius = innerRadius + (outerRadius - innerRadius) * 0.3;
48 | const x = cx + radius * Math.cos(-midAngle * RADIAN);
49 | const y = cy + radius * Math.sin(-midAngle * RADIAN);
50 |
51 | return (
52 | cx ? 'start' : 'end'}
58 | dominantBaseline="central"
59 | >
60 | {`${(percent * 100).toFixed(0)}%[${data[index].value}]`}
61 |
62 | );
63 | };
64 |
65 | return (
66 |
67 |
68 |
78 | {data.map((entry, index) => (
79 | |
84 | ))}
85 |
86 |
87 |
88 | );
89 | };
90 |
91 | export default CustomPieChart;
92 |
--------------------------------------------------------------------------------
/src/practice-parser/lexer.spec.ts:
--------------------------------------------------------------------------------
1 | import { marked } from 'marked';
2 | import { parsePractice, parsePractices, withId } from './lexer';
3 | import { getPracticeByIndex, mockMarkdown } from './__mocks__/mock-markdown';
4 |
5 | describe('marked parser', () => {
6 | const mockedLexer = marked.lexer(mockMarkdown('lexer-mock.md'));
7 | it('should be able to parse question section by using first selection list as stopping point', () => {
8 | const mockPracticeLexer = getPracticeByIndex(mockedLexer, 0);
9 | const { rawQuestion } = parsePractice(mockPracticeLexer);
10 | expect(rawQuestion).toMatchInlineSnapshot(`
11 | "#### Q0. Question with different content?
12 |
13 | 
14 | 
15 | 
16 | \`with-codespan 1\`
17 | \`with-codespan 2\`
18 | \`with-codespan 3\`
19 | \`\`\`javascript
20 | const data = null
21 | \`\`\`
22 | \`\`\`javascript
23 | const data = null
24 | \`\`\`
25 |
26 | "
27 | `);
28 | });
29 |
30 | it('should be able to get normal selection and its answers', () => {
31 | const mockPracticeLexer = getPracticeByIndex(mockedLexer, 1);
32 | const { selection, answers } = parsePractice(mockPracticeLexer);
33 | expect(selection).toHaveLength(4);
34 | expect(answers).toEqual([2]);
35 | });
36 |
37 | it('should be able to get code selection and its answers', () => {
38 | const mockPracticeLexer = getPracticeByIndex(mockedLexer, 2);
39 | const { selection, answers } = parsePractice(mockPracticeLexer);
40 | expect(selection).toHaveLength(4);
41 | expect(answers).toEqual([0]);
42 | });
43 |
44 | it('should be able to throw error if no selection is found', () => {
45 | const mockPracticeLexer = getPracticeByIndex(mockedLexer, 3);
46 | expect(() => parsePractice(mockPracticeLexer)).toThrowError(new Error('No selection found.'));
47 | });
48 |
49 | it('should be able to parse multiple practice from an array', () => {
50 | const mockedPractices = parsePractices(mockedLexer);
51 | expect(mockedPractices).toHaveLength(3);
52 | });
53 |
54 | it('should be able to return with index as the practice id', () => {
55 | const mockedPractices = parsePractices(mockedLexer);
56 | const mockedPracticesWithId = withId(mockedPractices);
57 | expect(Object.keys(mockedPracticesWithId)).toEqual(['0', '1', '2']);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/api/__mocks__/correct-menu.md:
--------------------------------------------------------------------------------
1 | | Linkedin-quiz-questions | Questions | Answers | Your resource for answers. In case you have doubts please contact this person or add them to review your PR. | Translation |
2 | | ---------------------------------------------------------------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
3 | | [Accounting](accounting/accounting-quiz.md) | 31 | 21 | [@tujinwei](https://github.com/tujinwei) | |
4 | | [Adobe-Acrobat](https://www.base-2.com/adobe-acrobat/adobe-acrobat-quiz.md) | 19 | 19 | | |
5 | | Adobe-Illustrator | 76 | 62 | | |
6 | | [Adobe-InDesign](adobe-in-design/adobe-in-design-quiz.md) | 7 | 7 | | |
--------------------------------------------------------------------------------
/src/practice-parser/lexer.ts:
--------------------------------------------------------------------------------
1 | import { marked } from 'marked';
2 | import {
3 | OptionStatus, PracticeParams, PracticeStatus, SelectionOption,
4 | } from '../components/practice/practice';
5 | import { PracticesData } from '../components/practices/practices';
6 |
7 | const parseQuestion = (lexerData: marked.TokensList) => {
8 | const startIdx = lexerData.findIndex((item) => item.type === 'heading' && item.depth === 4);
9 | const endIdx = lexerData.findIndex((item) => item.type === 'list');
10 | return lexerData.slice(startIdx, endIdx).map((item) => item.raw).join('');
11 | };
12 |
13 | const isAnswer = (item: marked.Tokens.ListItem) => {
14 | if (item.checked) {
15 | return true;
16 | }
17 | if (item.raw.slice(0, 5).includes('- [x]')) {
18 | return true;
19 | }
20 | return false;
21 | };
22 |
23 | const parseSelectionAndAnswers = (lexerData: marked.TokensList) => {
24 | const selection: SelectionOption[] = [];
25 | const answers: number[] = [];
26 | const list = lexerData.find((item) => item.type === 'list') as marked.Tokens.List;
27 | if (list) {
28 | list.items.forEach((item, idx) => {
29 | if (isAnswer(item)) {
30 | answers.push(idx);
31 | }
32 | const cleanRaw = item.raw.replace('- [ ]', '').replace('- [x]', '');
33 | selection.push({ rawOption: cleanRaw, status: OptionStatus.IDLE });
34 | });
35 | } else {
36 | throw new Error('No selection found.');
37 | }
38 | return { selection, answers };
39 | };
40 |
41 | export const parsePractice = (lexerData: marked.TokensList): PracticeParams => {
42 | const question = parseQuestion(lexerData);
43 | const { selection, answers } = parseSelectionAndAnswers(lexerData);
44 | const result = {
45 | rawQuestion: question,
46 | selection,
47 | answers,
48 | status: PracticeStatus.IDLE,
49 | };
50 | return result;
51 | };
52 |
53 | export const parsePractices = (lexer: marked.TokensList): PracticeParams[] => {
54 | let chunk: marked.Token[] = [];
55 | const lexerPractices = lexer.reduce((result:marked.Token[][], token, idx) => {
56 | if (chunk.length > 0 && token.type === 'heading' && token.depth === 4) {
57 | result.push(chunk);
58 | chunk = [];
59 | }
60 | chunk.push(token);
61 |
62 | if (idx + 1 === lexer.length) {
63 | result.push(chunk);
64 | }
65 | return result;
66 | }, []);
67 |
68 | const practices = lexerPractices.reduce((result, curr) => {
69 | try {
70 | const practice = parsePractice(curr as marked.TokensList);
71 | if (practice.answers.length < 1) {
72 | throw new Error('No answer found.');
73 | }
74 | return [...result, practice];
75 | } catch (e) {
76 | return result;
77 | }
78 | }, [] as PracticeParams[]);
79 | return practices;
80 | };
81 |
82 | export const withId = (
83 | data: PracticeParams[],
84 | ): PracticesData => data.reduce((result, curr, idx) => ({
85 | ...result,
86 | [idx]: curr,
87 | }), {});
88 |
--------------------------------------------------------------------------------
/src/pages/linkedin-practices-menu.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import ReactGA from 'react-ga4';
4 | import Logo from '../assets/md2practice-logo.svg';
5 | import { fetchLinkedInPracticeInfos, LinkedInPracticeInfo } from '../api/linkedin-practices';
6 |
7 | const LinkedInCard: React.FC = ({ title, url }) => {
8 | const handleGA = () => {
9 | ReactGA.event(
10 | 'select_linkedin_practice',
11 | {
12 | github_link: url,
13 | },
14 | );
15 | };
16 |
17 | return (
18 | handleGA()}>
19 |
30 | {title}
31 |
32 |
33 | );
34 | };
35 |
36 | const LinkedInPracticesMenuPage: React.FC = () => {
37 | const [infos, setInfos] = useState([]);
38 | const [filteredInfos, setFilteredInfos] = useState([]);
39 |
40 | const handleFilterOnChange = (e: React.ChangeEvent) => {
41 | const filterValue = e.target.value.toLowerCase();
42 | const filtered = infos.filter((item) => item.title.toLowerCase().includes(filterValue));
43 | setFilteredInfos(filtered);
44 | };
45 |
46 | useEffect(() => {
47 | fetchLinkedInPracticeInfos().then((data) => {
48 | setInfos(data);
49 | setFilteredInfos(data);
50 | });
51 | ReactGA.send({ hitType: 'pageview', page: '/' });
52 | }, []);
53 |
54 | return (
55 | <>
56 |
60 |
61 |
62 |
63 | LinkedIn Practices Collection
64 |
65 |
66 | By MD2Practice
67 |
68 |
69 |

74 |
75 |
76 |
97 | >
98 | );
99 | };
100 |
101 | export default LinkedInPracticesMenuPage;
102 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/assets/md2practice-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/components/practices/practices.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import {
3 | useDrag, useScroll,
4 | } from '@use-gesture/react';
5 | import React, {
6 | createRef,
7 | memo, useEffect, useRef, useState,
8 | } from 'react';
9 | import { useSpring, animated } from 'react-spring';
10 | import Modal from '../common/modal';
11 | import Practice, { PracticeParams, SelectionOption } from '../practice/practice';
12 | import InformationPanel from './information-panel';
13 |
14 | export type PracticesData = Record
15 |
16 | const MemoizedPractice = memo(Practice);
17 |
18 | const Cube: React.FC = () => (
19 |
29 | );
30 | interface PracticesProps {
31 | data: PracticesData;
32 | baseImageURL?: string;
33 | onSubmit: (id: number, selection: SelectionOption[]) => void;
34 | onSelectionChange: (id: number, selection: SelectionOption[]) => void;
35 | onResetPractices?: () => void;
36 | }
37 |
38 | export const Practices: React.FC = (
39 | {
40 | data, baseImageURL, onSubmit, onSelectionChange, onResetPractices,
41 | },
42 | ) => {
43 | const [isOpen, setIsOpen] = useState(false);
44 | const [maxHeight, setMaxHeight] = useState(0);
45 | const wrapperRef = useRef(null);
46 | const practiceRefs = Object.keys(data).map(() => createRef());
47 | const [{ x: actionButtonX, y: actionButtonY }, api] = useSpring(() => ({ x: 0, y: 0 }));
48 |
49 | /* istanbul ignore next */
50 | useScroll(({ offset }) => {
51 | api.start({
52 | y: offset[1], immediate: true,
53 | });
54 | }, {
55 | from: () => [actionButtonX.get(), actionButtonY.get()],
56 | target: window,
57 | bounds: { top: 0, bottom: maxHeight },
58 | });
59 |
60 | /* istanbul ignore next */
61 | const bindAction = useDrag(({ offset: [x, y], down }) => {
62 | api.start({
63 | x: down ? x : 0, y, immediate: down,
64 | });
65 | }, {
66 | from: () => [actionButtonX.get(), actionButtonY.get()],
67 | bounds: wrapperRef,
68 | rubberband: true,
69 | filterTaps: true,
70 | });
71 |
72 | useEffect(() => {
73 | if (wrapperRef.current) {
74 | setMaxHeight(wrapperRef.current.clientHeight);
75 | }
76 | });
77 |
78 | const handleNavigatePractice = (id: number) => {
79 | setIsOpen(!isOpen);
80 | if (practiceRefs) {
81 | window.scrollTo({
82 | top: practiceRefs[id].current?.offsetTop,
83 | behavior: 'smooth',
84 | });
85 | }
86 | };
87 |
88 | return (
89 |
90 |
setIsOpen(!isOpen)}>
91 |
96 |
97 | {/*
*/}
110 |
111 | {Object.entries(data).map(([key, practice]) => (
112 |
117 |
127 |
128 | ))}
129 |
130 | );
131 | };
132 |
--------------------------------------------------------------------------------
/src/components/practices/practices.hook.spec.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act, RenderResult } from '@testing-library/react-hooks';
2 | import { OptionStatus, PracticeStatus } from '../practice/practice';
3 | import { PracticesData } from './practices';
4 | import { usePractices } from './practices.hook';
5 | import { mockedPractices } from './__mocks__/practices.mock';
6 |
7 | describe('Practices hook', () => {
8 | const initPractices = mockedPractices(3);
9 | const getCurrentValue = (
10 | result: RenderResult>,
11 | ) => result.current[0];
12 | const getHandleSubmit = (
13 | result: RenderResult>,
14 | ) => result.current[1].handleSubmit;
15 | const getHandleSelectionChange = (
16 | result: RenderResult>,
17 | ) => result.current[1].handleSelectionChange;
18 |
19 | it('should be able to set the practices state', () => {
20 | const { result } = renderHook(() => usePractices(initPractices));
21 | const newMockedPractice = mockedPractices(4);
22 | act(() => {
23 | result.current[1].setPractices(newMockedPractice);
24 | });
25 | expect(Object.keys(getCurrentValue(result))).toHaveLength(4);
26 | });
27 |
28 | it('should be able to change the selection state', () => {
29 | const { result } = renderHook(() => usePractices(initPractices));
30 | const practiceId = 0;
31 | const practice = getCurrentValue(result)[practiceId];
32 | const selection = [...practice.selection];
33 | selection[0].status = OptionStatus.SELECTED;
34 | act(() => {
35 | getHandleSelectionChange(result)(practiceId, selection);
36 | });
37 | expect(getCurrentValue(result)[practiceId].selection).toMatchObject(selection);
38 | });
39 |
40 | describe('should be able to submit result to change practices state, for radio option', () => {
41 | it('correct selection', () => {
42 | const practices: PracticesData = {
43 | ...initPractices,
44 | 0: {
45 | ...initPractices[0],
46 | answers: [0],
47 | },
48 | };
49 | const { result } = renderHook(() => usePractices(practices));
50 | act(() => {
51 | const selected = practices[0].selection;
52 | selected[0].status = OptionStatus.SELECTED;
53 | getHandleSubmit(result)(0, selected);
54 | });
55 | expect(getCurrentValue(result)[0].status).toEqual(PracticeStatus.CORRECT);
56 | });
57 |
58 | it('wrong selection', () => {
59 | const practices: PracticesData = {
60 | ...initPractices,
61 | 0: {
62 | ...initPractices[0],
63 | answers: [0],
64 | },
65 | };
66 | const { result } = renderHook(() => usePractices(practices));
67 | act(() => {
68 | const selected = practices[0].selection;
69 | selected[1].status = OptionStatus.SELECTED;
70 | getHandleSubmit(result)(0, selected);
71 | });
72 | expect(getCurrentValue(result)[0].status).toEqual(PracticeStatus.WRONG);
73 | });
74 | });
75 |
76 | describe('should be able to submit result to change practices state, for checkbox option', () => {
77 | it('correct selection', () => {
78 | const practices: PracticesData = {
79 | ...initPractices,
80 | 0: {
81 | ...initPractices[0],
82 | answers: [0, 1],
83 | },
84 | };
85 | const { result } = renderHook(() => usePractices(practices));
86 | act(() => {
87 | const selected = practices[0].selection;
88 | selected[0].status = OptionStatus.SELECTED;
89 | selected[1].status = OptionStatus.SELECTED;
90 | getHandleSubmit(result)(0, selected);
91 | });
92 | expect(getCurrentValue(result)[0].status).toEqual(PracticeStatus.CORRECT);
93 | });
94 |
95 | it('wrong selection', () => {
96 | const practices: PracticesData = {
97 | ...initPractices,
98 | 0: {
99 | ...initPractices[0],
100 | answers: [0, 1],
101 | },
102 | };
103 | const { result } = renderHook(() => usePractices(practices));
104 | act(() => {
105 | const selected = practices[0].selection;
106 | selected[2].status = OptionStatus.SELECTED;
107 | selected[3].status = OptionStatus.SELECTED;
108 | getHandleSubmit(result)(0, selected);
109 | });
110 | expect(getCurrentValue(result)[0].status).toEqual(PracticeStatus.WRONG);
111 | });
112 | });
113 | });
114 |
--------------------------------------------------------------------------------
/src/components/practices/practices.hook.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useReducer } from 'react';
2 | import { useLocalStorageState } from '../common/local-storage.hook';
3 | import { OptionStatus, PracticeStatus, SelectionOption } from '../practice/practice';
4 | import { PracticesData } from './practices';
5 |
6 | const arrSame = (arrA: any[], arrB: any[]) => {
7 | const intersection = arrA.filter((x) => arrB.includes(x));
8 | if (intersection.length !== arrA.length) {
9 | return false;
10 | }
11 | return true;
12 | };
13 |
14 | interface SubmitPracticeAction {
15 | type: 'submitPractice',
16 | payload: {
17 | id: number,
18 | selection: SelectionOption[]
19 | }
20 | }
21 |
22 | interface onSelectionChangeAction {
23 | type: 'onSelectionChange',
24 | payload: {
25 | id: number,
26 | selection: SelectionOption[]
27 | }
28 | }
29 |
30 | interface InitializationAction {
31 | type: 'setPractices',
32 | payload: PracticesData
33 | }
34 |
35 | const practiceReducer = (
36 | state: PracticesData,
37 | action: SubmitPracticeAction | InitializationAction | onSelectionChangeAction,
38 | ) => {
39 | const { type, payload } = action;
40 | switch (type) {
41 | case 'submitPractice': {
42 | const practices = state;
43 | const { id, selection } = payload;
44 | const practice = practices[id];
45 | const { answers } = practice;
46 |
47 | const newSelection = selection.map((item, idx) => {
48 | if (answers.includes(idx)) {
49 | return { ...item, status: OptionStatus.CORRECT };
50 | } if (item.status === OptionStatus.SELECTED && !answers.includes(idx)) {
51 | return { ...item, status: OptionStatus.WRONG };
52 | }
53 | return item;
54 | });
55 |
56 | const selectedIndices = selection.reduce(
57 | (result: number[], curr, index) => (
58 | curr.status === OptionStatus.SELECTED ? result.concat(index) : result),
59 | [],
60 | );
61 |
62 | const practiceStatus = arrSame(selectedIndices, answers)
63 | ? PracticeStatus.CORRECT : PracticeStatus.WRONG;
64 |
65 | const newPractices = {
66 | ...practices,
67 | [id]: {
68 | ...practices[id],
69 | selection: newSelection,
70 | status: practiceStatus,
71 | },
72 | };
73 | return newPractices;
74 | }
75 | case 'onSelectionChange': {
76 | const { id, selection } = payload;
77 | const practices = state;
78 | const newPractices = {
79 | ...practices,
80 | [id]: {
81 | ...practices[id],
82 | selection,
83 | },
84 | };
85 | return newPractices;
86 | }
87 | case 'setPractices': {
88 | return payload;
89 | }
90 | /* istanbul ignore next */
91 | default:
92 | return state;
93 | }
94 | };
95 |
96 | export const usePractices = (
97 | initPractices: PracticesData,
98 | ) => {
99 | const [practices, dispatch] = useReducer(
100 | practiceReducer,
101 | initPractices,
102 | );
103 |
104 | // useCallback to memoize the function (during props drill) so that the list of practice component
105 | // will not rerender every practices state changed.
106 | const handleSubmit = useCallback((
107 | id: number,
108 | selection: SelectionOption[],
109 | ) => {
110 | dispatch({ type: 'submitPractice', payload: { id, selection } });
111 | }, []);
112 |
113 | const handleSelectionChange = useCallback((
114 | id: number,
115 | selection: SelectionOption[],
116 | ) => {
117 | dispatch({ type: 'onSelectionChange', payload: { id, selection } });
118 | }, []);
119 |
120 | const setPractices = (
121 | data: PracticesData,
122 | ) => {
123 | dispatch({ type: 'setPractices', payload: data });
124 | };
125 |
126 | return [practices, { handleSubmit, handleSelectionChange, setPractices }] as const;
127 | };
128 |
129 | /* istanbul ignore next */
130 | export const usePracticesWithLocalStorage = (id: string, initPractices: PracticesData) => {
131 | const [storage, setStorage] = useLocalStorageState(id, initPractices);
132 | const [practices, { handleSubmit, handleSelectionChange, setPractices }] = usePractices(storage);
133 |
134 | useEffect(() => {
135 | setStorage(practices);
136 | }, [practices]);
137 |
138 | const resetStorage = () => {
139 | setStorage({});
140 | setPractices({});
141 | };
142 |
143 | return [practices, {
144 | handleSubmit, handleSelectionChange, setPractices, resetStorage,
145 | }] as const;
146 | };
147 |
148 | export const isEmptyPractices = (data: PracticesData) => Object.keys(data).length === 0;
149 |
--------------------------------------------------------------------------------
/src/components/practice/practice.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MarkdownContent from '../common/markdown-content';
3 |
4 | // eslint-disable-next-line no-shadow
5 | export enum OptionStatus {
6 | IDLE = 'idle',
7 | SELECTED = 'selected',
8 | CORRECT = 'correct',
9 | WRONG = 'wrong'
10 | }
11 | export interface SelectionOption {
12 | rawOption: string;
13 | status: OptionStatus
14 | }
15 |
16 | // eslint-disable-next-line no-shadow
17 | export enum PracticeStatus {
18 | IDLE = 'idle',
19 | CORRECT = 'correct',
20 | WRONG = 'wrong'
21 | }
22 | export interface PracticeParams {
23 | rawQuestion: string;
24 | selection: SelectionOption[];
25 | answers: number[]
26 | status: PracticeStatus
27 | }
28 |
29 | interface PracticeProps extends PracticeParams {
30 | id: number;
31 | baseImageURL?: string;
32 | onSubmit: (id: number, selection: SelectionOption[]) => void;
33 | onSelectionChange: (id: number, selection: SelectionOption[]) => void;
34 | }
35 |
36 | interface OptionProps {
37 | optionId: number;
38 | disabled: boolean;
39 | optionStatus: OptionStatus
40 | type: 'checkbox' | 'radio'
41 | onChange: (optionId: number) => void
42 | }
43 |
44 | const Option: React.FC = ({
45 | onChange, optionId, children, optionStatus, type, disabled,
46 | }) => {
47 | const getColorClassname = () => {
48 | switch (optionStatus) {
49 | case OptionStatus.CORRECT:
50 | return 'bg-gradient-to-r from-green-800';
51 | case OptionStatus.WRONG:
52 | return 'bg-gradient-to-r from-red-800';
53 | default:
54 | return '';
55 | }
56 | };
57 |
58 | const checked = optionStatus !== OptionStatus.IDLE;
59 |
60 | const selectedStyle = checked ? 'rounded-l-full' : '';
61 |
62 | return (
63 |
64 |
75 |
76 | );
77 | };
78 |
79 | const Practice:React.FC = ({
80 | id, rawQuestion, selection, answers, status, onSubmit, onSelectionChange, baseImageURL,
81 | }) => {
82 | const isMultiple = answers.length > 1;
83 |
84 | const getButtonColor = () => {
85 | switch (status) {
86 | case PracticeStatus.CORRECT:
87 | return 'bg-green-800';
88 | case PracticeStatus.WRONG:
89 | return 'bg-red-800';
90 | default:
91 | return 'bg-secondary-dark-700';
92 | }
93 | };
94 |
95 | const handleRadioChange = (optionId: number) => {
96 | const newSelection = selection.map((item, idx) => {
97 | const newStatus = idx === optionId ? OptionStatus.SELECTED : OptionStatus.IDLE;
98 | return { ...item, status: newStatus };
99 | });
100 | onSelectionChange(id, newSelection);
101 | };
102 |
103 | const handleCheckboxChange = (optionId: number) => {
104 | const newSelection = [...selection];
105 | const oldStatus = selection[optionId].status;
106 | newSelection[optionId].status = oldStatus === OptionStatus.SELECTED
107 | ? OptionStatus.IDLE : OptionStatus.SELECTED;
108 | onSelectionChange(id, newSelection);
109 | };
110 |
111 | return (
112 |
113 |
114 |
115 |
116 |
117 | {selection.map((item, idx) => (
118 |
129 | ))}
130 |
131 |
140 |
141 | );
142 | };
143 |
144 | export default Practice;
145 |
--------------------------------------------------------------------------------
/src/components/practices/information-panel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { PracticeStatus } from '../practice/practice';
4 | import CustomPieChart from './piechart';
5 | import { PracticesData } from './practices';
6 |
7 | const HomeSVG: React.FC = () => (
8 |
11 | );
12 |
13 | const RefreshSVG: React.FC = () => (
14 |
17 | );
18 |
19 | interface InformationPanelProps {
20 | data: PracticesData
21 | onNavigatePractice: (id: number) =>void
22 | onResetPractices?: () => void
23 | }
24 |
25 | const InformationPanel: React.FC = ({
26 | data, onNavigatePractice, onResetPractices,
27 | }) => {
28 | const navigate = useNavigate();
29 | const practiceItems = Object.values(data);
30 | const idle = practiceItems.filter((item) => item.status === PracticeStatus.IDLE).length;
31 | const wrong = practiceItems.filter((item) => item.status === PracticeStatus.WRONG).length;
32 | const correct = practiceItems.filter((item) => item.status === PracticeStatus.CORRECT).length;
33 | const done = correct + wrong;
34 |
35 | const getStatusColor = (status: PracticeStatus) => {
36 | switch (status) {
37 | case PracticeStatus.CORRECT:
38 | return 'text-green-800 font-bold';
39 | case PracticeStatus.WRONG:
40 | return 'text-red-800 font-bold';
41 | default:
42 | return '';
43 | }
44 | };
45 |
46 | return (
47 | e.stopPropagation()}
51 | role="presentation"
52 | >
53 |
63 |
64 |
65 |
66 |
67 | FINISHED:
68 | {done}
69 |
70 |
71 | IDLE:
72 | {idle}
73 |
74 | {done !== 0 ? (
75 |
76 |
77 |
78 | ) : (
79 |
No Data Available Yet.
80 | )}
81 |
82 |
83 |
84 |
FINISHED
85 |
{done}
86 |
87 |
88 |
IDLE
89 |
{idle}
90 |
91 |
92 |
93 |
94 |
99 | {Object.entries(data).map(([id, item]) => (
100 |
109 | ))}
110 |
111 |
112 | );
113 | };
114 |
115 | export default InformationPanel;
116 |
--------------------------------------------------------------------------------
/src/components/practices/practices.spec.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, within } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import React from 'react';
4 | import { MemoryRouter } from 'react-router-dom';
5 | import { Practices } from './practices';
6 | import { mockedPractices } from './__mocks__/practices.mock';
7 | import '@testing-library/jest-dom';
8 | import { PracticeStatus } from '../practice/practice';
9 |
10 | jest.mock('recharts', () => {
11 | const original = jest.requireActual('recharts');
12 | return {
13 | ...original,
14 | // eslint-disable-next-line react/jsx-props-no-spreading
15 | ResponsiveContainer: (props:any) => ,
16 | };
17 | });
18 | // jest.mock('react-router-dom', () => {
19 | // const original = jest.requireActual('react-router-dom');
20 | // return {
21 | // ...original,
22 | // // eslint-disable-next-line react/jsx-props-no-spreading
23 | // useNavigate: () => jest.fn(),
24 | // };
25 | // });
26 |
27 | const renderWithRouterProvider = (children: JSX.Element) => {
28 | render({children});
29 | };
30 |
31 | describe('Practices Component', () => {
32 | const TOTAL_MOCKED_PRACTICES = 3;
33 | const defaultPractices = mockedPractices(TOTAL_MOCKED_PRACTICES);
34 | const mockedHandleSubmit = jest.fn();
35 | const mockedHandleChange = jest.fn();
36 |
37 | it('should be able to render a set of practices', () => {
38 | const alotPractices = mockedPractices(20);
39 | renderWithRouterProvider();
44 | const practices = screen.getAllByTestId('practice-component');
45 | expect(practices).toHaveLength(20);
46 | });
47 |
48 | it('should be able to submit one of the practice', async () => {
49 | const practiceId = 1;
50 | renderWithRouterProvider();
55 | const practices = screen.getAllByTestId('practice-component');
56 | expect(practices).toHaveLength(TOTAL_MOCKED_PRACTICES);
57 | const submit = within(practices[practiceId]).getByRole('button');
58 | await userEvent.click(submit);
59 | expect(mockedHandleSubmit).toBeCalled();
60 | });
61 |
62 | it('should be able to display and close information panel', async () => {
63 | renderWithRouterProvider();
68 |
69 | const actionButton = screen.getByTestId('action-button');
70 | await userEvent.click(actionButton);
71 | expect(screen.queryByTestId('information-panel')).toBeInTheDocument();
72 | const modal = screen.getByTestId('modal');
73 | await userEvent.click(modal);
74 | expect(screen.queryByTestId('information-panel')).not.toBeInTheDocument();
75 | });
76 |
77 | describe('Information Panel', () => {
78 | it('should navigate to the practice selected in the information panel', async () => {
79 | window.scrollTo = jest.fn();
80 | renderWithRouterProvider();
85 | const actionButton = screen.getByTestId('action-button');
86 | await userEvent.click(actionButton);
87 | const practice = screen.getByTestId(`practice-navigation-${TOTAL_MOCKED_PRACTICES - 1}`);
88 | await userEvent.click(practice);
89 | expect(window.scrollTo).toBeCalled();
90 | });
91 |
92 | it('should display result visualization if there is question answered', async () => {
93 | const practices = mockedPractices(3);
94 | practices[0].status = PracticeStatus.CORRECT;
95 | practices[1].status = PracticeStatus.WRONG;
96 | renderWithRouterProvider();
101 | const actionButton = screen.getByTestId('action-button');
102 | await userEvent.click(actionButton);
103 | expect(screen.queryByTestId('information-panel')).toBeInTheDocument();
104 | expect(screen.queryByTestId('result-visualization')).toBeInTheDocument();
105 | });
106 |
107 | it('should reset practice if reset button is clicked', async () => {
108 | const resetHandler = jest.fn();
109 | renderWithRouterProvider();
115 | const actionButton = screen.getByTestId('action-button');
116 | await userEvent.click(actionButton);
117 | await userEvent.click(screen.getByTestId('reset-button'));
118 | expect(resetHandler).toBeCalled();
119 | // Test home button is working
120 | await userEvent.click(screen.getByTestId('homepage-button'));
121 | });
122 | });
123 | });
124 |
--------------------------------------------------------------------------------
/src/components/practice/practice.spec.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import React from 'react';
4 | import Practice, { OptionStatus, PracticeStatus } from './practice';
5 | import { mockedPractice, mockedSelection } from './__mocks__/data.mock';
6 | import '@testing-library/jest-dom';
7 |
8 | describe('test practice component', () => {
9 | const defaultPractice = mockedPractice(mockedSelection(4));
10 | const mockedHandleSubmit = jest.fn();
11 | const mockedHandleOnChange = jest.fn();
12 | const CORRECT_CLASS = 'from-green-800';
13 | const WRONG_CLASS = 'from-red-800';
14 |
15 | it('should be able to render data, like question and selection correctly', () => {
16 | const data = defaultPractice;
17 | render();
26 | const question = screen.getByLabelText('question-container');
27 | expect(question).toHaveTextContent(data.rawQuestion);
28 | data.selection.forEach((item) => {
29 | expect(screen.getByText(item.rawOption)).toBeInTheDocument();
30 | });
31 | });
32 |
33 | it('should be able to submit data', async () => {
34 | const data = defaultPractice;
35 | render();
44 | const button = screen.getByRole('button');
45 | await userEvent.click(button);
46 | expect(mockedHandleSubmit).toBeCalled();
47 | });
48 |
49 | it('should be able to check on radiobox', async () => {
50 | const data = defaultPractice;
51 | render();
60 | const options = screen.getAllByRole('radio');
61 | await userEvent.click(options[1]);
62 | expect(mockedHandleOnChange).toBeCalledWith(1, expect.arrayContaining([
63 | expect.objectContaining({
64 | rawOption: data.selection[1].rawOption,
65 | status: OptionStatus.SELECTED,
66 | }),
67 | ]));
68 | await userEvent.click(options[0]);
69 | expect(mockedHandleOnChange).toBeCalledWith(1, expect.arrayContaining([
70 | expect.objectContaining({
71 | rawOption: data.selection[0].rawOption,
72 | status: OptionStatus.SELECTED,
73 | }),
74 | ]));
75 |
76 | const button = screen.getByRole('button');
77 | await userEvent.click(button);
78 | expect(mockedHandleSubmit).toBeCalledWith(1, data.selection);
79 | });
80 |
81 | it('should be able to check on checkbox', async () => {
82 | const data = defaultPractice;
83 | render();
92 | const options = screen.getAllByRole('checkbox');
93 | await userEvent.click(options[1]);
94 | await userEvent.click(options[0]);
95 | await userEvent.click(options[2]);
96 | await userEvent.click(options[2]);
97 | expect(mockedHandleOnChange).toBeCalledWith(1, expect.arrayContaining([
98 | {
99 | rawOption: data.selection[0].rawOption,
100 | status: OptionStatus.SELECTED,
101 | },
102 | {
103 | rawOption: data.selection[1].rawOption,
104 | status: OptionStatus.SELECTED,
105 | },
106 | ]));
107 |
108 | const button = screen.getByRole('button');
109 | await userEvent.click(button);
110 | expect(mockedHandleSubmit).toBeCalledWith(1, data.selection);
111 | });
112 |
113 | it('should be able to show the correct answer for radio group', () => {
114 | const selection = mockedSelection(4);
115 | selection[0].status = OptionStatus.CORRECT;
116 | const practice = mockedPractice(selection);
117 | practice.status = PracticeStatus.CORRECT;
118 |
119 | render();
128 | const options = screen.getAllByRole('radio');
129 | expect(options[0].parentElement).toHaveClass(CORRECT_CLASS);
130 | expect(1).toBe(1);
131 | });
132 |
133 | it('should be able to show the wrong answer for radio group', () => {
134 | const selection = mockedSelection(4);
135 | selection[0].status = OptionStatus.WRONG;
136 | selection[1].status = OptionStatus.CORRECT;
137 | const practice = mockedPractice(selection);
138 | practice.status = PracticeStatus.WRONG;
139 |
140 | render();
149 | const options = screen.getAllByRole('radio');
150 | // Wrong option
151 | expect(options[0].parentElement).toHaveClass(WRONG_CLASS);
152 | // Indicate correct option
153 | expect(options[1].parentElement).toHaveClass(CORRECT_CLASS);
154 | expect(1).toBe(1);
155 | });
156 | });
157 |
--------------------------------------------------------------------------------