├── .env.development ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── .prettierrc.js ├── .vscode └── settings.json ├── README.md ├── commitlint.config.js ├── index.d.ts ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── solid.svg ├── src ├── app │ ├── App.app.tsx │ ├── ErrorBoundary.app.tsx │ └── RootProvider.app.tsx ├── assets │ └── solidjs.webp ├── env.d.ts ├── index.css ├── index.tsx ├── lib │ └── wc │ │ ├── MyCounter.constant.ts │ │ └── MyCounter.wc.tsx ├── mocks │ ├── browser.mock.ts │ ├── file.mock.ts │ ├── http │ │ ├── endpoints │ │ │ ├── auth.endpoint.ts │ │ │ └── todo.endpoint.ts │ │ ├── entities.http.ts │ │ ├── handlers.http.ts │ │ └── server.http.ts │ ├── module.mock.ts │ └── util.mock.ts ├── modules │ ├── auth │ │ ├── api │ │ │ ├── auth.api.ts │ │ │ └── auth.schema.ts │ │ ├── components │ │ │ └── LoginForm │ │ │ │ ├── LoginForm.component.tsx │ │ │ │ ├── LoginForm.test.tsx │ │ │ │ └── useLoginForm.hook.ts │ │ ├── constants │ │ │ └── login.constant.ts │ │ └── pages │ │ │ ├── Login │ │ │ ├── Login.page.test.tsx │ │ │ ├── Login.page.tsx │ │ │ └── Login.vm.tsx │ │ │ └── NotFound │ │ │ ├── NotFound.page.test.tsx │ │ │ ├── NotFound.page.tsx │ │ │ └── NotFound.vm.tsx │ ├── home │ │ ├── components │ │ │ └── HomeClock │ │ │ │ ├── HomeClock.component.tsx │ │ │ │ ├── HomeClock.test.tsx │ │ │ │ └── useHomeClock.hook.ts │ │ └── pages │ │ │ └── Home │ │ │ ├── Home.page.test.tsx │ │ │ ├── Home.page.tsx │ │ │ └── Home.vm.tsx │ ├── playground │ │ ├── components │ │ │ ├── Directive │ │ │ │ └── Directive.component.tsx │ │ │ ├── Resource │ │ │ │ └── Resource.component.tsx │ │ │ └── WebComponents │ │ │ │ └── WebComponents.component.tsx │ │ └── pages │ │ │ └── Playground.page.tsx │ ├── setting │ │ └── api │ │ │ └── setting.schema.ts │ ├── shared │ │ ├── api │ │ │ └── api.schema.ts │ │ ├── components │ │ │ ├── atoms │ │ │ │ ├── SvgIcon.atom.tsx │ │ │ │ └── index.ts │ │ │ ├── molecules │ │ │ │ ├── SuspenseWithFallbackSpinner │ │ │ │ │ └── SuspenseWithFallbackSpinner.molecule.tsx │ │ │ │ ├── Toaster │ │ │ │ │ ├── Toaster.molecule.tsx │ │ │ │ │ └── style.css │ │ │ │ ├── ToasterPromise │ │ │ │ │ └── ToasterPromise.molecule.tsx │ │ │ │ └── index.ts │ │ │ ├── organisms │ │ │ │ ├── Navbar │ │ │ │ │ ├── Navbar.organism.tsx │ │ │ │ │ ├── Navbar.test.tsx │ │ │ │ │ └── useNavbar.hook.ts │ │ │ │ ├── NavbarMenuContent │ │ │ │ │ ├── NavbarMenuContent.molecule.tsx │ │ │ │ │ ├── NavbarMenuContent.test.tsx │ │ │ │ │ └── useNavbarMenuContent.hook.ts │ │ │ │ └── index.ts │ │ │ └── templates │ │ │ │ ├── PageWrapper │ │ │ │ └── PageWrapper.template.tsx │ │ │ │ └── index.ts │ │ ├── configs │ │ │ ├── env │ │ │ │ ├── env.config.test.ts │ │ │ │ └── env.config.ts │ │ │ └── locale │ │ │ │ ├── auth.locale.ts │ │ │ │ ├── common.locale.ts │ │ │ │ ├── home.locale.ts │ │ │ │ ├── locale.config.test.ts │ │ │ │ ├── locale.config.ts │ │ │ │ ├── locale.type.ts │ │ │ │ ├── playground.locale.ts │ │ │ │ └── todo.locale.ts │ │ ├── constants │ │ │ └── global.constant.ts │ │ ├── directives │ │ │ └── clickOutside.directive.tsx │ │ ├── hooks │ │ │ ├── createColorMode │ │ │ │ └── createColorMode.hook.ts │ │ │ ├── createLocalStore │ │ │ │ ├── createLocalStore.hook.test.tsx │ │ │ │ └── createLocalStore.hook.ts │ │ │ ├── useAppStorage │ │ │ │ └── useAppStorage.hook.ts │ │ │ ├── useAppStore │ │ │ │ └── useAppStore.hook.ts │ │ │ ├── useAuth │ │ │ │ ├── useAuth.hook.test.tsx │ │ │ │ └── useAuth.hook.tsx │ │ │ └── usei18n │ │ │ │ ├── usei18n.hook.test.tsx │ │ │ │ └── usei18n.hook.ts │ │ ├── services │ │ │ └── api │ │ │ │ └── http.api.ts │ │ ├── types │ │ │ ├── form.type.ts │ │ │ └── store.type.ts │ │ └── utils │ │ │ ├── checker │ │ │ └── checker.util.ts │ │ │ ├── formatter │ │ │ └── formatter.util.ts │ │ │ ├── helper │ │ │ ├── helper.util.test.ts │ │ │ └── helper.util.ts │ │ │ ├── mapper │ │ │ └── mapper.util.ts │ │ │ └── test.util.tsx │ └── todo │ │ ├── api │ │ ├── todo.api.ts │ │ └── todo.schema.ts │ │ ├── components │ │ ├── TodosCreate │ │ │ ├── TodosCreate.component.tsx │ │ │ ├── TodosCreate.test.tsx │ │ │ └── useTodosCreate.hook.tsx │ │ ├── TodosFilter │ │ │ ├── TodosFilter.component.tsx │ │ │ ├── TodosFilter.test.tsx │ │ │ └── useTodosFilter.hook.tsx │ │ ├── TodosItem │ │ │ ├── TodosItem.component.tsx │ │ │ ├── TodosItem.test.tsx │ │ │ └── useTodosItem.hook.tsx │ │ └── TodosList │ │ │ ├── TodosList.component.tsx │ │ │ ├── TodosList.test.tsx │ │ │ └── useTodosList.hook.tsx │ │ ├── constants │ │ └── todos.constant.ts │ │ ├── hooks │ │ ├── useTodo │ │ │ └── useTodo.hook.ts │ │ ├── useTodoCreate │ │ │ └── useTodoCreate.hook.ts │ │ ├── useTodoDelete │ │ │ └── useTodoDelete.hook.tsx │ │ ├── useTodoUpdate │ │ │ └── useTodoUpdate.hook.tsx │ │ └── useTodos │ │ │ └── useTodos.hook.ts │ │ └── pages │ │ ├── Todo │ │ ├── Todo.data.test.tsx │ │ ├── Todo.data.tsx │ │ ├── Todo.page.test.tsx │ │ ├── Todo.page.tsx │ │ └── Todo.vm.tsx │ │ └── Todos │ │ ├── Todos.data.tsx │ │ ├── Todos.page.test.tsx │ │ └── Todos.page.tsx └── setup-test.ts ├── tailwind.config.ts ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.paths.json ├── vercel.json └── vite.config.ts /.env.development: -------------------------------------------------------------------------------- 1 | VITE_APP_TITLE="Solid Template - Development" 2 | VITE_API_BASE_URL="https://dummyjson.com" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | html 2 | dist 3 | __mocks__ 4 | public/mockServiceWorker.js 5 | index.d.ts 6 | .eslintrc.js 7 | babel.config.js 8 | metro.config.js 9 | jest.config.js 10 | tailwind.config.js 11 | postcss.config.js 12 | commitlint.config.js 13 | coverage 14 | .vscode 15 | .yarn 16 | .husky -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, node: true, es2020: true }, 4 | parser: '@typescript-eslint/parser', 5 | parserOptions: { 6 | sourceType: 'module', 7 | project: ['./tsconfig.json'], 8 | }, 9 | plugins: [ 10 | '@typescript-eslint', 11 | 'solid', 12 | 'jsx-a11y', 13 | '@tanstack/query', 14 | 'testing-library', 15 | 'jest-dom', 16 | ], 17 | extends: [ 18 | 'airbnb-base', 19 | 'airbnb-typescript/base', 20 | 'prettier', 21 | 'eslint:recommended', 22 | 'plugin:jsx-a11y/recommended', 23 | 'plugin:@typescript-eslint/recommended', 24 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 25 | 'plugin:solid/typescript', 26 | 'plugin:@tanstack/eslint-plugin-query/recommended', 27 | 'plugin:jest-dom/recommended', 28 | 'plugin:tailwindcss/recommended', 29 | 'plugin:testing-library/react', 30 | ], 31 | rules: { 32 | 'import/no-cycle': 'off', 33 | 'import/prefer-default-export': 'off', 34 | 'import/no-extraneous-dependencies': 'off', 35 | 'no-void': 'off', 36 | 'no-nested-ternary': 'off', 37 | 'testing-library/no-node-access': ['error', { allowContainerFirstChild: true }], 38 | '@tanstack/query/exhaustive-deps': 'off', 39 | 'tailwindcss/no-custom-classname': 'off', 40 | 'tailwindcss/classnames-order': 'warn', 41 | }, 42 | settings: { 43 | tailwindcss: { 44 | callees: ['classnames', 'clsx', 'ctl', 'tw', 'twMerge', 'twJoin'], 45 | config: 'tailwind.config.ts', 46 | // classRegex: '^class(Name)?$', // can be modified to support custom attributes. E.g. "^tw$" for `twin.macro` 47 | }, 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /html 11 | 12 | # production 13 | /build 14 | 15 | # MSW browser mocking init file 16 | public/mockServiceWorker.js 17 | 18 | # misc 19 | .DS_Store 20 | .env.* 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | node_modules 27 | dist 28 | dist-ssr 29 | *.local 30 | 31 | # Editor directories and files 32 | .vscode/* 33 | !.vscode/extensions.json 34 | .idea 35 | .DS_Store 36 | *.suo 37 | *.ntvs* 38 | *.njsproj 39 | *.sln 40 | *.sw? 41 | 42 | 43 | .pnp.* 44 | .yarn/* 45 | !.yarn/patches 46 | !.yarn/plugins 47 | !.yarn/releases 48 | !.yarn/sdks 49 | !.yarn/versions 50 | 51 | .yarn/install-state.gz 52 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .yarn 3 | .vscode 4 | .husky 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | quoteProps: 'as-needed', 8 | jsxSingleQuote: false, 9 | trailingComma: 'all', 10 | bracketSpacing: true, 11 | bracketSameLine: false, 12 | arrowParens: 'always', 13 | rangeStart: 0, 14 | rangeEnd: Infinity, 15 | requirePragma: false, 16 | insertPragma: false, 17 | proseWrap: 'preserve', 18 | htmlWhitespaceSensitivity: 'css', 19 | endOfLine: 'lf', 20 | embeddedLanguageFormatting: 'auto', 21 | }; 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | 3 | [![DeepScan grade](https://deepscan.io/api/teams/13942/projects/24678/branches/761600/badge/grade.svg)](https://deepscan.io/dashboard#view=project&tid=13942&pid=24678&bid=761600) 4 | 5 | Solid template built with: 6 | 7 | - `vite` + `typescript` + `eslint` + `prettier` -> dev productivity 8 | - `@solid-primitives` -> common primitives (similar to custom hooks or `react-use` in React) 9 | - `@solidjs/router` -> routing 10 | - `vitest` + `@solidjs/testing-library` -> unit test, integration test, coverage 11 | - `msw` -> browser and server mocking 12 | - `tailwindcss` + `tailwindcss-animate` + `tailwind-merge` + `daisyui` -> styling 13 | - `@formkit/auto-animate` -> automate transition animation when component mount/unmount 14 | - `@kobalte/core` -> unstyled UI component library (similar to `radix-ui` in React) 15 | - `axios` + `@tanstack/solid-query` -> data fetching 16 | - `zod` -> schema validation 17 | - `@felte/solid` -> form management 18 | - `@iconify-icon/solid` -> icon on demand (based on web-component) 19 | - `type-fest` -> useful type helpers 20 | 21 | ## Development 22 | 23 | ```bash 24 | # install deps 25 | $ pnpm install 26 | 27 | # init msw for browser mocking 28 | $ pnpm msw:init 29 | 30 | # Runs the app 31 | $ pnpm start 32 | ``` 33 | 34 | ```bash 35 | # run test 36 | $ pnpm test 37 | 38 | # coverage with instanbul 39 | $ pnpm test:coverage 40 | ``` 41 | 42 | ## Build 43 | 44 | Builds the app for production to the `dist` folder.
45 | It correctly bundles Solid in production mode and optimizes the build for the best performance. 46 | 47 | The build is minified and the filenames include the hashes.
48 | Your app is ready to be deployed! 49 | 50 | ```bash 51 | # build app 52 | $ pnpm build 53 | ``` 54 | 55 | ## Deployment 56 | 57 | You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) 58 | 59 | ## Notes 60 | 61 | Todos: 62 | 63 | - [ ] fix all tests 64 | - [ ] add `/docs` folder, including all my decisions why or technical considerations. 65 | - [x] use router routes configuration object, instead of `Routes`. Currently `v0.8.2` not possible. Currently, using router configuration object OR separate the routes in a component breaks the app. `Uncaught Error: Make sure your app is wrapped in a ` 66 | - [x] integrate `solid-devtools`. Currently `v0.27.3` not possible. Using `"type": "module"` in `package.json` breaks the app. Importing `import 'solid-devtools'` in `index.tsx` also breaks the app. 67 | - [x] solidjs inside react. Currently `reactjs-solidjs-bridge` library exists, but the DX is so horrible. If we want to run both libraries side-by-side, we need to have separate Vite configs for React and Solid JSX. While both are JSX, they require different pragmas. 68 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { SetupWorker } from 'msw'; 2 | import 'solid-js'; 3 | import { MyCounterProps } from './src/lib/wc/MyCounter.constant'; 4 | import { ClickOutsideDirectiveParams } from './src/modules/shared/directives/clickOutside.directive'; 5 | 6 | declare global { 7 | interface Window { 8 | msw: { 9 | worker: SetupWorker; 10 | }; 11 | } 12 | 13 | interface HTMLElementTagNameMap { 14 | 'my-counter': MyCounterProps; 15 | } 16 | } 17 | 18 | declare module 'solid-js' { 19 | namespace JSX { 20 | interface Directives { 21 | form: true; 22 | clickOutside: ClickOutsideDirectiveParams; 23 | // autoAnimate: Partial | AutoAnimationPlugin | true; 24 | } 25 | 26 | // add custom element to global elements list 27 | // interface IntrinsicElements { 28 | // 'my-counter': HTMLAttributes & { 29 | // 'initial-count': string; 30 | // }; 31 | // } 32 | 33 | // Prefixes all properties with `attr:` to match Solid's property setting syntax 34 | type Props = { 35 | [K in keyof T as `attr:${string & K}`]?: T[K]; 36 | }; 37 | type ElementProps = { 38 | // Add both the element's prefixed properties and the attributes 39 | [K in keyof T]: Props & HTMLAttributes; 40 | }; 41 | interface IntrinsicElements extends ElementProps {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | Solid Template 13 | 14 | 15 | 16 | 17 | 96 | 97 |
98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-app", 3 | "version": "0.0.0", 4 | "description": "Bulletproof Solid SPA Template", 5 | "license": "MIT", 6 | "author": "Tri Rizeki Rifandani", 7 | "packageManager": "pnpm@8.7.4", 8 | "msw": { 9 | "workerDirectory": "public" 10 | }, 11 | "scripts": { 12 | "prepare": "husky install", 13 | "format": "prettier --write .", 14 | "type": "tsc --project tsconfig.json", 15 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 16 | "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", 17 | "msw:init": "msw init public/ --save", 18 | "msw:delete": "rm -rf public/mockServiceWorker.js", 19 | "start": "vite", 20 | "build": "pnpm msw:delete && vite build", 21 | "preview": "vite preview", 22 | "test": "vitest", 23 | "test:ui": "vitest --ui", 24 | "test:coverage": "vitest run --coverage", 25 | "test:preview": "vite preview --outDir html", 26 | "test:preview:coverage": "vite preview --outDir coverage" 27 | }, 28 | "devDependencies": { 29 | "@commitlint/cli": "^17.7.1", 30 | "@commitlint/config-conventional": "^17.7.0", 31 | "@iconify-icon/solid": "^1.0.8", 32 | "@solidjs/testing-library": "^0.8.4", 33 | "@tailwindcss/forms": "^0.5.6", 34 | "@tailwindcss/typography": "^0.5.10", 35 | "@tanstack/eslint-plugin-query": "^4.34.1", 36 | "@testing-library/jest-dom": "^6.1.3", 37 | "@types/node": "^20.6.2", 38 | "@typescript-eslint/eslint-plugin": "^6.7.0", 39 | "@typescript-eslint/parser": "^6.7.0", 40 | "@vitest/coverage-istanbul": "^0.34.4", 41 | "@vitest/ui": "^0.34.4", 42 | "autoprefixer": "^10.4.15", 43 | "daisyui": "^3.7.4", 44 | "eslint": "^8.49.0", 45 | "eslint-config-airbnb-base": "^15.0.0", 46 | "eslint-config-airbnb-typescript": "^17.1.0", 47 | "eslint-config-prettier": "^9.0.0", 48 | "eslint-plugin-import": "^2.28.1", 49 | "eslint-plugin-jest-dom": "^5.1.0", 50 | "eslint-plugin-jsx-a11y": "^6.7.1", 51 | "eslint-plugin-solid": "^0.13.0", 52 | "eslint-plugin-tailwindcss": "^3.13.0", 53 | "eslint-plugin-testing-library": "^6.0.1", 54 | "husky": "^8.0.3", 55 | "jsdom": "^22.1.0", 56 | "msw": "^1.3.1", 57 | "postcss": "^8.4.29", 58 | "prettier": "^3.0.3", 59 | "tailwindcss": "^3.3.3", 60 | "tailwindcss-animate": "^1.0.7", 61 | "typescript": "^5.2.2", 62 | "vite": "^4.4.9", 63 | "vite-plugin-solid": "^2.7.0", 64 | "vite-tsconfig-paths": "^4.2.1", 65 | "vitest": "^0.34.4" 66 | }, 67 | "dependencies": { 68 | "@felte/solid": "^1.2.11", 69 | "@felte/validator-zod": "^1.0.17", 70 | "@formkit/auto-animate": "^0.8.0", 71 | "@kobalte/core": "^0.11.0", 72 | "@rifandani/nxact-yutiriti": "^1.2.2", 73 | "@solid-primitives/connectivity": "^0.3.17", 74 | "@solid-primitives/event-listener": "^2.3.0", 75 | "@solid-primitives/media": "^2.2.5", 76 | "@solid-primitives/storage": "^2.1.1", 77 | "@solid-primitives/timer": "^1.3.7", 78 | "@solidjs/router": "^0.8.3", 79 | "@tanstack/solid-query": "^4.35.3", 80 | "axios": "^1.5.0", 81 | "solid-element": "^1.7.1", 82 | "solid-js": "^1.7.11", 83 | "tailwind-merge": "^1.14.0", 84 | "type-fest": "^4.3.1", 85 | "zod": "^3.22.2" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/App.app.tsx: -------------------------------------------------------------------------------- 1 | import LoginPage from '@auth/pages/Login/Login.page'; 2 | import NotFoundPage from '@auth/pages/NotFound/NotFound.page'; 3 | import { SuspenseWithFallbackSpinner } from '@shared/components/molecules'; 4 | import { PageWrapper } from '@shared/components/templates'; 5 | import { Route, Router, Routes } from '@solidjs/router'; 6 | import routeDataTodo from '@todo/pages/Todo/Todo.data'; 7 | import { Component, lazy } from 'solid-js'; 8 | import AppErrorBoundary from './ErrorBoundary.app'; 9 | import { RootProvider, queryClient } from './RootProvider.app'; 10 | 11 | export const LazyHomePage = lazy(() => import('../modules/home/pages/Home/Home.page')); 12 | export const LazyPlaygroundPage = lazy(() => import('../modules/playground/pages/Playground.page')); 13 | export const LazyTodosPage = lazy(() => import('../modules/todo/pages/Todos/Todos.page')); 14 | export const LazyTodoPage = lazy(() => import('../modules/todo/pages/Todo/Todo.page')); 15 | 16 | const App: Component = () => ( 17 | 18 | 19 | 20 | 21 | {/* home routes */} 22 | 23 | 27 | 28 | 29 | } 30 | /> 31 | 32 | 33 | {/* login routes */} 34 | 38 | 39 | 40 | } 41 | /> 42 | 43 | {/* playground routes */} 44 | 48 | 49 | 50 | } 51 | /> 52 | 53 | {/* todos routes */} 54 | 55 | 59 | 60 | 61 | } 62 | /> 63 | 64 | 69 | 70 | 71 | } 72 | /> 73 | 74 | 75 | {/* not found routes */} 76 | 80 | 81 | 82 | } 83 | /> 84 | 85 | 86 | 87 | 88 | ); 89 | 90 | export default App; 91 | -------------------------------------------------------------------------------- /src/app/ErrorBoundary.app.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorBoundary, onMount, ParentComponent } from 'solid-js'; 2 | 3 | const Fallback: Component<{ err: unknown; reset: () => void }> = (props) => { 4 | onMount(() => { 5 | // TODO: Log Error to Sentry or other error monitoring service 6 | }); 7 | 8 | return ( 9 |
10 |

Something went wrong.

11 | 12 | 15 | 16 |
{JSON.stringify(props.err, null, 2)}
17 |
18 | ); 19 | }; 20 | 21 | const AppErrorBoundary: ParentComponent = (props) => ( 22 | }> 23 | {props.children} 24 | 25 | ); 26 | 27 | export default AppErrorBoundary; 28 | -------------------------------------------------------------------------------- /src/app/RootProvider.app.tsx: -------------------------------------------------------------------------------- 1 | import { Toast } from '@kobalte/core'; 2 | import { localeDict } from '@shared/configs/locale/locale.config'; 3 | import { LocaleDictLanguage } from '@shared/configs/locale/locale.type'; 4 | import { AppStoreContext, createAppStoreContext } from '@shared/hooks/useAppStore/useAppStore.hook'; 5 | import { I18nContext, createI18nContext } from '@shared/hooks/usei18n/usei18n.hook'; 6 | import { AppStore } from '@shared/types/store.type'; 7 | import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; 8 | import { ParentComponent } from 'solid-js'; 9 | import { Portal } from 'solid-js/web'; 10 | 11 | export const queryClient = new QueryClient({ 12 | defaultOptions: { 13 | queries: { 14 | staleTime: 1_000 * 30, // 30 secs. This will be the default in v5 15 | }, 16 | }, 17 | }); 18 | 19 | // #region PROVIDERS 20 | export const AppStoreProvider: ParentComponent<{ 21 | store?: AppStore; 22 | }> = (props) => { 23 | // eslint-disable-next-line solid/reactivity 24 | const value = createAppStoreContext(props.store); 25 | 26 | return {props.children}; 27 | }; 28 | 29 | export const I18nProvider: ParentComponent<{ 30 | dict?: Record>; 31 | locale?: LocaleDictLanguage; 32 | }> = (props) => { 33 | // eslint-disable-next-line solid/reactivity 34 | const value = createI18nContext(props.dict, props.locale); 35 | 36 | return {props.children}; 37 | }; 38 | 39 | export const QueryProvider: ParentComponent = (props) => ( 40 | {props.children} 41 | ); 42 | 43 | export const RootProvider: ParentComponent = (props) => ( 44 | 45 | 46 | 47 | {/* toast with portal */} 48 | 49 | 50 | 51 | 52 | 53 | 54 | {props.children} 55 | 56 | 57 | 58 | ); 59 | // #endregion 60 | -------------------------------------------------------------------------------- /src/assets/solidjs.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/solid-app/c928a8da45aae490881eccb631b8d01079652fce/src/assets/solidjs.webp -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_APP_TITLE: string; // prefixed with "VITE_" -> exposed to our Vite-processed code 5 | readonly VITE_API_BASE_URL: string; // this WON'T be exposed to Vite-processed code 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | @layer base { 7 | html, 8 | body { 9 | @apply bg-base-100 scroll-smooth min-h-full; 10 | 11 | font-synthesis: none; 12 | text-rendering: optimizeLegibility; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | -webkit-text-size-adjust: 100%; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | /* @refresh reload */ 3 | import { render } from 'solid-js/web'; 4 | import App from './app/App.app'; 5 | import './index.css'; 6 | 7 | const root = document.getElementById('root'); 8 | 9 | if (import.meta.env.DEV && !(root instanceof HTMLElement)) { 10 | throw new Error( 11 | 'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got mispelled?', 12 | ); 13 | } 14 | 15 | // ONLY include browser worker on 'development' env 16 | if (import.meta.env.DEV) { 17 | void import('./mocks/browser.mock') 18 | .then(({ worker }) => { 19 | // insert it into global window object, so we can debug the worker in runtime (e.g Chrome DevTools) 20 | window.msw = { worker }; 21 | // start browser worker 22 | return worker.start({ onUnhandledRequest: 'bypass' }); 23 | }) 24 | .then(() => render(() => , root!)); 25 | } else { 26 | render(() => , root!); 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/wc/MyCounter.constant.ts: -------------------------------------------------------------------------------- 1 | export type MyCounterEventDetail = { 2 | count: string; 3 | }; 4 | export type MyCounterProps = { 5 | initialCount: string; 6 | }; 7 | 8 | export const myCounterEventDecrement = 'my-counter:decrement' as const; 9 | export const myCounterEventIncrement = 'my-counter:increment' as const; 10 | -------------------------------------------------------------------------------- /src/lib/wc/MyCounter.wc.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonOnClick } from '@shared/types/form.type'; 2 | import { ComponentType, customElement, noShadowDOM } from 'solid-element'; 3 | import { createEffect, createSignal } from 'solid-js'; 4 | import { 5 | MyCounterProps, 6 | myCounterEventDecrement, 7 | myCounterEventIncrement, 8 | } from './MyCounter.constant'; 9 | 10 | const MyCounter: ComponentType = (props) => { 11 | // disable shadow dom to apply tailwind class 12 | noShadowDOM(); 13 | 14 | const [count, setCount] = createSignal(props.initialCount); 15 | 16 | // #region HANDLERS 17 | const onDecrement: ButtonOnClick = (ev) => { 18 | const newCount = (Number(count()) - 1).toString(); 19 | 20 | setCount(newCount); 21 | ev.currentTarget.dispatchEvent( 22 | new CustomEvent(myCounterEventDecrement, { 23 | bubbles: true, 24 | composed: true, // to cross the Shadow DOM boundaries 25 | detail: { count: newCount }, 26 | }), 27 | ); 28 | }; 29 | 30 | const onIncrement: ButtonOnClick = (ev) => { 31 | const newCount = (Number(count()) + 1).toString(); 32 | 33 | setCount(newCount); 34 | ev.currentTarget.dispatchEvent( 35 | new CustomEvent(myCounterEventIncrement, { 36 | bubbles: true, 37 | composed: true, // to cross the Shadow DOM boundaries 38 | detail: { count: newCount }, 39 | }), 40 | ); 41 | }; 42 | // #endregion 43 | 44 | // sync the props to `count` state 45 | createEffect(() => { 46 | setCount(props.initialCount); 47 | }); 48 | 49 | return ( 50 |
51 | 54 |

{count()}

55 | 58 |
59 | ); 60 | }; 61 | 62 | export default customElement('my-counter', { initialCount: '0' }, MyCounter); 63 | -------------------------------------------------------------------------------- /src/mocks/browser.mock.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | 3 | // This configures a Service Worker with the given request handlers. 4 | export const worker = setupWorker(); 5 | -------------------------------------------------------------------------------- /src/mocks/file.mock.ts: -------------------------------------------------------------------------------- 1 | export default 'test-file'; 2 | -------------------------------------------------------------------------------- /src/mocks/http/endpoints/auth.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { getBaseUrl } from '@mocks/util.mock'; 2 | import { RestHandler, rest } from 'msw'; 3 | 4 | export const authHandlers: RestHandler[] = [ 5 | rest.post(getBaseUrl('auth/login'), async (req, res, ctx) => { 6 | const { email, password } = await req.json<{ 7 | email: string; 8 | password: string; 9 | }>(); 10 | 11 | if (email === 'email@email.com' && password === 'password') { 12 | return res( 13 | ctx.json({ 14 | ok: true, 15 | login: { 16 | token: 'token', 17 | }, 18 | }), 19 | ); 20 | } 21 | 22 | return res( 23 | ctx.status(401), 24 | ctx.json({ ok: false, error: { code: 'auth/invalid-credentials' } }), 25 | ); 26 | }), 27 | 28 | rest.post(getBaseUrl('auth/refresh-token'), (_req, res, ctx) => 29 | res( 30 | ctx.json({ 31 | ok: true, 32 | login: { 33 | token: 'refreshed-token', 34 | }, 35 | }), 36 | ), 37 | ), 38 | ]; 39 | -------------------------------------------------------------------------------- /src/mocks/http/endpoints/todo.endpoint.ts: -------------------------------------------------------------------------------- 1 | import { mockTodo } from '@mocks/http/entities.http'; 2 | import { getBaseUrl } from '@mocks/util.mock'; 3 | import { ResourceParamsSchema, resourceParamsSchema } from '@shared/api/api.schema'; 4 | import { 5 | CreateTodoSchema, 6 | DeleteTodoApiResponseSchema, 7 | TodoSchema, 8 | UpdateTodoSchema, 9 | } from '@todo/api/todo.schema'; 10 | import { RestHandler, rest } from 'msw'; 11 | 12 | function getTodos(length: number) { 13 | return Array.from({ length }, (_, idx) => 14 | mockTodo({ 15 | id: idx + 1, 16 | userId: idx + 1, 17 | todo: `Todo title ${idx + 1}`, 18 | completed: idx % 2 === 0, 19 | }), 20 | ); 21 | } 22 | 23 | // mock 10 Todo entity 24 | let todos = Array.from({ length: 10 }, (_, idx) => 25 | mockTodo({ 26 | id: idx + 1, 27 | userId: idx + 1, 28 | todo: `Todo title ${idx + 1}`, 29 | completed: idx % 2 === 0, 30 | }), 31 | ); 32 | 33 | export const todoHandlers: RestHandler[] = [ 34 | rest.get(getBaseUrl('todos'), async (req, res, ctx) => { 35 | const searchParamsObject = Object.fromEntries(req.url.searchParams) as ResourceParamsSchema; 36 | const hasSearchParams = !!Object.keys(searchParamsObject).length; 37 | 38 | const parsedSearchParams = resourceParamsSchema.safeParse(searchParamsObject); 39 | 40 | if (!hasSearchParams || !parsedSearchParams.success) 41 | return res( 42 | ctx.status(200), 43 | ctx.json({ 44 | todos: getTodos(10), 45 | limit: 10, 46 | skip: 0, 47 | total: 150, 48 | }), 49 | ); 50 | 51 | const limit = parsedSearchParams.data?.limit ?? 10; 52 | const skip = parsedSearchParams.data?.skip ?? 0; 53 | 54 | return res( 55 | ctx.status(200), 56 | ctx.json({ 57 | todos: getTodos(limit), 58 | limit, 59 | skip, 60 | total: 150, 61 | }), 62 | ); 63 | }), 64 | rest.post(getBaseUrl('todos/add'), async (req, res, ctx) => { 65 | const todoPayload = await req.json(); 66 | const todoId = todos.at(-1)?.id; 67 | 68 | if (todoId) { 69 | const newTodo: TodoSchema = todoPayload; 70 | 71 | todos = [newTodo, ...todos]; 72 | 73 | return res(ctx.status(200), ctx.json(todoPayload)); 74 | } 75 | 76 | return res( 77 | ctx.status(400), 78 | ctx.json({ 79 | message: `ooppss, unknown error occurred`, 80 | }), 81 | ); 82 | }), 83 | rest.put(getBaseUrl('todos/:id'), async (req, res, ctx) => { 84 | const todoPayload = await req.json(); 85 | const { id } = req.params; 86 | const todoId = parseInt(id as string, 10); 87 | 88 | const todo = todos.find((_todo) => _todo.id === todoId); 89 | 90 | if (todo) { 91 | todos = todos.map((_todo) => 92 | _todo.id === todo.id ? { ..._todo, completed: todoPayload.completed } : _todo, 93 | ); 94 | 95 | return res(ctx.status(200), ctx.json({ ...todo, completed: todoPayload.completed })); 96 | } 97 | 98 | return res( 99 | ctx.status(404), 100 | ctx.json({ 101 | message: `there is no todo with id: ${todoId}`, 102 | }), 103 | ); 104 | }), 105 | rest.delete(getBaseUrl('todos/:id'), async (req, res, ctx) => { 106 | const { id } = req.params; 107 | const todoId = parseInt(id as string, 10); 108 | 109 | const todo = todos.find((_todo) => _todo.id === todoId); 110 | 111 | if (todo) { 112 | todos = todos.filter((_todo) => _todo.id !== todo.id); 113 | 114 | const deleteResponse: DeleteTodoApiResponseSchema = { 115 | ...todo, 116 | isDeleted: true, 117 | deletedOn: new Date().toISOString(), 118 | }; 119 | 120 | return res(ctx.status(200), ctx.json(deleteResponse)); 121 | } 122 | 123 | return res( 124 | ctx.status(404), 125 | ctx.json({ 126 | message: `there is no todo with id: ${todoId}`, 127 | }), 128 | ); 129 | }), 130 | ]; 131 | -------------------------------------------------------------------------------- /src/mocks/http/entities.http.ts: -------------------------------------------------------------------------------- 1 | import type { LoginApiResponseSchema } from '@auth/api/auth.schema'; 2 | import type { TodoListApiResponseSchema, TodoSchema } from '@todo/api/todo.schema'; 3 | 4 | export function mockLogin(initialValue?: Partial): LoginApiResponseSchema { 5 | return { 6 | id: 15, 7 | username: 'kminchelle', 8 | email: 'kminchelle@qq.com', 9 | firstName: 'Jeanne', 10 | lastName: 'Halvorson', 11 | gender: 'female', 12 | image: 'https://robohash.org/autquiaut.png', 13 | token: 14 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTUsInVzZXJuYW1lIjoia21pbmNoZWxsZSIsImVtYWlsIjoia21pbmNoZWxsZUBxcS5jb20iLCJmaXJzdE5hbWUiOiJKZWFubmUiLCJsYXN0TmFtZSI6IkhhbHZvcnNvbiIsImdlbmRlciI6ImZlbWFsZSIsImltYWdlIjoiaHR0cHM6Ly9yb2JvaGFzaC5vcmcvYXV0cXVpYXV0LnBuZyIsImlhdCI6MTY4NTU5NzcxNiwiZXhwIjoxNjg1NjAxMzE2fQ.nmKDmEtDztT2ufadJrDiJfolMtiP-fS_ZNk1XJVPSJE', 15 | ...(initialValue && initialValue), 16 | }; 17 | } 18 | 19 | export function mockTodo(initialValue?: Partial): TodoSchema { 20 | return { 21 | id: 1, 22 | userId: 1, 23 | todo: 'Mocked Todo 1', 24 | completed: false, 25 | ...(initialValue && initialValue), 26 | }; 27 | } 28 | 29 | export function mockTodoListApiResponse( 30 | initialValue?: Partial, 31 | ): TodoListApiResponseSchema { 32 | return { 33 | limit: 10, 34 | skip: 0, 35 | total: 100, 36 | todos: Array.from({ length: 10 }).map((_, id) => mockTodo({ id, userId: id })), 37 | ...(initialValue && initialValue), 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/mocks/http/handlers.http.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'msw'; 2 | import { authHandlers } from './endpoints/auth.endpoint'; 3 | import { todoHandlers } from './endpoints/todo.endpoint'; 4 | 5 | export const handlers: Array = [...authHandlers, ...todoHandlers]; 6 | -------------------------------------------------------------------------------- /src/mocks/http/server.http.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { setupServer } from 'msw/node'; 3 | import { handlers } from './handlers.http'; 4 | 5 | // This configures a request mocking server with the given request handlers. 6 | const server = setupServer(...handlers); 7 | 8 | export { rest, server }; 9 | -------------------------------------------------------------------------------- /src/mocks/module.mock.ts: -------------------------------------------------------------------------------- 1 | import router from '@solidjs/router'; 2 | import solid from 'solid-js'; 3 | import { vi } from 'vitest'; 4 | 5 | // mock ResizeObserver 6 | global.ResizeObserver = vi.fn().mockImplementation(() => ({ 7 | observe: vi.fn(), 8 | unobserve: vi.fn(), 9 | disconnect: vi.fn(), 10 | })); 11 | 12 | // mock window matchMedia 13 | window.matchMedia = function matchMedia(query) { 14 | return { 15 | media: query, 16 | matches: false, 17 | onchange: null, 18 | addListener: vi.fn(), // Deprecated 19 | removeListener: vi.fn(), // Deprecated 20 | addEventListener: vi.fn(), 21 | removeEventListener: vi.fn(), 22 | dispatchEvent: vi.fn(), 23 | }; 24 | }; 25 | 26 | // implementation of window.resizeTo for dispatching event 27 | window.resizeTo = function resizeTo(width, height) { 28 | Object.assign(this, { 29 | innerWidth: width, 30 | innerHeight: height, 31 | outerWidth: width, 32 | outerHeight: height, 33 | }).dispatchEvent(new this.Event('resize')); 34 | }; 35 | 36 | export const mockedNavigator = vi.fn(() => (path: string) => path); 37 | export const mockedLocation = vi.fn(() => ({ pathname: '/login' })); 38 | export const mockedCreateResource = vi.fn(() => [ 39 | () => ({ 40 | id: 1, 41 | }), 42 | { refetch: () => {} }, 43 | ]); 44 | 45 | vi.mock('@solidjs/router', async () => { 46 | const actual = await vi.importActual('@solidjs/router'); 47 | 48 | return { 49 | ...actual, 50 | useNavigate: mockedNavigator, 51 | useLocation: mockedLocation, 52 | }; 53 | }); 54 | 55 | vi.mock('solid-js', async () => { 56 | const actual = await vi.importActual('solid-js'); 57 | 58 | return { 59 | ...actual, 60 | createResource: mockedCreateResource, 61 | }; 62 | }); 63 | 64 | vi.mock('@solid-primitives/event-listener'); 65 | -------------------------------------------------------------------------------- /src/mocks/util.mock.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@shared/configs/env/env.config'; 2 | 3 | // make sure this URL is the same like VITE_API_BASE_URL in the .env.development 4 | export const getBaseUrl = (path: string) => env.apiBaseUrl + path; 5 | -------------------------------------------------------------------------------- /src/modules/auth/api/auth.api.ts: -------------------------------------------------------------------------------- 1 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 2 | import { http } from '@shared/services/api/http.api'; 3 | import { LoginApiResponseSchema, LoginSchema, loginApiResponseSchema } from './auth.schema'; 4 | 5 | export const authApi = { 6 | login: async (creds: LoginSchema) => { 7 | const resp = await http.post( 8 | `auth/login`, 9 | creds, 10 | ); 11 | 12 | // `parse` will throw if `resp.data` is not correct 13 | const loginApiResponse = loginApiResponseSchema.parse(resp.data); 14 | // set 'Authorization' headers to 15 | http.defaults.headers.common.Authorization = `Bearer ${loginApiResponse.token}`; 16 | 17 | return loginApiResponse; 18 | }, 19 | } as const; 20 | -------------------------------------------------------------------------------- /src/modules/auth/api/auth.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // #region SCHEMAS 4 | export const loginSchema = z.object({ 5 | username: z.string().min(3, 'username must contain at least 3 characters'), 6 | password: z.string().min(6, 'password must contain at least 6 characters'), 7 | expiresInMins: z.number().optional(), 8 | }); 9 | 10 | export const loginApiResponseSchema = z.object({ 11 | id: z.number(), 12 | username: z.string(), 13 | email: z.string().email(), 14 | firstName: z.string(), 15 | lastName: z.string(), 16 | gender: z.union([z.literal('male'), z.literal('female')]), 17 | image: z.string().url(), 18 | token: z.string(), 19 | }); 20 | // #endregion 21 | 22 | export type LoginSchema = z.infer; 23 | export type LoginApiResponseSchema = z.infer; 24 | -------------------------------------------------------------------------------- /src/modules/auth/components/LoginForm/LoginForm.component.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Show } from 'solid-js'; 2 | import { twJoin } from 'tailwind-merge'; 3 | import useLoginForm from './useLoginForm.hook'; 4 | 5 | const LoginForm: Component = () => { 6 | const { t, loginMutation, form, errors } = useLoginForm(); 7 | 8 | return ( 9 |
10 | {/* username */} 11 |
12 | 15 | 16 | 30 | 31 | 32 |

33 | {t('errorMinLength', { field: 'username', length: '3' })} 34 |

35 |
36 |
37 | 38 | {/* password */} 39 |
40 | 43 | 44 | 58 | 59 | 60 | 63 | 64 |
65 | 66 | 67 |
68 |

❌ {(loginMutation.error as Error).message}

69 |
70 |
71 | 72 | 79 |
80 | ); 81 | }; 82 | 83 | export default LoginForm; 84 | -------------------------------------------------------------------------------- /src/modules/auth/components/LoginForm/LoginForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { fireEvent, screen } from '@solidjs/testing-library'; 4 | import { vi } from 'vitest'; 5 | import LoginForm from './LoginForm.component'; 6 | 7 | describe('LoginForm', () => { 8 | const validUsernameValue = 'kminchelle'; 9 | const validPasswordValue = '0lelplR'; 10 | const mockSubmitFn = vi.fn(); 11 | 12 | it('should render properly', () => { 13 | const view = renderProviders(() => ); 14 | expect(() => view).not.toThrow(); 15 | }); 16 | 17 | it('should be able to type the inputs and submit the login form', () => { 18 | // ARRANGE 19 | renderProviders(() => ); 20 | const formLogin: HTMLFormElement = screen.getByRole('form', { name: /login/i }); 21 | const inputUsername: HTMLInputElement = screen.getByPlaceholderText(/username/i); 22 | const inputPassword: HTMLInputElement = screen.getByPlaceholderText(/password/i); 23 | const buttonSubmit: HTMLButtonElement = screen.getByRole('button', { name: /login/i }); 24 | formLogin.addEventListener('submit', mockSubmitFn); 25 | 26 | // ACT & ASSERT 27 | expect(formLogin).toBeInTheDocument(); 28 | expect(inputUsername).toBeInTheDocument(); 29 | expect(inputPassword).toBeInTheDocument(); 30 | fireEvent.change(inputUsername, { target: { value: validUsernameValue } }); 31 | fireEvent.change(inputPassword, { target: { value: validPasswordValue } }); 32 | expect(inputUsername).toHaveValue(validUsernameValue); 33 | expect(inputPassword).toHaveValue(validPasswordValue); 34 | fireEvent.click(buttonSubmit); 35 | expect(mockSubmitFn).toHaveBeenCalled(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/modules/auth/components/LoginForm/useLoginForm.hook.ts: -------------------------------------------------------------------------------- 1 | import { authApi } from '@auth/api/auth.api'; 2 | import { LoginApiResponseSchema, LoginSchema, loginSchema } from '@auth/api/auth.schema'; 3 | import { createForm } from '@felte/solid'; 4 | import { validator } from '@felte/validator-zod'; 5 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 6 | import { useAppStorage } from '@shared/hooks/useAppStorage/useAppStorage.hook'; 7 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 8 | import { useNavigate } from '@solidjs/router'; 9 | import { createMutation } from '@tanstack/solid-query'; 10 | 11 | export default function useLoginForm() { 12 | const navigate = useNavigate(); 13 | const [, setApp] = useAppStorage(); 14 | const [t] = useI18n(); 15 | 16 | const loginMutation = createMutation( 17 | { 18 | mutationFn: (creds) => authApi.login(creds), 19 | onSuccess: (resp) => { 20 | // set user data to local storage and global store 21 | setApp('user', resp); 22 | navigate('/'); 23 | }, 24 | }, 25 | ); 26 | 27 | const felte = createForm({ 28 | extend: [validator({ schema: loginSchema })], 29 | initialValues: { 30 | username: '', 31 | password: '', 32 | }, 33 | onSubmit: (values, { reset }) => { 34 | loginMutation.mutate(values, { 35 | onError: () => { 36 | // reset form 37 | reset(); 38 | }, 39 | }); 40 | }, 41 | }); 42 | 43 | return { t, loginMutation, ...felte }; 44 | } 45 | -------------------------------------------------------------------------------- /src/modules/auth/constants/login.constant.ts: -------------------------------------------------------------------------------- 1 | import { LoginSchema } from '@auth/api/auth.schema'; 2 | 3 | export const loginFormInitialValue: LoginSchema = { 4 | username: '', 5 | password: '', 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/auth/pages/Login/Login.page.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { screen } from '@solidjs/testing-library'; 4 | import LoginPage from './Login.page'; 5 | 6 | describe('LoginPage', () => { 7 | it('should render properly', () => { 8 | const view = renderProviders(() => ); 9 | expect(() => view).not.toThrow(); 10 | }); 11 | 12 | it('should render content roles correctly', () => { 13 | // ARRANGE 14 | renderProviders(() => ); 15 | const linkHome: HTMLAnchorElement = screen.getByRole('link', { name: /home/i }); 16 | const linkRegister: HTMLAnchorElement = screen.getByRole('link', { name: /register/i }); 17 | const imgCover: HTMLImageElement = screen.getByRole('img', { name: /cover/i }); 18 | 19 | // ASSERT 20 | expect(linkHome).toBeInTheDocument(); 21 | expect(linkRegister).toBeInTheDocument(); 22 | expect(imgCover).toBeInTheDocument(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/auth/pages/Login/Login.page.tsx: -------------------------------------------------------------------------------- 1 | import solidjs from '@assets/solidjs.webp'; 2 | import LoginForm from '@auth/components/LoginForm/LoginForm.component'; 3 | import { Icon } from '@iconify-icon/solid'; 4 | import { Link } from '@solidjs/router'; 5 | import { Component } from 'solid-js'; 6 | import useLoginPageVM from './Login.vm'; 7 | 8 | const LoginPage: Component = () => { 9 | const vm = useLoginPageVM(); 10 | 11 | return ( 12 |
13 |
14 | {/* */} 15 |
16 |
17 | 22 | 23 | 24 |
25 | 26 |
27 |

{vm.t('welcome')}

28 | 29 | 30 | 31 |

32 | {vm.t('noAccount')}{' '} 33 | 34 | {vm.t('registerHere')} 35 | 36 |

37 |
38 |
39 | 40 | {/* */} 41 |
42 | 51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default LoginPage; 58 | -------------------------------------------------------------------------------- /src/modules/auth/pages/Login/Login.vm.tsx: -------------------------------------------------------------------------------- 1 | import useAuth from '@shared/hooks/useAuth/useAuth.hook'; 2 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 3 | 4 | const useLoginPageVM = () => { 5 | useAuth(); 6 | const [t] = useI18n(); 7 | 8 | return { t }; 9 | }; 10 | 11 | export default useLoginPageVM; 12 | -------------------------------------------------------------------------------- /src/modules/auth/pages/NotFound/NotFound.page.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { screen } from '@solidjs/testing-library'; 4 | import NotFoundPage from './NotFound.page'; 5 | 6 | describe('NotFoundPage', () => { 7 | it('should render properly', () => { 8 | const view = renderProviders(() => ); 9 | expect(() => view).not.toThrow(); 10 | }); 11 | 12 | it('should render contents correctly', () => { 13 | // ARRANGE 14 | renderProviders(() => ); 15 | const heading: HTMLHeadingElement = screen.getByText(/404: not found/i); 16 | const paragraph: HTMLParagraphElement = screen.getByText(/It's gone/i); 17 | const anchor: HTMLAnchorElement = screen.getByRole('link'); 18 | 19 | // ASSERT 20 | expect(heading).toBeInTheDocument(); 21 | expect(paragraph).toBeInTheDocument(); 22 | expect(anchor).toBeInTheDocument(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/auth/pages/NotFound/NotFound.page.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@solidjs/router'; 2 | import { Component } from 'solid-js'; 3 | import useNotFoundPageVM from './NotFound.vm'; 4 | 5 | const NotFoundPage: Component = () => { 6 | const vm = useNotFoundPageVM(); 7 | 8 | return ( 9 |
10 |

{vm.t('notFound404')}

11 |

{vm.t('gone')}

12 | 13 | 14 | {vm.t('goBackTo', { target: vm.app.user ? 'home' : 'login' })} 15 | 16 |
17 | ); 18 | }; 19 | 20 | export default NotFoundPage; 21 | -------------------------------------------------------------------------------- /src/modules/auth/pages/NotFound/NotFound.vm.tsx: -------------------------------------------------------------------------------- 1 | import { useAppStorage } from '@shared/hooks/useAppStorage/useAppStorage.hook'; 2 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 3 | 4 | const useNotFoundPageVM = () => { 5 | const [app] = useAppStorage(); 6 | const [t] = useI18n(); 7 | 8 | return { app, t }; 9 | }; 10 | 11 | export default useNotFoundPageVM; 12 | -------------------------------------------------------------------------------- /src/modules/home/components/HomeClock/HomeClock.component.tsx: -------------------------------------------------------------------------------- 1 | import { Component, For, Show } from 'solid-js'; 2 | import useHomeClock from './useHomeClock.hook'; 3 | 4 | const HomeClock: Component = () => { 5 | const vm = useHomeClock(); 6 | 7 | return ( 8 | <> 9 | 10 |
11 |
12 |
{vm.t('clock')}:
13 |
14 | {vm.hours()} : {vm.minutes()} : {vm.seconds()}{' '} 15 |
16 |
{vm.t('clickToggleClock')}
17 |
18 |
19 |
20 | 21 |
vm.setParent(elem)} 23 | class="mt-8 grid grid-cols-1 gap-2 duration-300 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" 24 | > 25 | 26 | {(btn) => ( 27 | 35 | )} 36 | 37 |
38 | 39 | ); 40 | }; 41 | 42 | export default HomeClock; 43 | -------------------------------------------------------------------------------- /src/modules/home/components/HomeClock/HomeClock.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { fireEvent, screen } from '@solidjs/testing-library'; 4 | import { vi } from 'vitest'; 5 | import HomeClock from './HomeClock.component'; 6 | 7 | describe('HomeClock', () => { 8 | const mockButtonFn = vi.fn(); 9 | 10 | it('should render properly', () => { 11 | const view = renderProviders(() => ); 12 | expect(() => view).not.toThrow(); 13 | }); 14 | 15 | // FIXME: TestingLibraryElementError: Unable to find an element by: [data-testid="home-clock-show"] 16 | it.todo('should render clock when toggle clock button clicked', async () => { 17 | // ARRANGE 18 | renderProviders(() => ); 19 | const button: HTMLButtonElement = screen.getByTestId(/home-clock-button-clock/i); 20 | 21 | // ACT & ASSERT 22 | expect(button).toBeInTheDocument(); 23 | fireEvent.click(button); 24 | expect(await screen.findByTestId('home-clock-show')).toBeInTheDocument(); 25 | }); 26 | 27 | // FIXME: figure out how to solve the randomness behavior 28 | it.todo('should shuffle buttons when sort button clicked', () => { 29 | // ARRANGE 30 | renderProviders(() => ); 31 | const buttonsBefore: HTMLButtonElement[] = screen.queryAllByTestId(/home-clock-button/i); 32 | const button: HTMLButtonElement = screen.getByTestId(/home-clock-button-sort/i); 33 | 34 | // ACT & ASSERT 35 | fireEvent.click(button); 36 | const buttonsAfter: HTMLButtonElement[] = screen.queryAllByTestId(/home-clock-button/i); 37 | expect(buttonsBefore[0]).not.toHaveTextContent(buttonsAfter[0].textContent as string); 38 | expect(buttonsBefore[1]).not.toHaveTextContent(buttonsAfter[1].textContent as string); 39 | expect(buttonsBefore[2]).not.toHaveTextContent(buttonsAfter[2].textContent as string); 40 | expect(buttonsBefore[3]).not.toHaveTextContent(buttonsAfter[3].textContent as string); 41 | }); 42 | 43 | it('should translate text when change language button clicked', () => { 44 | // ARRANGE 45 | renderProviders(() => ); 46 | const button: HTMLButtonElement = screen.getByTestId(/home-clock-button-language/i); 47 | 48 | // ACT & ASSERT 49 | expect(button).toHaveTextContent(/change language/i); 50 | fireEvent.click(button); 51 | expect(button).toHaveTextContent(/ganti bahasa/i); 52 | }); 53 | 54 | it('should call mocked navigate function when get started button clicked', () => { 55 | // ARRANGE 56 | renderProviders(() => ); 57 | const button: HTMLButtonElement = screen.getByTestId(/home-clock-button-start/i); 58 | button.addEventListener('click', mockButtonFn); 59 | 60 | // ACT & ASSERT 61 | fireEvent.click(button); 62 | expect(mockButtonFn).toHaveBeenCalled(); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/modules/home/components/HomeClock/useHomeClock.hook.ts: -------------------------------------------------------------------------------- 1 | import { createAutoAnimate } from '@formkit/auto-animate/solid'; 2 | import { shuffle } from '@rifandani/nxact-yutiriti'; 3 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 4 | import { useNavigate } from '@solidjs/router'; 5 | import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'; 6 | import { createStore } from 'solid-js/store'; 7 | 8 | const useHomeClock = () => { 9 | const navigate = useNavigate(); 10 | const [t, { locale }] = useI18n(); 11 | 12 | const [setParent] = createAutoAnimate(); 13 | const [showClock, setShowClock] = createSignal(true); 14 | const [seconds, setSeconds] = createSignal(0); 15 | 16 | const toggleClock = () => setShowClock((prev) => !prev); 17 | const [buttons, setButtons] = createStore([ 18 | { 19 | id: 'sort' as const, 20 | class: 'btn-neutral btn', 21 | text: 'sortButtons' as const, 22 | }, 23 | { 24 | id: 'clock' as const, 25 | class: 'btn-active btn', 26 | text: 'toggleClock' as const, 27 | }, 28 | { 29 | id: 'language' as const, 30 | class: 'btn-accent btn', 31 | text: 'changeLanguage' as const, 32 | }, 33 | { 34 | id: 'start' as const, 35 | class: 'btn-secondary btn', 36 | text: 'getStarted' as const, 37 | }, 38 | ]); 39 | 40 | const minutes = createMemo( 41 | (prev: number) => (seconds() > 0 ? (seconds() % 2 === 0 ? prev + 1 : prev) : 0), 42 | 0, 43 | ); 44 | const hours = createMemo( 45 | (prev: number) => (minutes() > 0 ? (minutes() % 2 === 0 ? prev + 1 : prev) : 0), 46 | 0, 47 | ); 48 | 49 | const onClickMapper = (btnId: 'sort' | 'clock' | 'language' | 'start') => { 50 | const mapper: Record void> = { 51 | sort: () => setButtons((prev) => shuffle(prev)), 52 | clock: () => toggleClock(), 53 | language: () => locale(locale() === 'en' ? 'id' : 'en'), 54 | start: () => navigate('/todos'), 55 | }; 56 | 57 | mapper[btnId](); 58 | }; 59 | 60 | // The first execution of the effect function is not immediate; it's scheduled to run after the current rendering phase 61 | createEffect(() => { 62 | let id: ReturnType; 63 | 64 | if (showClock()) { 65 | // if the clock is shown, increment the seconds 66 | id = setInterval(() => { 67 | setSeconds((prev) => +(prev + 0.1).toFixed(2)); 68 | }, 100); 69 | } else { 70 | // if the clock is NOT shown, reset the seconds 71 | setSeconds(0); 72 | } 73 | 74 | onCleanup(() => clearInterval(id)); 75 | }); 76 | 77 | return { 78 | t, 79 | seconds, 80 | minutes, 81 | hours, 82 | showClock, 83 | buttons, 84 | setButtons, 85 | setParent, 86 | onClickMapper, 87 | }; 88 | }; 89 | 90 | export default useHomeClock; 91 | -------------------------------------------------------------------------------- /src/modules/home/pages/Home/Home.page.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { screen } from '@solidjs/testing-library'; 4 | import HomePage from './Home.page'; 5 | 6 | describe('HomePage', () => { 7 | it('should render correctly', () => { 8 | const view = renderProviders(() => ); 9 | expect(() => view).not.toThrow(); 10 | }); 11 | 12 | it('should render text correctly', () => { 13 | // ARRANGE 14 | renderProviders(() => ); 15 | const heading: HTMLHeadingElement = screen.getByRole('heading', { level: 1 }); 16 | 17 | // ASSERT 18 | expect(heading).toBeInTheDocument(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/modules/home/pages/Home/Home.page.tsx: -------------------------------------------------------------------------------- 1 | import HomeClock from '@home/components/HomeClock/HomeClock.component'; 2 | import { Component } from 'solid-js'; 3 | import useHomePageVM from './Home.vm'; 4 | 5 | const HomePage: Component = () => { 6 | const vm = useHomePageVM(); 7 | 8 | return ( 9 |
vm.setParent(elem)} 11 | class="container mx-auto flex flex-col items-center py-24 duration-300" 12 | > 13 |

{vm.t('title')}

14 | 15 | 16 |
17 | ); 18 | }; 19 | 20 | export default HomePage; 21 | -------------------------------------------------------------------------------- /src/modules/home/pages/Home/Home.vm.tsx: -------------------------------------------------------------------------------- 1 | import { createAutoAnimate } from '@formkit/auto-animate/solid'; 2 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 3 | import { useNavigate } from '@solidjs/router'; 4 | 5 | const useHomePageVM = () => { 6 | const navigate = useNavigate(); 7 | const [t] = useI18n(); 8 | // refer to this issues: https://github.com/formkit/auto-animate/issues/121 9 | const [setParent] = createAutoAnimate(); 10 | 11 | return { navigate, t, setParent }; 12 | }; 13 | 14 | export default useHomePageVM; 15 | -------------------------------------------------------------------------------- /src/modules/playground/components/Directive/Directive.component.tsx: -------------------------------------------------------------------------------- 1 | import clickOutside from '@shared/directives/clickOutside.directive'; 2 | import { Component, Show, createSignal } from 'solid-js'; 3 | 4 | const Directive: Component = () => { 5 | const [show, setShow] = createSignal(false); 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 8 | clickOutside; 9 | 10 | return ( 11 |
12 |

Directive

13 | 14 | setShow(true)}> 18 | Open Modal 19 | 20 | } 21 | > 22 |
setShow(false)}> 23 |
24 |

Modal Showed!

25 |
26 |
27 |
28 |
29 | ); 30 | }; 31 | 32 | export default Directive; 33 | -------------------------------------------------------------------------------- /src/modules/playground/components/Resource/Resource.component.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { createConnectivitySignal } from '@solid-primitives/connectivity'; 3 | import { createEventListener } from '@solid-primitives/event-listener'; 4 | import { createTimer } from '@solid-primitives/timer'; 5 | import { todoApi } from '@todo/api/todo.api'; 6 | import { TodoListApiResponseSchema, TodoSchema } from '@todo/api/todo.schema'; 7 | import TodosFilter from '@todo/components/TodosFilter/TodosFilter.component'; 8 | import { useTodosParams } from '@todo/hooks/useTodos/useTodos.hook'; 9 | import { Component, For, createEffect, createResource } from 'solid-js'; 10 | import { Dynamic } from 'solid-js/web'; 11 | 12 | // #region DYNAMIC RENDERING 13 | const Pending: Component = () => ( 14 |
15 | 16 |
17 | ); 18 | const Errored: Component<{ error: unknown }> = (props) => ( 19 |
20 |
21 |
{JSON.stringify(props.error, null, 2)}
22 |
23 |
24 | ); 25 | const Success: Component<{ todos: TodoSchema[]; onChange: (_todo: TodoSchema) => void }> = ( 26 | props, 27 | ) => ( 28 | Empty}> 29 | {(todo) => ( 30 |
31 | props.onChange(todo)} 39 | /> 40 | 41 |

46 | {todo.todo} 47 |

48 |
49 | )} 50 |
51 | ); 52 | 53 | // bad example, the good one should be typed NOT `any`, because the component should have the same props type 54 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 55 | const resourceMap: Record<'unresolved' | 'pending' | 'ready' | 'refreshing' | 'errored', any> = { 56 | unresolved: null, 57 | pending: Pending, 58 | ready: Success, 59 | refreshing: Pending, 60 | errored: Errored, 61 | }; 62 | // #endregion 63 | 64 | const Resource: Component = () => { 65 | const { params } = useTodosParams(); 66 | const isOnline = createConnectivitySignal(); 67 | // refetch when back online 68 | const source = () => isOnline() && params(); 69 | 70 | const [todosResource, { mutate, refetch }] = createResource( 71 | source, 72 | async (_source) => { 73 | const res = await todoApi.list(_source); 74 | return res; 75 | }, 76 | // { initialValue: mockTodoListApiResponse() }, 77 | ); 78 | 79 | // window focus refetching 80 | createEventListener(document, 'visibilitychange', () => document.hidden || void refetch()); 81 | 82 | // polling -> refetch every 10s 83 | createTimer(() => void refetch(), 10_000, setInterval); 84 | 85 | // Effects are meant primarily for side effects that read but don't write to the reactive system 86 | // it's best to avoid setting signals in effects, 87 | createEffect((prev: number) => { 88 | if (todosResource.state === 'ready') { 89 | const newFetchTimes = prev + 1; 90 | // eslint-disable-next-line no-console 91 | console.log('🚀 ~ file: Resource.component.tsx:23 ~ createEffect', { prev, newFetchTimes }); 92 | 93 | return newFetchTimes; 94 | } 95 | 96 | return prev; 97 | }, 0); 98 | 99 | const onClickRefetch = () => 100 | // refetch will re-run the fetcher without changing the source 101 | void refetch(); 102 | 103 | return ( 104 |
105 |

Resource

106 | 107 | 108 | 109 | 116 | 117 | 124 | // `mutate` allows to manually overwrite the resource without calling the fetcher 125 | mutate((prev) => 126 | prev 127 | ? { 128 | ...prev, 129 | todos: prev.todos.map((_todo) => 130 | _todo.id === todo.id ? { ..._todo, completed: !_todo.completed } : _todo, 131 | ), 132 | } 133 | : prev, 134 | ) 135 | } 136 | /> 137 |
138 | ); 139 | }; 140 | 141 | export default Resource; 142 | -------------------------------------------------------------------------------- /src/modules/playground/components/WebComponents/WebComponents.component.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | myCounterEventDecrement, 3 | myCounterEventIncrement, 4 | type MyCounterEventDetail, 5 | } from '@lib/wc/MyCounter.constant'; 6 | import '@lib/wc/MyCounter.wc'; 7 | import { ButtonOnClick } from '@shared/types/form.type'; 8 | import { Component, createSignal, onCleanup, onMount } from 'solid-js'; 9 | 10 | const WebComponents: Component = () => { 11 | let sectionRef: HTMLElement; 12 | const [initialCount, setInitialCount] = createSignal('10'); 13 | 14 | const handleClickTambah: ButtonOnClick = () => { 15 | setInitialCount((Number(initialCount()) + 1).toString()); 16 | }; 17 | 18 | const onDecrement: EventListenerOrEventListenerObject = (ev) => { 19 | const customEv = ev as CustomEvent; 20 | setInitialCount(customEv.detail.count); 21 | }; 22 | const onIncrement: EventListenerOrEventListenerObject = (ev) => { 23 | const customEv = ev as CustomEvent; 24 | setInitialCount(customEv.detail.count); 25 | }; 26 | 27 | onMount(() => { 28 | sectionRef.addEventListener(myCounterEventDecrement, onDecrement); 29 | sectionRef.addEventListener(myCounterEventIncrement, onIncrement); 30 | }); 31 | 32 | onCleanup(() => { 33 | sectionRef.removeEventListener(myCounterEventDecrement, onDecrement); 34 | sectionRef.removeEventListener(myCounterEventIncrement, onIncrement); 35 | }); 36 | 37 | return ( 38 |
{ 41 | sectionRef = ref; 42 | }} 43 | > 44 |

Web Components

45 | 46 | 49 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export default WebComponents; 56 | -------------------------------------------------------------------------------- /src/modules/playground/pages/Playground.page.tsx: -------------------------------------------------------------------------------- 1 | import Directive from '@playground/components/Directive/Directive.component'; 2 | import Resource from '@playground/components/Resource/Resource.component'; 3 | import WebComponents from '@playground/components/WebComponents/WebComponents.component'; 4 | import { modes } from '@shared/constants/global.constant'; 5 | import { createColorMode } from '@shared/hooks/createColorMode/createColorMode.hook'; 6 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 7 | import { Component } from 'solid-js'; 8 | 9 | const PlaygroundPage: Component = () => { 10 | const [t] = useI18n(); 11 | createColorMode({ 12 | modes, 13 | attribute: 'data-theme', 14 | }); 15 | 16 | return ( 17 |
18 |

19 | {t('playgroundTitle')} 20 |

21 | 22 | 23 | 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default PlaygroundPage; 30 | -------------------------------------------------------------------------------- /src/modules/setting/api/setting.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // #region SCHEMA 4 | export const settingSchema = z.object({ 5 | showNotification: z.boolean(), 6 | }); 7 | // #endregion 8 | 9 | export type SettingSchema = z.infer; 10 | -------------------------------------------------------------------------------- /src/modules/shared/api/api.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | // #region SCHEMAS 4 | export const errorApiResponseSchema = z.object({ 5 | message: z.string(), 6 | }); 7 | 8 | export const resourceParamsSchema = z.object({ 9 | limit: z.number().optional(), 10 | skip: z.number().optional(), 11 | select: z.string().optional(), 12 | }); 13 | 14 | export const resourceListSchema = z.object({ 15 | total: z.number(), 16 | skip: z.number(), 17 | limit: z.number(), 18 | }); 19 | // #endregion 20 | 21 | export type ErrorApiResponseSchema = z.infer; 22 | export type ResourceParamsSchema = z.infer; 23 | export type ResourceListSchema = z.infer; 24 | -------------------------------------------------------------------------------- /src/modules/shared/components/atoms/SvgIcon.atom.tsx: -------------------------------------------------------------------------------- 1 | import { Component, JSX, splitProps } from 'solid-js'; 2 | 3 | // #region INTERFACES 4 | type SVGProps = JSX.SvgSVGAttributes & { 5 | id: 'icon-solidjs'; 6 | }; 7 | // #endregion 8 | 9 | const SvgIcon: Component = (props) => { 10 | const [id, rest] = splitProps(props, ['id']); 11 | 12 | return ( 13 | 16 | ); 17 | }; 18 | 19 | export default SvgIcon; 20 | -------------------------------------------------------------------------------- /src/modules/shared/components/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SvgIcon } from './SvgIcon.atom'; 2 | -------------------------------------------------------------------------------- /src/modules/shared/components/molecules/SuspenseWithFallbackSpinner/SuspenseWithFallbackSpinner.molecule.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { ParentComponent, Suspense } from 'solid-js'; 3 | 4 | const SuspenseWithFallbackSpinner: ParentComponent = (props) => ( 5 | 8 | 9 | 10 | } 11 | > 12 | {props.children} 13 | 14 | ); 15 | 16 | export default SuspenseWithFallbackSpinner; 17 | -------------------------------------------------------------------------------- /src/modules/shared/components/molecules/Toaster/Toaster.molecule.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { Toast } from '@kobalte/core'; 3 | import { Component, Show } from 'solid-js'; 4 | import { twJoin } from 'tailwind-merge'; 5 | import './style.css'; 6 | 7 | // #region INTERFACES 8 | type ToastComponentProps = { 9 | toastId: number; 10 | }; 11 | type ToasterProps = ToastComponentProps & { 12 | type: 'success' | 'error' | 'info' | 'warning'; 13 | title: string; 14 | description?: string; 15 | }; 16 | // #endregion 17 | 18 | const Toaster: Component = (props) => { 19 | const mapper = { 20 | root: { 21 | success: 'alert-success', 22 | error: 'alert-error', 23 | info: 'alert-info', 24 | warning: 'alert-warning', 25 | }, 26 | text: { 27 | success: 'text-success-content', 28 | error: 'text-error-content', 29 | info: 'text-info-content', 30 | warning: 'text-warning-content', 31 | }, 32 | progress: { 33 | success: 'bg-success-content', 34 | error: 'bg-error-content', 35 | info: 'bg-info-content', 36 | warning: 'bg-warning-content', 37 | }, 38 | }; 39 | 40 | return ( 41 | 48 |
49 | 50 | {props.title} 51 | 52 | 53 | 54 | 55 | 56 |
57 | 58 | 59 | 66 | {props.description} 67 | 68 | 69 | 70 | 71 | 74 | 75 |
76 | ); 77 | }; 78 | 79 | export default Toaster; 80 | -------------------------------------------------------------------------------- /src/modules/shared/components/molecules/Toaster/style.css: -------------------------------------------------------------------------------- 1 | .my-toast[data-opened] { 2 | animation: slideIn 250ms cubic-bezier(0.16, 1, 0.3, 1); 3 | } 4 | .my-toast[data-closed] { 5 | animation: swipeOut 250ms ease-out; 6 | } 7 | .my-toast[data-swipe='move'] { 8 | transform: translateX(var(--kb-toast-swipe-move-x)); 9 | } 10 | .my-toast[data-swipe='cancel'] { 11 | transform: translateX(0); 12 | transition: transform 250ms ease-out; 13 | } 14 | .my-toast[data-swipe='end'] { 15 | animation: swipeOut 250ms ease-out; 16 | } 17 | .my-toast__progress-fill { 18 | width: var(--kb-toast-progress-fill-width); 19 | transition: width 250ms linear; 20 | } 21 | 22 | @keyframes slideIn { 23 | from { 24 | transform: translateX(calc(100% + 16px)); 25 | } 26 | to { 27 | transform: translateX(0); 28 | } 29 | } 30 | @keyframes swipeOut { 31 | from { 32 | transform: translateX(var(--kb-toast-swipe-end-x)); 33 | } 34 | to { 35 | transform: translateX(calc(100% + 16px)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/modules/shared/components/molecules/ToasterPromise/ToasterPromise.molecule.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { Toast } from '@kobalte/core'; 3 | import { JSX, Show } from 'solid-js'; 4 | 5 | // #region INTERFACES 6 | type ToasterPromiseProps = { 7 | toastId: number; 8 | promise: Promise | (() => Promise); 9 | options: { 10 | loading?: JSX.Element; 11 | success?: (data: T) => JSX.Element; 12 | error?: (error: U) => JSX.Element; 13 | }; 14 | title: string; 15 | description?: string; 16 | }; 17 | // #endregion 18 | 19 | // TODO: work in progress 20 | const ToasterPromise = (props: ToasterPromiseProps): JSX.Element => ( 21 | 22 |
23 | 24 | {props.title} 25 | 26 | 27 | 28 | 29 | {props.description} 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 38 |
39 | 40 | 41 | 42 |
43 |
44 | ); 45 | 46 | export default ToasterPromise; 47 | -------------------------------------------------------------------------------- /src/modules/shared/components/molecules/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NavbarMenuContent } from '../organisms/NavbarMenuContent/NavbarMenuContent.molecule'; 2 | export { default as SuspenseWithFallbackSpinner } from './SuspenseWithFallbackSpinner/SuspenseWithFallbackSpinner.molecule'; 3 | export { default as Toaster } from './Toaster/Toaster.molecule'; 4 | export { default as ToasterPromise } from './ToasterPromise/ToasterPromise.molecule'; 5 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/Navbar/Navbar.organism.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { SvgIcon } from '@shared/components/atoms'; 3 | import { NavbarMenuContent } from '@shared/components/molecules'; 4 | import { Link } from '@solidjs/router'; 5 | import { ParentComponent } from 'solid-js'; 6 | import useNavbar from './useNavbar.hook'; 7 | 8 | const Navbar: ParentComponent = (props) => { 9 | const { t } = useNavbar(); 10 | 11 | return ( 12 | 56 | ); 57 | }; 58 | 59 | export default Navbar; 60 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/Navbar/Navbar.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { screen } from '@solidjs/testing-library'; 4 | import Navbar from './Navbar.organism'; 5 | 6 | describe('Navbar', () => { 7 | it('should render properly', () => { 8 | const view = renderProviders(() => ); 9 | expect(() => view).not.toThrow(); 10 | }); 11 | 12 | it('should be able to type the inputs and submit the login form', () => { 13 | // ARRANGE 14 | renderProviders(() => ); 15 | const link: HTMLAnchorElement = screen.getByRole('link', { name: /logo/i }); 16 | const checkbox: HTMLInputElement = screen.getByRole('checkbox', { name: /drawer/i }); 17 | 18 | // ASSERT 19 | expect(link).toBeInTheDocument(); 20 | expect(checkbox).toBeInTheDocument(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/Navbar/useNavbar.hook.ts: -------------------------------------------------------------------------------- 1 | import useAuth from '@shared/hooks/useAuth/useAuth.hook'; 2 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 3 | 4 | export default function useNavbar() { 5 | useAuth(); 6 | const [t] = useI18n(); 7 | 8 | return { t }; 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/NavbarMenuContent/NavbarMenuContent.molecule.tsx: -------------------------------------------------------------------------------- 1 | import { themes } from '@shared/constants/global.constant'; 2 | import { NavLink } from '@solidjs/router'; 3 | import { Component, For, Show } from 'solid-js'; 4 | import useNavbarMenuContent from './useNavbarMenuContent.hook'; 5 | 6 | const NavbarMenuContent: Component = () => { 7 | const { t, appStorage, setTheme, handleClickLogout } = useNavbarMenuContent(); 8 | 9 | return ( 10 | <> 11 |
  • 12 | 18 | Todos 19 | 20 |
  • 21 | 22 | 49 | 50 | 51 | {(user) => ( 52 |
  • 53 | 60 |
  • 61 | )} 62 |
    63 | 64 | ); 65 | }; 66 | 67 | export default NavbarMenuContent; 68 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/NavbarMenuContent/NavbarMenuContent.test.tsx: -------------------------------------------------------------------------------- 1 | import { themes } from '@shared/constants/global.constant'; 2 | import { renderProviders } from '@shared/utils/test.util'; 3 | import { Route } from '@solidjs/router'; 4 | import { fireEvent, screen } from '@solidjs/testing-library'; 5 | import { vi } from 'vitest'; 6 | import NavbarMenuContent from './NavbarMenuContent.molecule'; 7 | 8 | describe('NavBarMenuContent', () => { 9 | const mockModeBtn = vi.fn(); 10 | 11 | it('should render properly', () => { 12 | const view = renderProviders(() => ); 13 | expect(() => view).not.toThrow(); 14 | }); 15 | 16 | it('should render role contents correctly', () => { 17 | // ARRANGE 18 | renderProviders(() => ); 19 | const link: HTMLAnchorElement = screen.getByRole('link', { name: /todos/i }); 20 | const themeBtn: HTMLButtonElement = screen.getByRole('button', { name: /themes-opener/i }); 21 | const modesBtn: HTMLButtonElement[] = screen.getAllByRole('button', { name: /theme-/i }); 22 | 23 | // ACT & ASSERT 24 | modesBtn[0].addEventListener('click', mockModeBtn); 25 | fireEvent.click(modesBtn[0]); 26 | expect(link).toBeInTheDocument(); 27 | expect(themeBtn).toBeInTheDocument(); 28 | expect(modesBtn).toHaveLength(themes.length); 29 | expect(mockModeBtn).toHaveBeenCalled(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/NavbarMenuContent/useNavbarMenuContent.hook.ts: -------------------------------------------------------------------------------- 1 | import { modes } from '@shared/constants/global.constant'; 2 | import { createColorMode } from '@shared/hooks/createColorMode/createColorMode.hook'; 3 | import { useAppStorage } from '@shared/hooks/useAppStorage/useAppStorage.hook'; 4 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 5 | import { useNavigate } from '@solidjs/router'; 6 | 7 | export default function useNavbarMenuContent() { 8 | const [t] = useI18n(); 9 | const [appStorage, , { remove }] = useAppStorage(); 10 | const [, setTheme] = createColorMode({ 11 | modes, 12 | attribute: 'data-theme', 13 | }); 14 | const navigate = useNavigate(); 15 | 16 | // #region HANDLERS 17 | const handleClickLogout = () => { 18 | // remove `user` key in local storage 19 | remove('user'); 20 | // back to login 21 | navigate('/login'); 22 | }; 23 | // #endregion 24 | 25 | return { t, appStorage, setTheme, handleClickLogout }; 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/shared/components/organisms/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Navbar } from './Navbar/Navbar.organism'; 2 | -------------------------------------------------------------------------------- /src/modules/shared/components/templates/PageWrapper/PageWrapper.template.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from '@shared/components/organisms'; 2 | import { Outlet } from '@solidjs/router'; 3 | import { Component } from 'solid-js'; 4 | 5 | const PageWrapper: Component = () => ( 6 | 7 | 8 | 9 | ); 10 | 11 | export default PageWrapper; 12 | -------------------------------------------------------------------------------- /src/modules/shared/components/templates/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PageWrapper } from './PageWrapper/PageWrapper.template'; 2 | -------------------------------------------------------------------------------- /src/modules/shared/configs/env/env.config.test.ts: -------------------------------------------------------------------------------- 1 | import { env } from './env.config'; 2 | 3 | describe('env config', () => { 4 | it('should have a apiBaseUrl defined', () => { 5 | // ASSERT 6 | expect(env.apiBaseUrl).toBeDefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/modules/shared/configs/env/env.config.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | appTitle: import.meta.env.VITE_APP_TITLE ?? 'appTitle', 3 | apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? 'apiBaseUrl', 4 | }; 5 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/auth.locale.ts: -------------------------------------------------------------------------------- 1 | export const authEnLocale = { 2 | username: 'Username', 3 | usernamePlaceholder: 'Your username...', 4 | password: 'Password', 5 | passwordPlaceholder: 'Your password...', 6 | loginLoading: 'Logging in...', 7 | login: 'Login', 8 | logout: 'Logout', 9 | notFound404: '404: Not Found', 10 | gone: "It's gone", 11 | welcome: 'Welcome Back', 12 | noAccount: "Don't have an account?", 13 | registerHere: 'Register here', 14 | } as const; 15 | 16 | export const authIdLocale = { 17 | username: 'Username', 18 | usernamePlaceholder: 'Username anda...', 19 | password: 'Password', 20 | passwordPlaceholder: 'Password anda...', 21 | loginLoading: 'Sedang masuk...', 22 | login: 'Masuk', 23 | logout: 'Keluar', 24 | notFound404: '404: Tidak Ditemukan', 25 | gone: 'Halaman Kosong', 26 | welcome: 'Selamat Datang Kembali', 27 | noAccount: 'Tidak punya akun?', 28 | registerHere: 'Daftar disini', 29 | } as const; 30 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/common.locale.ts: -------------------------------------------------------------------------------- 1 | export const commonEnLocale = { 2 | loading: 'Loading...', 3 | xList: '{{feature}} List', 4 | xDetail: '{{feature}} Detail', 5 | xCreateSuccess: '{{feature}} successfully created', 6 | xCreateError: '{{feature}} failed to create', 7 | xUpdateSuccess: '{{feature}} successfully updated', 8 | xUpdateError: '{{feature}} failed to update', 9 | xDeleteSuccess: '{{feature}} successfully deleted', 10 | xDeleteError: '{{feature}} failed to delete', 11 | goBackTo: 'Go back to {{target}}', 12 | todoUpdateError: 'Todo failed to update', 13 | errorMinLength: '{{field}} must contain at least {{length}} characters', 14 | error: '❌ {{module}} error', 15 | theme: 'Theme', 16 | add: 'Add {{icon}}', 17 | update: 'Update {{icon}}', 18 | remove: 'Remove {{icon}}', 19 | empty: 'Empty Data', 20 | unsavedChanges: 'Discard unsaved changes - are you sure?', 21 | appName: 'Solid Template', 22 | noPageContent: 'No Page Content', 23 | unauthorized: 'Unauthorized. Please login first', 24 | authorized: 'Already authorized', 25 | } as const; 26 | 27 | export const commonIdLocale = { 28 | loading: 'Menunggu...', 29 | xList: 'Daftar {{feature}}', 30 | xDetail: 'Detail {{feature}}', 31 | xCreateSuccess: '{{feature}} berhasil dibuat', 32 | xCreateError: '{{feature}} gagal dibuat', 33 | xUpdateSuccess: '{{feature}} berhasil diubah', 34 | xUpdateError: '{{feature}} gagal diubah', 35 | xDeleteSuccess: '{{feature}} berhasil dihapus', 36 | xDeleteError: '{{feature}} gagal dihapus', 37 | goBackTo: 'Kembali ke {{target}}', 38 | errorMinLength: '{{field}} harus memiliki minimal {{length}} karakter', 39 | error: '❌ {{module}} eror', 40 | theme: 'Tema', 41 | add: 'Tambah {{icon}}', 42 | update: 'Ubah {{icon}}', 43 | remove: 'Hapus {{icon}}', 44 | empty: 'Data Kosong', 45 | unsavedChanges: 'Buang perubahan yang belum disimpan - anda yakin?', 46 | appName: 'Templat Solid', 47 | noPageContent: 'Tidak Ada Konten', 48 | authorized: 'Sudah Ada Akses', 49 | } as const; 50 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/home.locale.ts: -------------------------------------------------------------------------------- 1 | export const homeEnLocale = { 2 | title: 'Solid App', 3 | sortButtons: 'Sort Buttons', 4 | clock: 'Clock', 5 | toggleClock: 'Toggle Clock', 6 | clickToggleClock: 'Click toggle clock to restart the clock', 7 | changeLanguage: 'Change Language', 8 | getStarted: 'Get Started', 9 | } as const; 10 | 11 | export const homeIdLocale = { 12 | title: 'Aplikasi Solid', 13 | sortButtons: 'Urutkan Tombol', 14 | clock: 'Jam', 15 | toggleClock: 'Toggle Jam', 16 | clickToggleClock: 'Klik beralih jam untuk mengulang kalkulasi jam', 17 | changeLanguage: 'Ganti Bahasa', 18 | getStarted: 'Mulai Sekarang', 19 | } as const; 20 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/locale.config.test.ts: -------------------------------------------------------------------------------- 1 | import { localeDict } from './locale.config'; 2 | 3 | describe('locale configuration', () => { 4 | it('should have correct keys', () => { 5 | // ASSERT 6 | expect(Object.keys(localeDict)).toEqual(['en', 'id']); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/locale.config.ts: -------------------------------------------------------------------------------- 1 | import { authEnLocale, authIdLocale } from './auth.locale'; 2 | import { commonEnLocale, commonIdLocale } from './common.locale'; 3 | import { homeEnLocale, homeIdLocale } from './home.locale'; 4 | import { LocaleDict } from './locale.type'; 5 | import { playgroundEnLocale, playgroundIdLocale } from './playground.locale'; 6 | import { todoEnLocale, todoIdLocale } from './todo.locale'; 7 | 8 | export const enLocale = { 9 | ...commonEnLocale, 10 | ...authEnLocale, 11 | ...playgroundEnLocale, 12 | ...homeEnLocale, 13 | ...todoEnLocale, 14 | } as const; 15 | 16 | export const idLocale = { 17 | ...commonIdLocale, 18 | ...authIdLocale, 19 | ...playgroundIdLocale, 20 | ...homeIdLocale, 21 | ...todoIdLocale, 22 | } as const; 23 | 24 | export const localeDict: LocaleDict = { 25 | en: enLocale, 26 | id: idLocale, 27 | } as const; 28 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/locale.type.ts: -------------------------------------------------------------------------------- 1 | import { enLocale } from './locale.config'; 2 | 3 | export type LocaleDictLanguage = 'en' | 'id'; 4 | export type LocaleDict = Record>; 5 | 6 | export type Translations = typeof enLocale; 7 | export type InterpolateInner< 8 | S extends string, 9 | // eslint-disable-next-line @typescript-eslint/ban-types 10 | U extends object = {}, 11 | > = S extends `${string}{{${infer V}}}${infer Rest}` 12 | ? InterpolateInner 13 | : U; 14 | 15 | export type Interpolate = InterpolateInner; 16 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/playground.locale.ts: -------------------------------------------------------------------------------- 1 | export const playgroundEnLocale = { 2 | playgroundTitle: 'Playground', 3 | } as const; 4 | 5 | export const playgroundIdLocale = { 6 | playgroundTitle: 'Taman Bermain', 7 | } as const; 8 | -------------------------------------------------------------------------------- /src/modules/shared/configs/locale/todo.locale.ts: -------------------------------------------------------------------------------- 1 | export const todoEnLocale = { 2 | todoPlaceholder: 'What should you do next...', 3 | limit: 'Limit', 4 | } as const; 5 | 6 | export const todoIdLocale = { 7 | todoPlaceholder: 'Apa yang akan anda lakukan selanjutnya...', 8 | limit: 'Batas', 9 | } as const; 10 | -------------------------------------------------------------------------------- /src/modules/shared/constants/global.constant.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@shared/types/store.type'; 2 | 3 | export const themes: Theme[] = [ 4 | 'auto', 5 | 'light', 6 | 'dark', 7 | 'cupcake', 8 | 'bumblebee', 9 | 'emerald', 10 | 'corporate', 11 | 'synthwave', 12 | 'retro', 13 | 'cyberpunk', 14 | 'valentine', 15 | 'halloween', 16 | 'garden', 17 | 'forest', 18 | 'aqua', 19 | 'lofi', 20 | 'pastel', 21 | 'fantasy', 22 | 'wireframe', 23 | 'black', 24 | 'luxury', 25 | 'dracula', 26 | 'cmyk', 27 | 'autumn', 28 | 'business', 29 | 'acid', 30 | 'lemonade', 31 | 'night', 32 | 'coffee', 33 | 'winter', 34 | ]; 35 | 36 | // object version of `themes` 37 | export const modes = themes.reduce( 38 | (acc, item) => { 39 | acc[item] = item; 40 | return acc; 41 | }, 42 | {} as Record, 43 | ); 44 | -------------------------------------------------------------------------------- /src/modules/shared/directives/clickOutside.directive.tsx: -------------------------------------------------------------------------------- 1 | import { Accessor, onCleanup } from 'solid-js'; 2 | 3 | export type ClickOutsideDirectiveParams = () => unknown; 4 | 5 | /** 6 | * a callback function will be fired whenever the user clicks outside of the dom node the action is applied to. 7 | * 8 | * @example 9 | * 10 | * ```tsx 11 | * 19 | * 20 | * setShow(true)}> 24 | * Open Modal 25 | * 26 | * } 27 | * > 28 | *
    setShow(false)}> 29 | * Modal Showed! 30 | *
    31 | *
    32 | * ``` 33 | */ 34 | const clickOutside = (node: HTMLElement, params: Accessor): void => { 35 | const callback = params(); 36 | 37 | const handleClickOutside = ({ target }: MouseEvent) => { 38 | if (!node.contains(target as Node)) callback(); 39 | }; 40 | 41 | document.body.addEventListener('click', handleClickOutside); 42 | 43 | onCleanup(() => document.body.removeEventListener('click', handleClickOutside)); 44 | }; 45 | 46 | export default clickOutside; 47 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/createColorMode/createColorMode.hook.ts: -------------------------------------------------------------------------------- 1 | import { createPrefersDark } from '@solid-primitives/media'; 2 | import { makePersisted } from '@solid-primitives/storage'; 3 | import { createEffect, createMemo, createSignal } from 'solid-js'; 4 | 5 | export type BasicColorSchema = BasicColorMode | 'auto'; 6 | export type BasicColorMode = 'light' | 'dark'; 7 | 8 | export interface UseColorModeOptions { 9 | /** 10 | * CSS Selector for the target element applying to 11 | * 12 | * @default 'html' 13 | */ 14 | selector?: string; 15 | 16 | /** 17 | * HTML attribute applying the target element 18 | * 19 | * @default 'class' 20 | */ 21 | attribute?: string; 22 | 23 | /** 24 | * The initial color mode 25 | * 26 | * @default 'auto' 27 | */ 28 | initialValue?: T | BasicColorSchema; 29 | 30 | /** 31 | * Prefix when adding value to the attribute 32 | */ 33 | modes?: Partial>; 34 | 35 | /** 36 | * A custom handler for handle the updates. 37 | * When specified, the default behavior will be overridden. 38 | * 39 | * @default undefined 40 | */ 41 | onChanged?: ( 42 | mode: T | BasicColorMode, 43 | defaultHandler: (mode: T | BasicColorMode) => void, 44 | ) => void; 45 | 46 | /** 47 | * Key to persist the data into localStorage/sessionStorage. 48 | * 49 | * Pass `null` to disable persistence 50 | * 51 | * @default 'vueuse-color-scheme' 52 | */ 53 | storageKey?: string | null; 54 | 55 | /** 56 | * Disable transition on switch 57 | * 58 | * @see https://paco.me/writing/disable-theme-transitions 59 | * @default true 60 | */ 61 | disableTransition?: boolean; 62 | } 63 | 64 | /** 65 | * Reactive color mode with auto data persistence. 66 | */ 67 | export function createColorMode( 68 | options: UseColorModeOptions, 69 | ) { 70 | const { 71 | selector = 'html', 72 | attribute = 'class', 73 | initialValue = 'auto', 74 | storageKey = 'app-color-scheme', 75 | disableTransition = true, 76 | } = options; 77 | 78 | const modes = { 79 | auto: '', 80 | light: 'light', 81 | dark: 'dark', 82 | ...(options.modes || {}), 83 | } as Record; 84 | 85 | const store = !storageKey 86 | ? // eslint-disable-next-line solid/reactivity 87 | createSignal(initialValue) 88 | : // eslint-disable-next-line solid/reactivity 89 | makePersisted(createSignal(initialValue), { name: storageKey }); 90 | 91 | const preferredDark = createPrefersDark(); 92 | const system = createMemo(() => (preferredDark() ? 'dark' : 'light')); 93 | const state = createMemo( 94 | () => (store[0]() === 'auto' ? system() : store[0]()) as T | BasicColorMode, 95 | ); 96 | 97 | const updateHTMLAttrs = (_selector: string, _attribute: string, _value: string) => { 98 | const el = window.document.querySelector(_selector); 99 | if (!el) return; 100 | 101 | let style: HTMLStyleElement | undefined; 102 | if (disableTransition) { 103 | style = window.document.createElement('style'); 104 | const styleString = 105 | '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}'; 106 | style.appendChild(document.createTextNode(styleString)); 107 | window.document.head.appendChild(style); 108 | } 109 | 110 | if (_attribute === 'class') { 111 | const current = _value.split(/\s/g); 112 | Object.values(modes) 113 | .flatMap((i) => (i || '').split(/\s/g)) 114 | .filter(Boolean) 115 | .forEach((v) => { 116 | if (current.includes(v)) el.classList.add(v); 117 | else el.classList.remove(v); 118 | }); 119 | } else { 120 | el.setAttribute(_attribute, _value); 121 | } 122 | 123 | if (disableTransition) { 124 | // Calling getComputedStyle forces the browser to redraw 125 | (() => window.getComputedStyle(style as Element).opacity)(); 126 | document.head.removeChild(style as HTMLStyleElement); 127 | } 128 | }; 129 | 130 | createEffect(() => { 131 | if (options.onChanged) 132 | options.onChanged(state(), (mode: T | BasicColorMode) => { 133 | updateHTMLAttrs(selector, attribute, modes[mode] ?? mode); 134 | }); 135 | else updateHTMLAttrs(selector, attribute, modes[state()] ?? state()); 136 | }); 137 | 138 | return store; 139 | } 140 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/createLocalStore/createLocalStore.hook.test.tsx: -------------------------------------------------------------------------------- 1 | import { createEffect, createRoot } from 'solid-js'; 2 | import { createLocalStore } from './createLocalStore.hook'; 3 | 4 | describe('createLocalStore', () => { 5 | beforeEach(() => { 6 | localStorage.removeItem('todos'); 7 | }); 8 | 9 | const initialState = { 10 | todos: [], 11 | newTitle: '', 12 | }; 13 | 14 | test('it reads pre-existing state from localStorage', () => 15 | createRoot((dispose) => { 16 | // ARRANGE 17 | const savedState = { todos: [], newTitle: 'saved' }; 18 | localStorage.setItem('todos', JSON.stringify(savedState)); 19 | const [state] = createLocalStore('todos', initialState); 20 | 21 | // ASSERT 22 | expect(state).toEqual(savedState); 23 | 24 | // cleanup 25 | dispose(); 26 | })); 27 | 28 | test('it stores new state to localStorage', () => 29 | createRoot((dispose) => { 30 | // ARRANGE 31 | const [, setState] = createLocalStore('todos', initialState); 32 | setState('newTitle', 'updated'); 33 | 34 | // to catch an effect, use an effect 35 | return new Promise((resolve) => 36 | // eslint-disable-next-line no-promise-executor-return 37 | createEffect(() => { 38 | // ASSERT 39 | expect(JSON.parse((localStorage.todos as string) || '')).toEqual({ 40 | todos: [], 41 | newTitle: 'updated', 42 | }); 43 | 44 | // cleanup 45 | dispose(); 46 | resolve(); 47 | }), 48 | ); 49 | })); 50 | 51 | test('it updates state multiple times', async () => { 52 | // ARRANGE 53 | const { dispose, setState } = createRoot((_dispose) => { 54 | const [, SetState] = createLocalStore('todos', initialState); 55 | return { dispose: _dispose, setState: SetState }; 56 | }); 57 | setState('newTitle', 'first'); 58 | 59 | // ACT -> wait a tick to resolve all effects 60 | // eslint-disable-next-line no-promise-executor-return 61 | await new Promise((done) => setTimeout(done, 0)); 62 | 63 | // ASSERT 64 | expect(JSON.parse((localStorage.todos as string) || '')).toEqual({ 65 | todos: [], 66 | newTitle: 'first', 67 | }); 68 | 69 | // ACT 70 | setState('newTitle', 'second'); 71 | // eslint-disable-next-line no-promise-executor-return 72 | await new Promise((done) => setTimeout(done, 0)); 73 | 74 | // ASSERT 75 | expect(JSON.parse((localStorage.todos as string) || '')).toEqual({ 76 | todos: [], 77 | newTitle: 'second', 78 | }); 79 | 80 | // cleanup 81 | dispose(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/createLocalStore/createLocalStore.hook.ts: -------------------------------------------------------------------------------- 1 | import { createEffect } from 'solid-js'; 2 | import { createStore, SetStoreFunction, Store } from 'solid-js/store'; 3 | 4 | /** 5 | * This creates a store that also integrated with local storage. 6 | * 7 | * @example 8 | * 9 | * ```ts 10 | * // don't pass second argument to get the already instantiated instance 11 | * const [store, setStore] = createLocalStore('user') 12 | * 13 | * // pass second argument to instantiates a new instance 14 | * const [store, setStore] = createLocalStore('user', { username: 'rifandani' }) 15 | * ``` 16 | */ 17 | export function createLocalStore( 18 | name: string, 19 | init: T, 20 | ): [Store, SetStoreFunction] { 21 | const localState = localStorage.getItem(name); 22 | const [store, setStore] = createStore(localState ? (JSON.parse(localState) as T) : init); 23 | 24 | createEffect(() => localStorage.setItem(name, JSON.stringify(store))); 25 | 26 | return [store, setStore]; 27 | } 28 | 29 | export type CreateLocalStore = ReturnType; 30 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/useAppStorage/useAppStorage.hook.ts: -------------------------------------------------------------------------------- 1 | import { loginApiResponseSchema } from '@auth/api/auth.schema'; 2 | import { createStorage } from '@solid-primitives/storage'; 3 | import { z } from 'zod'; 4 | 5 | export type AppStorageSchema = z.infer; 6 | export type AppStorageInterface = ReturnType; 7 | 8 | type AppStorageKeys = keyof AppStorageSchema; 9 | type AppStorageValues = AppStorageSchema[AppStorageKeys]; 10 | 11 | type StorageActions = { 12 | remove: (key: AppStorageKeys) => void; 13 | clear: () => void; 14 | error: () => Error | undefined; 15 | toJSON: () => { [key: string]: T }; 16 | }; 17 | 18 | export const appStorageSchema = z.object({ 19 | user: loginApiResponseSchema.optional(), 20 | }); 21 | 22 | /** 23 | * like createStore, but bound to a localStorage-like API 24 | * 25 | * @prop prefix - app 26 | * @prop serializer - JSON.stringify(value) 27 | * @prop deserializer - JSON.parse(value) 28 | */ 29 | export const useAppStorage = () => 30 | createStorage({ 31 | prefix: 'app', 32 | serializer: (value) => JSON.stringify(value), 33 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 34 | // @ts-ignore 35 | deserializer: (value) => JSON.parse(value) as AppStorageSchema, 36 | }) as unknown as [ 37 | store: AppStorageSchema, 38 | setter: (key: AppStorageKeys, value: AppStorageValues, options?: unknown) => void, 39 | actions: StorageActions, 40 | ]; 41 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/useAppStore/useAppStore.hook.ts: -------------------------------------------------------------------------------- 1 | import { AppAction, AppStore } from '@shared/types/store.type'; 2 | import { createContext, useContext } from 'solid-js'; 3 | import { createStore } from 'solid-js/store'; 4 | 5 | const appStoreInitialState = { 6 | setting: { 7 | showNotification: true, 8 | }, 9 | } satisfies AppStore; 10 | 11 | /** 12 | * This creates a AppStoreContext. 13 | * It's extracted into a function to be able to type the Context before it's even initialized. 14 | */ 15 | export const createAppStoreContext = (init: AppStore = appStoreInitialState) => { 16 | const [store, setStore] = createStore(init); 17 | 18 | const actions: AppAction = { 19 | /** 20 | * Toggle global notifications 21 | */ 22 | toggleNotif: () => setStore('setting', 'showNotification', (prev) => !prev), 23 | /** 24 | * Enable global notifications 25 | */ 26 | enableNotif: () => setStore('setting', 'showNotification', true), 27 | /** 28 | * Disable global notifications 29 | */ 30 | disableNotif: () => setStore('setting', 'showNotification', false), 31 | }; 32 | 33 | return [store, actions] as const; 34 | }; 35 | 36 | export type AppStoreContextInterface = ReturnType; 37 | 38 | export const AppStoreContext = createContext( 39 | {} as AppStoreContextInterface, 40 | ); 41 | 42 | export const useAppStore = () => { 43 | const context = useContext(AppStoreContext); 44 | if (!context) { 45 | throw new Error('useAppStore: cannot find the AppStoreContext'); 46 | } 47 | 48 | return context; 49 | }; 50 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/useAuth/useAuth.hook.test.tsx: -------------------------------------------------------------------------------- 1 | import { mockedLocation, mockedNavigator } from '@mocks/module.mock'; 2 | import { renderProviders } from '@shared/utils/test.util'; 3 | import { Route } from '@solidjs/router'; 4 | import { vi } from 'vitest'; 5 | import useAuth from './useAuth.hook'; 6 | 7 | function TestComponent() { 8 | useAuth(); 9 | 10 | return
    ; 11 | } 12 | 13 | describe('useAuth hook', () => { 14 | // assign the spy instance to a const 15 | // const getItemSpy = vi.spyOn(Storage.prototype, 'getItem'); 16 | // with happy dom we can do: 17 | const getItemSpy = vi.spyOn(localStorage, 'getItem'); 18 | 19 | afterEach(() => { 20 | getItemSpy.mockClear(); // clear call history 21 | localStorage.clear(); 22 | }); 23 | 24 | it('should be defined', () => { 25 | // ASSERT 26 | expect(useAuth).toBeDefined(); 27 | }); 28 | 29 | it.todo('should navigate to "/login", when "user" key does not exists in localStorage', () => { 30 | // ARRANGE 31 | renderProviders(() => ); 32 | 33 | // ASSERT 34 | expect(getItemSpy).toHaveBeenCalled(); 35 | expect(mockedNavigator).toHaveBeenCalled(); 36 | }); 37 | 38 | it.todo( 39 | 'should navigate to /, when "user" key exists in localStorage && current location pathname includes "/login"', 40 | () => { 41 | // ARRANGE 42 | getItemSpy.mockImplementationOnce(() => '{"email":"my email"}'); 43 | renderProviders(() => ); 44 | 45 | // ASSERT 46 | expect(getItemSpy).toHaveBeenCalled(); 47 | expect(mockedLocation).toHaveBeenCalled(); 48 | expect(mockedNavigator).toHaveBeenCalled(); 49 | }, 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/useAuth/useAuth.hook.tsx: -------------------------------------------------------------------------------- 1 | import { toaster } from '@kobalte/core'; 2 | import { Toaster } from '@shared/components/molecules'; 3 | import { useLocation, useNavigate } from '@solidjs/router'; 4 | import { onMount } from 'solid-js'; 5 | import { useAppStorage } from '../useAppStorage/useAppStorage.hook'; 6 | import { useI18n } from '../usei18n/usei18n.hook'; 7 | 8 | /** 9 | * Hooks to authenticate your user, wheter they're logged in or not 10 | * 11 | * @example 12 | * 13 | * ```tsx 14 | * useAuth() 15 | * ``` 16 | */ 17 | export default function useAuth() { 18 | const [t] = useI18n(); 19 | const navigate = useNavigate(); 20 | const location = useLocation(); 21 | const [appStorage] = useAppStorage(); 22 | 23 | onMount(() => { 24 | if (!appStorage.user && location.pathname.includes('login')) return; 25 | 26 | if (!appStorage.user) { 27 | navigate('/login', { replace: true }); 28 | toaster.show((props) => ( 29 | 30 | )); 31 | return; 32 | } 33 | 34 | if (location.pathname.includes('login')) { 35 | navigate('/'); 36 | toaster.show((props) => ( 37 | 38 | )); 39 | } 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/usei18n/usei18n.hook.test.tsx: -------------------------------------------------------------------------------- 1 | import { localeDict } from '@shared/configs/locale/locale.config'; 2 | import { createRoot } from 'solid-js'; 3 | import { describe } from 'vitest'; 4 | import { createI18nContext } from './usei18n.hook'; 5 | 6 | describe('createI18nContext', () => { 7 | it('should be able to switch locale', () => { 8 | const [t, { add, locale }] = createRoot(() => createI18nContext(localeDict, 'en')); 9 | Object.entries(localeDict).forEach(([lang, translations]) => add(lang, translations)); 10 | 11 | locale('en'); 12 | expect(t('xList', { feature: 'Post' })).toBe('Post List'); 13 | 14 | locale('id'); 15 | expect(t('xList', { feature: 'Post' })).toBe('Daftar Post'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/modules/shared/hooks/usei18n/usei18n.hook.ts: -------------------------------------------------------------------------------- 1 | import { deepReadObject, template } from '@rifandani/nxact-yutiriti'; 2 | import { Interpolate, LocaleDictLanguage, Translations } from '@shared/configs/locale/locale.type'; 3 | import { createContext, createSignal, useContext } from 'solid-js'; 4 | import { createStore } from 'solid-js/store'; 5 | 6 | /** 7 | * This creates a I18nContext. 8 | * It's extracted into a function to be able to type the Context before it's even initialized. 9 | * 10 | * @param [init={}] {Record>} - Initial dictionary of languages 11 | * @param [lang=navigator.language] {string} - The default language fallback to browser language if not set 12 | */ 13 | export const createI18nContext = ( 14 | init: Record> = {}, 15 | lang: string = navigator.language in init ? navigator.language : Object.keys(init)[0], 16 | ) => { 17 | const [locale, setLocale] = createSignal(lang); 18 | const [dict, setDict] = createStore(init); 19 | 20 | /** 21 | * The main translation function of the library, given a key, it will look into its 22 | * dictionnaries to find th right translationb for that key and fallback to the default 23 | * translation provided in last argument (if provided). 24 | * 25 | * You can additionally give as a second arguments dynamic parameters to inject into the 26 | * the translation. 27 | * 28 | * @param key {string} - The key to look translation for 29 | * @param [params] {Record} - Parameters to pass into the translation template 30 | * @param [defaultValue] {string} - Default value if the translation isn't found 31 | * 32 | * @returns {string} - The translated string 33 | * 34 | * @example 35 | * ```tsx 36 | * const [t] = useI18n(); 37 | * 38 | * const dict = { fr: 'Bonjour {{name}} !' } 39 | * 40 | * t('hello', { name: 'John' }, 'Hello, {{name}}!'); 41 | * locale('fr') 42 | * // => 'Bonjour John !' 43 | * locale('unknown') 44 | * // => 'Hello, John!' 45 | * ``` 46 | */ 47 | const translate = >( 48 | ...args: keyof Payload extends never 49 | ? [translation: T] 50 | : [translation: T, payload: Interpolate] 51 | ): string => { 52 | const [key, params] = args; 53 | 54 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 55 | const val = deepReadObject(dict[locale()], key); // we can pass `defaultValue` as third parameter 56 | 57 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call 58 | if (typeof val === 'function') return val(params); 59 | if (typeof val === 'string') return template(val, params || {}); 60 | 61 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 62 | return val; 63 | }; 64 | 65 | const actions = { 66 | /** 67 | * Add (or edit an existing) locale 68 | * 69 | * @param lang {string} - The locale to add or edit 70 | * @param table {Record} - The dictionary 71 | * 72 | * @example 73 | * ```js 74 | * const [_, { add }] = useI18n(); 75 | * 76 | * const addSwedish = () => add('sw', { hello: 'Hej {{name}}' }) 77 | * ``` 78 | */ 79 | add(_lang: string, table: Record) { 80 | setDict(_lang, (t) => Object.assign(t || {}, table)); 81 | }, 82 | /** 83 | * Switch to the language in the parameters. 84 | * 85 | * @example 86 | * 87 | * ```js 88 | * const [_, { locale }] = useI18n(); 89 | * 90 | * locale() 91 | * // => 'en' 92 | * locale('id') 93 | * locale() 94 | * // => 'id' 95 | * 96 | * ``` 97 | */ 98 | locale: (_lang?: LocaleDictLanguage) => (_lang ? setLocale(_lang) : locale()), 99 | /** 100 | * Retrieve the dictionary of a language 101 | * 102 | * @param lang {string} - The language to retrieve from 103 | * @returns dict {Record>} 104 | */ 105 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 106 | dict: (_lang: string) => deepReadObject(dict, _lang), 107 | }; 108 | 109 | return [translate, actions] as const; 110 | }; 111 | 112 | export type I18nContextInterface = ReturnType; 113 | 114 | export const I18nContext = createContext({} as I18nContextInterface); 115 | 116 | export const useI18n = () => { 117 | const context = useContext(I18nContext); 118 | if (!context) { 119 | throw new Error('useI18n: cannot find the I18nContext'); 120 | } 121 | 122 | return context; 123 | }; 124 | -------------------------------------------------------------------------------- /src/modules/shared/services/api/http.api.ts: -------------------------------------------------------------------------------- 1 | import { env } from '@shared/configs/env/env.config'; 2 | import axios from 'axios'; 3 | 4 | // Set config defaults when creating the instance 5 | export const http = axios.create({ 6 | baseURL: env.apiBaseUrl, 7 | validateStatus: (status) => 8 | // Resolve only if the status code is less than 500 9 | status < 500, 10 | }); 11 | -------------------------------------------------------------------------------- /src/modules/shared/types/form.type.ts: -------------------------------------------------------------------------------- 1 | import type { JSX } from 'solid-js'; 2 | 3 | export type FormOnSubmitEvent = Event & { 4 | submitter: HTMLElement; 5 | } & { 6 | currentTarget: HTMLFormElement; 7 | target: Element; 8 | }; 9 | 10 | export type InputOnChangeEvent = Event & { 11 | currentTarget: HTMLInputElement; 12 | target: Element; 13 | }; 14 | 15 | export type FormOnSubmit = 16 | | JSX.EventHandlerUnion< 17 | HTMLFormElement, 18 | Event & { 19 | submitter: HTMLElement; 20 | } 21 | > 22 | | undefined; 23 | 24 | export type InputOnInput = JSX.EventHandlerUnion | undefined; 25 | 26 | export type InputOnChange = JSX.EventHandlerUnion | undefined; 27 | 28 | export type InputOnKeyUp = 29 | | JSX.EventHandlerUnion 30 | | undefined; 31 | 32 | export type ButtonOnClick = JSX.EventHandlerUnion | undefined; 33 | 34 | export type SelectOnChange = JSX.EventHandlerUnion | undefined; 35 | -------------------------------------------------------------------------------- /src/modules/shared/types/store.type.ts: -------------------------------------------------------------------------------- 1 | import { SettingSchema } from '@setting/api/setting.schema'; 2 | 3 | export type AppStore = { 4 | setting: SettingSchema; 5 | }; 6 | 7 | export type AppAction = Readonly<{ 8 | toggleNotif: () => void; 9 | enableNotif: () => void; 10 | disableNotif: () => void; 11 | }>; 12 | 13 | export type Theme = 14 | | 'auto' 15 | | 'light' 16 | | 'dark' 17 | | 'cupcake' 18 | | 'bumblebee' 19 | | 'emerald' 20 | | 'corporate' 21 | | 'synthwave' 22 | | 'retro' 23 | | 'cyberpunk' 24 | | 'valentine' 25 | | 'halloween' 26 | | 'garden' 27 | | 'forest' 28 | | 'aqua' 29 | | 'lofi' 30 | | 'pastel' 31 | | 'fantasy' 32 | | 'wireframe' 33 | | 'black' 34 | | 'luxury' 35 | | 'dracula' 36 | | 'cmyk' 37 | | 'autumn' 38 | | 'business' 39 | | 'acid' 40 | | 'lemonade' 41 | | 'night' 42 | | 'coffee' 43 | | 'winter'; 44 | -------------------------------------------------------------------------------- /src/modules/shared/utils/checker/checker.util.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/solid-app/c928a8da45aae490881eccb631b8d01079652fce/src/modules/shared/utils/checker/checker.util.ts -------------------------------------------------------------------------------- /src/modules/shared/utils/formatter/formatter.util.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/solid-app/c928a8da45aae490881eccb631b8d01079652fce/src/modules/shared/utils/formatter/formatter.util.ts -------------------------------------------------------------------------------- /src/modules/shared/utils/helper/helper.util.test.ts: -------------------------------------------------------------------------------- 1 | import { template } from './helper.util'; 2 | 3 | describe('template', () => { 4 | it('should work correctly', () => { 5 | const helloTom = template('Hello {{name}}', { name: 'Tom' }); 6 | const itIsBlue = template('It is {{color}}', { color: 'blue' }); 7 | const itIsBlueRegex = template('It is ', { color: 'blue' }, /<(.+?)>/g); 8 | 9 | expect(helloTom).toBe('Hello Tom'); 10 | expect(itIsBlue).toBe('It is blue'); 11 | expect(itIsBlueRegex).toBe('It is blue'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/modules/shared/utils/helper/helper.util.ts: -------------------------------------------------------------------------------- 1 | import { deepReadObject } from '@rifandani/nxact-yutiriti'; 2 | import { extendTailwindMerge } from 'tailwind-merge'; 3 | 4 | /** 5 | * Provided a string template it will replace dynamics parts in place of variables. 6 | * This util is largely inspired by [templite](https://github.com/lukeed/templite/blob/master/src/index.js) 7 | * 8 | * @param str {string} - The string you wish to use as template 9 | * @param params {Record} - The params to inject into the template 10 | * @param reg {RegExp} - The RegExp used to find and replace. Default to `/{{(.*?)}}/g` 11 | * 12 | * @returns {string} - The fully injected template 13 | * 14 | * @example 15 | * ```ts 16 | * const txt = template('Hello {{ name }}', { name: 'Tom' }); 17 | * // => 'Hello Tom' 18 | * ``` 19 | */ 20 | export const template = (str: string, params: Record, reg = /{{(.*?)}}/g): string => 21 | str.replace(reg, (_, key: string) => deepReadObject(params, key, '')); 22 | 23 | export const clamp = ({ value, min, max }: { value: number; min: number; max: number }) => 24 | Math.min(Math.max(value, min), max); 25 | 26 | /** 27 | * Check if we are in browser, not server 28 | */ 29 | export const isBrowser = () => typeof window !== 'undefined'; 30 | 31 | /** 32 | * Format phone number based on mockup, currently only covered minimum 11 characters and max 15 characters include +62 33 | * e.g +62-812-7363-6365 34 | * 35 | * @param phoneNumber 36 | */ 37 | export const indonesianPhoneNumberFormat = (phoneNumber?: string) => { 38 | if (!phoneNumber) return ''; 39 | // e.g: +62 40 | const code = phoneNumber.slice(0, 3); 41 | const numbers = phoneNumber.slice(3); 42 | // e.g 812, 852 43 | const ndc = numbers.slice(0, 3); 44 | // e.g the rest of the numbers 45 | const uniqNumber = numbers.slice(3); 46 | let regexp: RegExp; 47 | 48 | if (uniqNumber.length <= 6) { 49 | regexp = /(\d{3})(\d{1,})/; 50 | } else if (uniqNumber.length === 7) { 51 | regexp = /(\d{3})(\d{4})/; 52 | } else if (uniqNumber.length === 8) { 53 | regexp = /(\d{4})(\d{4})/; 54 | } else { 55 | regexp = /(\d{4})(\d{5,})/; 56 | } 57 | 58 | const matches = uniqNumber.replace(regexp, '$1-$2'); 59 | 60 | return [code, ndc, matches].join('-'); 61 | }; 62 | 63 | /** 64 | * convert deep nested object keys to camelCase. 65 | */ 66 | export const toCamelCase = (object: unknown): T => { 67 | let transformedObject = object as Record; 68 | if (typeof object === 'object' && object !== null) { 69 | if (object instanceof Array) { 70 | transformedObject = object.map(toCamelCase) as unknown as Record; 71 | } else { 72 | transformedObject = {}; 73 | Object.keys(object).forEach((key) => { 74 | if ((object as Record)[key] !== undefined) { 75 | const firstUnderscore = key.replace(/^_/, ''); 76 | const newKey = firstUnderscore.replace(/(_\w)|(-\w)/g, (k) => k[1].toUpperCase()); 77 | transformedObject[newKey] = toCamelCase((object as Record)[key]); 78 | } 79 | }); 80 | } 81 | } 82 | return transformedObject as T; 83 | }; 84 | 85 | /** 86 | * convert deep nested object keys to snake_case. 87 | */ 88 | export const toSnakeCase = (object: unknown): T => { 89 | let transformedObject = object as Record; 90 | if (typeof object === 'object' && object !== null) { 91 | if (object instanceof Array) { 92 | transformedObject = object.map(toSnakeCase) as unknown as Record; 93 | } else { 94 | transformedObject = {}; 95 | Object.keys(object).forEach((key) => { 96 | if ((object as Record)[key] !== undefined) { 97 | const newKey = key 98 | .replace(/\.?([A-Z]+)/g, (_, y) => `_${y ? (y as string).toLowerCase() : ''}`) 99 | .replace(/^_/, ''); 100 | transformedObject[newKey] = toSnakeCase((object as Record)[key]); 101 | } 102 | }); 103 | } 104 | } 105 | return transformedObject as T; 106 | }; 107 | 108 | /** 109 | * Remove leading zero 110 | */ 111 | export const removeLeadingZeros = (value: string) => { 112 | if (/^([0]{1,})([1-9]{1,})/i.test(value)) { 113 | return value.replace(/^(0)/i, ''); 114 | } 115 | 116 | return value.replace(/^[0]{2,}/i, '0'); 117 | }; 118 | 119 | /** 120 | * Remove leading whitespaces 121 | */ 122 | export const removeLeadingWhitespace = (value?: string) => { 123 | if (!value) return ''; 124 | if (/^[\s]*$/i.test(value)) { 125 | return value.replace(/^[\s]*/i, ''); 126 | } 127 | 128 | return value; 129 | }; 130 | 131 | /** 132 | * This will works with some rules: 133 | * 1. If the file source located in the same origin as the application. 134 | * 2. If the file source is on different location e.g s3 bucket, etc. Set the response headers `Content-Disposition: attachment`. 135 | * Otherwise it only view on new tab. 136 | */ 137 | export const doDownload = (url: string) => { 138 | if (!url) return; 139 | const link = document.createElement('a'); 140 | link.href = url; 141 | link.download = url; 142 | link.target = '_blank'; 143 | document.body.appendChild(link); 144 | link.click(); 145 | document.body.removeChild(link); 146 | }; 147 | 148 | /** 149 | * create merge function with custom config which extends the default config. 150 | * Use this if you use the default Tailwind config and just extend it in some places. 151 | */ 152 | export const tw = extendTailwindMerge({ 153 | classGroups: { 154 | // ↓ The `foo` key here is the class group ID 155 | // ↓ Creates group of classes which have conflicting styles 156 | // Classes here: 'alert-info', 'alert-success', 'alert-warning', 'alert-error' 157 | alert: ['alert-info', 'alert-success', 'alert-warning', 'alert-error'], 158 | }, 159 | // ↓ Here you can define additional conflicts across different groups 160 | conflictingClassGroups: { 161 | // ↓ ID of class group which creates a conflict with… 162 | // ↓ …classes from groups with these IDs 163 | // In this case `tw('alert-success alert-error') → 'alert-error'` 164 | alert: ['alert'], 165 | }, 166 | }); 167 | 168 | /** 169 | * create `URLSearchParams` from object query params 170 | * 171 | * @example 172 | * 173 | * ```ts 174 | * createSearchParamsFromObject({ limit: '10', skip: 2 }).toString() // -> 'limit=10&skip=2' 175 | * createSearchParamsFromObject({ limit: ['10', '20], skip: 2 }).toString() // -> 'limit=10&limit=20&skip=2' 176 | * ``` 177 | */ 178 | export const createSearchParamsFromObject = (obj: Record) => 179 | new URLSearchParams( 180 | Object.entries(obj).flatMap(([key, values]) => 181 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 182 | Array.isArray(values) ? values.map((value) => [key, value]) : [[key, values]], 183 | ), 184 | ); 185 | -------------------------------------------------------------------------------- /src/modules/shared/utils/mapper/mapper.util.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rifandani/solid-app/c928a8da45aae490881eccb631b8d01079652fce/src/modules/shared/utils/mapper/mapper.util.ts -------------------------------------------------------------------------------- /src/modules/shared/utils/test.util.tsx: -------------------------------------------------------------------------------- 1 | import { AppStoreProvider, I18nProvider } from '@app/RootProvider.app'; 2 | import { localeDict } from '@shared/configs/locale/locale.config'; 3 | import { Router, Routes } from '@solidjs/router'; 4 | import { render } from '@solidjs/testing-library'; 5 | import { QueryClient, QueryClientProvider } from '@tanstack/solid-query'; 6 | 7 | const queryClient = new QueryClient({ 8 | defaultOptions: { 9 | queries: { 10 | retry: false, 11 | cacheTime: 0, 12 | }, 13 | }, 14 | }); 15 | 16 | export const renderProviders = ( 17 | ui: Parameters[0], 18 | options?: Parameters[1], 19 | ) => 20 | render(ui, { 21 | wrapper: (props) => ( 22 | 23 | 24 | 25 | 26 | {props.children} 27 | 28 | 29 | 30 | 31 | ), 32 | ...options, 33 | }); 34 | -------------------------------------------------------------------------------- /src/modules/todo/api/todo.api.ts: -------------------------------------------------------------------------------- 1 | import { ErrorApiResponseSchema, ResourceParamsSchema } from '@shared/api/api.schema'; 2 | import { http } from '@shared/services/api/http.api'; 3 | import { 4 | CreateTodoApiResponseSchema, 5 | DeleteTodoApiResponseSchema, 6 | TodoDetailApiResponseSchema, 7 | TodoListApiResponseSchema, 8 | UpdateTodoApiResponseSchema, 9 | createTodoApiResponseSchema, 10 | deleteTodoApiResponseSchema, 11 | todoDetailApiResponseSchema, 12 | todoListApiResponseSchema, 13 | updateTodoApiResponseSchema, 14 | type CreateTodoSchema, 15 | type DeleteTodoSchema, 16 | type TodoSchema, 17 | type UpdateTodoSchema, 18 | } from './todo.schema'; 19 | 20 | export const todoKeys = { 21 | all: ['todos'] as const, 22 | lists: () => [...todoKeys.all, 'list'] as const, 23 | list: (params: ResourceParamsSchema) => [...todoKeys.lists(), params] as const, 24 | details: () => [...todoKeys.all, 'detail'] as const, 25 | detail: (id: TodoSchema['id']) => [...todoKeys.details(), id] as const, 26 | }; 27 | 28 | export const todoApi = { 29 | list: async (params: ResourceParamsSchema) => { 30 | const resp = await http.get('todos', { 31 | params, 32 | }); 33 | 34 | // `parse` will throw if `resp.data` is not correct 35 | return todoListApiResponseSchema.parse(resp.data); 36 | }, 37 | detail: async (id: TodoSchema['id']) => { 38 | const resp = await http.get( 39 | `todos/${id}`, 40 | ); 41 | 42 | return todoDetailApiResponseSchema.parse(resp.data); 43 | }, 44 | create: async (todo: CreateTodoSchema) => { 45 | const resp = await http.post( 46 | `todos/add`, 47 | todo, 48 | ); 49 | 50 | return createTodoApiResponseSchema.parse(resp.data); 51 | }, 52 | update: async ({ id, ...body }: UpdateTodoSchema) => { 53 | const resp = await http.put( 54 | `todos/${id}`, 55 | body, 56 | ); 57 | 58 | return updateTodoApiResponseSchema.parse(resp.data); 59 | }, 60 | delete: async (id: DeleteTodoSchema['id']) => { 61 | const resp = await http.delete( 62 | `todos/${id}`, 63 | ); 64 | 65 | return deleteTodoApiResponseSchema.parse(resp.data); 66 | }, 67 | } as const; 68 | -------------------------------------------------------------------------------- /src/modules/todo/api/todo.schema.ts: -------------------------------------------------------------------------------- 1 | import { resourceListSchema } from '@shared/api/api.schema'; 2 | import { z } from 'zod'; 3 | 4 | // #region ENTITY SCHEMA 5 | export const todoSchema = z.object({ 6 | id: z.number().positive(), 7 | todo: z.string(), 8 | completed: z.boolean(), 9 | userId: z.number().positive(), 10 | }); 11 | export const detailTodoSchema = todoSchema.pick({ id: true }); 12 | export const createTodoSchema = todoSchema; 13 | export const updateTodoSchema = todoSchema.omit({ userId: true }); 14 | export const deleteTodoSchema = detailTodoSchema; 15 | // #endregion 16 | 17 | // #region API SCHEMA 18 | export const todoListApiResponseSchema = resourceListSchema.extend({ 19 | todos: z.array(todoSchema), 20 | }); 21 | export const todoDetailApiResponseSchema = todoSchema; 22 | export const createTodoApiResponseSchema = todoSchema; 23 | export const updateTodoApiResponseSchema = todoSchema; 24 | export const deleteTodoApiResponseSchema = todoSchema.extend({ 25 | isDeleted: z.literal(true), 26 | deletedOn: z.string().datetime(), 27 | }); 28 | // #endregion 29 | 30 | // #region SCHEMA TYPES 31 | export type TodoSchema = z.infer; 32 | export type DetailTodoSchema = z.infer; 33 | export type CreateTodoSchema = z.infer; 34 | export type UpdateTodoSchema = z.infer; 35 | export type DeleteTodoSchema = z.infer; 36 | export type TodoListApiResponseSchema = z.infer; 37 | export type TodoDetailApiResponseSchema = z.infer; 38 | export type CreateTodoApiResponseSchema = z.infer; 39 | export type UpdateTodoApiResponseSchema = z.infer; 40 | export type DeleteTodoApiResponseSchema = z.infer; 41 | // #endregion 42 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosCreate/TodosCreate.component.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'solid-js'; 2 | import useTodosCreate from './useTodosCreate.hook'; 3 | 4 | const TodosCreate: Component = () => { 5 | const { t, form, isSubmitting } = useTodosCreate(); 6 | 7 | return ( 8 |
    9 | 18 | 19 | 27 |
    28 | ); 29 | }; 30 | 31 | export default TodosCreate; 32 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosCreate/TodosCreate.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { ByRoleOptions, fireEvent, screen } from '@solidjs/testing-library'; 4 | import { vi } from 'vitest'; 5 | import TodosCreate from './TodosCreate.component'; 6 | 7 | describe('TodosCreate', () => { 8 | const todoValue = 'new todo'; 9 | const mockCreateSubmitFn = vi.fn(); 10 | 11 | it('should render properly', () => { 12 | const view = renderProviders(() => ); 13 | expect(() => view).not.toThrow(); 14 | }); 15 | 16 | it('should be able to type the inputs and submit the create todo form', () => { 17 | // ARRANGE 18 | renderProviders(() => ); 19 | const createOptions: ByRoleOptions = { name: /add/i }; 20 | const formCreate: HTMLFormElement = screen.getByRole('form'); 21 | const inputTodo: HTMLInputElement = screen.getByRole('textbox', createOptions); 22 | const buttonSubmit: HTMLButtonElement = screen.getByRole('button', createOptions); 23 | formCreate.addEventListener('submit', mockCreateSubmitFn); 24 | 25 | // ACT & ASSERT 26 | expect(formCreate).toBeInTheDocument(); 27 | expect(inputTodo).toBeInTheDocument(); 28 | expect(buttonSubmit).toBeInTheDocument(); 29 | fireEvent.change(inputTodo, { target: { value: todoValue } }); 30 | expect(inputTodo).toHaveValue(todoValue); 31 | fireEvent.click(buttonSubmit); 32 | expect(mockCreateSubmitFn).toHaveBeenCalled(); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosCreate/useTodosCreate.hook.tsx: -------------------------------------------------------------------------------- 1 | import { createForm } from '@felte/solid'; 2 | import { validator } from '@felte/validator-zod'; 3 | import { toaster } from '@kobalte/core'; 4 | import { random } from '@rifandani/nxact-yutiriti'; 5 | import { Toaster } from '@shared/components/molecules'; 6 | import { useAppStorage } from '@shared/hooks/useAppStorage/useAppStorage.hook'; 7 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 8 | import { useBeforeLeave } from '@solidjs/router'; 9 | import { useQueryClient } from '@tanstack/solid-query'; 10 | import { TodoSchema, todoSchema } from '@todo/api/todo.schema'; 11 | import useTodoCreate from '@todo/hooks/useTodoCreate/useTodoCreate.hook'; 12 | import { useTodosParams } from '@todo/hooks/useTodos/useTodos.hook'; 13 | import { onCleanup } from 'solid-js'; 14 | 15 | export default function useTodosCreate() { 16 | const [t] = useI18n(); 17 | const queryClient = useQueryClient(); 18 | const [appStorage] = useAppStorage(); 19 | const { queryKey } = useTodosParams(); 20 | const todoCreateMutation = useTodoCreate(); 21 | 22 | let timeoutId: NodeJS.Timeout; 23 | 24 | const felte = createForm({ 25 | extend: [validator({ schema: todoSchema })], 26 | initialValues: { 27 | id: 1, // doesn't matter, we override it later on `onSubmit` anyway 28 | todo: '', 29 | userId: appStorage.user?.id ?? 1, 30 | completed: false, 31 | }, 32 | onSubmit: (values, { reset }) => { 33 | const payload = { 34 | ...values, 35 | id: random(11, 999_999), 36 | }; 37 | 38 | todoCreateMutation.mutate(payload, { 39 | onSettled: (_newTodo, error, _variables, context) => { 40 | // reset form 41 | reset(); 42 | 43 | toaster.show((props) => ( 44 | 49 | )); 50 | 51 | // If the mutation fails, use the context returned from `onMutate` to roll back 52 | if (error) queryClient.setQueryData(queryKey(), context?.previousTodosQueryResponse); 53 | }, 54 | }); 55 | }, 56 | }); 57 | 58 | useBeforeLeave((e) => { 59 | if (!e.defaultPrevented && !!felte.data().todo) { 60 | // preventDefault to block immediately and prompt user async 61 | e.preventDefault(); 62 | 63 | timeoutId = setTimeout(() => { 64 | // eslint-disable-next-line no-alert 65 | if (window.confirm(t('unsavedChanges'))) { 66 | // user wants to proceed anyway so retry with force=true 67 | e.retry(true); 68 | } 69 | }, 100); 70 | } 71 | }); 72 | 73 | onCleanup(() => clearTimeout(timeoutId)); 74 | 75 | return { t, ...felte }; 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosFilter/TodosFilter.component.tsx: -------------------------------------------------------------------------------- 1 | import { limits } from '@todo/constants/todos.constant'; 2 | import { Component, For } from 'solid-js'; 3 | import useTodosFilter from './useTodosFilter.hook'; 4 | 5 | const TodosFilter: Component = () => { 6 | const { t, selectedOption, handleChangeLimit } = useTodosFilter(); 7 | 8 | return ( 9 |
    13 | 16 | 17 | 33 |
    34 | ); 35 | }; 36 | 37 | export default TodosFilter; 38 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosFilter/TodosFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { fireEvent, screen } from '@solidjs/testing-library'; 4 | import { vi } from 'vitest'; 5 | import TodosFilter from './TodosFilter.component'; 6 | 7 | describe('TodosFilter', () => { 8 | const validLimit = '10'; 9 | const mockChangeFn = vi.fn(); 10 | 11 | it('should render properly', () => { 12 | const view = renderProviders(() => ); 13 | expect(() => view).not.toThrow(); 14 | }); 15 | 16 | it('should render and change limit correctly', () => { 17 | // ARRANGE 18 | renderProviders(() => ); 19 | const form: HTMLFormElement = screen.getByRole('form'); 20 | const select: HTMLInputElement = screen.getByRole('combobox', { name: /filter/i }); 21 | const options: HTMLOptionElement[] = screen.getAllByRole('option'); 22 | select.addEventListener('select', mockChangeFn); 23 | 24 | // ACT & ASSERT 25 | expect(form).toBeInTheDocument(); 26 | expect(select).toBeInTheDocument(); 27 | expect(options).toHaveLength(4); 28 | fireEvent.select(select, { target: { value: validLimit } }); 29 | expect(select).toHaveValue(validLimit); 30 | expect(mockChangeFn).toHaveBeenCalled(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosFilter/useTodosFilter.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 2 | import { SelectOnChange } from '@shared/types/form.type'; 3 | import { useSearchParams } from '@solidjs/router'; 4 | import { defaultLimit } from '@todo/constants/todos.constant'; 5 | 6 | export default function useTodosFilter() { 7 | const [searchParams, setSearchParams] = useSearchParams(); 8 | const [t] = useI18n(); 9 | 10 | const selectedOption = () => searchParams?.limit ?? defaultLimit; 11 | 12 | // #region HANDLERS 13 | const handleChangeLimit: SelectOnChange = ({ currentTarget }) => { 14 | // set to url params 15 | setSearchParams({ ...searchParams, limit: currentTarget.value }); 16 | }; 17 | // #endregion 18 | 19 | return { t, selectedOption, handleChangeLimit }; 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosItem/TodosItem.component.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@solidjs/router'; 2 | import { TodoSchema } from '@todo/api/todo.schema'; 3 | import { Component, Show } from 'solid-js'; 4 | import useTodosItem from './useTodosItem.hook'; 5 | 6 | // #region INTERFACES 7 | export type TodosItemProps = { 8 | todo: TodoSchema; 9 | }; 10 | // #endregion 11 | 12 | const TodosItem: Component = (props) => { 13 | const { t, appStorage, handleUpdateTodo, handleDeleteTodo } = useTodosItem(props); 14 | 15 | return ( 16 |
    22 | 29 | 30 | handleUpdateTodo(props.todo)} 38 | /> 39 | 40 | 46 | {props.todo.todo} 47 | 48 | 49 | 50 | 53 | 54 |
    55 | ); 56 | }; 57 | 58 | export default TodosItem; 59 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosItem/TodosItem.test.tsx: -------------------------------------------------------------------------------- 1 | import { mockTodo } from '@mocks/http/entities.http'; 2 | import { renderProviders } from '@shared/utils/test.util'; 3 | import { Route } from '@solidjs/router'; 4 | import { fireEvent, screen } from '@solidjs/testing-library'; 5 | import { TodoSchema } from '@todo/api/todo.schema'; 6 | import TodosItem from './TodosItem.component'; 7 | 8 | describe('TodosItem', () => { 9 | const todo: TodoSchema = mockTodo(); 10 | // const onDeleteTodo = vi.fn(); 11 | const mockSubmit = vi.fn(); 12 | const mockChangeTodo = vi.fn(); 13 | const getItemSpy = vi.spyOn(localStorage, 'getItem'); 14 | localStorage.getItem = vi.fn(() => JSON.stringify({ id: todo.id })); 15 | 16 | afterEach(() => { 17 | getItemSpy.mockClear(); // clear call history 18 | localStorage.clear(); 19 | }); 20 | 21 | it('should render properly', () => { 22 | const view = renderProviders(() => ( 23 | } /> 24 | )); 25 | expect(() => view).not.toThrow(); 26 | }); 27 | 28 | it('should render, check, and remove todo correctly', async () => { 29 | // ARRANGE 30 | renderProviders(() => } />); 31 | const form: HTMLFormElement = await screen.findByRole('form', { name: /todo/i }); 32 | const inputId: HTMLInputElement = await screen.findByTestId('input-todoId'); 33 | const inputTodo: HTMLInputElement = await screen.findByRole('checkbox', { name: /todo/i }); 34 | const link: HTMLAnchorElement = await screen.findByRole('link', { name: /todo/i }); 35 | form.addEventListener('submit', mockSubmit); 36 | inputTodo.addEventListener('change', mockChangeTodo); 37 | 38 | // ACT & ASSERT 39 | expect(form).toBeInTheDocument(); 40 | expect(inputId).toBeInTheDocument(); 41 | expect(inputId).toHaveValue(todo.id.toString()); 42 | expect(inputTodo).toBeInTheDocument(); 43 | expect(inputTodo).not.toBeChecked(); 44 | expect(link).toBeInTheDocument(); 45 | fireEvent.click(inputTodo); 46 | expect(mockChangeTodo).toHaveBeenCalled(); 47 | }); 48 | 49 | // FIXME: Unable to find role="button" -> mock storage doesn't work 50 | it.todo('should remove todo item correctly', async () => { 51 | // ARRANGE 52 | renderProviders(() => } />); 53 | const removeBtn: HTMLButtonElement = await screen.findByRole('button'); 54 | 55 | // ACT & ASSERT 56 | expect(removeBtn).toBeInTheDocument(); 57 | // await fireEvent.click(buttonRemove); 58 | // expect(mockSubmit).toHaveBeenCalled(); 59 | // expect(onDeleteTodo).toHaveBeenCalled(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosItem/useTodosItem.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useAppStorage } from '@shared/hooks/useAppStorage/useAppStorage.hook'; 2 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 3 | import { FormOnSubmitEvent } from '@shared/types/form.type'; 4 | import { TodoSchema } from '@todo/api/todo.schema'; 5 | import useTodoDelete from '@todo/hooks/useTodoDelete/useTodoDelete.hook'; 6 | import useTodoUpdate from '@todo/hooks/useTodoUpdate/useTodoUpdate.hook'; 7 | import { TodosItemProps } from './TodosItem.component'; 8 | 9 | export default function useTodosItem(props: TodosItemProps) { 10 | const [t] = useI18n(); 11 | const [appStorage] = useAppStorage(); 12 | const updateTodoMutation = useTodoUpdate(); 13 | const deleteTodoMutation = useTodoDelete(); 14 | 15 | // #region HANDLERS 16 | const handleUpdateTodo = (todo: TodoSchema) => { 17 | updateTodoMutation.mutate({ ...todo, completed: !todo.completed }); 18 | }; 19 | const handleDeleteTodo = (evt: FormOnSubmitEvent) => { 20 | evt.preventDefault(); 21 | // only allow for the correct auth user 22 | if (props.todo.userId === appStorage.user?.id) deleteTodoMutation.mutate(props.todo.id); 23 | }; 24 | // #endregion 25 | 26 | return { t, appStorage, handleUpdateTodo, handleDeleteTodo }; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosList/TodosList.component.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { TodoListApiResponseSchema } from '@todo/api/todo.schema'; 3 | import { Component, For, Match, Switch } from 'solid-js'; 4 | import TodosItem from '../TodosItem/TodosItem.component'; 5 | import useTodosList from './useTodosList.hook'; 6 | 7 | const TodosList: Component = () => { 8 | const { t, todosQuery } = useTodosList(); 9 | 10 | return ( 11 | 12 | 13 |
    14 | 15 |
    16 |
    17 | 18 | 19 |
    20 |
    21 | {t('error', { module: 'Todos' })}: 22 |
    {JSON.stringify(todosQuery.error, null, 2)}
    23 |
    24 |
    25 |
    26 | 27 | 28 | 32 | {t('empty')} 33 |
    34 | } 35 | > 36 | {(todo) => } 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default TodosList; 44 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosList/TodosList.test.tsx: -------------------------------------------------------------------------------- 1 | import { rest, server } from '@mocks/http/server.http'; 2 | import { getBaseUrl } from '@mocks/util.mock'; 3 | import { renderProviders } from '@shared/utils/test.util'; 4 | import { Route } from '@solidjs/router'; 5 | import { screen, waitFor } from '@solidjs/testing-library'; 6 | import TodosList from './TodosList.component'; 7 | 8 | describe('TodosList', () => { 9 | const loadingId = 'list-loading'; 10 | 11 | // FIXME: TypeError: mutate is not a function 12 | it.todo('should render properly', () => { 13 | const view = renderProviders(() => ); 14 | expect(() => view).not.toThrow(); 15 | }); 16 | 17 | // FIXME: TypeError: mutate is not a function 18 | it.todo('should be able to query and show error alert', async () => { 19 | // ARRANGE 20 | server.use( 21 | rest.get(getBaseUrl('todos'), (_, res, ctx) => 22 | res.once(ctx.status(500), ctx.json({ message: 'error' })), 23 | ), 24 | ); 25 | 26 | // ASSERT 27 | expect(screen.queryByTestId(loadingId)).not.toBeInTheDocument(); 28 | renderProviders(() => ); 29 | await waitFor(() => { 30 | // wait for appearance inside an assertion 31 | expect(screen.getByTestId(loadingId)).toBeInTheDocument(); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/modules/todo/components/TodosList/useTodosList.hook.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 2 | import useTodos from '@todo/hooks/useTodos/useTodos.hook'; 3 | 4 | export default function useTodosList() { 5 | const [t] = useI18n(); 6 | const todosQuery = useTodos(); 7 | 8 | return { t, todosQuery }; 9 | } 10 | -------------------------------------------------------------------------------- /src/modules/todo/constants/todos.constant.ts: -------------------------------------------------------------------------------- 1 | export const defaultLimit = '10'; 2 | export const limits = [defaultLimit, '25', '50', '100'] as const; 3 | -------------------------------------------------------------------------------- /src/modules/todo/hooks/useTodo/useTodo.hook.ts: -------------------------------------------------------------------------------- 1 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 2 | import { QueryOptions, createQuery } from '@tanstack/solid-query'; 3 | import { todoApi, todoKeys } from '@todo/api/todo.api'; 4 | import { TodoDetailApiResponseSchema, TodoSchema } from '@todo/api/todo.schema'; 5 | import { Except } from 'type-fest'; 6 | 7 | /** 8 | * fetch todo 9 | * 10 | * @param {number} id - todo id 11 | */ 12 | const useTodo = ( 13 | id: TodoSchema['id'], 14 | options?: Except< 15 | QueryOptions, 16 | 'queryKey' | 'queryFn' 17 | >, 18 | ) => 19 | createQuery({ 20 | ...options, 21 | queryKey: () => todoKeys.detail(id), 22 | queryFn: () => todoApi.detail(id), 23 | }); 24 | 25 | export default useTodo; 26 | -------------------------------------------------------------------------------- /src/modules/todo/hooks/useTodoCreate/useTodoCreate.hook.ts: -------------------------------------------------------------------------------- 1 | import { queryClient } from '@app/RootProvider.app'; 2 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 3 | import { createMutation } from '@tanstack/solid-query'; 4 | import { todoApi } from '@todo/api/todo.api'; 5 | import { 6 | CreateTodoApiResponseSchema, 7 | CreateTodoSchema, 8 | TodoListApiResponseSchema, 9 | } from '@todo/api/todo.schema'; 10 | import { useTodosParams } from '@todo/hooks/useTodos/useTodos.hook'; 11 | 12 | /** 13 | * create todo mutation (optimistic update) based on `useTodosParams` 14 | */ 15 | const useTodoCreate = () => { 16 | const { params, queryKey } = useTodosParams(); 17 | 18 | return createMutation< 19 | CreateTodoApiResponseSchema, 20 | ErrorApiResponseSchema, 21 | CreateTodoSchema, 22 | { previousTodosQueryResponse: TodoListApiResponseSchema } 23 | >({ 24 | // Called before `mutationFn`: 25 | onMutate: async (newTodo) => { 26 | const emptyResponse: TodoListApiResponseSchema = { 27 | todos: [], 28 | limit: params().limit, 29 | skip: 0, 30 | total: 0, 31 | }; 32 | // Cancel any outgoing refetches (so they don't overwrite our optimistic update) 33 | await queryClient.cancelQueries({ queryKey: queryKey() }); 34 | 35 | // Snapshot the previous value 36 | const previousTodosQueryResponse = 37 | (queryClient.getQueryData(queryKey()) as TodoListApiResponseSchema) ?? emptyResponse; 38 | 39 | // Optimistically update to the new value & delete the last value 40 | queryClient.setQueryData(queryKey(), { 41 | ...previousTodosQueryResponse, 42 | todos: [newTodo, ...previousTodosQueryResponse.todos.slice(0, Number(params().limit - 1))], 43 | }); 44 | 45 | // Return a context object with the snapshotted value 46 | return { previousTodosQueryResponse }; 47 | }, 48 | mutationFn: (newTodo) => todoApi.create(newTodo), 49 | }); 50 | }; 51 | 52 | export default useTodoCreate; 53 | -------------------------------------------------------------------------------- /src/modules/todo/hooks/useTodoDelete/useTodoDelete.hook.tsx: -------------------------------------------------------------------------------- 1 | import { toaster } from '@kobalte/core'; 2 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 3 | import { Toaster } from '@shared/components/molecules'; 4 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 5 | import { createMutation, useQueryClient } from '@tanstack/solid-query'; 6 | import { todoApi } from '@todo/api/todo.api'; 7 | import { 8 | DeleteTodoApiResponseSchema, 9 | DeleteTodoSchema, 10 | TodoListApiResponseSchema, 11 | } from '@todo/api/todo.schema'; 12 | import { useTodosParams } from '@todo/hooks/useTodos/useTodos.hook'; 13 | 14 | /** 15 | * delete todo mutation based on `useTodosParams` and show toast 16 | */ 17 | const useTodoDelete = () => { 18 | const queryClient = useQueryClient(); 19 | const { queryKey } = useTodosParams(); 20 | const [t] = useI18n(); 21 | 22 | return createMutation< 23 | DeleteTodoApiResponseSchema, 24 | ErrorApiResponseSchema, 25 | DeleteTodoSchema['id'], 26 | { previousTodosQueryResponse: TodoListApiResponseSchema } 27 | >({ 28 | // Called before `mutationFn`: 29 | onMutate: async (id) => { 30 | // Cancel any outgoing refetches (so they don't overwrite our optimistic update) 31 | await queryClient.cancelQueries({ queryKey: queryKey() }); 32 | 33 | // Snapshot the previous value 34 | const previousTodosQueryResponse = (queryClient.getQueryData(queryKey()) ?? 35 | []) as TodoListApiResponseSchema; 36 | 37 | // Optimistically update to the new value 38 | queryClient.setQueryData(queryKey(), { 39 | ...previousTodosQueryResponse, 40 | todos: previousTodosQueryResponse.todos?.filter((_todo) => _todo.id !== id), 41 | }); 42 | 43 | // Return a context object with the snapshotted value 44 | return { previousTodosQueryResponse }; 45 | }, 46 | mutationFn: (id) => todoApi.delete(id), 47 | onSettled: (_id, error, _variables, context) => { 48 | toaster.show((props) => ( 49 | 58 | )); 59 | 60 | // If the mutation fails, use the context returned from `onMutate` to roll back 61 | if (error) queryClient.setQueryData(queryKey(), context?.previousTodosQueryResponse); 62 | 63 | // if we want to refetch after error or success: 64 | // await queryClient.invalidateQueries({ queryKey: queryKey() }); 65 | }, 66 | }); 67 | }; 68 | 69 | export default useTodoDelete; 70 | -------------------------------------------------------------------------------- /src/modules/todo/hooks/useTodoUpdate/useTodoUpdate.hook.tsx: -------------------------------------------------------------------------------- 1 | import { toaster } from '@kobalte/core'; 2 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 3 | import { Toaster } from '@shared/components/molecules'; 4 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 5 | import { createMutation, useQueryClient } from '@tanstack/solid-query'; 6 | import { todoApi } from '@todo/api/todo.api'; 7 | import { 8 | TodoListApiResponseSchema, 9 | UpdateTodoApiResponseSchema, 10 | UpdateTodoSchema, 11 | } from '@todo/api/todo.schema'; 12 | import { useTodosParams } from '@todo/hooks/useTodos/useTodos.hook'; 13 | 14 | /** 15 | * update todo mutation based on `useTodosParams` and show toast 16 | */ 17 | const useTodoUpdate = () => { 18 | const queryClient = useQueryClient(); 19 | const { queryKey } = useTodosParams(); 20 | const [t] = useI18n(); 21 | 22 | return createMutation< 23 | UpdateTodoApiResponseSchema, 24 | ErrorApiResponseSchema, 25 | UpdateTodoSchema, 26 | { previousTodosQueryResponse: TodoListApiResponseSchema } 27 | >({ 28 | // Called before `mutationFn`: 29 | onMutate: async ({ id, ...body }) => { 30 | // Cancel any outgoing refetches (so they don't overwrite our optimistic update) 31 | await queryClient.cancelQueries({ queryKey: queryKey() }); 32 | 33 | // Snapshot the previous value 34 | const previousTodosQueryResponse = (queryClient.getQueryData(queryKey()) ?? 35 | []) as TodoListApiResponseSchema; 36 | 37 | // Optimistically update to the new value 38 | queryClient.setQueryData(queryKey(), { 39 | ...previousTodosQueryResponse, 40 | todos: previousTodosQueryResponse.todos?.map((_todo) => 41 | _todo.id === id ? { ..._todo, ...body } : _todo, 42 | ), 43 | }); 44 | 45 | // Return a context object with the snapshotted value 46 | return { previousTodosQueryResponse }; 47 | }, 48 | mutationFn: (updateTodo) => todoApi.update(updateTodo), 49 | onSettled: (_updateTodo, error, _variables, context) => { 50 | toaster.show((props) => ( 51 | 60 | )); 61 | 62 | // If the mutation fails, use the context returned from `onMutate` to roll back 63 | if (error) queryClient.setQueryData(queryKey(), context?.previousTodosQueryResponse); 64 | 65 | // if we want to refetch after error or success: 66 | // await queryClient.invalidateQueries({ queryKey: queryKey() }); 67 | }, 68 | }); 69 | }; 70 | 71 | export default useTodoUpdate; 72 | -------------------------------------------------------------------------------- /src/modules/todo/hooks/useTodos/useTodos.hook.ts: -------------------------------------------------------------------------------- 1 | import { ErrorApiResponseSchema, ResourceParamsSchema } from '@shared/api/api.schema'; 2 | import { useSearchParams } from '@solidjs/router'; 3 | import { QueryOptions, createQuery } from '@tanstack/solid-query'; 4 | import { todoApi, todoKeys } from '@todo/api/todo.api'; 5 | import { TodoListApiResponseSchema } from '@todo/api/todo.schema'; 6 | import { defaultLimit } from '@todo/constants/todos.constant'; 7 | import { Except, SetRequired } from 'type-fest'; 8 | 9 | /** 10 | * todos search params in object 11 | */ 12 | export const useTodosParams = () => { 13 | const [searchParams] = useSearchParams(); 14 | const params = () => 15 | ({ 16 | ...searchParams, 17 | limit: Number(searchParams?.limit ?? defaultLimit), 18 | }) as SetRequired; 19 | const queryKey = () => todoKeys.list(params()); 20 | const queryFn = () => todoApi.list(params()); 21 | 22 | return { params, queryKey, queryFn }; 23 | }; 24 | 25 | /** 26 | * fetch todos based on search params 27 | */ 28 | const useTodos = ( 29 | options?: Except< 30 | QueryOptions, 31 | 'queryKey' | 'queryFn' 32 | >, 33 | ) => { 34 | const { queryKey, queryFn } = useTodosParams(); 35 | 36 | return createQuery({ 37 | ...options, 38 | queryKey, 39 | queryFn, 40 | }); 41 | }; 42 | 43 | export default useTodos; 44 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todo/Todo.data.test.tsx: -------------------------------------------------------------------------------- 1 | import { mockedCreateResource } from '@mocks/module.mock'; 2 | import routeDataTodo from './Todo.data'; 3 | 4 | describe('routeDataTodo', () => { 5 | it('should work correctly', () => { 6 | // ARRANGE 7 | mockedCreateResource.mockReturnValue([]); 8 | 9 | // ACT & ASSERT 10 | mockedCreateResource(); 11 | expect(routeDataTodo).toBeDefined(); 12 | expect(mockedCreateResource).toHaveBeenCalled(); 13 | expect(mockedCreateResource()).toEqual([]); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todo/Todo.data.tsx: -------------------------------------------------------------------------------- 1 | import { queryClient } from '@app/RootProvider.app'; 2 | import { RouteDataFuncArgs } from '@solidjs/router'; 3 | import { todoApi, todoKeys } from '@todo/api/todo.api'; 4 | import { TodoDetailApiResponseSchema } from '@todo/api/todo.schema'; 5 | import { z } from 'zod'; 6 | 7 | const routeDataTodo = 8 | (_queryClient: typeof queryClient) => 9 | async ({ params }: RouteDataFuncArgs) => { 10 | // will throw error if `params.id` is not a number 11 | const id = z.coerce.number().parse(params.id); 12 | const queryKey = todoKeys.detail(id); 13 | const queryFn = () => todoApi.detail(id); 14 | const staleTime = 1_000 * 60 * 1; // 1 min 15 | 16 | // or we can use `_queryClient.ensureQueryData` 17 | const todoInitialData = _queryClient.getQueryData(queryKey); 18 | const todoFetchedData = await _queryClient.fetchQuery({ 19 | queryKey, 20 | queryFn, 21 | staleTime, 22 | }); 23 | 24 | return todoInitialData ?? todoFetchedData; 25 | }; 26 | 27 | export default routeDataTodo; 28 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todo/Todo.page.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderProviders } from '@shared/utils/test.util'; 2 | import { Route } from '@solidjs/router'; 3 | import { screen } from '@solidjs/testing-library'; 4 | import TodoPage from './Todo.page'; 5 | 6 | describe('TodoPage', () => { 7 | const Component = ( 8 | [{ name: 'Name', body: 'Body', email: 'Email' }]} 11 | component={TodoPage} 12 | /> 13 | ); 14 | 15 | // FIXME: TypeError: mutate is not a function 16 | it.todo('should render correctly', () => { 17 | const view = renderProviders(() => Component); 18 | expect(() => view).not.toThrow(); 19 | }); 20 | 21 | // FIXME: TypeError: mutate is not a function 22 | it.todo('should render role contents correctly', () => { 23 | // ARRANGE 24 | renderProviders(() => Component); 25 | const link: HTMLAnchorElement = screen.getByRole('link', { name: /go-back/i }); 26 | const title: HTMLHeadingElement = screen.getByRole('heading', { level: 1 }); 27 | 28 | // ASSERT 29 | expect(link).toBeInTheDocument(); 30 | expect(title).toBeInTheDocument(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todo/Todo.page.tsx: -------------------------------------------------------------------------------- 1 | import { Icon } from '@iconify-icon/solid'; 2 | import { Link } from '@solidjs/router'; 3 | import { Component, Match, Show, Switch } from 'solid-js'; 4 | import useTodoPageVM from './Todo.vm'; 5 | 6 | const TodoPage: Component = () => { 7 | const { t, appStorage, todoQuery, todoUpdateMutation, form } = useTodoPageVM(); 8 | 9 | return ( 10 |
    11 |
    12 | 18 | ⬅ {t('goBackTo', { target: 'Todos' })} 19 | 20 | 21 |

    {t('xDetail', { feature: 'Todo' })}

    22 |
    23 | 24 | 25 |
    26 |
    27 | 28 | {t('error', { module: 'Todo Mutation' })}:{' '} 29 | {(todoUpdateMutation.error as Error).message} 30 | 31 |
    32 |
    33 |
    34 | 35 | 36 | 37 |
    38 | 39 |
    40 |
    41 | 42 | 43 |
    44 |
    45 | {t('error', { module: 'Todos' })}: 46 |
    {JSON.stringify(todoQuery.error, null, 2)}
    47 |
    48 |
    49 |
    50 | 51 | 52 |
    53 | 62 | 63 | 64 | 72 | 73 |
    74 |
    75 |
    76 |
    77 | ); 78 | }; 79 | 80 | export default TodoPage; 81 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todo/Todo.vm.tsx: -------------------------------------------------------------------------------- 1 | import { createForm } from '@felte/solid'; 2 | import { toaster } from '@kobalte/core'; 3 | import { ErrorApiResponseSchema } from '@shared/api/api.schema'; 4 | import { Toaster } from '@shared/components/molecules'; 5 | import { useAppStorage } from '@shared/hooks/useAppStorage/useAppStorage.hook'; 6 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 7 | import { useNavigate, useParams, useRouteData } from '@solidjs/router'; 8 | import { createMutation, useQueryClient } from '@tanstack/solid-query'; 9 | import { todoApi, todoKeys } from '@todo/api/todo.api'; 10 | import { 11 | TodoDetailApiResponseSchema, 12 | UpdateTodoApiResponseSchema, 13 | UpdateTodoSchema, 14 | } from '@todo/api/todo.schema'; 15 | import useTodo from '@todo/hooks/useTodo/useTodo.hook'; 16 | 17 | const useTodoUpdate = () => { 18 | const [t] = useI18n(); 19 | const navigate = useNavigate(); 20 | const queryClient = useQueryClient(); 21 | 22 | return createMutation({ 23 | mutationFn: (updateTodo) => todoApi.update(updateTodo), 24 | onSuccess: async (updatedTodo) => { 25 | // NOTE: the order of function call MATTERS 26 | navigate('/todos'); 27 | queryClient.removeQueries({ queryKey: todoKeys.detail(updatedTodo.id) }); // delete the query cache 28 | await queryClient.invalidateQueries({ queryKey: todoKeys.lists() }); 29 | }, 30 | onSettled: (_updateTodo, error) => { 31 | toaster.show((props) => ( 32 | 41 | )); 42 | }, 43 | }); 44 | }; 45 | 46 | const useTodoPageVM = () => { 47 | const [t] = useI18n(); 48 | const params = useParams(); 49 | const [appStorage] = useAppStorage(); 50 | const initialData = useRouteData(); 51 | const todoQuery = useTodo(Number(params?.id), { initialData }); 52 | const todoUpdateMutation = useTodoUpdate(); 53 | 54 | const felte = createForm>({ 55 | onSubmit: (values) => { 56 | const payload: UpdateTodoSchema = { 57 | ...values, 58 | id: todoQuery.data?.id ?? 1, 59 | completed: todoQuery.data?.completed ?? false, 60 | }; 61 | 62 | todoUpdateMutation.mutate(payload); 63 | }, 64 | }); 65 | 66 | return { t, appStorage, todoQuery, todoUpdateMutation, ...felte }; 67 | }; 68 | 69 | export default useTodoPageVM; 70 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todos/Todos.data.tsx: -------------------------------------------------------------------------------- 1 | import { queryClient } from '@app/RootProvider.app'; 2 | import { ResourceParamsSchema } from '@shared/api/api.schema'; 3 | import { RouteDataFuncArgs } from '@solidjs/router'; 4 | import { todoApi, todoKeys } from '@todo/api/todo.api'; 5 | import { TodoListApiResponseSchema } from '@todo/api/todo.schema'; 6 | import { defaultLimit } from '@todo/constants/todos.constant'; 7 | import { SetRequired } from 'type-fest'; 8 | 9 | const routeDataTodos = 10 | (_queryClient: typeof queryClient) => 11 | async ({ location }: RouteDataFuncArgs) => { 12 | const search = new URLSearchParams(location.search); 13 | const searchParams = Object.fromEntries(search); 14 | const params: SetRequired = { 15 | ...searchParams, 16 | limit: Number(searchParams?.limit ?? defaultLimit), 17 | }; 18 | const queryKey = todoKeys.list(params); 19 | const queryFn = () => todoApi.list(params); 20 | const staleTime = 1_000 * 60 * 1; // 1 min 21 | 22 | // or we can use `_queryClient.ensureQueryData` 23 | const todosInitialData = _queryClient.getQueryData(queryKey); 24 | // NOTE: somehow returned promise, and app will malfunction if we pass this value to `initialData` 25 | const todosFetchedData = await _queryClient.fetchQuery({ 26 | queryKey, 27 | queryFn, 28 | staleTime, 29 | }); 30 | 31 | return todosInitialData ?? todosFetchedData; 32 | }; 33 | 34 | export default routeDataTodos; 35 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todos/Todos.page.test.tsx: -------------------------------------------------------------------------------- 1 | import { mockedCreateResource } from '@mocks/module.mock'; 2 | import { renderProviders } from '@shared/utils/test.util'; 3 | import { Route } from '@solidjs/router'; 4 | import { screen } from '@solidjs/testing-library'; 5 | import TodosPage from './Todos.page'; 6 | 7 | describe('TodosPage', () => { 8 | // FIXME: TypeError: mutate is not a function 9 | it.todo('should render correctly', () => { 10 | const view = renderProviders(() => ); 11 | expect(() => view).not.toThrow(); 12 | }); 13 | 14 | // FIXME: TypeError: mutate is not a function 15 | it.todo('should render content roles correctly', () => { 16 | // ARRANGE 17 | renderProviders(() => ); 18 | const title = screen.getByRole('heading', { level: 1 }); 19 | 20 | // ASSERT 21 | expect(mockedCreateResource).toHaveBeenCalled(); 22 | expect(title).toBeInTheDocument(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/modules/todo/pages/Todos/Todos.page.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@shared/hooks/usei18n/usei18n.hook'; 2 | import TodosCreate from '@todo/components/TodosCreate/TodosCreate.component'; 3 | import TodosFilter from '@todo/components/TodosFilter/TodosFilter.component'; 4 | import TodosList from '@todo/components/TodosList/TodosList.component'; 5 | import { Component } from 'solid-js'; 6 | 7 | const TodosPage: Component = () => { 8 | const [t] = useI18n(); 9 | 10 | return ( 11 |
    12 |

    {t('xList', { feature: 'Todo' })}

    13 | 14 |
    15 | 16 | 17 | 18 | 19 | 20 |
    21 |
    22 | ); 23 | }; 24 | 25 | export default TodosPage; 26 | -------------------------------------------------------------------------------- /src/setup-test.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; // automatically `expect.extend(matchers)` 2 | import { server } from './mocks/http/server.http'; 3 | import './mocks/module.mock'; 4 | 5 | // Establish API mocking before all tests with MSW 6 | beforeAll(() => { 7 | server.listen({ 8 | onUnhandledRequest: 'warn', 9 | }); 10 | }); 11 | 12 | // Reset any request handlers that we may add during the tests, so they don't affect other tests. 13 | afterEach(() => { 14 | server.resetHandlers(); 15 | // vi.resetAllMocks(); 16 | // vi.restoreAllMocks(); 17 | // vi.clearAllMocks(); 18 | }); 19 | 20 | // Clean up after the tests are finished. 21 | afterAll(() => { 22 | server.close(); 23 | }); 24 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import forms from '@tailwindcss/forms'; 2 | import typography from '@tailwindcss/typography'; 3 | import daisyui from 'daisyui'; 4 | import type { Config } from 'tailwindcss'; 5 | import defaultTheme from 'tailwindcss/defaultTheme'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires 8 | const animate = require('tailwindcss-animate'); 9 | 10 | export default { 11 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 12 | darkMode: 'class', // default 'media' 13 | theme: { 14 | extend: { 15 | fontFamily: { 16 | sans: ['Josefin Sans', ...defaultTheme.fontFamily.sans], 17 | }, 18 | lineClamp: { 19 | 7: '7', 20 | 8: '8', 21 | 9: '9', 22 | 10: '10', 23 | }, 24 | keyframes: { 25 | 'swipe-right': { 26 | '0%': { 27 | '-webkit-transform': 'translateX(var(--kb-toast-swipe-end-x))', 28 | transform: 'translateX(var(--kb-toast-swipe-end-x))', 29 | }, 30 | '100%': { 31 | '-webkit-transform': 'translateX(var(--kb-toast-swipe-end-x))', 32 | transform: 'translateX(var(--kb-toast-swipe-end-x))', 33 | }, 34 | }, 35 | }, 36 | animation: { 37 | 'swipe-right': 'swipe-right 0.5s ease-out both', 38 | }, 39 | }, 40 | }, 41 | plugins: [typography, forms, daisyui, animate], 42 | daisyui: { 43 | logs: false, 44 | themes: [ 45 | 'light', 46 | 'dark', 47 | 'cupcake', 48 | 'bumblebee', 49 | 'emerald', 50 | 'corporate', 51 | 'synthwave', 52 | 'retro', 53 | 'cyberpunk', 54 | 'valentine', 55 | 'halloween', 56 | 'garden', 57 | 'forest', 58 | 'aqua', 59 | 'lofi', 60 | 'pastel', 61 | 'fantasy', 62 | 'wireframe', 63 | 'black', 64 | 'luxury', 65 | 'dracula', 66 | 'cmyk', 67 | 'autumn', 68 | 'business', 69 | 'acid', 70 | 'lemonade', 71 | 'night', 72 | 'coffee', 73 | 'winter', 74 | ], 75 | }, 76 | } satisfies Config; 77 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | "jsxImportSource": "solid-js", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "allowSyntheticDefaultImports": true, 24 | "esModuleInterop": true, 25 | "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"] 26 | }, 27 | "extends": "./tsconfig.paths.json", 28 | "include": ["index.d.ts", "tailwind.config.ts", "**/*.ts", "**/*.tsx"], 29 | "exclude": ["node_modules", "postcss.config.js"], 30 | "references": [{ "path": "./tsconfig.node.json" }] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "types": ["node"] 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "@app/*": ["./app/*"], 6 | "@assets/*": ["./assets/*"], 7 | "@lib/*": ["./lib/*"], 8 | "@mocks/*": ["./mocks/*"], 9 | "@auth/*": ["./modules/auth/*"], 10 | "@home/*": ["./modules/home/*"], 11 | "@playground/*": ["./modules/playground/*"], 12 | "@setting/*": ["./modules/setting/*"], 13 | "@shared/*": ["./modules/shared/*"], 14 | "@todo/*": ["./modules/todo/*"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] 3 | } 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import solid from 'vite-plugin-solid'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | import { configDefaults } from 'vitest/config'; 5 | 6 | export default defineConfig({ 7 | plugins: [tsconfigPaths(), solid()], 8 | server: { 9 | port: 5000, 10 | }, 11 | test: { 12 | // to see how your tests are running in real time in the terminal, add "default" 13 | // to generate HTML output and preview the results of your tests, add "html" 14 | reporters: ['default', 'html'], 15 | environment: 'jsdom', // mocking the DOM API 16 | globals: true, // use APIs globally like jest 17 | setupFiles: ['src/setup-test.ts'], 18 | exclude: [...configDefaults.exclude, 'e2e/*'], 19 | // Will call .mockRestore() on all spies before each test. This will clear mock history and reset its implementation to the original one. 20 | restoreMocks: true, 21 | // otherwise, solid would be loaded twice: 22 | deps: { registerNodeLoader: true }, 23 | // if you have few tests, try commenting one 24 | // or both out to improve performance: 25 | // threads: false, 26 | // isolate: false, 27 | coverage: { 28 | provider: 'istanbul', // 'istanbul' / 'v8' 29 | reporter: ['text', 'json', 'html'], 30 | statements: 50, 31 | branches: 50, 32 | functions: 50, 33 | lines: 50, 34 | exclude: [ 35 | 'coverage/**', 36 | 'dist/**', 37 | 'packages/*/test{,s}/**', 38 | '**/*.d.ts', 39 | 'cypress/**', 40 | 'test{,s}/**', 41 | 'test{,-*}.{js,cjs,mjs,ts,tsx,jsx}', 42 | '**/*{.,-}test.{js,cjs,mjs,ts,tsx,jsx}', 43 | '**/*{.,-}spec.{js,cjs,mjs,ts,tsx,jsx}', 44 | '**/__tests__/**', 45 | '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', 46 | '**/.{eslint,mocha,prettier}rc.{js,cjs,yml}', 47 | // above is default 48 | 'src/setup-test.ts', 49 | 'src/index.tsx', 50 | 'src/mocks/**', 51 | 'src/assets/**', 52 | 'src/lib/**', 53 | ], 54 | }, 55 | }, 56 | }); 57 | --------------------------------------------------------------------------------