├── .editorconfig ├── .github └── workflows │ ├── ci.yaml │ └── publish.yaml ├── .gitignore ├── .nvmrc ├── .parcelrc ├── .prettierrc.json ├── .releaserc.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── examples ├── with-angular │ ├── .browserslistrc │ ├── .editorconfig │ ├── .gitignore │ ├── README.md │ ├── angular.json │ ├── karma.conf.js │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app │ │ │ ├── app-routing.module.ts │ │ │ ├── app.component.css │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ ├── app.module.ts │ │ │ └── features │ │ │ │ └── todo │ │ │ │ ├── application │ │ │ │ ├── complete-todo-cmd.ts │ │ │ │ ├── create-todo-cmd.ts │ │ │ │ ├── get-todo-qry.ts │ │ │ │ └── get-todos-qry.ts │ │ │ │ ├── domain │ │ │ │ ├── new-todo.ts │ │ │ │ ├── todo-repository.ts │ │ │ │ └── todo.ts │ │ │ │ ├── infrastructure │ │ │ │ ├── http-todo-repository.ts │ │ │ │ └── local-todo-repository.ts │ │ │ │ ├── todo.module.ts │ │ │ │ └── ui │ │ │ │ ├── create-todo │ │ │ │ ├── create-todo.page.css │ │ │ │ ├── create-todo.page.html │ │ │ │ └── create-todo.page.ts │ │ │ │ └── todo-list │ │ │ │ ├── todo-list.page.css │ │ │ │ ├── todo-list.page.html │ │ │ │ └── todo-list.page.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── core │ │ │ ├── archimedes │ │ │ │ └── archimedes.module.ts │ │ │ └── di │ │ │ │ ├── container.module.ts │ │ │ │ └── injection-tokens.ts │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ ├── styles.css │ │ └── test.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ └── tsconfig.spec.json └── with-react │ ├── .gitignore │ ├── README.md │ ├── craco.config.js │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.tsx │ ├── app.module.css │ ├── core │ │ └── di │ │ │ ├── container.ts │ │ │ └── injection-tokens.ts │ ├── features │ │ └── todo │ │ │ ├── application │ │ │ ├── complete-todo-cmd.ts │ │ │ ├── create-todo-cmd.ts │ │ │ ├── get-todo-qry.ts │ │ │ └── get-todos-qry.ts │ │ │ ├── domain │ │ │ ├── new-todo.ts │ │ │ ├── todo-repository.ts │ │ │ └── todo.ts │ │ │ ├── infrastructure │ │ │ ├── http-todo-repository.ts │ │ │ └── local-todo-repository.ts │ │ │ └── ui │ │ │ ├── create-todo │ │ │ ├── create-todo.module.css │ │ │ └── create-todo.tsx │ │ │ └── todo-list │ │ │ ├── todo-list.module.css │ │ │ └── todo-list.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── use-di.ts │ └── tsconfig.json ├── jest.config.js ├── package-lock.json ├── package.json ├── packages ├── arch │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── archimedes.ts │ │ ├── cache │ │ │ ├── cache-key.ts │ │ │ ├── cache-manager.spec.ts │ │ │ ├── cache-manager.ts │ │ │ ├── cache-options.ts │ │ │ ├── cache.ts │ │ │ ├── invalidate-cache.ts │ │ │ ├── invalidation-policy.ts │ │ │ ├── lru-cache.ts │ │ │ └── use-case-key.ts │ │ ├── index.ts │ │ ├── notifications │ │ │ ├── notification-center-options.ts │ │ │ ├── notification-center.spec.ts │ │ │ ├── notification-center.ts │ │ │ └── notification.ts │ │ ├── runner │ │ │ ├── cache-invalidations.spec.ts │ │ │ ├── cache-invalidations.ts │ │ │ ├── chain-error.ts │ │ │ ├── context.ts │ │ │ ├── links │ │ │ │ ├── base-link.ts │ │ │ │ ├── cache-link.spec.ts │ │ │ │ ├── cache-link.ts │ │ │ │ ├── empty-link.spec.ts │ │ │ │ ├── empty-link.ts │ │ │ │ ├── executor-link.spec.ts │ │ │ │ ├── executor-link.ts │ │ │ │ ├── link.ts │ │ │ │ ├── logger-link.ts │ │ │ │ ├── notification-link.spec.ts │ │ │ │ ├── notification-link.ts │ │ │ │ ├── null-link.spec.ts │ │ │ │ └── null-link.ts │ │ │ ├── logger.ts │ │ │ ├── runner.spec.ts │ │ │ └── runner.ts │ │ └── use-case │ │ │ ├── command.spec.ts │ │ │ ├── command.ts │ │ │ ├── execution-options.ts │ │ │ ├── query.spec.ts │ │ │ ├── query.ts │ │ │ ├── use-case.spec.ts │ │ │ └── use-case.ts │ └── tsconfig.json ├── components │ ├── .storybook │ │ ├── main.js │ │ └── preview.js │ ├── CHANGELOG.md │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── behavioural-components │ │ │ │ ├── activable-item │ │ │ │ │ ├── activable-item.css │ │ │ │ │ ├── activable-item.e2e.ts │ │ │ │ │ ├── activable-item.tsx │ │ │ │ │ ├── activated-item-handler.ts │ │ │ │ │ ├── activated-item-listener.e2e.ts │ │ │ │ │ └── activated-item-listener.tsx │ │ │ │ ├── focusable-item │ │ │ │ │ ├── focusable-item.css │ │ │ │ │ ├── focusable-item.e2e.ts │ │ │ │ │ ├── focusable-item.tsx │ │ │ │ │ ├── focused-item-handler.ts │ │ │ │ │ ├── focused-item-listener.e2e.ts │ │ │ │ │ └── focused-item-listener.tsx │ │ │ │ └── keyboard-navigable │ │ │ │ │ ├── keyboard-navigable.css │ │ │ │ │ ├── keyboard-navigable.e2e.ts │ │ │ │ │ ├── keyboard-navigable.tsx │ │ │ │ │ ├── keyboard-navigation-handler.ts │ │ │ │ │ ├── keyboard-navigation-listener.e2e.ts │ │ │ │ │ └── keyboard-navigation-listener.tsx │ │ │ ├── button │ │ │ │ ├── button.css │ │ │ │ ├── button.e2e.tsx │ │ │ │ ├── button.stories.tsx │ │ │ │ └── button.tsx │ │ │ ├── index.ts │ │ │ ├── swiper │ │ │ │ ├── refresh.svg │ │ │ │ ├── swiper.css │ │ │ │ ├── swiper.stories.tsx │ │ │ │ └── swiper.tsx │ │ │ └── toast │ │ │ │ ├── toast.css │ │ │ │ ├── toast.stories.tsx │ │ │ │ └── toast.tsx │ │ ├── index.ts │ │ ├── material-theme.css │ │ ├── theme.css │ │ └── utils │ │ │ ├── index.ts │ │ │ ├── keycodes.ts │ │ │ ├── position.ts │ │ │ └── theme.ts │ ├── stencil.config.ts │ └── tsconfig.json └── utils │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── datetime │ │ ├── date-object.ts │ │ ├── datetime.spec.ts │ │ ├── datetime.ts │ │ ├── duration.spec.ts │ │ ├── duration.ts │ │ ├── info-options.ts │ │ ├── mock-datetime.ts │ │ ├── string-unit-length.ts │ │ ├── time-unit.ts │ │ ├── time-units.ts │ │ └── timestamp.ts │ ├── extended-error │ │ └── extended-error.ts │ ├── http-client │ │ ├── http-client.spec.ts │ │ ├── http-client.ts │ │ ├── http-error.ts │ │ ├── http-params.spec.ts │ │ ├── http-params.ts │ │ └── http-status-code.ts │ ├── index.ts │ ├── is-promise │ │ └── is-promise.ts │ ├── maybe │ │ ├── callback-function.ts │ │ ├── maybe-empty-error.ts │ │ ├── maybe.spec.ts │ │ └── maybe.ts │ ├── observer │ │ ├── observer.ts │ │ └── subject.ts │ ├── range │ │ ├── range.spec.ts │ │ └── range.ts │ ├── timer │ │ ├── timer.spec.ts │ │ └── timer.ts │ └── types │ │ └── class.ts │ └── tsconfig.json ├── tests └── setup.js ├── tsconfig.json └── tsconfig.project.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | charset = utf-8 10 | 11 | [*.md] 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '17.3.0' 14 | - uses: actions/cache@v2 15 | with: 16 | path: ~/.npm 17 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 18 | restore-keys: | 19 | ${{ runner.os }}-node- 20 | - name: install 21 | run: npm ci 22 | - name: build 23 | run: npm run build 24 | - name: compile 25 | run: npm run compile 26 | - name: test 27 | run: npm run test:ci 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - beta 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | fetch-depth: '0' 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '17.3.0' 17 | always-auth: true 18 | registry-url: 'https://registry.npmjs.org' 19 | scope: '@archimedes' 20 | - uses: actions/cache@v2 21 | with: 22 | path: ~/.npm 23 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 24 | restore-keys: | 25 | ${{ runner.os }}-node- 26 | - name: Install 27 | run: npm ci 28 | - name: Build 29 | run: npm run build 30 | - name: Release 31 | run: npm run release 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.tmp 3 | *.tmp.* 4 | log.txt 5 | *.sublime-project 6 | *.sublime-workspace 7 | npm-debug.log* 8 | tsconfig.tsbuildinfo 9 | components.d.ts 10 | 11 | .idea/ 12 | .vscode/ 13 | 14 | .sourcemaps/ 15 | .tmp/ 16 | .versions/ 17 | .parcel-cache/ 18 | coverage/ 19 | builds/ 20 | www/ 21 | node_modules/ 22 | tmp/ 23 | loader/ 24 | temp/ 25 | 26 | .DS_Store 27 | Thumbs.db 28 | UserInterfaceState.xcuserstate 29 | 30 | cypress/videos 31 | cypress/screenshots 32 | storybook-static 33 | 34 | dist 35 | lib 36 | .rpt2_cache 37 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v17.3.0 2 | -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@parcel/config-default", 3 | "transformers": { 4 | "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "semi": false, 5 | "trailingComma": "none", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | { 4 | "name": "main" 5 | }, 6 | { 7 | "name": "beta", 8 | "prerelease": true 9 | } 10 | ], 11 | "plugins": [ 12 | "@semantic-release/commit-analyzer", 13 | "@semantic-release/release-notes-generator", 14 | "@semantic-release/changelog", 15 | "@semantic-release/npm", 16 | "@semantic-release/github", 17 | [ 18 | "@semantic-release/git", 19 | { 20 | "assets": ["package.json", "CHANGELOG.md"], 21 | "message": "chore(release): ${nextRelease.version} [skip ci]" 22 | } 23 | ] 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [1.5.0](https://github.com/archimedes-projects/archimedes-js/compare/v1.4.0...v1.5.0) (2021-11-23) 7 | 8 | ### Features 9 | 10 | - add button tests ([2811db2](https://github.com/archimedes-projects/archimedes-js/commit/2811db253cb20c6623ab057b8636238d6da3c2bf)) 11 | - create button component ([6d8e2c8](https://github.com/archimedes-projects/archimedes-js/commit/6d8e2c8e8fd2758e8d415164d66d98f489a2b7a2)) 12 | - refactor button component ([b391434](https://github.com/archimedes-projects/archimedes-js/commit/b391434edc3120e53756778c77577e2810354bf9)) 13 | - return result in the after hook ([4f29d99](https://github.com/archimedes-projects/archimedes-js/commit/4f29d99cb2c4916f31975230e8c98280ecd7d993)) 14 | 15 | # [1.4.0](https://github.com/archimedes-projects/archimedes-js/compare/v1.4.0-beta.1...v1.4.0) (2021-09-16) 16 | 17 | **Note:** Version bump only for package archimedes 18 | 19 | # [1.4.0-beta.1](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-beta.3...v1.4.0-beta.1) (2021-09-16) 20 | 21 | **Note:** Version bump only for package archimedes 22 | 23 | # [1.3.0-beta.3](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-beta.2...v1.3.0-beta.3) (2021-09-16) 24 | 25 | ### Features 26 | 27 | - use type exports ([9e0e473](https://github.com/archimedes-projects/archimedes-js/commit/9e0e473e49f8c95afa958ab050a76322efb1ecaa)) 28 | 29 | # [1.3.0-beta.2](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-beta.1...v1.3.0-beta.2) (2021-09-16) 30 | 31 | ### Bug Fixes 32 | 33 | - make execution options optional ([06aea77](https://github.com/archimedes-projects/archimedes-js/commit/06aea778252f785d8bc67ad52eb5634b49d3fd69)) 34 | 35 | # [1.3.0-beta.1](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-beta.0...v1.3.0-beta.1) (2021-09-07) 36 | 37 | ### Features 38 | 39 | - pass result, param and executionOptions to subscribe callback ([a6f46a7](https://github.com/archimedes-projects/archimedes-js/commit/a6f46a754fa70661e40046d80974f2b5f155eb3b)) 40 | 41 | # [1.3.0-beta.0](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-alpha.2...v1.3.0-beta.0) (2021-09-02) 42 | 43 | **Note:** Version bump only for package archimedes 44 | 45 | # [1.3.0-alpha.2](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-alpha.1...v1.3.0-alpha.2) (2021-09-02) 46 | 47 | ### Bug Fixes 48 | 49 | - add missing runner exports ([0dff355](https://github.com/archimedes-projects/archimedes-js/commit/0dff355265d60a01e6745569ef85f9ea751b6945)) 50 | 51 | # [1.3.0](https://github.com/archimedes-projects/archimedes-js/compare/v1.2.0...v1.3.0) (2021-07-09) 52 | 53 | # [1.3.0-alpha.1](https://github.com/archimedes-projects/archimedes-js/compare/v1.3.0-alpha.0...v1.3.0-alpha.1) (2021-09-01) 54 | 55 | ### Bug Fixes 56 | 57 | - export execution options ([0b46d3a](https://github.com/archimedes-projects/archimedes-js/commit/0b46d3abc573c1a94ce739a13816bffd7b84fb62)) 58 | 59 | # [1.3.0-alpha.0](https://github.com/archimedes-projects/archidemes-js/compare/v1.2.0...v1.3.0-alpha.0) (2021-08-31) 60 | 61 | ### Features 62 | 63 | - add use cases subscriptions ([ae47e26](https://github.com/archimedes-projects/archidemes-js/commit/ae47e260255950c7f3272bec4e65389b744eccb1)) 64 | - use request defaults ([901210e](https://github.com/archimedes-projects/archidemes-js/commit/901210e5fa3bf505c20da597302a90aec8daf02b)) 65 | 66 | # [1.3.0](https://github.com/archimedes-projects/archimedes-js/compare/v1.2.0...v1.3.0) (2021-07-09) 67 | 68 | ### Features 69 | 70 | - use request defaults ([901210e](https://github.com/archimedes-projects/archimedes-js/commit/901210e5fa3bf505c20da597302a90aec8daf02b)) 71 | 72 | # [1.2.0](https://github.com/archimedes-projects/archimedes-js/compare/v1.1.3...v1.2.0) (2021-06-23) 73 | 74 | ### Features 75 | 76 | - add "as" method to duration to get a given duration in a time unit ([44d6786](https://github.com/archimedes-projects/archimedes-js/commit/44d67860caefc0c04f0f74b14ebedcbd2e9a202e)) 77 | - add difference method to get the difference between two dates ([a42dcc3](https://github.com/archimedes-projects/archimedes-js/commit/a42dcc3094e4d80f28aa445514b0c96fa52028c5)) 78 | 79 | ## [1.1.3](https://github.com/archimedes-projects/archimedes-js/compare/v1.1.2...v1.1.3) (2021-06-21) 80 | 81 | ### Bug Fixes 82 | 83 | - check publish script ([1540579](https://github.com/archimedes-projects/archimedes-js/commit/1540579717dd7de773cb52d2969e2d6d5c4e3fca)) 84 | 85 | ## [1.1.2](https://github.com/archimedes-projects/archimedes-js/compare/v1.1.1...v1.1.2) (2021-06-21) 86 | 87 | **Note:** Version bump only for package archimedes 88 | 89 | ## [1.1.1](https://github.com/archimedes-projects/archimedes-js/compare/v1.1.0...v1.1.1) (2021-06-21) 90 | 91 | ### Bug Fixes 92 | 93 | - don't cache commands ([67eeb84](https://github.com/archimedes-projects/archimedes-js/commit/67eeb84a89f57dc89a019ef7327fa6d7d5eef950)) 94 | 95 | # [1.1.0](https://github.com/archimedes-projects/archidemes-js/compare/v1.0.1...v1.1.0) (2021-06-10) 96 | 97 | ### Features 98 | 99 | - add default headers ([1ea6510](https://github.com/archimedes-projects/archidemes-js/commit/1ea651087c8259cbc889b4b6e54d97881d854cfd)) 100 | - create range function ([0c980b2](https://github.com/archimedes-projects/archidemes-js/commit/0c980b29b22b3165c148aaaa6a2bff031f2abc4d)) 101 | 102 | ## [1.0.1](https://github.com/archimedes-projects/archidemes-js/compare/v1.0.0...v1.0.1) (2021-06-01) 103 | 104 | ### Bug Fixes 105 | 106 | - remove window ([8aba18a](https://github.com/archimedes-projects/archidemes-js/commit/8aba18abfd1a864b9cae93f54935fe192da5acf1)) 107 | 108 | # [1.0.0](https://github.com/archimedes-projects/archidemes-js/compare/v0.1.2...v1.0.0) (2021-05-26) 109 | 110 | - chore!: release version 1.0 ([2e189e4](https://github.com/archimedes-projects/archidemes-js/commit/2e189e4f24c216edb0cf1706003242d115bc0e64)) 111 | 112 | ### BREAKING CHANGES 113 | 114 | - release new version 115 | 116 | # [1.0.0](https://github.com/archimedes-projects/archidemes-js/compare/v0.1.2...v1.0.0) (2021-05-26) 117 | 118 | - chore!: release version 1.0 ([6bbee1f](https://github.com/archimedes-projects/archidemes-js/commit/6bbee1f937ed51db6cab14eaa317dd203e2e064f)) 119 | 120 | ### BREAKING CHANGES 121 | 122 | - release new version 123 | 124 | ## [0.1.2](https://github.com/archimedes-projects/archidemes-js/compare/v0.1.1...v0.1.2) (2021-05-24) 125 | 126 | ### Bug Fixes 127 | 128 | - add object formatting ([4307dd7](https://github.com/archimedes-projects/archidemes-js/commit/4307dd766b193be0479638d0e6eba30437bd91d5)) 129 | 130 | ## [0.1.1](https://github.com/archimedes-projects/archidemes-js/compare/v0.1.0...v0.1.1) (2021-05-19) 131 | 132 | **Note:** Version bump only for package archimedes 133 | 134 | # 0.1.0 (2021-05-18) 135 | 136 | ### Features 137 | 138 | - check deployment process ([4e61334](https://github.com/archimedes-projects/archidemes-js/commit/4e6133424e962a87167b87dd4e613ee1df93318a)) 139 | - use bili and yarn to build ([0fdbccc](https://github.com/archimedes-projects/archidemes-js/commit/0fdbcccff1bb1704a1579531c798a1d398218a50)) 140 | - **arch:** add base url ([f2a2f0b](https://github.com/archimedes-projects/archidemes-js/commit/f2a2f0b55e0d56b498b93f411d203060ade9dfe9)) 141 | 142 | # (2021-03-18) 143 | 144 | ### Features 145 | 146 | - **arch:** add base url ([f2a2f0b](https://github.com/archimedes-projects/archidemes-js/commit/f2a2f0b55e0d56b498b93f411d203060ade9dfe9)) 147 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for contributing to this project! You should follow these steps: 4 | 5 | You need to install [yarn 1.x.x](https://classic.yarnpkg.com/en/docs/install). 6 | 7 | 1. Fork the project 8 | 2. Clone the project locally 9 | 3. Install the dependencies running in the project's root directory `yarn` 10 | 4. Make the changes making sure you follow [conventional-commits](https://www.conventionalcommits.org/en/v1.0.0/) when committing 11 | 5. You can test them locally after running `yarn build` inside the `examples` folder or use [yarn link](https://classic.yarnpkg.com/en/docs/cli/link/) to test the changes in another project as follows: 12 | a. `cd` inside the package that you want to test 13 | b. `yarn link`. Copy the package name that gets printed 14 | c. Go over to your project and run `yarn link ` 15 | d. To finish linking you have to `yarn unlink ` 16 | 6. Create a PR 17 | 18 | ## Publish 19 | 20 | In order to publish, if you have the necessary permissions, you should run one of the followings workflows: 21 | 22 | - Publish 23 | - Publish Beta 24 | - Publish Alpha 25 | 26 | This will determine the next version using [conventional-commits](https://www.conventionalcommits.org/en/v1.0.0/), update the CHANGELOGs and publish to NPM the new version. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Archimedes logo

2 | 3 |

4 | Build Status 5 | Downloads 6 | Version 7 | License 8 | Twitter Follow 9 |

10 | 11 | # ArchimedesJS 12 | 13 | Archimedes is a series of architectural concepts that are implemented in different languages. Using a given Archimedes 14 | implementation provides a set of solid and flexible architectural pieces. This is the implementation of Archimedes in 15 | TypeScript. 16 | 17 | With `archimedes-js` you have: 18 | 19 | - [Use cases](https://www.archimedesfw.io/docs/js/arch#use-cases) to define your business logic 20 | - [Powerful runner](https://www.archimedesfw.io/docs/js/arch#runner) that 21 | handles [cache](https://www.archimedesfw.io/docs/js/arch#cachelink) 22 | , [errors](https://www.archimedesfw.io/docs/js/arch#notificationlink) 23 | , [logging](https://www.archimedesfw.io/docs/js/arch#loggerlink) and much more 24 | - Utilities like [date handling](https://www.archimedesfw.io/docs/js/utils#datetime), a 25 | lightweight [http client](https://www.archimedesfw.io/docs/js/utils#httpclient) 26 | , [monads](https://www.archimedesfw.io/docs/js/utils#maybe), etc 27 | - [Headless component library](https://www.archimedesfw.io/docs/js/components) (WIP) 28 | 29 | ## Installation 30 | 31 | Install any `archimedes` packages: 32 | 33 | ```bash 34 | npm i @archimedes/utils -SE 35 | npm i @archimedes/arch -SE 36 | npm i @archimedes/components -SE 37 | ``` 38 | 39 | ## Documentation 40 | 41 | - [Checkout the documentation](https://www.archimedesfw.io/) 42 | - [Checkout the examples](./examples) 43 | 44 | ## Contributing 45 | 46 | All contributions are welcome! You should read our [contributing guide](./CONTRIBUTING.md). 47 | 48 | ### License 49 | 50 | [Apache](https://opensource.org/licenses/Apache-2.0). 51 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } 4 | -------------------------------------------------------------------------------- /examples/with-angular/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | -------------------------------------------------------------------------------- /examples/with-angular/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /examples/with-angular/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /examples/with-angular/README.md: -------------------------------------------------------------------------------- 1 | # WithAngular 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.0. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /examples/with-angular/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "with-angular": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:application": { 10 | "strict": true 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/with-angular", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": ["src/favicon.ico", "src/assets"], 26 | "styles": ["src/styles.css"], 27 | "scripts": [] 28 | }, 29 | "configurations": { 30 | "production": { 31 | "budgets": [ 32 | { 33 | "type": "initial", 34 | "maximumWarning": "500kb", 35 | "maximumError": "1mb" 36 | }, 37 | { 38 | "type": "anyComponentStyle", 39 | "maximumWarning": "2kb", 40 | "maximumError": "4kb" 41 | } 42 | ], 43 | "fileReplacements": [ 44 | { 45 | "replace": "src/environments/environment.ts", 46 | "with": "src/environments/environment.prod.ts" 47 | } 48 | ], 49 | "outputHashing": "all" 50 | }, 51 | "development": { 52 | "buildOptimizer": false, 53 | "optimization": false, 54 | "vendorChunk": true, 55 | "extractLicenses": false, 56 | "sourceMap": true, 57 | "namedChunks": true 58 | } 59 | }, 60 | "defaultConfiguration": "production" 61 | }, 62 | "serve": { 63 | "builder": "@angular-devkit/build-angular:dev-server", 64 | "configurations": { 65 | "production": { 66 | "browserTarget": "with-angular:build:production" 67 | }, 68 | "development": { 69 | "browserTarget": "with-angular:build:development" 70 | } 71 | }, 72 | "defaultConfiguration": "development" 73 | }, 74 | "extract-i18n": { 75 | "builder": "@angular-devkit/build-angular:extract-i18n", 76 | "options": { 77 | "browserTarget": "with-angular:build" 78 | } 79 | }, 80 | "test": { 81 | "builder": "@angular-devkit/build-angular:karma", 82 | "options": { 83 | "main": "src/test.ts", 84 | "polyfills": "src/polyfills.ts", 85 | "tsConfig": "tsconfig.spec.json", 86 | "karmaConfig": "karma.conf.js", 87 | "assets": ["src/favicon.ico", "src/assets"], 88 | "styles": ["src/styles.css"], 89 | "scripts": [] 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /examples/with-angular/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, './coverage/with-angular'), 29 | subdir: '.', 30 | reporters: [{ type: 'html' }, { type: 'text-summary' }] 31 | }, 32 | reporters: ['progress', 'kjhtml'], 33 | port: 9876, 34 | colors: true, 35 | logLevel: config.LOG_INFO, 36 | autoWatch: true, 37 | browsers: ['Chrome'], 38 | singleRun: false, 39 | restartOnFileChange: true 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /examples/with-angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-angular", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^14.0.0", 14 | "@angular/common": "^14.0.0", 15 | "@angular/compiler": "^14.0.0", 16 | "@angular/core": "^14.0.0", 17 | "@angular/forms": "^14.0.0", 18 | "@angular/platform-browser": "^14.0.0", 19 | "@angular/platform-browser-dynamic": "^14.0.0", 20 | "@angular/router": "^14.0.0", 21 | "@archimedes/arch": "2.2.0-beta.3", 22 | "rxjs": "~7.5.0", 23 | "tslib": "^2.3.0", 24 | "zone.js": "~0.11.4" 25 | }, 26 | "devDependencies": { 27 | "@angular-devkit/build-angular": "^14.0.0", 28 | "@angular/cli": "~14.0.0", 29 | "@angular/compiler-cli": "^14.0.0", 30 | "@types/jasmine": "~4.0.0", 31 | "jasmine-core": "~4.1.0", 32 | "karma": "~6.3.0", 33 | "karma-chrome-launcher": "~3.1.0", 34 | "karma-coverage": "~2.2.0", 35 | "karma-jasmine": "~5.0.0", 36 | "karma-jasmine-html-reporter": "~1.7.0", 37 | "typescript": "~4.7.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { RouterModule, Routes } from '@angular/router' 3 | 4 | const routes: Routes = [ 5 | { 6 | path: '', 7 | loadChildren: async () => (await import('./features/todo/todo.module')).TodoModule 8 | } 9 | ] 10 | 11 | @NgModule({ 12 | imports: [RouterModule.forRoot(routes)], 13 | exports: [RouterModule] 14 | }) 15 | export class AppRoutingModule {} 16 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | flex: 1; 3 | } 4 | 5 | .toolbar { 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | height: 60px; 11 | display: flex; 12 | align-items: center; 13 | background-color: var(--primary-color); 14 | color: white; 15 | font-weight: 600; 16 | font-size: 1.25rem; 17 | } 18 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.css'] 7 | }) 8 | export class AppComponent { 9 | title = 'with-angular' 10 | } 11 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http' 2 | import { NgModule } from '@angular/core' 3 | import { BrowserModule } from '@angular/platform-browser' 4 | import { CacheInvalidations, InvalidationPolicy } from '@archimedes/arch' 5 | import { ArchimedesModule } from 'src/core/archimedes/archimedes.module' 6 | import { ContainerModule } from 'src/core/di/container.module' 7 | 8 | import { AppRoutingModule } from './app-routing.module' 9 | import { AppComponent } from './app.component' 10 | import { CompleteTodoCmd } from './features/todo/application/complete-todo-cmd' 11 | import { CreateTodoCmd } from './features/todo/application/create-todo-cmd' 12 | import { GetTodosQry } from './features/todo/application/get-todos-qry' 13 | 14 | @NgModule({ 15 | declarations: [AppComponent], 16 | imports: [BrowserModule, HttpClientModule, AppRoutingModule, ArchimedesModule, ContainerModule], 17 | providers: [], 18 | bootstrap: [AppComponent] 19 | }) 20 | export class AppModule { 21 | constructor() { 22 | CacheInvalidations.set(CreateTodoCmd.prototype.key, [InvalidationPolicy.ALL]) 23 | CacheInvalidations.set(CompleteTodoCmd.prototype.key, [GetTodosQry.prototype.key]) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/application/complete-todo-cmd.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core' 2 | import { Command, UseCaseKey } from '@archimedes/arch' 3 | import { InjectionTokens } from 'src/core/di/injection-tokens' 4 | import { TodoRepository } from '../domain/todo-repository' 5 | 6 | @UseCaseKey('CompleteTodoCmd') 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class CompleteTodoCmd extends Command<{ id: number; isCompleted: boolean }> { 11 | constructor(@Inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 12 | super() 13 | } 14 | 15 | internalExecute({ id, isCompleted }: { id: number; isCompleted: boolean }): Promise { 16 | return this.todoRepository.update(id, { completed: isCompleted }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/application/create-todo-cmd.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core' 2 | import { Command, UseCaseKey } from '@archimedes/arch' 3 | import { InjectionTokens } from 'src/core/di/injection-tokens' 4 | import { NewTodo } from '../domain/new-todo' 5 | import { TodoRepository } from '../domain/todo-repository' 6 | 7 | @UseCaseKey('CreateTodoCmd') 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class CreateTodoCmd extends Command { 12 | constructor(@Inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 13 | super() 14 | } 15 | 16 | internalExecute(newTodo: NewTodo): Promise { 17 | return this.todoRepository.create(newTodo) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/application/get-todo-qry.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core' 2 | 3 | import { Query, UseCaseKey } from '@archimedes/arch' 4 | import { InjectionTokens } from 'src/core/di/injection-tokens' 5 | import { Todo } from '../domain/todo' 6 | import { TodoRepository } from '../domain/todo-repository' 7 | 8 | @UseCaseKey('GetTodoQry') 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class GetTodoQry extends Query { 13 | constructor(@Inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 14 | super() 15 | } 16 | 17 | internalExecute(id: number): Promise { 18 | return this.todoRepository.get(id) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/application/get-todos-qry.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core' 2 | import { InvalidateCache, Query, UseCaseKey } from '@archimedes/arch' 3 | import { InjectionTokens } from 'src/core/di/injection-tokens' 4 | import { Todo } from '../domain/todo' 5 | import { TodoRepository } from '../domain/todo-repository' 6 | 7 | @UseCaseKey('GetTodosQry') 8 | @InvalidateCache 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class GetTodosQry extends Query { 13 | constructor(@Inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 14 | super() 15 | } 16 | 17 | internalExecute(): Promise { 18 | return this.todoRepository.getAll() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/domain/new-todo.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from './todo' 2 | 3 | export type NewTodo = Omit 4 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/domain/todo-repository.ts: -------------------------------------------------------------------------------- 1 | import { NewTodo } from './new-todo' 2 | import { Todo } from './todo' 3 | 4 | export interface TodoRepository { 5 | getAll(): Promise 6 | get(id: number): Promise 7 | create(newTodo: NewTodo): Promise 8 | update(id: number, todo: Partial): Promise 9 | } 10 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/domain/todo.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | id: number 3 | title: string 4 | completed: boolean 5 | } 6 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/infrastructure/http-todo-repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { NewTodo } from '../domain/new-todo' 3 | import { Todo } from '../domain/todo' 4 | import { TodoRepository } from '../domain/todo-repository' 5 | import { HttpClient } from '@angular/common/http' 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class HttpTodoRepository implements TodoRepository { 11 | private static TODO_URL = 'https://jsonplaceholder.typicode.com/todos' 12 | private static TODO_BY_ID_URL = (id: number) => `${HttpTodoRepository.TODO_URL}/${id}` 13 | 14 | constructor(private readonly httpClient: HttpClient) {} 15 | get(id: number): Promise { 16 | return this.httpClient.get(HttpTodoRepository.TODO_BY_ID_URL(id)).toPromise() 17 | } 18 | 19 | async getAll(): Promise { 20 | const todos = await this.httpClient.get(HttpTodoRepository.TODO_URL).toPromise() 21 | return todos ?? [] 22 | } 23 | 24 | async create(newTodo: NewTodo): Promise { 25 | await this.httpClient.post(HttpTodoRepository.TODO_URL, newTodo).toPromise() 26 | } 27 | 28 | async update(id: number, todo: Partial): Promise { 29 | await this.httpClient.patch(HttpTodoRepository.TODO_BY_ID_URL(id), todo).toPromise() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/infrastructure/local-todo-repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { NewTodo } from '../domain/new-todo' 3 | import { Todo } from '../domain/todo' 4 | import { TodoRepository } from '../domain/todo-repository' 5 | 6 | @Injectable({ 7 | providedIn: 'root' 8 | }) 9 | export class LocalTodoRepository implements TodoRepository { 10 | private readonly todos = new Map() 11 | 12 | async getAll(): Promise { 13 | return Array.from(this.todos.values()) 14 | } 15 | 16 | async get(id: number): Promise { 17 | return this.todos.get(id) 18 | } 19 | 20 | async create(newTodo: NewTodo): Promise { 21 | const newId = this.todos.size + 1 22 | this.todos.set(newId, { ...newTodo, id: newId }) 23 | } 24 | 25 | async update(id: number, todo: Partial): Promise { 26 | const previousTodo = this.todos.get(id) 27 | this.todos.set(id, { ...previousTodo, ...todo } as Todo) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/todo.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { NgModule } from '@angular/core' 3 | import { FormsModule } from '@angular/forms' 4 | import { RouterModule } from '@angular/router' 5 | import { CreateTodoPage } from './ui/create-todo/create-todo.page' 6 | import { TodoListPage } from './ui/todo-list/todo-list.page' 7 | 8 | @NgModule({ 9 | declarations: [TodoListPage, CreateTodoPage], 10 | imports: [ 11 | CommonModule, 12 | FormsModule, 13 | RouterModule.forChild([ 14 | { 15 | path: '', 16 | component: TodoListPage 17 | }, 18 | { 19 | path: 'create', 20 | component: CreateTodoPage 21 | } 22 | ]) 23 | ] 24 | }) 25 | export class TodoModule {} 26 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/ui/create-todo/create-todo.page.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 1rem; 6 | margin-top: 72px; 7 | } 8 | 9 | .actions { 10 | display: flex; 11 | gap: 1rem; 12 | } 13 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/ui/create-todo/create-todo.page.html: -------------------------------------------------------------------------------- 1 |

CREATE TODO

2 | 3 | 7 | 8 |
9 | Back 10 | 11 |
12 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/ui/create-todo/create-todo.page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { CreateTodoCmd } from '../../application/create-todo-cmd' 4 | 5 | @Component({ 6 | selector: 'app-create-todo', 7 | templateUrl: './create-todo.page.html', 8 | styleUrls: ['./create-todo.page.css'] 9 | }) 10 | export class CreateTodoPage { 11 | title: string = '' 12 | 13 | constructor(private readonly router: Router, private readonly createTodoCmd: CreateTodoCmd) {} 14 | 15 | async saveTodo() { 16 | await this.createTodoCmd.execute({ title: this.title, completed: false }) 17 | this.router.navigateByUrl('/') 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/ui/todo-list/todo-list.page.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 1rem; 6 | margin-top: 72px; 7 | } 8 | 9 | li { 10 | margin-bottom: 1rem; 11 | } 12 | 13 | .todo { 14 | border-radius: 4px; 15 | border: 1px solid #eee; 16 | background-color: #fefefe; 17 | padding: 1rem; 18 | display: flex; 19 | cursor: pointer; 20 | font-size: 1rem; 21 | font-weight: normal; 22 | color: #111; 23 | } 24 | 25 | .completed { 26 | text-decoration: line-through; 27 | background-color: var(--primary-color); 28 | color: white; 29 | } 30 | 31 | .actions { 32 | display: flex; 33 | gap: 1rem; 34 | } 35 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/ui/todo-list/todo-list.page.html: -------------------------------------------------------------------------------- 1 |

TODO LIST

2 | 3 |
4 | Create 5 | 6 |
7 |
8 |
    9 |
  • 10 | 13 |
  • 14 |
15 |
16 | -------------------------------------------------------------------------------- /examples/with-angular/src/app/features/todo/ui/todo-list/todo-list.page.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core' 2 | import { CompleteTodoCmd } from '../../application/complete-todo-cmd' 3 | import { GetTodosQry } from '../../application/get-todos-qry' 4 | import { Todo } from '../../domain/todo' 5 | 6 | @Component({ 7 | selector: 'app-todo-list', 8 | templateUrl: './todo-list.page.html', 9 | styleUrls: ['./todo-list.page.css'] 10 | }) 11 | export class TodoListPage implements OnInit, OnDestroy { 12 | private completeSubscriptionId!: number 13 | 14 | todos: Todo[] = [] 15 | 16 | constructor(private readonly getTodosQry: GetTodosQry, private readonly completeTodoCmd: CompleteTodoCmd) {} 17 | 18 | async ngOnInit(): Promise { 19 | this.todos = await this.getTodosQry.execute() 20 | this.completeSubscriptionId = this.completeTodoCmd.subscribe(async () => { 21 | this.todos = await this.getTodosQry.execute() 22 | }) 23 | } 24 | 25 | async refreshTodos() { 26 | this.todos = await this.getTodosQry.execute(undefined, { invalidateCache: true }) 27 | } 28 | 29 | ngOnDestroy(): void { 30 | this.completeTodoCmd.unsubscribe(this.completeSubscriptionId) 31 | } 32 | 33 | async completeTodo(todo: Todo) { 34 | await this.completeTodoCmd.execute({ id: todo.id, isCompleted: !todo.completed }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /examples/with-angular/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archimedes-projects/archimedes-js/99e344bdc67816146a1f6122976beb056705f051/examples/with-angular/src/assets/.gitkeep -------------------------------------------------------------------------------- /examples/with-angular/src/core/archimedes/archimedes.module.ts: -------------------------------------------------------------------------------- 1 | import { Archimedes, CacheLink, CacheManager, ExecutorLink, LoggerLink } from '@archimedes/arch' 2 | import { CommonModule } from '@angular/common' 3 | import { APP_INITIALIZER, NgModule } from '@angular/core' 4 | 5 | const ARCHIMEDES_PROVIDERS = [ 6 | { provide: LoggerLink, useFactory: () => new LoggerLink(console) }, 7 | { provide: ExecutorLink, useClass: ExecutorLink }, 8 | { provide: CacheManager, useFactory: () => new CacheManager() }, 9 | { provide: CacheLink, useClass: CacheLink, deps: [CacheManager] } 10 | ] 11 | 12 | @NgModule({ 13 | imports: [CommonModule], 14 | providers: [ 15 | ...ARCHIMEDES_PROVIDERS, 16 | { 17 | provide: APP_INITIALIZER, 18 | useFactory: (cacheLink: CacheLink, executorLink: ExecutorLink, loggerLink: LoggerLink) => () => { 19 | return Archimedes.createChain([cacheLink, executorLink, loggerLink]) 20 | }, 21 | deps: [CacheLink, ExecutorLink, LoggerLink], 22 | multi: true 23 | } 24 | ] 25 | }) 26 | export class ArchimedesModule {} 27 | -------------------------------------------------------------------------------- /examples/with-angular/src/core/di/container.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core' 2 | import { LocalTodoRepository } from 'src/app/features/todo/infrastructure/local-todo-repository' 3 | import { InjectionTokens } from './injection-tokens' 4 | 5 | @NgModule({ 6 | providers: [ 7 | { 8 | provide: InjectionTokens.TODO_REPOSITORY, 9 | useClass: LocalTodoRepository 10 | } 11 | ] 12 | }) 13 | export class ContainerModule {} 14 | -------------------------------------------------------------------------------- /examples/with-angular/src/core/di/injection-tokens.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core' 2 | import { TodoRepository } from 'src/app/features/todo/domain/todo-repository' 3 | 4 | export class InjectionTokens { 5 | static TODO_REPOSITORY = new InjectionToken('TODO_REPOSITORY') 6 | } 7 | -------------------------------------------------------------------------------- /examples/with-angular/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | } 4 | -------------------------------------------------------------------------------- /examples/with-angular/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | } 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /examples/with-angular/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archimedes-projects/archimedes-js/99e344bdc67816146a1f6122976beb056705f051/examples/with-angular/src/favicon.ico -------------------------------------------------------------------------------- /examples/with-angular/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WithAngular 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/with-angular/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core' 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic' 3 | 4 | import { AppModule } from './app/app.module' 5 | import { environment } from './environments/environment' 6 | 7 | if (environment.production) { 8 | enableProdMode() 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch(err => console.error(err)) 14 | -------------------------------------------------------------------------------- /examples/with-angular/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes recent versions of Safari, Chrome (including 12 | * Opera), Edge on the desktop, and iOS and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js' // Included with Angular CLI. 49 | 50 | /*************************************************************************************************** 51 | * APPLICATION IMPORTS 52 | */ 53 | -------------------------------------------------------------------------------- /examples/with-angular/src/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #1976d2; 3 | } 4 | 5 | body { 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 7 | 'Segoe UI Emoji', 'Segoe UI Symbol'; 8 | font-size: 14px; 9 | color: #333; 10 | box-sizing: border-box; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | h5, 20 | h6 { 21 | margin: 8px 0; 22 | } 23 | 24 | p { 25 | margin: 0; 26 | } 27 | 28 | ul { 29 | list-style: none; 30 | } 31 | 32 | button { 33 | margin: 0; 34 | padding: 0.5rem 1rem; 35 | background: var(--primary-color); 36 | color: white; 37 | font-weight: bold; 38 | border: 1px solid var(--primary-color); 39 | border-radius: 4px; 40 | cursor: pointer; 41 | } 42 | 43 | a { 44 | color: var(--primary-color); 45 | padding: 0.5rem 1rem; 46 | text-decoration: none; 47 | font-weight: bold; 48 | border: 1px solid var(--primary-color); 49 | border-radius: 4px; 50 | } 51 | 52 | input { 53 | display: block; 54 | padding: 0.5rem; 55 | margin: 0.25rem 0; 56 | border: 1px solid #ccc; 57 | border-radius: 4px; 58 | } 59 | 60 | input:focus { 61 | border-color: var(--primary-color); 62 | } 63 | -------------------------------------------------------------------------------- /examples/with-angular/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing' 4 | import { getTestBed } from '@angular/core/testing' 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing' 6 | 7 | declare const require: { 8 | context( 9 | path: string, 10 | deep?: boolean, 11 | filter?: RegExp 12 | ): { 13 | (id: string): T 14 | keys(): string[] 15 | } 16 | } 17 | 18 | // First, initialize the Angular testing environment. 19 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()) 20 | 21 | // Then we find all the tests. 22 | const context = require.context('./', true, /\.spec\.ts$/) 23 | // And load the modules. 24 | context.keys().forEach(context) 25 | -------------------------------------------------------------------------------- /examples/with-angular/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/with-angular/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "sourceMap": true, 13 | "declaration": false, 14 | "downlevelIteration": true, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "es2020", 19 | "module": "es2020", 20 | "lib": ["es2020", "dom"], 21 | "resolveJsonModule": true 22 | }, 23 | "angularCompilerOptions": { 24 | "enableI18nLegacyMessageIdFormat": false, 25 | "strictInjectionParameters": true, 26 | "strictInputAccessModifiers": true, 27 | "strictTemplates": true 28 | }, 29 | "references": [ 30 | { 31 | "path": "../../packages/arch" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /examples/with-angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "files": ["src/test.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /examples/with-react/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/with-react/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /examples/with-react/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babel: { 3 | plugins: [ 4 | 'babel-plugin-transform-typescript-metadata', 5 | ['@babel/plugin-proposal-decorators', { legacy: true }], 6 | ['@babel/plugin-proposal-class-properties', { loose: true }] 7 | ], 8 | presets: ['@babel/preset-typescript'] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/with-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "with-react", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@archimedes/arch": "2.2.0-beta.3", 7 | "@testing-library/jest-dom": "5.16.4", 8 | "@testing-library/react": "13.3.0", 9 | "@testing-library/user-event": "13.5.0", 10 | "@types/jest": "27.5.2", 11 | "@types/node": "16.11.39", 12 | "@types/react": "18.0.12", 13 | "@types/react-dom": "18.0.5", 14 | "react": "18.1.0", 15 | "react-dom": "18.1.0", 16 | "react-router-dom": "^6.0.2", 17 | "react-scripts": "5.0.1", 18 | "reflect-metadata": "0.1.13", 19 | "tsyringe": "4.7.0", 20 | "typescript": "4.7.3", 21 | "web-vitals": "2.1.4" 22 | }, 23 | "scripts": { 24 | "start": "craco start", 25 | "build": "craco build", 26 | "test": "craco test", 27 | "eject": "craco eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@babel/plugin-proposal-decorators": "7.18.2", 49 | "@craco/craco": "6.4.3", 50 | "babel-plugin-transform-typescript-metadata": "0.3.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /examples/with-react/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archimedes-projects/archimedes-js/99e344bdc67816146a1f6122976beb056705f051/examples/with-react/public/favicon.ico -------------------------------------------------------------------------------- /examples/with-react/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | React App 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/with-react/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archimedes-projects/archimedes-js/99e344bdc67816146a1f6122976beb056705f051/examples/with-react/public/logo192.png -------------------------------------------------------------------------------- /examples/with-react/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/archimedes-projects/archimedes-js/99e344bdc67816146a1f6122976beb056705f051/examples/with-react/public/logo512.png -------------------------------------------------------------------------------- /examples/with-react/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/with-react/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/with-react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom' 2 | import styles from './app.module.css' 3 | import { CreateTodo } from './features/todo/ui/create-todo/create-todo' 4 | import { TodoList } from './features/todo/ui/todo-list/todo-list' 5 | 6 | function App() { 7 | return ( 8 |
9 |
10 |
11 | ARCHIMEDES WITH REACT 12 |
13 |
14 | 15 | 16 | } /> 17 | } /> 18 | 19 |
20 | ) 21 | } 22 | 23 | export default App 24 | -------------------------------------------------------------------------------- /examples/with-react/src/app.module.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | flex: 1; 3 | } 4 | 5 | .toolbar { 6 | position: absolute; 7 | top: 0; 8 | left: 0; 9 | right: 0; 10 | height: 60px; 11 | display: flex; 12 | align-items: center; 13 | background-color: var(--primary-color); 14 | color: white; 15 | font-weight: 600; 16 | font-size: 1.25rem; 17 | } 18 | -------------------------------------------------------------------------------- /examples/with-react/src/core/di/container.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe' 2 | import { LocalTodoRepository } from '../../features/todo/infrastructure/local-todo-repository' 3 | import { InjectionTokens } from './injection-tokens' 4 | 5 | container.registerSingleton(InjectionTokens.TODO_REPOSITORY, LocalTodoRepository) 6 | 7 | export { container } 8 | -------------------------------------------------------------------------------- /examples/with-react/src/core/di/injection-tokens.ts: -------------------------------------------------------------------------------- 1 | export class InjectionTokens { 2 | static TODO_REPOSITORY = Symbol.for('TODO_REPOSITORY') 3 | } 4 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/application/complete-todo-cmd.ts: -------------------------------------------------------------------------------- 1 | import { Command, UseCaseKey } from '@archimedes/arch' 2 | import { inject, injectable } from 'tsyringe' 3 | import { InjectionTokens } from '../../../core/di/injection-tokens' 4 | import type { TodoRepository } from '../domain/todo-repository' 5 | 6 | @UseCaseKey('CompleteTodoCmd') 7 | @injectable() 8 | export class CompleteTodoCmd extends Command<{ id: number; isCompleted: boolean }> { 9 | constructor(@inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 10 | super() 11 | } 12 | 13 | internalExecute({ id, isCompleted }: { id: number; isCompleted: boolean }): Promise { 14 | return this.todoRepository.update(id, { completed: isCompleted }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/application/create-todo-cmd.ts: -------------------------------------------------------------------------------- 1 | import { Command, UseCaseKey } from '@archimedes/arch' 2 | import { inject, injectable } from 'tsyringe' 3 | import { InjectionTokens } from '../../../core/di/injection-tokens' 4 | import { NewTodo } from '../domain/new-todo' 5 | import type { TodoRepository } from '../domain/todo-repository' 6 | 7 | @UseCaseKey('CreateTodoCmd') 8 | @injectable() 9 | export class CreateTodoCmd extends Command { 10 | constructor(@inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 11 | super() 12 | } 13 | 14 | internalExecute(newTodo: NewTodo): Promise { 15 | return this.todoRepository.create(newTodo) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/application/get-todo-qry.ts: -------------------------------------------------------------------------------- 1 | import { Query, UseCaseKey } from '@archimedes/arch' 2 | import { inject, injectable } from 'tsyringe' 3 | import { InjectionTokens } from '../../../core/di/injection-tokens' 4 | import { Todo } from '../domain/todo' 5 | import type { TodoRepository } from '../domain/todo-repository' 6 | 7 | @UseCaseKey('GetTodoQry') 8 | @injectable() 9 | export class GetTodoQry extends Query { 10 | constructor(@inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 11 | super() 12 | } 13 | 14 | internalExecute(id: number): Promise { 15 | return this.todoRepository.get(id) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/application/get-todos-qry.ts: -------------------------------------------------------------------------------- 1 | import { InvalidateCache, Query, UseCaseKey } from '@archimedes/arch' 2 | import { inject, injectable } from 'tsyringe' 3 | import { InjectionTokens } from '../../../core/di/injection-tokens' 4 | import { Todo } from '../domain/todo' 5 | import type { TodoRepository } from '../domain/todo-repository' 6 | 7 | @UseCaseKey('GetTodosQry') 8 | @InvalidateCache 9 | @injectable() 10 | export class GetTodosQry extends Query { 11 | constructor(@inject(InjectionTokens.TODO_REPOSITORY) private readonly todoRepository: TodoRepository) { 12 | super() 13 | } 14 | 15 | internalExecute(): Promise { 16 | return this.todoRepository.getAll() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/domain/new-todo.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from './todo' 2 | 3 | export type NewTodo = Omit 4 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/domain/todo-repository.ts: -------------------------------------------------------------------------------- 1 | import { NewTodo } from './new-todo' 2 | import { Todo } from './todo' 3 | 4 | export interface TodoRepository { 5 | getAll(): Promise 6 | get(id: number): Promise 7 | create(newTodo: NewTodo): Promise 8 | update(id: number, todo: Partial): Promise 9 | } 10 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/domain/todo.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | id: number 3 | title: string 4 | completed: boolean 5 | } 6 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/infrastructure/http-todo-repository.ts: -------------------------------------------------------------------------------- 1 | import { NewTodo } from '../domain/new-todo' 2 | import { Todo } from '../domain/todo' 3 | import { TodoRepository } from '../domain/todo-repository' 4 | import { injectable } from 'tsyringe' 5 | 6 | @injectable() 7 | export class HttpTodoRepository implements TodoRepository { 8 | private static TODO_URL = 'https://jsonplaceholder.typicode.com/todos' 9 | private static TODO_BY_ID_URL = (id: number) => `${HttpTodoRepository.TODO_URL}/${id}` 10 | 11 | constructor() {} 12 | async get(id: number): Promise { 13 | const response = await fetch(HttpTodoRepository.TODO_BY_ID_URL(id)) 14 | return response.json() 15 | } 16 | 17 | async getAll(): Promise { 18 | const response = await fetch(HttpTodoRepository.TODO_URL) 19 | return response.json() 20 | } 21 | 22 | async create(newTodo: NewTodo): Promise { 23 | await fetch(HttpTodoRepository.TODO_URL, { method: 'POST', body: JSON.stringify(newTodo) }) 24 | } 25 | 26 | async update(id: number, todo: Partial): Promise { 27 | await fetch(HttpTodoRepository.TODO_BY_ID_URL(id), { method: 'PATCH', body: JSON.stringify(todo) }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/infrastructure/local-todo-repository.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'tsyringe' 2 | import { NewTodo } from '../domain/new-todo' 3 | import { Todo } from '../domain/todo' 4 | import { TodoRepository } from '../domain/todo-repository' 5 | 6 | @injectable() 7 | export class LocalTodoRepository implements TodoRepository { 8 | private readonly todos = new Map() 9 | 10 | async getAll(): Promise { 11 | return Array.from(this.todos.values()) 12 | } 13 | 14 | async get(id: number): Promise { 15 | return this.todos.get(id) 16 | } 17 | 18 | async create(newTodo: NewTodo): Promise { 19 | const newId = this.todos.size + 1 20 | this.todos.set(newId, { ...newTodo, id: newId }) 21 | } 22 | 23 | async update(id: number, todo: Partial): Promise { 24 | const previousTodo = this.todos.get(id) 25 | this.todos.set(id, { ...previousTodo, ...todo } as Todo) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/ui/create-todo/create-todo.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 1rem; 6 | margin-top: 72px; 7 | } 8 | 9 | .actions { 10 | display: flex; 11 | gap: 1rem; 12 | } 13 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/ui/create-todo/create-todo.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react' 2 | import { Link, useNavigate } from 'react-router-dom' 3 | import { useDi } from '../../../../use-di' 4 | import { CreateTodoCmd } from '../../application/create-todo-cmd' 5 | import styles from './create-todo.module.css' 6 | 7 | export const CreateTodo: FC = () => { 8 | const [title, setTitle] = useState('') 9 | const createTodoCmd = useDi(CreateTodoCmd) 10 | const navigate = useNavigate() 11 | 12 | const saveTodo = async () => { 13 | await createTodoCmd.execute({ 14 | title, 15 | completed: false 16 | }) 17 | navigate('/') 18 | } 19 | 20 | return ( 21 |
22 |

CREATE TODO

23 | 24 | 28 | 29 |
30 | Back 31 | 32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/ui/todo-list/todo-list.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | gap: 1rem; 6 | margin-top: 72px; 7 | } 8 | 9 | li { 10 | margin-bottom: 1rem; 11 | } 12 | 13 | .todo { 14 | border-radius: 4px; 15 | border: 1px solid #eee; 16 | background-color: #fefefe; 17 | padding: 1rem; 18 | display: flex; 19 | cursor: pointer; 20 | font-size: 1rem; 21 | font-weight: normal; 22 | color: #111; 23 | } 24 | 25 | .completed { 26 | text-decoration: line-through; 27 | background-color: var(--primary-color); 28 | color: white; 29 | } 30 | 31 | .actions { 32 | display: flex; 33 | gap: 1rem; 34 | } 35 | -------------------------------------------------------------------------------- /examples/with-react/src/features/todo/ui/todo-list/todo-list.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { useDi } from '../../../../use-di' 4 | import { CompleteTodoCmd } from '../../application/complete-todo-cmd' 5 | import { GetTodosQry } from '../../application/get-todos-qry' 6 | import { Todo } from '../../domain/todo' 7 | 8 | import styles from './todo-list.module.css' 9 | 10 | export const TodoList: FC = () => { 11 | const [todos, setTodos] = useState([]) 12 | const getTodosQry = useDi(GetTodosQry) 13 | const completeTodoCmd = useDi(CompleteTodoCmd) 14 | 15 | async function getTodos(refresh = false) { 16 | const newTodos = await getTodosQry.execute(undefined, { invalidateCache: refresh }) 17 | setTodos(newTodos) 18 | } 19 | 20 | useEffect(() => { 21 | getTodos() 22 | const id = completeTodoCmd.subscribe(() => getTodos()) 23 | return () => completeTodoCmd.unsubscribe(id) 24 | }, []) 25 | 26 | const completeTodo = async (todo: Todo) => { 27 | await completeTodoCmd.execute({ id: todo.id, isCompleted: !todo.completed }) 28 | } 29 | 30 | return ( 31 |
32 |

TODO LIST

33 | 34 |
35 | Create 36 | 37 |
38 |
39 |
    40 | {todos.map(todo => ( 41 |
  • 42 | 49 |
  • 50 | ))} 51 |
52 |
53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /examples/with-react/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary-color: #149eca; 3 | } 4 | 5 | body { 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 7 | 'Segoe UI Emoji', 'Segoe UI Symbol'; 8 | font-size: 14px; 9 | color: #333; 10 | box-sizing: border-box; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | } 14 | 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | h5, 20 | h6 { 21 | margin: 8px 0; 22 | } 23 | 24 | p { 25 | margin: 0; 26 | } 27 | 28 | ul { 29 | list-style: none; 30 | } 31 | 32 | button { 33 | margin: 0; 34 | padding: 0.5rem 1rem; 35 | background: var(--primary-color); 36 | color: white; 37 | font-weight: bold; 38 | border: 1px solid var(--primary-color); 39 | border-radius: 4px; 40 | cursor: pointer; 41 | } 42 | 43 | a { 44 | color: var(--primary-color); 45 | padding: 0.5rem 1rem; 46 | text-decoration: none; 47 | font-weight: bold; 48 | border: 1px solid var(--primary-color); 49 | border-radius: 4px; 50 | } 51 | 52 | input { 53 | display: block; 54 | padding: 0.5rem; 55 | margin: 0.25rem 0; 56 | border: 1px solid #ccc; 57 | border-radius: 4px; 58 | } 59 | 60 | input:focus { 61 | border-color: var(--primary-color); 62 | } 63 | -------------------------------------------------------------------------------- /examples/with-react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom/client' 4 | import './index.css' 5 | import App from './App' 6 | import { 7 | Archimedes, 8 | CacheInvalidations, 9 | CacheLink, 10 | CacheManager, 11 | ExecutorLink, 12 | InvalidationPolicy, 13 | LoggerLink 14 | } from '@archimedes/arch' 15 | import { BrowserRouter } from 'react-router-dom' 16 | import { CreateTodoCmd } from './features/todo/application/create-todo-cmd' 17 | import { CompleteTodoCmd } from './features/todo/application/complete-todo-cmd' 18 | import { GetTodosQry } from './features/todo/application/get-todos-qry' 19 | 20 | CacheInvalidations.set(CreateTodoCmd.prototype.key, [InvalidationPolicy.ALL]) 21 | CacheInvalidations.set(CompleteTodoCmd.prototype.key, [GetTodosQry.prototype.key]) 22 | 23 | Archimedes.createChain([new CacheLink(new CacheManager()), new ExecutorLink(), new LoggerLink(console)]) 24 | 25 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) 26 | 27 | root.render( 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | -------------------------------------------------------------------------------- /examples/with-react/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/with-react/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/with-react/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals' 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry) 7 | getFID(onPerfEntry) 8 | getFCP(onPerfEntry) 9 | getLCP(onPerfEntry) 10 | getTTFB(onPerfEntry) 11 | }) 12 | } 13 | } 14 | 15 | export default reportWebVitals 16 | -------------------------------------------------------------------------------- /examples/with-react/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom' 6 | -------------------------------------------------------------------------------- /examples/with-react/src/use-di.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { constructor } from 'tsyringe/dist/typings/types' 3 | import { container } from './core/di/container' 4 | 5 | export function useDi(token: constructor | string) { 6 | return useMemo(() => container.resolve(token), [token]) 7 | } 8 | -------------------------------------------------------------------------------- /examples/with-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx" 20 | }, 21 | "include": ["src"], 22 | "references": [ 23 | { 24 | "path": "../../packages/arch" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | errorOnDeprecated: true, 4 | resetMocks: true, 5 | resetModules: true, 6 | restoreMocks: true, 7 | coverageDirectory: 'coverage', 8 | setupFiles: ['./tests/setup.js'], 9 | modulePathIgnorePatterns: ['dist'], 10 | roots: ['/packages'], 11 | testEnvironment: 'jsdom', 12 | coverageReporters: ['text-summary', 'lcov'], 13 | collectCoverageFrom: ['/packages/**/src/**/*.ts', '!/**/*.d.ts'], 14 | transform: { 15 | '\\.[jt]sx?$': 'es-babel-jest' 16 | }, 17 | moduleNameMapper: { 18 | '^@archimedes/(.*)$': '/packages/$1/src' 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "archimedes", 3 | "private": true, 4 | "license": "Apache-2.0", 5 | "workspaces": [ 6 | "packages/arch", 7 | "packages/utils", 8 | "packages/components", 9 | "examples/*" 10 | ], 11 | "scripts": { 12 | "clean": "rimraf ./packages/*/dist packages/**/*.tsbuildinfo", 13 | "compile": "tsc --build tsconfig.project.json", 14 | "build": "npm run clean && npm run build:packages && npm run build:components", 15 | "build:components": "cd packages/components && npm run build", 16 | "build:packages": "parcel build packages/arch packages/utils", 17 | "watch": "parcel watch packages/arch packages/utils", 18 | "test": "jest", 19 | "test:watch": "jest --watchAll", 20 | "test:coverage": "jest --coverage", 21 | "test:ci": "jest --ci", 22 | "format": "prettier --write --ignore-path .gitignore .", 23 | "release": "multi-semantic-release --sequential-init --ignore-private-packages=true", 24 | "release:check": "multi-semantic-release --dry-run --sequential-init --ignore-private-packages=true", 25 | "postinstall": "update-ts-references" 26 | }, 27 | "dependencies": { 28 | "@parcel/transformer-typescript-tsc": "2.0.1" 29 | }, 30 | "devDependencies": { 31 | "@commitlint/cli": "13.1.0", 32 | "@commitlint/config-conventional": "13.1.0", 33 | "@parcel/packager-ts": "2.0.1", 34 | "@parcel/transformer-typescript-types": "2.0.1", 35 | "@semantic-release/changelog": "6.0.1", 36 | "@semantic-release/commit-analyzer": "9.0.1", 37 | "@semantic-release/git": "10.0.1", 38 | "@types/jest": "27.0.1", 39 | "es-babel-jest": "1.0.0", 40 | "isomorphic-fetch": "3.0.0", 41 | "jest": "27.4.5", 42 | "multi-semantic-release": "2.11.0", 43 | "parcel": "2.0.1", 44 | "prettier": "2.3.2", 45 | "pretty-quick": "3.1.1", 46 | "rimraf": "3.0.2", 47 | "simple-git-hooks": "2.6.1", 48 | "ts-jest": "27.1.2", 49 | "ts-mockito": "2.6.1", 50 | "typescript": "4.4.2", 51 | "update-ts-references": "2.4.1" 52 | }, 53 | "simple-git-hooks": { 54 | "commit-msg": "npx --no-install commitlint --edit", 55 | "pre-commit": "npx pretty-quick --staged", 56 | "pre-push": "npm run test:ci" 57 | }, 58 | "engines": { 59 | "node": "17.3.0" 60 | }, 61 | "volta": { 62 | "node": "17.3.0", 63 | "npm": "8.3.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/arch/README.md: -------------------------------------------------------------------------------- 1 | # `@archimedes/arch` 2 | 3 | Refer to [the official documentation here](https://www.archimedesfw.io/docs/js/arch). 4 | 5 | Different architectural pieces to use: 6 | 7 | - Use Cases 8 | - Commands 9 | - Runner 10 | - Links 11 | - Notifications 12 | 13 | ## Installation 14 | 15 | `npm i @archimedes/arch -SE` 16 | -------------------------------------------------------------------------------- /packages/arch/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@archimedes/arch", 3 | "version": "2.2.1", 4 | "description": "Archimedes architecture", 5 | "author": "<>", 6 | "license": "Apache-2.0", 7 | "source": "src/index.ts", 8 | "main": "dist/index.js", 9 | "module": "dist/index.module.js", 10 | "types": "dist/types.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "devDependencies": { 15 | "typescript": "4.7.3" 16 | }, 17 | "dependencies": { 18 | "@archimedes/utils": "2.1.1", 19 | "reflect-metadata": "0.1.13", 20 | "tiny-lru": "8.0.2", 21 | "ts-md5": "1.2.11" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/arch/src/archimedes.ts: -------------------------------------------------------------------------------- 1 | import { Link } from './runner/links/link' 2 | import { Runner } from './runner/runner' 3 | 4 | export class Archimedes { 5 | static createChain(links: Link[]) { 6 | Runner.createChain(links) 7 | return this 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/arch/src/cache/cache-key.ts: -------------------------------------------------------------------------------- 1 | export type CacheKey = string 2 | -------------------------------------------------------------------------------- /packages/arch/src/cache/cache-manager.spec.ts: -------------------------------------------------------------------------------- 1 | import { CacheManager } from './cache-manager' 2 | import { CacheOptions } from './cache-options' 3 | import { anyString, capture, instance, mock, verify, when } from 'ts-mockito' 4 | import { Cache, CacheResult } from './cache' 5 | import { Datetime, MockDatetime } from '@archimedes/utils' 6 | 7 | describe('CacheManager', () => { 8 | it('should return the result', () => { 9 | const { cacheManager } = setup() 10 | let count = 0 11 | 12 | const actual = cacheManager.set('foo', () => { 13 | count++ 14 | return count 15 | }) 16 | 17 | expect(actual).toBe(1) 18 | }) 19 | 20 | it('should cache the execution', () => { 21 | const { cacheManager } = setup() 22 | let count = 0 23 | 24 | const fn = () => { 25 | count++ 26 | return count 27 | } 28 | cacheManager.set('foo', fn) 29 | cacheManager.set('foo', fn) 30 | const actual = cacheManager.set('foo', fn) 31 | 32 | expect(actual).toBe(1) 33 | }) 34 | 35 | it('should cache the execution depending on the parameters', () => { 36 | const { cacheManager } = setup() 37 | let count = 0 38 | 39 | const fn = (otherValue?: number): number => { 40 | count++ 41 | return count + (otherValue ?? 0) 42 | } 43 | cacheManager.set('foo', () => fn()) 44 | cacheManager.set('foo', () => fn()) 45 | cacheManager.set('foo', () => fn(1)) 46 | const actual = cacheManager.set('foo', () => fn(1)) 47 | 48 | expect(actual).toBe(1) 49 | }) 50 | 51 | it('should use custom cache', () => { 52 | MockDatetime.mock(Datetime.fromIsoString('2020-01-01T00:00:00Z')) 53 | const cache = mock() 54 | when(cache.create()).thenReturn(instance(cache)) 55 | when(cache.get(anyString())).thenReturn({ createdAt: 1, returns: 1 }) 56 | const { cacheManager } = setup({ cache: instance(cache) }) 57 | let count = 0 58 | 59 | const fn = () => { 60 | count++ 61 | return count 62 | } 63 | 64 | cacheManager.set('foo', fn) 65 | 66 | const [first, second] = capture(cache.set).last() 67 | expect(first).toBe('d751713988987e9331980363e24189ce') 68 | expect(second).toEqual({ createdAt: 1577836800000, returns: 1 }) 69 | }) 70 | 71 | it('should delete cache if ttl has passed', () => { 72 | MockDatetime.mock(Datetime.fromIsoString('2020-01-01T00:00:00Z')) 73 | const mockedDatetime = Datetime.now().toMillis() - 1 74 | const cache = mock() 75 | when(cache.create()).thenReturn(instance(cache)) 76 | when(cache.get(anyString())).thenReturn({ createdAt: mockedDatetime, returns: 1 }) 77 | const { cacheManager } = setup({ cache: instance(cache), ttl: 0 }) 78 | let count = 0 79 | 80 | const fn = () => { 81 | count++ 82 | return count 83 | } 84 | 85 | cacheManager.set('foo', fn) 86 | 87 | verify(cache.delete(anyString())).once() 88 | }) 89 | 90 | it('should not delete cache if the ttl has not passed', () => { 91 | MockDatetime.mock(Datetime.fromIsoString('2020-01-01T00:00:00Z')) 92 | const mockedDatetime = Datetime.now().toMillis() - 1 93 | const cache = mock() 94 | when(cache.create()).thenReturn(instance(cache)) 95 | when(cache.get(anyString())).thenReturn({ createdAt: mockedDatetime, returns: 1 }) 96 | const { cacheManager } = setup({ cache: instance(cache), ttl: 1 }) 97 | let count = 0 98 | 99 | const fn = () => { 100 | count++ 101 | return count 102 | } 103 | 104 | cacheManager.set('foo', fn) 105 | 106 | verify(cache.delete(anyString())).never() 107 | }) 108 | 109 | it('should use different caches for different keys', () => { 110 | MockDatetime.mock(Datetime.fromIsoString('2020-01-01T00:00:00Z')) 111 | const mockedDatetime = Datetime.now().toMillis() - 1 112 | const cache = mock() 113 | when(cache.create()).thenReturn(instance(cache)) 114 | when(cache.get(anyString())).thenReturn({ createdAt: mockedDatetime, returns: 1 }) 115 | const { cacheManager } = setup({ cache: instance(cache) }) 116 | 117 | const fn = (): string => 'foo' 118 | 119 | cacheManager.set('foo', () => fn()) 120 | cacheManager.set('bar', () => fn()) 121 | 122 | verify(cache.create()).twice() 123 | }) 124 | }) 125 | 126 | function setup(cacheOptions?: Partial) { 127 | return { 128 | cacheManager: new CacheManager(cacheOptions) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /packages/arch/src/cache/cache-manager.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from './cache' 2 | import { CacheKey } from './cache-key' 3 | import { Md5 } from 'ts-md5' 4 | import { Datetime, isPromise } from '@archimedes/utils' 5 | import { CacheOptions } from './cache-options' 6 | import { LruCache } from './lru-cache' 7 | 8 | export class CacheManager { 9 | private caches: Map = new Map() 10 | 11 | private readonly cacheOptions: CacheOptions 12 | 13 | constructor(cacheOptions?: Partial) { 14 | this.cacheOptions = { 15 | ttl: cacheOptions?.ttl ?? 500_000, 16 | cache: cacheOptions?.cache ?? new LruCache() 17 | } 18 | } 19 | 20 | has(cacheKey: CacheKey, args: unknown[]): boolean { 21 | const existingCache = this.caches.get(cacheKey) 22 | const hash = this.getHash(args) 23 | return existingCache?.has(hash) ?? false 24 | } 25 | 26 | set(cacheKey: CacheKey, fn: (...fnArgs: unknown[]) => unknown, ...args: any[]): unknown { 27 | if (!this.caches.has(cacheKey)) { 28 | this.caches.set(cacheKey, this.cacheOptions.cache.create()) 29 | } 30 | 31 | const existingCache = this.caches.get(cacheKey)! 32 | const hash = this.getHash(args) 33 | const now = Datetime.now() 34 | 35 | if (!this.has(cacheKey, args)) { 36 | existingCache.set(hash, { createdAt: now.toMillis(), returns: fn.apply(this, args) }) 37 | } 38 | 39 | const existingResult = existingCache.get(hash)! 40 | if (isPromise(existingResult.returns)) { 41 | existingResult.returns.catch((error: Error) => { 42 | existingCache.delete(hash) 43 | throw error 44 | }) 45 | } 46 | 47 | if (now.toMillis() - existingResult.createdAt > this.cacheOptions.ttl) { 48 | existingCache.delete(hash) 49 | } 50 | 51 | return existingResult.returns 52 | } 53 | 54 | invalidate(cacheKey: CacheKey): void { 55 | this.caches.delete(cacheKey) 56 | } 57 | 58 | invalidateAll(): void { 59 | this.caches.clear() 60 | } 61 | 62 | private getHash(args: unknown[]): string { 63 | return new Md5().appendStr(JSON.stringify(args)).end() as string 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/arch/src/cache/cache-options.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from './cache' 2 | 3 | export interface CacheOptions { 4 | ttl: number 5 | cache: Cache 6 | } 7 | -------------------------------------------------------------------------------- /packages/arch/src/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import { CacheKey } from './cache-key' 2 | 3 | type Milliseconds = number 4 | 5 | export interface CacheResult { 6 | createdAt: Milliseconds 7 | returns: T 8 | } 9 | 10 | export interface Cache { 11 | create(): Cache 12 | get(key: CacheKey): CacheResult | undefined 13 | set(key: CacheKey, value: CacheResult): void 14 | has(key: CacheKey): boolean 15 | delete(key: CacheKey): void 16 | clear(): void 17 | } 18 | -------------------------------------------------------------------------------- /packages/arch/src/cache/invalidate-cache.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | import { Class } from '@archimedes/utils' 3 | import { UseCase } from '../use-case/use-case' 4 | import { CacheInvalidations } from '../runner/cache-invalidations' 5 | 6 | export function InvalidateCache(clazz: Class): void { 7 | const metadata: Class[] | undefined = Reflect.getMetadata('design:paramtypes', clazz) 8 | if (metadata !== undefined) { 9 | CacheInvalidations.set( 10 | clazz.prototype.key, 11 | metadata.filter(x => x.prototype instanceof UseCase).map(x => x.prototype.key) 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/arch/src/cache/invalidation-policy.ts: -------------------------------------------------------------------------------- 1 | export enum InvalidationPolicy { 2 | NO_CACHE, 3 | ALL 4 | } 5 | -------------------------------------------------------------------------------- /packages/arch/src/cache/lru-cache.ts: -------------------------------------------------------------------------------- 1 | import { Cache, CacheResult } from './cache' 2 | import lru, { Lru } from 'tiny-lru' 3 | import { CacheKey } from './cache-key' 4 | 5 | export class LruCache implements Cache { 6 | private _lru: Lru> = lru(100) 7 | 8 | get(key: CacheKey): CacheResult | undefined { 9 | return this._lru.get(key) 10 | } 11 | 12 | set(key: CacheKey, value: CacheResult) { 13 | this._lru.set(key, value) 14 | } 15 | 16 | delete(key: CacheKey) { 17 | this._lru.delete(key) 18 | } 19 | 20 | clear() { 21 | this._lru.clear() 22 | } 23 | 24 | has(key: CacheKey) { 25 | return this._lru.has(key) 26 | } 27 | 28 | create(): Cache { 29 | return new LruCache() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/arch/src/cache/use-case-key.ts: -------------------------------------------------------------------------------- 1 | import { Class } from '@archimedes/utils' 2 | 3 | export const USE_CASE_KEY = '__useCaseKey' 4 | 5 | export function UseCaseKey(key: string) { 6 | return (useCase: Class) => { 7 | const useCaseName = useCase.name 8 | if (process.env.NODE_ENV !== 'production' && useCaseName !== key) { 9 | // eslint-disable-next-line no-console 10 | console.error(`Provided use case key [${key}] is different than use case name [${useCaseName}]`) 11 | } 12 | 13 | useCase.prototype[USE_CASE_KEY] = key 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/arch/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Cache, CacheResult } from './cache/cache' 2 | export { InvalidationPolicy } from './cache/invalidation-policy' 3 | export { LruCache } from './cache/lru-cache' 4 | export { CacheManager } from './cache/cache-manager' 5 | export { UseCaseKey, USE_CASE_KEY } from './cache/use-case-key' 6 | export { InvalidateCache } from './cache/invalidate-cache' 7 | export { NotificationCenter } from './notifications/notification-center' 8 | export type { Notification } from './notifications/notification' 9 | export { Runner } from './runner/runner' 10 | export { Context } from './runner/context' 11 | export { CacheLink } from './runner/links/cache-link' 12 | export { NotificationLink } from './runner/links/notification-link' 13 | export type { Link } from './runner/links/link' 14 | export { BaseLink } from './runner/links/base-link' 15 | export { LoggerLink } from './runner/links/logger-link' 16 | export { ExecutorLink } from './runner/links/executor-link' 17 | export { EmptyLink } from './runner/links/empty-link' 18 | export { NullLink } from './runner/links/null-link' 19 | export { ChainError } from './runner/chain-error' 20 | export { CacheInvalidations } from './runner/cache-invalidations' 21 | export type { Logger } from './runner/logger' 22 | export { Command } from './use-case/command' 23 | export { Query } from './use-case/query' 24 | export { UseCase } from './use-case/use-case' 25 | export type { ExecutionOptions } from './use-case/execution-options' 26 | export { Archimedes } from './archimedes' 27 | -------------------------------------------------------------------------------- /packages/arch/src/notifications/notification-center-options.ts: -------------------------------------------------------------------------------- 1 | export interface NotificationCenterOptions { 2 | notificationTimeout: number 3 | } 4 | -------------------------------------------------------------------------------- /packages/arch/src/notifications/notification-center.spec.ts: -------------------------------------------------------------------------------- 1 | import { NotificationCenter } from './notification-center' 2 | import { Subject, Timer } from '@archimedes/utils' 3 | 4 | jest.mock('@archimedes/utils', () => { 5 | return { 6 | Subject: jest.fn(), 7 | Timer: { 8 | create: jest.fn() 9 | } 10 | } 11 | }) 12 | 13 | describe('NotificationCenter', () => { 14 | it('should create a notification', () => { 15 | jest.useFakeTimers() 16 | const notificationCenter = new NotificationCenter() 17 | 18 | notificationCenter.new({ message: 'foo' }) 19 | 20 | expect(notificationCenter.notifications).toEqual([{ message: 'foo' }]) 21 | }) 22 | 23 | it('should register a subscriber', () => { 24 | const notificationCenter = new NotificationCenter() 25 | 26 | notificationCenter.register({ update(_subject: Subject) {} }) 27 | 28 | expect(notificationCenter.observers).toHaveLength(1) 29 | }) 30 | 31 | it('should unregister a subscriber', () => { 32 | const notificationCenter = new NotificationCenter() 33 | const subscriber = { update(_subject: Subject) {} } 34 | notificationCenter.register(subscriber) 35 | 36 | notificationCenter.unregister(subscriber) 37 | 38 | expect(notificationCenter.observers).toHaveLength(0) 39 | }) 40 | 41 | it('should publish', () => { 42 | const notificationCenter = new NotificationCenter() 43 | let count = 0 44 | const subscriber = { 45 | update(_subject: Subject) { 46 | count++ 47 | } 48 | } 49 | notificationCenter.register(subscriber) 50 | 51 | notificationCenter.publish() 52 | 53 | expect(count).toBe(1) 54 | }) 55 | 56 | it('should set new timeout', () => { 57 | const notificationTimeout = 10_000 58 | const notificationCenter = new NotificationCenter({ notificationTimeout }) 59 | 60 | expect(notificationCenter.notificationCenterOptions.notificationTimeout).toBe(notificationTimeout) 61 | }) 62 | 63 | it('should use the timeout configured when setTimeout is called', () => { 64 | const notificationTimeout = 10_000 65 | const notificationCenter = new NotificationCenter({ notificationTimeout }) 66 | notificationCenter.new({ message: 'foo' }) 67 | 68 | expect(Timer.create).toHaveBeenCalledTimes(1) 69 | expect(Timer.create).toHaveBeenCalledWith(expect.any(Function), notificationTimeout) 70 | }) 71 | 72 | it('should pause the timeout', () => { 73 | const notificationCenter = new NotificationCenter() 74 | 75 | notificationCenter.new({ message: 'foo' }) 76 | notificationCenter.timer = { 77 | pause: jest.fn() 78 | } 79 | notificationCenter.pause() 80 | 81 | expect(notificationCenter.timer.pause).toHaveBeenCalledTimes(1) 82 | }) 83 | 84 | it('should resume the timeout', () => { 85 | const notificationCenter = new NotificationCenter() 86 | 87 | notificationCenter.new({ message: 'foo' }) 88 | notificationCenter.timer = { 89 | resume: jest.fn() 90 | } 91 | notificationCenter.resume() 92 | 93 | expect(notificationCenter.timer.resume).toHaveBeenCalledTimes(1) 94 | }) 95 | 96 | it('should remove the notification closed and publish the changes', () => { 97 | const notificationCenter = new NotificationCenter() 98 | const fooNotification = { message: 'foo' } 99 | const barNotification = { message: 'bar' } 100 | const bazNotification = { message: 'baz' } 101 | const publishSpy = jest.spyOn(notificationCenter, 'publish') 102 | 103 | notificationCenter.notifications = [fooNotification, barNotification, bazNotification] 104 | notificationCenter.close(1) 105 | 106 | expect(notificationCenter.notifications.length).toBe(2) 107 | expect(notificationCenter.notifications).toEqual([fooNotification, bazNotification]) 108 | expect(publishSpy).toHaveBeenCalledTimes(1) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /packages/arch/src/notifications/notification-center.ts: -------------------------------------------------------------------------------- 1 | import { Subject, Observer, Timer } from '@archimedes/utils' 2 | import { Notification } from './notification' 3 | import { NotificationCenterOptions } from './notification-center-options' 4 | 5 | export class NotificationCenter implements Subject { 6 | observers: Observer[] = [] 7 | notifications: Notification[] = [] 8 | notificationCenterOptions: NotificationCenterOptions 9 | timer?: Timer 10 | 11 | constructor(notificationOptions?: Partial) { 12 | this.notificationCenterOptions = { 13 | notificationTimeout: notificationOptions?.notificationTimeout ?? 5_000 14 | } 15 | } 16 | 17 | new(notification: Notification) { 18 | this.notifications.push(notification) 19 | this.publish() 20 | this.timer = Timer.create(() => { 21 | this.notifications.pop() 22 | this.publish() 23 | }, this.notificationCenterOptions.notificationTimeout) 24 | } 25 | 26 | publish() { 27 | this.observers.forEach(x => x.update(this)) 28 | } 29 | 30 | register(observer: Observer) { 31 | this.observers.push(observer) 32 | } 33 | 34 | unregister(observer: Observer) { 35 | this.observers = this.observers.filter(x => x !== observer) 36 | } 37 | 38 | pause() { 39 | this.timer?.pause() 40 | } 41 | 42 | resume() { 43 | this.timer?.resume() 44 | } 45 | 46 | close(index: number) { 47 | this.notifications.splice(index, 1) 48 | this.publish() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/arch/src/notifications/notification.ts: -------------------------------------------------------------------------------- 1 | export interface Notification { 2 | message: string 3 | } 4 | -------------------------------------------------------------------------------- /packages/arch/src/runner/cache-invalidations.spec.ts: -------------------------------------------------------------------------------- 1 | import { CacheInvalidations } from './cache-invalidations' 2 | 3 | describe('CacheInvalidations', () => { 4 | it('should set invalidations', () => { 5 | CacheInvalidations.set('foo', ['bar']) 6 | 7 | const actual = CacheInvalidations.invalidations 8 | 9 | expect(actual).toEqual(new Map([['foo', ['bar']]])) 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/arch/src/runner/cache-invalidations.ts: -------------------------------------------------------------------------------- 1 | import { InvalidationPolicy } from '../cache/invalidation-policy' 2 | import { CacheKey } from '../cache/cache-key' 3 | 4 | type Invalidation = InvalidationPolicy | CacheKey 5 | 6 | export class CacheInvalidations { 7 | private static readonly cacheInvalidations: Map = new Map() 8 | 9 | static set(cacheKey: CacheKey, invalidations: Invalidation[]) { 10 | this.cacheInvalidations.set(cacheKey, [...(this.cacheInvalidations.get(cacheKey) ?? []), ...invalidations]) 11 | } 12 | 13 | static get invalidations() { 14 | return this.cacheInvalidations 15 | } 16 | 17 | static clear() { 18 | this.cacheInvalidations.clear() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/arch/src/runner/chain-error.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedError } from '@archimedes/utils' 2 | 3 | export class ChainError extends ExtendedError { 4 | constructor() { 5 | super('The chain of responsibility has not been created. Use createChain first and then run the use case again.') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/arch/src/runner/context.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from '../use-case/use-case' 2 | import { ExecutionOptions } from '../use-case/execution-options' 3 | 4 | export class Context { 5 | result?: Promise 6 | 7 | private constructor( 8 | public useCase: UseCase, 9 | readonly executionOptions: Partial, 10 | readonly param?: unknown 11 | ) {} 12 | 13 | static create({ 14 | useCase, 15 | param, 16 | executionOptions 17 | }: { 18 | useCase: UseCase 19 | param?: unknown 20 | executionOptions: Partial 21 | }) { 22 | return new Context(useCase, executionOptions, param) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/base-link.ts: -------------------------------------------------------------------------------- 1 | import { EmptyLink } from './empty-link' 2 | import { Context } from '../context' 3 | import { Link } from './link' 4 | 5 | export abstract class BaseLink { 6 | nextLink: Link = new EmptyLink() 7 | 8 | setNext(link: Link): Link { 9 | this.nextLink = link 10 | return this 11 | } 12 | 13 | abstract next(context: Context): Promise 14 | } 15 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/cache-link.spec.ts: -------------------------------------------------------------------------------- 1 | import { CacheLink } from './cache-link' 2 | import { anything, instance, mock, verify, when } from 'ts-mockito' 3 | import { CacheManager } from '../../cache/cache-manager' 4 | import { Context } from '../context' 5 | import { UseCase } from '../../use-case/use-case' 6 | import { Link } from './link' 7 | import { CacheInvalidations } from '../cache-invalidations' 8 | import { InvalidationPolicy } from '../../cache/invalidation-policy' 9 | import { Command } from '../../use-case/command' 10 | 11 | describe('CacheLink', () => { 12 | it('should not use the cache when cache manager has not this execution registered', async () => { 13 | const { link, cacheManager, cacheLink } = setup() 14 | when(cacheManager.has(anything(), anything())).thenReturn(false) 15 | 16 | class MockUseCase extends UseCase { 17 | readonly = true 18 | 19 | async internalExecute(): Promise {} 20 | } 21 | 22 | const context = Context.create({ 23 | useCase: new MockUseCase(), 24 | param: undefined, 25 | executionOptions: { inlineError: false } 26 | }) 27 | cacheLink.setNext(instance(link)) 28 | 29 | await cacheLink.next(context) 30 | 31 | verify(link.next(anything())).once() 32 | }) 33 | 34 | it("should invalidate the cache when 'invalidateCache' flag is true", async () => { 35 | const { link, cacheManager, cacheLink } = setup() 36 | 37 | class MockUseCase extends UseCase { 38 | readonly = true 39 | 40 | async internalExecute(): Promise {} 41 | } 42 | 43 | const context = Context.create({ 44 | useCase: new MockUseCase(), 45 | param: undefined, 46 | executionOptions: { inlineError: false, invalidateCache: true } 47 | }) 48 | cacheLink.setNext(instance(link)) 49 | 50 | await cacheLink.next(context) 51 | 52 | verify(link.next(anything())).once() 53 | verify(cacheManager.invalidate(anything())).once() 54 | }) 55 | 56 | it('should break the link if it is cached', async () => { 57 | const { link, cacheManager, cacheLink } = setup() 58 | when(cacheManager.has(anything(), anything())).thenReturn(true) 59 | 60 | class MockUseCase extends UseCase { 61 | readonly = true 62 | 63 | async internalExecute(): Promise {} 64 | } 65 | 66 | const context = Context.create({ 67 | useCase: new MockUseCase(), 68 | param: undefined, 69 | executionOptions: { inlineError: false } 70 | }) 71 | cacheLink.setNext(instance(link)) 72 | 73 | await cacheLink.next(context) 74 | 75 | verify(link.next(anything())).never() 76 | }) 77 | 78 | it('should not cache commands', async () => { 79 | const { link, cacheManager, cacheLink } = setup() 80 | when(cacheManager.has(anything(), anything())).thenReturn(false) 81 | 82 | class MockUseCase extends Command { 83 | async internalExecute(): Promise {} 84 | } 85 | 86 | const context = Context.create({ 87 | useCase: new MockUseCase(), 88 | param: undefined, 89 | executionOptions: { inlineError: false } 90 | }) 91 | cacheLink.setNext(instance(link)) 92 | 93 | await cacheLink.next(context) 94 | 95 | verify(cacheManager.set(anything(), anything())).never() 96 | }) 97 | 98 | it('should invalidate using no cache policy', async () => { 99 | const { link, cacheManager, cacheLink } = setup() 100 | when(cacheManager.has(anything(), anything())).thenReturn(true) 101 | 102 | class MockUseCase extends UseCase { 103 | readonly = true 104 | 105 | async internalExecute(): Promise {} 106 | } 107 | 108 | CacheInvalidations.set(MockUseCase.name, [InvalidationPolicy.NO_CACHE]) 109 | const context = Context.create({ 110 | useCase: new MockUseCase(), 111 | param: undefined, 112 | executionOptions: { inlineError: false } 113 | }) 114 | cacheLink.setNext(instance(link)) 115 | 116 | await cacheLink.next(context) 117 | 118 | verify(cacheManager.invalidate(MockUseCase.name)).once() 119 | CacheInvalidations.clear() 120 | }) 121 | 122 | it('should invalidate using all cache policy', async () => { 123 | const { link, cacheManager, cacheLink } = setup() 124 | when(cacheManager.has(anything(), anything())).thenReturn(true) 125 | 126 | class MockUseCase extends UseCase { 127 | readonly = true 128 | 129 | async internalExecute(): Promise {} 130 | } 131 | 132 | CacheInvalidations.set(MockUseCase.name, [InvalidationPolicy.ALL]) 133 | const context = Context.create({ 134 | useCase: new MockUseCase(), 135 | param: undefined, 136 | executionOptions: { inlineError: false } 137 | }) 138 | cacheLink.setNext(instance(link)) 139 | 140 | await cacheLink.next(context) 141 | 142 | verify(cacheManager.invalidateAll()).once() 143 | CacheInvalidations.clear() 144 | }) 145 | 146 | it('should invalidate using all cache policy', async () => { 147 | const { link, cacheManager, cacheLink } = setup() 148 | when(cacheManager.has(anything(), anything())).thenReturn(true) 149 | 150 | class MockUseCase extends UseCase { 151 | readonly = true 152 | 153 | async internalExecute(): Promise {} 154 | } 155 | 156 | CacheInvalidations.set(MockUseCase.name, ['Foo']) 157 | CacheInvalidations.set('Foo', ['Bar']) 158 | const context = Context.create({ 159 | useCase: new MockUseCase(), 160 | param: undefined, 161 | executionOptions: { inlineError: false } 162 | }) 163 | cacheLink.setNext(instance(link)) 164 | 165 | await cacheLink.next(context) 166 | 167 | verify(cacheManager.invalidate('Foo')).once() 168 | verify(cacheManager.invalidate('Bar')).once() 169 | CacheInvalidations.clear() 170 | }) 171 | }) 172 | 173 | function setup() { 174 | const cacheManager = mock(CacheManager) 175 | const context = mock(Context) 176 | const link = mock() 177 | 178 | return { 179 | link, 180 | context, 181 | cacheManager, 182 | cacheLink: new CacheLink(instance(cacheManager)) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/cache-link.ts: -------------------------------------------------------------------------------- 1 | import { BaseLink } from './base-link' 2 | import { Context } from '../context' 3 | import { CacheManager } from '../../cache/cache-manager' 4 | import { InvalidationPolicy } from '../../cache/invalidation-policy' 5 | import { CacheKey } from '../../cache/cache-key' 6 | import { CacheInvalidations } from '../cache-invalidations' 7 | import { UseCase } from '../../use-case/use-case' 8 | 9 | export class CacheLink extends BaseLink { 10 | constructor(private readonly cacheManager: CacheManager) { 11 | super() 12 | } 13 | 14 | async next(context: Context): Promise { 15 | const asClass = context.useCase as unknown as UseCase 16 | const useCaseKey = asClass.key 17 | 18 | if (context.executionOptions.invalidateCache) { 19 | this.cacheManager.invalidate(useCaseKey) 20 | } 21 | 22 | if (!this.cacheManager.has(useCaseKey, [context.param])) { 23 | this.nextLink.next(context) 24 | } 25 | 26 | context.result = context.useCase.readonly 27 | ? (this.cacheManager.set(useCaseKey, () => context.result, context.param) as Promise) 28 | : context.result 29 | 30 | this.invalidateCache(useCaseKey) 31 | } 32 | 33 | private invalidateCache(cacheKey: CacheKey) { 34 | CacheInvalidations.invalidations.get(cacheKey)?.forEach(invalidation => { 35 | switch (invalidation) { 36 | case InvalidationPolicy.NO_CACHE: 37 | this.cacheManager.invalidate(cacheKey) 38 | break 39 | case InvalidationPolicy.ALL: 40 | this.cacheManager.invalidateAll() 41 | break 42 | default: 43 | this.cacheManager.invalidate(invalidation) 44 | if (CacheInvalidations.invalidations.has(invalidation)) { 45 | this.invalidateCache(invalidation) 46 | } 47 | } 48 | }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/empty-link.spec.ts: -------------------------------------------------------------------------------- 1 | import { EmptyLink } from './empty-link' 2 | import { instance, mock } from 'ts-mockito' 3 | import { Link } from './link' 4 | 5 | describe('EmptyLink', () => { 6 | it('should do nothing when setting link', () => { 7 | const emptyLink = new EmptyLink() 8 | const link = mock() 9 | 10 | const actual = emptyLink.setNext(instance(link)) 11 | 12 | expect(actual).toEqual(emptyLink) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/empty-link.ts: -------------------------------------------------------------------------------- 1 | import { Link } from './link' 2 | import { Context } from '../context' 3 | 4 | export class EmptyLink implements Link { 5 | setNext(_link: Link): Link { 6 | return this 7 | } 8 | 9 | async next(_context: Context) {} 10 | } 11 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/executor-link.spec.ts: -------------------------------------------------------------------------------- 1 | import { ExecutorLink } from './executor-link' 2 | import { anything, instance, mock, verify, when } from 'ts-mockito' 3 | import { Context } from '../context' 4 | import { UseCase } from '../../use-case/use-case' 5 | import { Link } from './link' 6 | 7 | describe('ExecutorLink', () => { 8 | it('should execute', async () => { 9 | const { context, nextLink, executorLink } = setup() 10 | const useCase = mock>() 11 | when(context.useCase).thenReturn(instance(useCase)) 12 | when(context.param).thenReturn(undefined) 13 | executorLink.setNext(instance(nextLink)) 14 | 15 | await executorLink.next(instance(context)) 16 | 17 | verify(useCase.internalExecute(anything())).once() 18 | }) 19 | 20 | it('should call next link', async () => { 21 | const { context, nextLink, executorLink } = setup() 22 | const useCase = mock>() 23 | when(context.useCase).thenReturn(instance(useCase)) 24 | when(context.param).thenReturn(undefined) 25 | executorLink.setNext(instance(nextLink)) 26 | 27 | await executorLink.next(instance(context)) 28 | 29 | verify(nextLink.next(anything())).once() 30 | }) 31 | }) 32 | 33 | function setup() { 34 | const context = mock(Context) 35 | const nextLink = mock() 36 | return { 37 | context, 38 | nextLink, 39 | executorLink: new ExecutorLink() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/executor-link.ts: -------------------------------------------------------------------------------- 1 | import { BaseLink } from './base-link' 2 | import { Context } from '../context' 3 | 4 | export class ExecutorLink extends BaseLink { 5 | async next(context: Context): Promise { 6 | context.result = context.useCase.internalExecute(context.param) 7 | this.nextLink.next(context) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/link.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../context' 2 | 3 | export interface Link { 4 | setNext(link: Link): Link 5 | next(context: Context): Promise 6 | } 7 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/logger-link.ts: -------------------------------------------------------------------------------- 1 | import { BaseLink } from './base-link' 2 | import { Context } from '../context' 3 | import { Logger } from '../logger' 4 | import { Datetime } from '@archimedes/utils' 5 | 6 | export class LoggerLink extends BaseLink { 7 | constructor(private readonly logger: Logger) { 8 | super() 9 | } 10 | 11 | async next(context: Context): Promise { 12 | context.result = context.result?.then(x => { 13 | this.logger.log( 14 | `[${Datetime.now().toIso()}] ${context.useCase.constructor.name} / Params: ${ 15 | context.param !== undefined ? this.formatObject(context.param) : '' 16 | } - Result: ${this.formatObject(x)}` 17 | ) 18 | return x 19 | }) 20 | this.nextLink.next(context) 21 | } 22 | 23 | private formatObject(object: unknown) { 24 | return JSON.stringify(object, null, 2) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/notification-link.spec.ts: -------------------------------------------------------------------------------- 1 | import { NotificationLink } from './notification-link' 2 | import { deepEqual, instance, mock, verify } from 'ts-mockito' 3 | import { NotificationCenter } from '../../notifications/notification-center' 4 | import { Context } from '../context' 5 | import { Link } from './link' 6 | import { UseCase } from '../../use-case/use-case' 7 | 8 | describe.skip('NotificationLink', () => { 9 | it('should create a new notification when there is an error', async () => { 10 | expect.assertions(1) 11 | const { notificationCenter, notificationLink, link } = setup() 12 | try { 13 | notificationLink.setNext(instance(link)) 14 | class MockUseCase extends UseCase { 15 | readonly = true 16 | internalExecute(): Promise { 17 | throw new Error('error') 18 | } 19 | } 20 | const mockUseCase = new MockUseCase() 21 | const context = Context.create({ 22 | useCase: mockUseCase, 23 | param: undefined, 24 | executionOptions: { inlineError: false } 25 | }) 26 | context.result = mockUseCase.internalExecute() 27 | 28 | await notificationLink.next(instance(context)) 29 | await context.result 30 | } catch (e) { 31 | expect(e).toEqual({ 32 | message: 'error' 33 | }) 34 | verify(notificationCenter.new(deepEqual({ message: 'error' }))).once() 35 | } 36 | }) 37 | 38 | it('should call next link', async () => { 39 | expect.assertions(1) 40 | const { notificationLink, link } = setup() 41 | notificationLink.setNext(instance(link)) 42 | class MockUseCase extends UseCase { 43 | readonly = true 44 | internalExecute(): Promise { 45 | throw new Error('error') 46 | } 47 | } 48 | 49 | const mockUseCase = new MockUseCase() 50 | const context = Context.create({ 51 | useCase: mockUseCase, 52 | param: undefined, 53 | executionOptions: { inlineError: false } 54 | }) 55 | 56 | context.result = mockUseCase.internalExecute() 57 | try { 58 | await notificationLink.next(instance(context)) 59 | await context.result 60 | } catch (e) { 61 | verify(link.next(deepEqual(context))).once() 62 | } 63 | }) 64 | 65 | it("should not create a new notification when there is an error an it's configured to inline the error", async () => { 66 | expect.assertions(1) 67 | const { notificationLink, link } = setup() 68 | notificationLink.setNext(instance(link)) 69 | class MockUseCase extends UseCase { 70 | readonly = true 71 | internalExecute(): Promise { 72 | throw new Error('error') 73 | } 74 | } 75 | 76 | const mockUseCase = new MockUseCase() 77 | const context = Context.create({ 78 | useCase: mockUseCase, 79 | param: undefined, 80 | executionOptions: { inlineError: true } 81 | }) 82 | 83 | context.result = mockUseCase.internalExecute() 84 | try { 85 | await notificationLink.next(instance(context)) 86 | await context.result 87 | } catch (e) { 88 | verify(link.next(deepEqual(context))).never() 89 | } 90 | }) 91 | }) 92 | 93 | function setup() { 94 | const notificationCenter = mock(NotificationCenter) 95 | const link = mock() 96 | 97 | return { 98 | link, 99 | notificationCenter, 100 | notificationLink: new NotificationLink(instance(notificationCenter)) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/notification-link.ts: -------------------------------------------------------------------------------- 1 | import { BaseLink } from './base-link' 2 | import { Context } from '../context' 3 | import { NotificationCenter } from '../../notifications/notification-center' 4 | 5 | export class NotificationLink extends BaseLink { 6 | constructor(private readonly notificationCenter: NotificationCenter) { 7 | super() 8 | } 9 | 10 | async next(context: Context): Promise { 11 | context.result = context.result?.catch(e => { 12 | if (!context.executionOptions.inlineError) { 13 | this.notificationCenter.new({ message: e.error?.message ?? 'Error' }) 14 | } 15 | console.error(e) 16 | throw e 17 | }) 18 | await this.nextLink.next(context) 19 | return 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/null-link.spec.ts: -------------------------------------------------------------------------------- 1 | import { NullLink } from './null-link' 2 | import { instance, mock } from 'ts-mockito' 3 | import { Context } from '../context' 4 | import { ChainError } from '../chain-error' 5 | 6 | describe('NullLink', () => { 7 | it('should throw chain error', async () => { 8 | const nullLink = new NullLink() 9 | const context = mock(Context) 10 | 11 | try { 12 | await nullLink.next(instance(context)) 13 | } catch (e) { 14 | expect(e).toEqual(new ChainError()) 15 | } 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/arch/src/runner/links/null-link.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../context' 2 | import { ChainError } from '../chain-error' 3 | import { BaseLink } from './base-link' 4 | 5 | export class NullLink extends BaseLink { 6 | async next(_context: Context) { 7 | throw new ChainError() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/arch/src/runner/logger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | log(message: T): void 3 | } 4 | -------------------------------------------------------------------------------- /packages/arch/src/runner/runner.spec.ts: -------------------------------------------------------------------------------- 1 | import { Runner } from './runner' 2 | import { Link } from './links/link' 3 | import { anything, instance, mock, verify } from 'ts-mockito' 4 | import { UseCase } from '../use-case/use-case' 5 | 6 | describe('Runner', () => { 7 | it('should run the runner', async () => { 8 | const link = mock() 9 | Runner.createChain([instance(link)]) 10 | const useCase = mock>() 11 | 12 | await Runner.run(instance(useCase), { inlineError: false }) 13 | 14 | verify(link.next(anything())).once() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /packages/arch/src/runner/runner.ts: -------------------------------------------------------------------------------- 1 | import { Context } from './context' 2 | import { UseCase } from '../use-case/use-case' 3 | import { Link } from './links/link' 4 | import { ExecutionOptions } from '../use-case/execution-options' 5 | import { NullLink } from './links/null-link' 6 | 7 | export class Runner { 8 | private static chain: Link = new NullLink() 9 | 10 | static async run( 11 | useCase: UseCase, 12 | executionOptions: ExecutionOptions, 13 | param?: unknown 14 | ): Promise { 15 | const context = Context.create({ useCase, param, executionOptions }) 16 | await this.chain.next(context) 17 | return context.result 18 | } 19 | 20 | static createChain(links: Link[]) { 21 | this.chain = links[0] ?? new NullLink() 22 | links.forEach((link, i) => { 23 | const isNotLastLink = i + 1 !== links.length 24 | if (isNotLastLink) { 25 | link.setNext(links[i + 1]) 26 | } 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/command.spec.ts: -------------------------------------------------------------------------------- 1 | import { Command } from './command' 2 | 3 | describe('Command', () => { 4 | it('should be an use case which is not readonly', () => { 5 | class MockCommand extends Command { 6 | async internalExecute(): Promise {} 7 | } 8 | const mockQry = new MockCommand() 9 | 10 | expect(mockQry.readonly).toBe(false) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/command.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from './use-case' 2 | 3 | export abstract class Command extends UseCase { 4 | readonly = false 5 | } 6 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/execution-options.ts: -------------------------------------------------------------------------------- 1 | export interface ExecutionOptions { 2 | inlineError: boolean 3 | invalidateCache: boolean 4 | } 5 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/query.spec.ts: -------------------------------------------------------------------------------- 1 | import { Query } from './query' 2 | 3 | describe('Query', () => { 4 | it('should be an use case which is readonly', () => { 5 | class MockQry extends Query { 6 | async internalExecute(): Promise {} 7 | } 8 | const mockQry = new MockQry() 9 | 10 | expect(mockQry.readonly).toBe(true) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/query.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from './use-case' 2 | 3 | export abstract class Query extends UseCase { 4 | readonly = true 5 | } 6 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/use-case.spec.ts: -------------------------------------------------------------------------------- 1 | import { UseCase } from './use-case' 2 | import { Runner } from '../runner/runner' 3 | import { ExecutionOptions } from './execution-options' 4 | 5 | jest.mock('../runner/runner') 6 | 7 | describe('UseCase', () => { 8 | it('should execute use case using runner', () => { 9 | class MockUseCase extends UseCase { 10 | readonly = false 11 | async internalExecute(value: number): Promise { 12 | return value 13 | } 14 | } 15 | const mockUseCase = new MockUseCase() 16 | 17 | mockUseCase.execute(42) 18 | 19 | expect(Runner.run).toBeCalledWith(mockUseCase, { inlineError: false, invalidateCache: false }, 42) 20 | }) 21 | 22 | it('should subscribe', async () => { 23 | class MockUseCase extends UseCase { 24 | readonly = false 25 | 26 | async internalExecute() {} 27 | } 28 | 29 | const mockUseCase = new MockUseCase() 30 | let called = false 31 | 32 | mockUseCase.subscribe(() => (called = true)) 33 | await mockUseCase.execute() 34 | 35 | expect(called).toBe(true) 36 | }) 37 | 38 | it('should be able to subscribe to multiple executions', async () => { 39 | class MockUseCase extends UseCase { 40 | readonly = false 41 | 42 | async internalExecute() {} 43 | } 44 | 45 | const mockUseCase = new MockUseCase() 46 | let calls = 0 47 | 48 | mockUseCase.subscribe(() => calls++) 49 | await mockUseCase.execute() 50 | await mockUseCase.execute() 51 | await mockUseCase.execute() 52 | 53 | expect(calls).toBe(3) 54 | }) 55 | 56 | it('should be able to unsubscribe', async () => { 57 | class MockUseCase extends UseCase { 58 | readonly = false 59 | 60 | async internalExecute() {} 61 | } 62 | 63 | const mockUseCase = new MockUseCase() 64 | let calls = 0 65 | 66 | const id = mockUseCase.subscribe(() => calls++) 67 | await mockUseCase.execute() 68 | mockUseCase.unsubscribe(id) 69 | await mockUseCase.execute() 70 | 71 | expect(calls).toBe(1) 72 | }) 73 | 74 | it('should be able execute with more than one executions options', async () => { 75 | class MockUseCase extends UseCase { 76 | readonly = false 77 | async internalExecute(): Promise {} 78 | } 79 | const mockUseCase = new MockUseCase() 80 | 81 | mockUseCase.execute(undefined, { foo: 'bar' } as ExecutionOptions & { foo: string }) 82 | 83 | expect(Runner.run).toBeCalledWith( 84 | mockUseCase, 85 | { inlineError: false, invalidateCache: false, foo: 'bar' }, 86 | undefined 87 | ) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /packages/arch/src/use-case/use-case.ts: -------------------------------------------------------------------------------- 1 | import { CacheKey } from '../cache/cache-key' 2 | import { USE_CASE_KEY } from '../cache/use-case-key' 3 | import { Runner } from '../runner/runner' 4 | import { ExecutionOptions } from './execution-options' 5 | 6 | type Fn = (params: { result: Result; param: Param; executionOptions: ExecutionOptions }) => void 7 | type Id = number 8 | 9 | export abstract class UseCase { 10 | abstract readonly: boolean 11 | abstract internalExecute(param: Param): Promise 12 | private static currentId = 0 13 | private subscriptions = new Map>() 14 | 15 | public get key(): CacheKey { 16 | return ( 17 | this.constructor.prototype?.[USE_CASE_KEY] ?? this.constructor.prototype?.[USE_CASE_KEY] ?? this.constructor.name 18 | ) 19 | } 20 | 21 | subscribe(fn: Fn): Id { 22 | const id = UseCase.currentId++ 23 | this.subscriptions.set(id, fn) 24 | return id 25 | } 26 | 27 | unsubscribe(id: Id) { 28 | this.subscriptions.delete(id) 29 | } 30 | 31 | async execute(param: Param, executionOptions?: Partial): Promise { 32 | const options: ExecutionOptions = { inlineError: false, invalidateCache: false, ...executionOptions } 33 | const value = (await Runner.run(this as any, options, param)) as Result 34 | this.subscriptions.forEach(x => x({ result: value, param, executionOptions: options })) 35 | return value 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/arch/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "references": [ 8 | { 9 | "path": "../utils" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /packages/components/.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.tsx'] 3 | } 4 | -------------------------------------------------------------------------------- /packages/components/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import '../src/material-theme.css' 2 | import '../src/theme.css' 3 | import { defineCustomElements } from '../dist/esm/loader' 4 | 5 | defineCustomElements() 6 | -------------------------------------------------------------------------------- /packages/components/README.md: -------------------------------------------------------------------------------- 1 | # `@archimedes/components` 2 | 3 | Refer to [the official documentation here](https://www.archimedesfw.io/docs/js/components). 4 | 5 | ## Installation 6 | 7 | `npm i @archimedes/components -SE` 8 | -------------------------------------------------------------------------------- /packages/components/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@archimedes/components", 3 | "version": "1.6.2", 4 | "description": "Archimedes components", 5 | "keywords": [ 6 | "stencil" 7 | ], 8 | "author": "<>", 9 | "source": "src/index.ts", 10 | "main": "dist/index.cjs.js", 11 | "module": "dist/index.js", 12 | "es2015": "dist/esm/index.mjs", 13 | "es2017": "dist/esm/index.mjs", 14 | "types": "dist/types/components.d.ts", 15 | "unpkg": "dist/my-project-name/my-project-name.esm.js", 16 | "collection:main": "dist/collection/index.js", 17 | "collection": "dist/collection/collection-manifest.json", 18 | "files": [ 19 | "dist/", 20 | "loader/" 21 | ], 22 | "license": "Apache-2.0", 23 | "directories": { 24 | "lib": "lib" 25 | }, 26 | "scripts": { 27 | "build": "stencil build", 28 | "start": "stencil build --dev --watch --serve", 29 | "compile:watch": "stencil build --watch", 30 | "test": "stencil test --spec --e2e", 31 | "test:watch": "stencil test --spec --e2e --watchAll", 32 | "storybook": "npm-run-all --parallel compile:watch storybook:watch", 33 | "storybook:watch": "start-storybook -p 6006", 34 | "storybook:build": "build-storybook" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "7.18.2", 38 | "@stencil/core": "2.16.1", 39 | "@storybook/html": "6.5.7", 40 | "@types/node": "17.0.41", 41 | "@types/puppeteer": "5.4.6", 42 | "babel-loader": "8.2.5", 43 | "jest-cli": "28.1.1", 44 | "npm-run-all": "4.1.5", 45 | "puppeteer": "14.3.0" 46 | }, 47 | "dependencies": { 48 | "@archimedes/utils": "2.1.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/activable-item/activable-item.css: -------------------------------------------------------------------------------- 1 | activable-item { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/activable-item/activable-item.e2e.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | import { KeyCodes } from '../../../utils/keycodes' 3 | 4 | describe('activable-item', () => { 5 | it('renders', async () => { 6 | const page = await newE2EPage() 7 | await page.setContent('') 8 | 9 | const element = await page.find('activable-item') 10 | 11 | expect(element).toHaveClass('hydrated') 12 | }) 13 | 14 | it('should be activated on pressing space', async () => { 15 | const page = await newE2EPage() 16 | await page.setContent('') 17 | const element = await page.find('activable-item') 18 | const spyEvent = await element.spyOnEvent('activatedItem') 19 | element.tabIndex = -1 20 | await page.waitForChanges() 21 | 22 | await element.press(KeyCodes.SPACE) 23 | 24 | expect(spyEvent).toHaveReceivedEventTimes(1) 25 | }) 26 | 27 | it('should be activated on pressing enter', async () => { 28 | const page = await newE2EPage() 29 | await page.setContent('') 30 | const element = await page.find('activable-item') 31 | const spyEvent = await element.spyOnEvent('activatedItem') 32 | element.tabIndex = -1 33 | await page.waitForChanges() 34 | 35 | await element.press(KeyCodes.RETURN) 36 | 37 | expect(spyEvent).toHaveReceivedEventTimes(1) 38 | }) 39 | 40 | it('should NOT be activated on pressing space', async () => { 41 | const page = await newE2EPage() 42 | await page.setContent('') 43 | const element = await page.find('activable-item') 44 | const spyEvent = await element.spyOnEvent('activatedItem') 45 | element.tabIndex = -1 46 | await page.waitForChanges() 47 | 48 | await element.press(KeyCodes.SPACE) 49 | await element.press(KeyCodes.RETURN) 50 | 51 | expect(spyEvent).toHaveReceivedEventTimes(1) 52 | }) 53 | 54 | it('should NOT be activated on pressing enter', async () => { 55 | const page = await newE2EPage() 56 | await page.setContent('') 57 | const element = await page.find('activable-item') 58 | const spyEvent = await element.spyOnEvent('activatedItem') 59 | element.tabIndex = -1 60 | await page.waitForChanges() 61 | 62 | await element.press(KeyCodes.SPACE) 63 | await element.press(KeyCodes.RETURN) 64 | 65 | expect(spyEvent).toHaveReceivedEventTimes(1) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/activable-item/activable-item.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Host, Prop, Event, EventEmitter, Listen } from '@stencil/core' 2 | import { KeyCodes } from '../../../utils/keycodes' 3 | import { ItemPosition } from '../../../utils/position' 4 | 5 | @Component({ 6 | tag: 'activable-item', 7 | styleUrl: 'activable-item.css', 8 | shadow: true 9 | }) 10 | export class ActivableItem { 11 | @Prop() position: ItemPosition | undefined = undefined 12 | @Prop() space: boolean = true 13 | @Prop() enter: boolean = true 14 | 15 | @Event() activatedItem!: EventEmitter 16 | 17 | @Listen('click') 18 | protected clickHandler() { 19 | this.activatedItem.emit(this.position) 20 | } 21 | 22 | @Listen('keyup') 23 | protected keyupHandler(event: KeyboardEvent) { 24 | function activate(event: KeyboardEvent, emitter: EventEmitter, position?: ItemPosition) { 25 | event.preventDefault() 26 | event.stopPropagation() 27 | emitter.emit(position) 28 | } 29 | 30 | if (this.space && event.key === KeyCodes.SPACE) activate(event, this.activatedItem, this.position) 31 | else if (this.enter && event.key === KeyCodes.RETURN) activate(event, this.activatedItem, this.position) 32 | } 33 | 34 | render() { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/activable-item/activated-item-handler.ts: -------------------------------------------------------------------------------- 1 | import { ItemPosition } from '../../../utils/position' 2 | 3 | export interface ActivatedItemHandler { 4 | notifyActivation(position: ItemPosition): void 5 | } 6 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/activable-item/activated-item-listener.e2e.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | import { KeyCodes } from '../../../utils/keycodes' 3 | 4 | describe('activated-item-listener', () => { 5 | it('renders', async () => { 6 | const page = await newE2EPage() 7 | await page.setContent('') 8 | 9 | const element = await page.find('activated-item-listener') 10 | 11 | expect(element).toHaveClass('hydrated') 12 | }) 13 | 14 | it('should notify item activation', async () => { 15 | const mockNotifyActivation = jest.fn().mockReturnValue(undefined) 16 | const page = await newE2EPage() 17 | await page.setContent('') 18 | await page.exposeFunction('notifyActivation', mockNotifyActivation) 19 | const activable = await page.find('activable-item') 20 | activable.tabIndex = -1 21 | await page.$eval('activated-item-listener', (element: any) => { 22 | // @ts-ignore 23 | element.handler = { notifyActivation: this.notifyActivation } 24 | }) 25 | await page.waitForChanges() 26 | 27 | await activable.press(KeyCodes.SPACE) 28 | 29 | expect(mockNotifyActivation).toHaveBeenCalled() 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/activable-item/activated-item-listener.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Prop, Host, Listen } from '@stencil/core' 2 | import { ItemPosition } from '../../../utils/position' 3 | import { ActivatedItemHandler } from './activated-item-handler' 4 | 5 | @Component({ 6 | tag: 'activated-item-listener', 7 | shadow: true 8 | }) 9 | export class ActivatedItemListener { 10 | @Prop() 11 | handler!: ActivatedItemHandler 12 | 13 | @Listen('activatedItem') 14 | protected activatedItemHandler(event: CustomEvent) { 15 | event.stopPropagation() 16 | this.handler.notifyActivation(event.detail) 17 | } 18 | 19 | render() { 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/focusable-item/focusable-item.css: -------------------------------------------------------------------------------- 1 | focusable-item { 2 | width: 100%; 3 | height: 100%; 4 | outline: thick; 5 | } 6 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/focusable-item/focusable-item.e2e.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | import { ItemPosition } from '../../../utils/position' 3 | 4 | describe('focusable-item', () => { 5 | it('should render out of tab sequence', async () => { 6 | const page = await newE2EPage() 7 | await page.setContent('') 8 | 9 | const element = await page.find('focusable-item') 10 | 11 | expect(element).toHaveClass('hydrated') 12 | expect(element.tabIndex).toBe(-1) 13 | }) 14 | 15 | it('should render into the tab sequence', async () => { 16 | const page = await newE2EPage() 17 | await page.setContent('') 18 | const element = await page.find('focusable-item') 19 | 20 | element.setProperty('isInTabSequence', true) 21 | await page.waitForChanges() 22 | 23 | expect(element).toHaveClass('hydrated') 24 | expect(element.tabIndex).toBe(0) 25 | }) 26 | 27 | it('should notify focused', async () => { 28 | const page = await newE2EPage() 29 | const position: ItemPosition = { x: 3, y: 1 } 30 | await page.setContent('') 31 | const element = await page.find('focusable-item') 32 | element.setProperty('position', position) 33 | await page.waitForChanges() 34 | const spyEvent = await element.spyOnEvent('focusedItem') 35 | 36 | await element.focus() 37 | 38 | expect(spyEvent).toHaveReceivedEventDetail(position) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/focusable-item/focusable-item.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Prop, Event, EventEmitter, Host, Listen } from '@stencil/core' 2 | import { ItemPosition } from '../../../utils/position' 3 | 4 | @Component({ 5 | tag: 'focusable-item', 6 | styleUrl: 'focusable-item.css', 7 | shadow: true 8 | }) 9 | export class FocusableItem { 10 | @Prop() isInTabSequence: boolean = false 11 | @Prop() position!: ItemPosition 12 | @Event() focusedItem!: EventEmitter 13 | 14 | @Listen('focus') 15 | protected focusHandler() { 16 | this.focusedItem.emit(this.position) 17 | } 18 | 19 | render() { 20 | return ( 21 | 22 | 23 | 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/focusable-item/focused-item-handler.ts: -------------------------------------------------------------------------------- 1 | import { ItemPosition } from '../../../utils/position' 2 | 3 | export interface FocusedItemHandler { 4 | notifyFocusedItem(position: ItemPosition): void 5 | } 6 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/focusable-item/focused-item-listener.e2e.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | import { ItemPosition } from '../../../utils/position' 3 | 4 | describe('focused-item-listener', () => { 5 | it('renders', async () => { 6 | const page = await newE2EPage() 7 | await page.setContent('') 8 | 9 | const element = await page.find('focused-item-listener') 10 | 11 | expect(element).toHaveClass('hydrated') 12 | }) 13 | 14 | it('should notify to handler when listens a focusedItem event', async () => { 15 | const mockNotify = jest.fn() 16 | const position: ItemPosition = { x: 3, y: 1 } 17 | const page = await newE2EPage() 18 | await page.setContent('') 19 | await page.exposeFunction('notifyFocusedItem', mockNotify) 20 | const focusable = await page.find('focusable-item') 21 | 22 | await focusable.setProperty('position', position) 23 | /* await listener.setProperty("handler", handler); 24 | * It doesn't work because Stencil Testing setProperty removes every function inside the object. 25 | */ 26 | await page.$eval('focused-item-listener', (element: any) => { 27 | // @ts-ignore 28 | element.handler = { notifyFocusedItem: this.notifyFocusedItem } 29 | }) 30 | await page.waitForChanges() 31 | await focusable.focus() 32 | 33 | expect(mockNotify).toHaveBeenCalledWith(position) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/focusable-item/focused-item-listener.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Host, Prop, Listen } from '@stencil/core' 2 | import { ItemPosition } from '../../../utils/position' 3 | import { FocusedItemHandler } from './focused-item-handler' 4 | 5 | @Component({ 6 | tag: 'focused-item-listener', 7 | shadow: true 8 | }) 9 | export class FocusedItemListener { 10 | @Prop() handler!: FocusedItemHandler 11 | 12 | @Listen('focusedItem') 13 | protected focusedItemHandler(event: CustomEvent) { 14 | event.stopPropagation() 15 | this.handler.notifyFocusedItem(event.detail) 16 | } 17 | 18 | render() { 19 | return ( 20 | 21 | 22 | 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/keyboard-navigable/keyboard-navigable.css: -------------------------------------------------------------------------------- 1 | keyboard-navigable { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/keyboard-navigable/keyboard-navigable.e2e.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | import { KeyCodes } from '../../../utils/keycodes' 3 | import { KeyboardNavigationAction } from './keyboard-navigable' 4 | 5 | describe('keyboard-navigable', () => { 6 | it('should renders', async () => { 7 | const page = await newE2EPage() 8 | 9 | await page.setContent('') 10 | const element = await page.find('keyboard-navigable') 11 | expect(element).toHaveClass('hydrated') 12 | }) 13 | 14 | it('should notify up arrow pressed', async () => { 15 | const page = await newE2EPage() 16 | 17 | await page.setContent('') 18 | const element = await page.find('keyboard-navigable') 19 | const action: KeyboardNavigationAction = { 20 | key: KeyCodes.UP, 21 | shift: false, 22 | ctrl: false, 23 | alt: false, 24 | meta: false 25 | } 26 | const spyEvent = await element.spyOnEvent('keyboardNavigation') 27 | element.tabIndex = -1 28 | await page.waitForChanges() 29 | await element.press(KeyCodes.UP) 30 | 31 | expect(spyEvent).toHaveReceivedEventTimes(1) 32 | expect(spyEvent).toHaveReceivedEventDetail(action) 33 | }) 34 | 35 | it('should notify down arrow pressed', async () => { 36 | const page = await newE2EPage() 37 | 38 | await page.setContent('') 39 | const element = await page.find('keyboard-navigable') 40 | const action: KeyboardNavigationAction = { 41 | key: KeyCodes.DOWN, 42 | shift: false, 43 | ctrl: false, 44 | alt: false, 45 | meta: false 46 | } 47 | const spyEvent = await element.spyOnEvent('keyboardNavigation') 48 | element.tabIndex = -1 49 | await page.waitForChanges() 50 | await element.press(KeyCodes.DOWN) 51 | 52 | expect(spyEvent).toHaveReceivedEventTimes(1) 53 | expect(spyEvent).toHaveReceivedEventDetail(action) 54 | }) 55 | 56 | it('should notify left arrow pressed', async () => { 57 | const page = await newE2EPage() 58 | 59 | await page.setContent('') 60 | const element = await page.find('keyboard-navigable') 61 | const action: KeyboardNavigationAction = { 62 | key: KeyCodes.LEFT, 63 | shift: false, 64 | ctrl: false, 65 | alt: false, 66 | meta: false 67 | } 68 | const spyEvent = await element.spyOnEvent('keyboardNavigation') 69 | element.tabIndex = -1 70 | await page.waitForChanges() 71 | await element.press(KeyCodes.LEFT) 72 | 73 | expect(spyEvent).toHaveReceivedEventTimes(1) 74 | expect(spyEvent).toHaveReceivedEventDetail(action) 75 | }) 76 | 77 | it('should notify right arrow pressed', async () => { 78 | const page = await newE2EPage() 79 | 80 | await page.setContent('') 81 | const element = await page.find('keyboard-navigable') 82 | const action: KeyboardNavigationAction = { 83 | key: KeyCodes.RIGHT, 84 | shift: false, 85 | ctrl: false, 86 | alt: false, 87 | meta: false 88 | } 89 | const spyEvent = await element.spyOnEvent('keyboardNavigation') 90 | element.tabIndex = -1 91 | await page.waitForChanges() 92 | await element.press(KeyCodes.RIGHT) 93 | 94 | expect(spyEvent).toHaveReceivedEventTimes(1) 95 | expect(spyEvent).toHaveReceivedEventDetail(action) 96 | }) 97 | 98 | it('should notify home pressed', async () => { 99 | const page = await newE2EPage() 100 | 101 | await page.setContent('') 102 | const element = await page.find('keyboard-navigable') 103 | const action: KeyboardNavigationAction = { 104 | key: KeyCodes.HOME, 105 | shift: false, 106 | ctrl: false, 107 | alt: false, 108 | meta: false 109 | } 110 | const spyEvent = await element.spyOnEvent('keyboardNavigation') 111 | element.tabIndex = -1 112 | await page.waitForChanges() 113 | await element.press(KeyCodes.HOME) 114 | 115 | expect(spyEvent).toHaveReceivedEventTimes(1) 116 | expect(spyEvent).toHaveReceivedEventDetail(action) 117 | }) 118 | 119 | it('should notify end pressed', async () => { 120 | const page = await newE2EPage() 121 | 122 | await page.setContent('') 123 | const element = await page.find('keyboard-navigable') 124 | const action: KeyboardNavigationAction = { 125 | key: KeyCodes.END, 126 | shift: false, 127 | ctrl: false, 128 | alt: false, 129 | meta: false 130 | } 131 | const spyEvent = await element.spyOnEvent('keyboardNavigation') 132 | element.tabIndex = -1 133 | await page.waitForChanges() 134 | await element.press(KeyCodes.END) 135 | 136 | expect(spyEvent).toHaveReceivedEventTimes(1) 137 | expect(spyEvent).toHaveReceivedEventDetail(action) 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/keyboard-navigable/keyboard-navigable.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Host, Event, EventEmitter, Listen } from '@stencil/core' 2 | import { KeyCodes } from '../../../utils/keycodes' 3 | 4 | export interface KeyboardNavigationAction { 5 | key: string 6 | shift: boolean 7 | ctrl: boolean 8 | alt: boolean 9 | meta: boolean 10 | } 11 | 12 | @Component({ 13 | tag: 'keyboard-navigable', 14 | styleUrl: 'keyboard-navigable.css', 15 | shadow: true 16 | }) 17 | export class KeyboardNavigable { 18 | @Event() keyboardNavigation!: EventEmitter 19 | 20 | @Listen('keyup') 21 | protected keyupHandler(event: KeyboardEvent) { 22 | const action: KeyboardNavigationAction = { 23 | key: '', 24 | shift: event.shiftKey, 25 | ctrl: event.ctrlKey, 26 | alt: event.altKey, 27 | meta: event.metaKey 28 | } 29 | switch (event.key) { 30 | case KeyCodes.UP: 31 | event.preventDefault() 32 | event.stopPropagation() 33 | action.key = KeyCodes.UP 34 | this.keyboardNavigation.emit(action) 35 | break 36 | case KeyCodes.DOWN: 37 | event.preventDefault() 38 | event.stopPropagation() 39 | action.key = KeyCodes.DOWN 40 | this.keyboardNavigation.emit(action) 41 | break 42 | case KeyCodes.LEFT: 43 | event.preventDefault() 44 | event.stopPropagation() 45 | action.key = KeyCodes.LEFT 46 | this.keyboardNavigation.emit(action) 47 | break 48 | case KeyCodes.RIGHT: 49 | event.preventDefault() 50 | event.stopPropagation() 51 | action.key = KeyCodes.RIGHT 52 | this.keyboardNavigation.emit(action) 53 | break 54 | case KeyCodes.HOME: 55 | event.preventDefault() 56 | event.stopPropagation() 57 | action.key = KeyCodes.HOME 58 | this.keyboardNavigation.emit(action) 59 | break 60 | case KeyCodes.END: 61 | event.preventDefault() 62 | event.stopPropagation() 63 | action.key = KeyCodes.END 64 | this.keyboardNavigation.emit(action) 65 | break 66 | } 67 | } 68 | 69 | render() { 70 | return ( 71 | 72 | 73 | 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/keyboard-navigable/keyboard-navigation-handler.ts: -------------------------------------------------------------------------------- 1 | import { KeyboardNavigationAction } from './keyboard-navigable' 2 | 3 | export interface KeyboardNavigationHandler { 4 | getLeftItem(action: KeyboardNavigationAction): HTMLElement | undefined 5 | getRightItem(action: KeyboardNavigationAction): HTMLElement | undefined 6 | getUpItem(action: KeyboardNavigationAction): HTMLElement | undefined 7 | getDownItem(action: KeyboardNavigationAction): HTMLElement | undefined 8 | getFirstItem(action: KeyboardNavigationAction): HTMLElement | undefined 9 | getLastItem(action: KeyboardNavigationAction): HTMLElement | undefined 10 | } 11 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/keyboard-navigable/keyboard-navigation-listener.e2e.ts: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | import { KeyCodes } from '../../../utils/keycodes' 3 | 4 | describe('keyboard-navigation-listener', () => { 5 | it('should render', async () => { 6 | const page = await newE2EPage() 7 | await page.setContent('') 8 | 9 | const element = await page.find('keyboard-navigation-listener') 10 | 11 | expect(element).toHaveClass('hydrated') 12 | }) 13 | 14 | it('should ask for the up item', async () => { 15 | const mockGetItem = jest.fn().mockReturnValue(undefined) 16 | const page = await newE2EPage() 17 | await page.setContent( 18 | '' 19 | ) 20 | await page.exposeFunction('getItem', mockGetItem) 21 | const navigable = await page.find('keyboard-navigable') 22 | navigable.tabIndex = -1 23 | await page.$eval('keyboard-navigation-listener', (element: any) => { 24 | // @ts-ignore 25 | element.handler = { getUpItem: this.getItem } 26 | }) 27 | await page.waitForChanges() 28 | 29 | await navigable.press(KeyCodes.UP) 30 | 31 | expect(mockGetItem).toHaveBeenCalled() 32 | }) 33 | 34 | it('should ask for the down item', async () => { 35 | const mockGetItem = jest.fn().mockReturnValue(undefined) 36 | const page = await newE2EPage() 37 | await page.setContent( 38 | '' 39 | ) 40 | await page.exposeFunction('getItem', mockGetItem) 41 | const navigable = await page.find('keyboard-navigable') 42 | navigable.tabIndex = -1 43 | await page.$eval('keyboard-navigation-listener', (element: any) => { 44 | // @ts-ignore 45 | element.handler = { getDownItem: this.getItem } 46 | }) 47 | await page.waitForChanges() 48 | 49 | await navigable.press(KeyCodes.DOWN) 50 | 51 | expect(mockGetItem).toHaveBeenCalled() 52 | }) 53 | 54 | it('should ask for the left item', async () => { 55 | const mockGetItem = jest.fn().mockReturnValue(undefined) 56 | const page = await newE2EPage() 57 | await page.setContent( 58 | '' 59 | ) 60 | await page.exposeFunction('getItem', mockGetItem) 61 | const navigable = await page.find('keyboard-navigable') 62 | navigable.tabIndex = -1 63 | await page.$eval('keyboard-navigation-listener', (element: any) => { 64 | // @ts-ignore 65 | element.handler = { getLeftItem: this.getItem } 66 | }) 67 | await page.waitForChanges() 68 | 69 | await navigable.press(KeyCodes.LEFT) 70 | 71 | expect(mockGetItem).toHaveBeenCalled() 72 | }) 73 | 74 | it('should ask for the right item', async () => { 75 | const mockGetItem = jest.fn().mockReturnValue(undefined) 76 | const page = await newE2EPage() 77 | await page.setContent( 78 | '' 79 | ) 80 | await page.exposeFunction('getItem', mockGetItem) 81 | const navigable = await page.find('keyboard-navigable') 82 | navigable.tabIndex = -1 83 | await page.$eval('keyboard-navigation-listener', (element: any) => { 84 | // @ts-ignore 85 | element.handler = { getRightItem: this.getItem } 86 | }) 87 | await page.waitForChanges() 88 | 89 | await navigable.press(KeyCodes.RIGHT) 90 | 91 | expect(mockGetItem).toHaveBeenCalled() 92 | }) 93 | 94 | it('should ask for the first item', async () => { 95 | const mockGetItem = jest.fn().mockReturnValue(undefined) 96 | const page = await newE2EPage() 97 | await page.setContent( 98 | '' 99 | ) 100 | await page.exposeFunction('getItem', mockGetItem) 101 | const navigable = await page.find('keyboard-navigable') 102 | navigable.tabIndex = -1 103 | await page.$eval('keyboard-navigation-listener', (element: any) => { 104 | // @ts-ignore 105 | element.handler = { getFirstItem: this.getItem } 106 | }) 107 | await page.waitForChanges() 108 | 109 | await navigable.press(KeyCodes.HOME) 110 | 111 | expect(mockGetItem).toHaveBeenCalled() 112 | }) 113 | 114 | it('should ask for the last item', async () => { 115 | const mockGetItem = jest.fn().mockReturnValue(undefined) 116 | const page = await newE2EPage() 117 | await page.setContent( 118 | '' 119 | ) 120 | await page.exposeFunction('getItem', mockGetItem) 121 | const navigable = await page.find('keyboard-navigable') 122 | navigable.tabIndex = -1 123 | await page.$eval('keyboard-navigation-listener', (element: any) => { 124 | // @ts-ignore 125 | element.handler = { getLastItem: this.getItem } 126 | }) 127 | await page.waitForChanges() 128 | 129 | await navigable.press(KeyCodes.END) 130 | 131 | expect(mockGetItem).toHaveBeenCalled() 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /packages/components/src/components/behavioural-components/keyboard-navigable/keyboard-navigation-listener.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Prop, Host, Listen } from '@stencil/core' 2 | import { KeyboardNavigationHandler } from './keyboard-navigation-handler' 3 | import { KeyboardNavigationAction } from './keyboard-navigable' 4 | import { KeyCodes } from '../../../utils/keycodes' 5 | 6 | @Component({ 7 | tag: 'keyboard-navigation-listener', 8 | shadow: true 9 | }) 10 | export class KeyboardNavigationListener { 11 | @Prop() handler!: KeyboardNavigationHandler 12 | 13 | @Listen('keyboardNavigation') 14 | protected navigationHandler(event: CustomEvent) { 15 | event?.stopPropagation() 16 | switch (event.detail.key) { 17 | case KeyCodes.UP: 18 | this.focus(this.handler.getUpItem(event.detail)) 19 | break 20 | case KeyCodes.DOWN: 21 | this.focus(this.handler.getDownItem(event.detail)) 22 | break 23 | case KeyCodes.LEFT: 24 | this.focus(this.handler.getLeftItem(event.detail)) 25 | break 26 | case KeyCodes.RIGHT: 27 | this.focus(this.handler.getRightItem(event.detail)) 28 | break 29 | case KeyCodes.HOME: 30 | this.focus(this.handler.getFirstItem(event.detail)) 31 | break 32 | case KeyCodes.END: 33 | this.focus(this.handler.getLastItem(event.detail)) 34 | break 35 | } 36 | } 37 | 38 | private focus = (element: HTMLElement | undefined): void => { 39 | if (element instanceof HTMLElement) element.focus() 40 | } 41 | 42 | render() { 43 | return ( 44 | 45 | 46 | 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/components/src/components/button/button.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .button { 6 | border: none; 7 | box-shadow: none; 8 | background: transparent; 9 | border-radius: var(--border-radius); 10 | padding: var(--s) var(--m); 11 | cursor: pointer; 12 | 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | .primary { 18 | background-color: var(--primary-color); 19 | color: var(--on-primary-color); 20 | } 21 | 22 | .secondary { 23 | background-color: var(--secondary-color); 24 | color: var(--on-secondary-color); 25 | } 26 | -------------------------------------------------------------------------------- /packages/components/src/components/button/button.e2e.tsx: -------------------------------------------------------------------------------- 1 | import { newE2EPage } from '@stencil/core/testing' 2 | 3 | describe('Button', () => { 4 | it('should add primary theme', async () => { 5 | const page = await newE2EPage() 6 | await page.setContent('') 7 | await page.waitForChanges() 8 | const button = await page.find('arch-button >>> button') 9 | 10 | expect(button).toHaveClass('primary') 11 | }) 12 | 13 | it('should add secondary theme', async () => { 14 | const page = await newE2EPage() 15 | await page.setContent('') 16 | const element = await page.find('arch-button') 17 | element.setProperty('theme', 'secondary') 18 | await page.waitForChanges() 19 | const button = await page.find('arch-button >>> button') 20 | 21 | expect(button).toHaveClass('secondary') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/components/src/components/button/button.stories.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Button' 3 | } 4 | 5 | export const Base = () => { 6 | return `Custom Button` 7 | } 8 | -------------------------------------------------------------------------------- /packages/components/src/components/button/button.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Event, EventEmitter, h, Prop } from '@stencil/core' 2 | import { Theme } from '../../utils/theme' 3 | 4 | @Component({ 5 | tag: 'arch-button', 6 | styleUrl: 'button.css', 7 | shadow: true 8 | }) 9 | export class Button { 10 | @Prop() 11 | type: 'button' | 'submit' = 'button' 12 | 13 | @Prop() 14 | disabled = false 15 | 16 | @Prop() 17 | theme: Theme = 'primary' 18 | 19 | @Event() 20 | clicked!: EventEmitter 21 | 22 | handleClick(event: MouseEvent) { 23 | this.clicked.emit(event) 24 | } 25 | 26 | render() { 27 | return ( 28 | 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/components/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Swiper } from './swiper/swiper' 2 | export { FocusableItem } from './behavioural-components/focusable-item/focusable-item' 3 | export { FocusedItemListener } from './behavioural-components/focusable-item/focused-item-listener' 4 | export { FocusedItemHandler } from './behavioural-components/focusable-item/focused-item-handler' 5 | 6 | export { ActivableItem } from './behavioural-components/activable-item/activable-item' 7 | export { ActivatedItemListener } from './behavioural-components/activable-item/activated-item-listener' 8 | export { ActivatedItemHandler } from './behavioural-components/activable-item/activated-item-handler' 9 | 10 | export { KeyboardNavigable } from './behavioural-components/keyboard-navigable/keyboard-navigable' 11 | export { KeyboardNavigationAction } from './behavioural-components/keyboard-navigable/keyboard-navigable' 12 | export { KeyboardNavigationListener } from './behavioural-components/keyboard-navigable/keyboard-navigation-listener' 13 | export { KeyboardNavigationHandler } from './behavioural-components/keyboard-navigable/keyboard-navigation-handler' 14 | -------------------------------------------------------------------------------- /packages/components/src/components/swiper/refresh.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/refresh@2x 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/components/src/components/swiper/swiper.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | position: relative; 4 | } 5 | 6 | .swiper { 7 | display: flex; 8 | position: absolute; 9 | top: var(--swiper-top, var(--m)); 10 | left: 50%; 11 | transform: translateX(-50%); 12 | justify-content: center; 13 | align-items: center; 14 | padding: var(--s) 0; 15 | padding-bottom: 0; 16 | } 17 | 18 | .icon { 19 | height: 30px; 20 | width: 30px; 21 | animation: rotate 1s infinite; 22 | } 23 | 24 | .wrapper { 25 | height: 100%; 26 | } 27 | 28 | .content { 29 | opacity: 1; 30 | } 31 | 32 | .veil { 33 | opacity: 0.5; 34 | } 35 | 36 | ::slotted(*) { 37 | height: 100%; 38 | } 39 | 40 | @keyframes rotate { 41 | 0% { 42 | transform: rotate(0deg); 43 | } 44 | 45 | 100% { 46 | transform: rotate(360deg); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/components/src/components/swiper/swiper.stories.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Swiper' 3 | } 4 | 5 | export const Base = () => { 6 | return `
7 |

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Corporis debitis doloremque dolorum eaque expedita explicabo illo ipsum labore laborum modi, mollitia non reiciendis sapiente sunt unde ut velit voluptate voluptates.Ipsum perspiciatis quibusdam repudiandae voluptatum? Aliquam autem corporis cum est eveniet ex excepturi, ipsum iure labore minima nemo numquam officia officiis perspiciatis, porro provident sint sit temporibus totam, ut voluptatum.Aspernatur consequatur dicta earum est exercitationem inventore iusto, maiores modi obcaecati, placeat quae quam unde voluptatem. Cum eaque eligendi eum, incidunt magni minima minus non odit placeat quas quasi, tempore.Accusantium assumenda atque aut beatae, consequatur cum distinctio dolor eaque eius explicabo harum ipsum natus numquam odit pariatur quo, quod sapiente sint soluta tempore vel veritatis vero, voluptas voluptatibus voluptatum.Aliquid, officiis, provident! Autem commodi ea illo magnam molestiae mollitia possimus, quasi quibusdam rem, sed vero voluptas! Alias culpa deserunt dolorem, earum enim facere iste iure, minima, minus quisquam similique.

8 |
` 9 | } 10 | -------------------------------------------------------------------------------- /packages/components/src/components/swiper/swiper.tsx: -------------------------------------------------------------------------------- 1 | import { Component, Event, EventEmitter, h, State } from '@stencil/core' 2 | import refresh from './refresh.svg' 3 | 4 | @Component({ 5 | tag: 'arch-swiper', 6 | styleUrl: 'swiper.css', 7 | shadow: true 8 | }) 9 | export class Swiper { 10 | @Event() 11 | swipe!: EventEmitter 12 | 13 | static readonly MAX_OFFSET = 200 14 | static readonly MIN_OFFSET = 100 15 | 16 | private swipeStartCoord = 0 17 | 18 | @State() 19 | swipingPixels = 0 20 | 21 | @State() 22 | isCurrentlySwiping = false 23 | 24 | wrapper!: HTMLDivElement 25 | 26 | touchStart(touchEvent: TouchEvent): void { 27 | this.swipeStartCoord = this.getY(touchEvent) 28 | this.isCurrentlySwiping = this.getWindow().pageYOffset === 0 29 | } 30 | 31 | touchMove(touchEvent: TouchEvent): void { 32 | if (!this.isCurrentlySwiping) { 33 | return 34 | } 35 | const touchCoord = this.getY(touchEvent) 36 | this.swipingPixels = touchCoord - this.swipeStartCoord 37 | 38 | if (this.swipingPixels < Swiper.MAX_OFFSET) { 39 | this.wrapper.style.paddingTop = `${this.swipingPixels}px` 40 | } 41 | } 42 | 43 | resetPull(touchEvent: TouchEvent): void { 44 | if (!this.isCurrentlySwiping) { 45 | return 46 | } 47 | const touchCoord = this.getY(touchEvent) 48 | this.isCurrentlySwiping = false 49 | this.wrapper.style.paddingTop = '0px' 50 | this.swipingPixels = touchCoord - this.swipeStartCoord 51 | if (this.swipingPixels >= Swiper.MAX_OFFSET) { 52 | this.swipe.emit() 53 | } 54 | this.swipingPixels = 0 55 | } 56 | 57 | private getY(e: TouchEvent) { 58 | return e.changedTouches[0].clientY 59 | } 60 | 61 | private getWindow(): Window { 62 | return window 63 | } 64 | 65 | private isSwiping() { 66 | return this.isCurrentlySwiping && this.swipingPixels > Swiper.MIN_OFFSET 67 | } 68 | 69 | render() { 70 | return ( 71 |
(this.wrapper = el as HTMLDivElement)} 74 | onTouchStart={(event: TouchEvent) => this.touchStart(event)} 75 | onTouchMove={(event: TouchEvent) => this.touchMove(event)} 76 | onTouchEnd={(event: TouchEvent) => this.resetPull(event)} 77 | > 78 | {this.isSwiping() && ( 79 |
80 | 81 |
82 | )} 83 |
84 | 85 |
86 |
87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /packages/components/src/components/toast/toast.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | 5 | .toast { 6 | position: absolute; 7 | width: calc(100% - var(--m) * 4); 8 | bottom: 0; 9 | left: 0; 10 | z-index: var(--toast-z-index); 11 | padding: var(--s) var(--m); 12 | margin: var(--s) var(--m); 13 | background-color: var(--toast-background-color, var(--surface-color)); 14 | color: var(--toast-color, var(--on-surface-color)); 15 | border-radius: var(--toast-border-radius, var(--border-radius)); 16 | } 17 | 18 | .info { 19 | --toast-color: var(--on-info-color); 20 | --toast-background-color: var(--info-color); 21 | } 22 | 23 | .success { 24 | --toast-color: var(--on-success-color); 25 | --toast-background-color: var(--success-color); 26 | } 27 | 28 | .error { 29 | --toast-color: var(--on-error-color); 30 | --toast-background-color: var(--error-color); 31 | } 32 | -------------------------------------------------------------------------------- /packages/components/src/components/toast/toast.stories.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | title: 'Toast' 3 | } 4 | 5 | export const Base = () => { 6 | return `There has been an error` 7 | } 8 | -------------------------------------------------------------------------------- /packages/components/src/components/toast/toast.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h, Prop } from '@stencil/core' 2 | 3 | @Component({ 4 | tag: 'arch-toast', 5 | styleUrl: 'toast.css', 6 | shadow: true 7 | }) 8 | export class Toast { 9 | @Prop() 10 | type?: 'error' | 'info' | 'success' 11 | 12 | render() { 13 | return ( 14 |
15 |

16 | 17 |

18 |
19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/components/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components' 2 | export * from './utils' 3 | -------------------------------------------------------------------------------- /packages/components/src/material-theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --surface-color: #323232; 3 | --on-surface-color: white; 4 | --border-radius: 0.25rem; 5 | --success-color: green; 6 | --on-success-color: green; 7 | --error-color: red; 8 | --on-error-color: white; 9 | --info-color: blue; 10 | --on-info-color: blue; 11 | 12 | --primary-color: blue; 13 | --on-primary-color: white; 14 | --secondary-color: #4949f5; 15 | --on-secondary-color: black; 16 | } 17 | -------------------------------------------------------------------------------- /packages/components/src/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --unit: 0.25rem; 3 | --xs: calc(var(--unit) * 1); 4 | --s: calc(var(--unit) * 1.5); 5 | --m: calc(var(--unit) * 3); 6 | --b: calc(var(--unit) * 4); 7 | --l: calc(var(--unit) * 5); 8 | --xl: calc(var(--unit) * 6); 9 | } 10 | -------------------------------------------------------------------------------- /packages/components/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { KeyCodes } from './keycodes' 2 | export { 3 | ItemPosition, 4 | ItemPosition1D, 5 | ItemPosition2D, 6 | ItemPosition3D, 7 | isPosition1D, 8 | isPosition2D, 9 | isPosition3D 10 | } from './position' 11 | -------------------------------------------------------------------------------- /packages/components/src/utils/keycodes.ts: -------------------------------------------------------------------------------- 1 | export enum KeyCodes { 2 | BACKSPACE = 'Backspace', 3 | TAB = 'Tab', 4 | RETURN = 'Enter', 5 | ESC = 'Escape', 6 | SPACE = ' ', 7 | PAGE_UP = 'PageUp', 8 | PAGE_DOWN = 'PageDown', 9 | END = 'End', 10 | HOME = 'Home', 11 | LEFT = 'ArrowLeft', 12 | UP = 'ArrowUp', 13 | RIGHT = 'ArrowRight', 14 | DOWN = 'ArrowDown', 15 | DELETE = 'Delete' 16 | } 17 | -------------------------------------------------------------------------------- /packages/components/src/utils/position.ts: -------------------------------------------------------------------------------- 1 | export type ItemPosition = ItemPosition1D | ItemPosition2D | ItemPosition3D 2 | 3 | export interface ItemPosition1D { 4 | x: number 5 | } 6 | 7 | export interface ItemPosition2D { 8 | x: number 9 | y: number 10 | } 11 | 12 | export interface ItemPosition3D { 13 | x: number 14 | y: number 15 | z: number 16 | } 17 | 18 | export function isPosition1D(position: ItemPosition): position is ItemPosition1D { 19 | return (position as ItemPosition1D).x !== undefined 20 | } 21 | 22 | export function isPosition2D(position: ItemPosition): position is ItemPosition2D { 23 | return (position as ItemPosition2D).x !== undefined && (position as ItemPosition2D).y !== undefined 24 | } 25 | 26 | export function isPosition3D(position: ItemPosition): position is ItemPosition3D { 27 | return ( 28 | (position as ItemPosition3D).x !== undefined && 29 | (position as ItemPosition3D).y !== undefined && 30 | (position as ItemPosition3D).z !== undefined 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /packages/components/src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | export type Theme = 'primary' | 'secondary' 2 | -------------------------------------------------------------------------------- /packages/components/stencil.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@stencil/core' 2 | 3 | export const config: Config = { 4 | namespace: 'archimedes', 5 | globalStyle: 'src/theme.css', 6 | outputTargets: [ 7 | { 8 | type: 'dist', 9 | esmLoaderPath: '../loader' 10 | }, 11 | { 12 | type: 'www', 13 | serviceWorker: null 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/components/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "exclude": ["node_modules"], 4 | "include": ["src", "types/jsx.d.ts"], 5 | "compilerOptions": { 6 | "moduleResolution": "node", 7 | "module": "esnext", 8 | "target": "es2017", 9 | "declarationDir": "./typings", 10 | "jsx": "react", 11 | "jsxFactory": "h" 12 | }, 13 | "references": [ 14 | { 15 | "path": "../utils" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /packages/utils/README.md: -------------------------------------------------------------------------------- 1 | # `@archimedes/utils` 2 | 3 | Refer to [the official documentation here](https://www.archimedesfw.io/docs/js/utils). 4 | 5 | Different utilities to use: 6 | 7 | - datetime 8 | - extended-error 9 | - http-client 10 | - is-promise 11 | - maybe 12 | - observer 13 | - range 14 | - types 15 | 16 | ## Installation 17 | 18 | `npm i @archimedes/utils -SE` 19 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@archimedes/utils", 3 | "version": "2.1.1", 4 | "description": "Archimedes utils", 5 | "author": "<>", 6 | "license": "Apache-2.0", 7 | "source": "src/index.ts", 8 | "main": "dist/index.js", 9 | "module": "dist/index.module.js", 10 | "types": "dist/types.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "devDependencies": { 15 | "typescript": "4.7.3" 16 | }, 17 | "dependencies": { 18 | "@types/luxon": "2.3.2", 19 | "luxon": "2.5.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/date-object.ts: -------------------------------------------------------------------------------- 1 | import { TimeUnit } from './time-unit' 2 | 3 | export type DateObject = Partial> 4 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/datetime.ts: -------------------------------------------------------------------------------- 1 | import { DateTime as LuxonDatetime, Info } from 'luxon' 2 | import { DateObject } from './date-object' 3 | import { Duration } from './duration' 4 | import { StringUnitLength } from './string-unit-length' 5 | import { InfoOptions } from './info-options' 6 | 7 | export interface DatetimeOptions { 8 | isLocal: boolean 9 | } 10 | 11 | export class Datetime { 12 | static now(): Datetime { 13 | return new Datetime(LuxonDatetime.local().setZone('utc')) 14 | } 15 | 16 | static new( 17 | year?: number, 18 | month?: number, 19 | day?: number, 20 | hour?: number, 21 | minute?: number, 22 | second?: number, 23 | millisecond?: number 24 | ): Datetime 25 | static new(dateObject: DateObject): Datetime 26 | static new( 27 | dateObject: DateObject | number = 1970, 28 | month: number = 1, 29 | day: number = 1, 30 | hour: number = 0, 31 | minute: number = 0, 32 | second: number = 0, 33 | millisecond: number = 0 34 | ): Datetime { 35 | if (typeof dateObject === 'object') { 36 | const defaultDateObject: DateObject = { 37 | year: 1970, 38 | month: 1, 39 | day: 1, 40 | hour: 0, 41 | minute: 0, 42 | second: 0, 43 | millisecond: 0, 44 | ...dateObject 45 | } 46 | return new Datetime(LuxonDatetime.fromObject(defaultDateObject, { zone: 'utc' })) 47 | } 48 | 49 | return new Datetime( 50 | LuxonDatetime.fromObject( 51 | { 52 | year: dateObject, 53 | month, 54 | day, 55 | hour, 56 | minute, 57 | second, 58 | millisecond 59 | }, 60 | { zone: 'utc' } 61 | ) 62 | ) 63 | } 64 | 65 | /** 66 | * 67 | * @param isoString Format is as follows: 2020-09-16T11:14:33Z. It must include the Z 68 | */ 69 | static fromIsoString(isoString: string) { 70 | return new Datetime(LuxonDatetime.fromISO(isoString)) 71 | } 72 | 73 | /** 74 | * Create from a JavaScript {Date} object 75 | * @param date 76 | * @param options 77 | */ 78 | static fromJsDate(date: Date, options?: DatetimeOptions) { 79 | return new Datetime(LuxonDatetime.fromJSDate(date), options) 80 | } 81 | 82 | /** 83 | * 84 | * @param value 2020/01/02 85 | * @param format YYYY/MM/DD 86 | * @param options 87 | */ 88 | static fromFormat( 89 | value: string, 90 | format: string, 91 | options: { locale: string; zone: string } = { locale: 'es-ES', zone: 'utc' } 92 | ) { 93 | return new Datetime(LuxonDatetime.fromFormat(value, format, options)) 94 | } 95 | 96 | static months(length?: StringUnitLength, options?: InfoOptions) { 97 | return Info.months(length, options) 98 | } 99 | 100 | static weekdays(length?: StringUnitLength, options?: InfoOptions) { 101 | return Info.weekdays(length, options) 102 | } 103 | 104 | constructor(private readonly _value: LuxonDatetime, options: DatetimeOptions = { isLocal: false }) { 105 | if (options.isLocal) { 106 | this._value = _value 107 | } else { 108 | this._value = this._value.setZone('utc') 109 | } 110 | } 111 | 112 | get jsDate(): Date { 113 | return this._value.toJSDate() 114 | } 115 | 116 | get daysInMonth(): number { 117 | return this._value.daysInMonth 118 | } 119 | 120 | get year(): number { 121 | return this._value.year 122 | } 123 | 124 | get month(): number { 125 | return this._value.month 126 | } 127 | 128 | get day(): number { 129 | return this._value.day 130 | } 131 | 132 | get hour(): number { 133 | return this._value.hour 134 | } 135 | 136 | get minute(): number { 137 | return this._value.minute 138 | } 139 | 140 | get second(): number { 141 | return this._value.second 142 | } 143 | 144 | get millisecond(): number { 145 | return this._value.millisecond 146 | } 147 | 148 | get weekday() { 149 | return this._value.weekday 150 | } 151 | 152 | clone(): Datetime { 153 | return new Datetime(this._value) 154 | } 155 | 156 | add(dateObject: DateObject | Duration): Datetime { 157 | if (Duration.isDuration(dateObject)) { 158 | return new Datetime(this._value.plus(dateObject.value)) 159 | } 160 | 161 | return new Datetime( 162 | this._value.plus({ 163 | day: dateObject.day, 164 | year: dateObject.year, 165 | month: dateObject.month, 166 | hour: dateObject.hour, 167 | minute: dateObject.minute, 168 | second: dateObject.second, 169 | millisecond: dateObject.millisecond 170 | }) 171 | ) 172 | } 173 | 174 | difference(datetime: Datetime): Duration { 175 | const iso8601 = this._value.diff(datetime._value).toISO() 176 | return Duration.fromIso(iso8601) 177 | } 178 | 179 | toIso() { 180 | const date = [this._value.year, this._value.month, this._value.day].map(this.addPadStart) 181 | const time = [this._value.hour, this._value.minute, this._value.second].map(this.addPadStart) 182 | 183 | return `${date.join('-')}T${time.join(':')}Z` 184 | } 185 | 186 | toLocal(): Datetime { 187 | return new Datetime(this._value.toLocal(), { isLocal: true }) 188 | } 189 | 190 | toMillis(): number { 191 | return this._value.toMillis() 192 | } 193 | 194 | asLocal() { 195 | return new Datetime(this._value.setZone('local', { keepLocalTime: true }), { isLocal: true }) 196 | } 197 | 198 | isValid() { 199 | return this._value.isValid 200 | } 201 | 202 | set({ year, month, day, hour, minute, second, millisecond }: DateObject): Datetime { 203 | return new Datetime( 204 | this._value.set({ 205 | year, 206 | month, 207 | day, 208 | hour, 209 | minute, 210 | second, 211 | millisecond 212 | }) 213 | ) 214 | } 215 | 216 | format(value: string, options?: Intl.DateTimeFormatOptions) { 217 | return this._value.toFormat(value, options) 218 | } 219 | 220 | equals(datetimeToCompare: Datetime): boolean { 221 | return +this === +datetimeToCompare 222 | } 223 | 224 | valueOf() { 225 | return this.jsDate.getTime() 226 | } 227 | 228 | toString() { 229 | return this.toIso() 230 | } 231 | 232 | private addPadStart(number: number) { 233 | return number.toString().padStart(2, '0') 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/duration.spec.ts: -------------------------------------------------------------------------------- 1 | import { Duration } from './duration' 2 | 3 | describe('Duration', () => { 4 | it('should create a duration from a timestamp', () => { 5 | const duration = Duration.fromTimeStamp('02:30') 6 | 7 | const actual = duration.toIso() 8 | 9 | expect(actual).toBe('PT2H30M') 10 | }) 11 | 12 | it('should check it it a duration', () => { 13 | const duration = Duration.fromTimeStamp('02:30') 14 | 15 | const actual = Duration.isDuration(duration) 16 | 17 | expect(actual).toBe(true) 18 | }) 19 | 20 | it('should check it it not a duration', () => { 21 | const duration = 'foo' 22 | 23 | const actual = Duration.isDuration(duration) 24 | 25 | expect(actual).toBe(false) 26 | }) 27 | 28 | test.each` 29 | a | b | expected 30 | ${'seconds'} | ${'PT1H'} | ${3600} 31 | ${'minutes'} | ${'PT1H'} | ${60} 32 | ${'months'} | ${'P1Y'} | ${12} 33 | `('should get the duration as $a', ({ a, b, expected }) => { 34 | const duration = Duration.fromIso(b) 35 | 36 | const actual = duration.as(a) 37 | 38 | expect(actual).toBe(expected) 39 | }) 40 | 41 | test.each` 42 | a | b | expected 43 | ${'seconds'} | ${'PT1S'} | ${1} 44 | ${'minutes'} | ${'PT5H12M59S'} | ${12} 45 | ${'years'} | ${'P42Y12M'} | ${42} 46 | `('should get $a from the duration ', ({ a, b, expected }) => { 47 | const duration = Duration.fromIso(b) 48 | 49 | // @ts-ignore 50 | const actual = duration[a] 51 | 52 | expect(actual).toBe(expected) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/duration.ts: -------------------------------------------------------------------------------- 1 | import { Duration as LuxonDuration } from 'luxon' 2 | import { Timestamp } from './timestamp' 3 | import { TimeUnits } from './time-units' 4 | 5 | export class Duration { 6 | private constructor(public readonly value: LuxonDuration) {} 7 | 8 | static fromTimeStamp(timestamp: Timestamp) { 9 | const [hours, minutes, seconds, milliseconds] = timestamp.split(':').map(Number) 10 | return new Duration(LuxonDuration.fromObject({ hours, minutes, seconds, milliseconds })) 11 | } 12 | 13 | static isDuration(obj: any): obj is Duration { 14 | return LuxonDuration.isDuration(obj?.value) 15 | } 16 | 17 | static fromIso(iso8601: string) { 18 | return new Duration(LuxonDuration.fromISO(iso8601)) 19 | } 20 | 21 | get years(): number { 22 | return this.value.years 23 | } 24 | 25 | get months(): number { 26 | return this.value.months 27 | } 28 | 29 | get days(): number { 30 | return this.value.days 31 | } 32 | 33 | get hours(): number { 34 | return this.value.hours 35 | } 36 | 37 | get minutes(): number { 38 | return this.value.minutes 39 | } 40 | 41 | get seconds(): number { 42 | return this.value.seconds 43 | } 44 | 45 | get milliseconds(): number { 46 | return this.value.milliseconds 47 | } 48 | 49 | as(timeUnits: TimeUnits) { 50 | return this.value.as(timeUnits) 51 | } 52 | 53 | toIso() { 54 | return this.value.toISO() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/info-options.ts: -------------------------------------------------------------------------------- 1 | import { NumberingSystem } from 'luxon' 2 | 3 | export interface InfoOptions { 4 | locale?: string 5 | numberingSystem?: NumberingSystem 6 | } 7 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/mock-datetime.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from 'luxon' 2 | import { Datetime } from './datetime' 3 | 4 | export class MockDatetime { 5 | private static ORIGINAL_NOW = Settings.now 6 | 7 | static mock(date: Datetime) { 8 | Settings.now = () => date.jsDate.valueOf() 9 | } 10 | 11 | static reset() { 12 | Settings.now = MockDatetime.ORIGINAL_NOW 13 | } 14 | 15 | static mockTimezone(timeZone: string) { 16 | Settings.defaultZone = timeZone 17 | } 18 | 19 | static resetMockTimezone() { 20 | Settings.defaultZone = 'utc' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/string-unit-length.ts: -------------------------------------------------------------------------------- 1 | export type StringUnitLength = 'narrow' | 'short' | 'long' 2 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/time-unit.ts: -------------------------------------------------------------------------------- 1 | export type TimeUnit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'millisecond' 2 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/time-units.ts: -------------------------------------------------------------------------------- 1 | export type TimeUnits = 'years' | 'months' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' 2 | -------------------------------------------------------------------------------- /packages/utils/src/datetime/timestamp.ts: -------------------------------------------------------------------------------- 1 | // HH:mm:ss:ms 2 | export type Timestamp = string 3 | -------------------------------------------------------------------------------- /packages/utils/src/extended-error/extended-error.ts: -------------------------------------------------------------------------------- 1 | export class ExtendedError extends Error { 2 | constructor(message: string) { 3 | super(message) 4 | this.name = this.constructor.name 5 | this.message = message 6 | if (typeof (Error as any).captureStackTrace === 'function') { 7 | ;(Error as any).captureStackTrace(this, this.constructor) 8 | } else { 9 | this.stack = new Error(message).stack 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/utils/src/http-client/http-client.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientCreateOptions, HttpClient } from './http-client' 2 | 3 | describe('HttpClient', () => { 4 | it('should configure base url', async () => { 5 | const { httpClient } = setup({ baseUrl: 'http://foo' }) 6 | 7 | await httpClient.get('bar') 8 | 9 | expect(window.fetch).toHaveBeenCalledWith( 10 | new Request('http://foo/bar', { 11 | headers: new Headers({ 12 | Accept: 'application/json', 13 | 'Content-Type': 'application/json' 14 | }) 15 | }), 16 | { 17 | method: 'GET' 18 | } 19 | ) 20 | }) 21 | 22 | it('should make a get request', async () => { 23 | const { httpClient } = setup() 24 | 25 | await httpClient.get('http://foo') 26 | 27 | expect(window.fetch).toHaveBeenCalledWith( 28 | new Request('http://foo', { 29 | headers: new Headers({ 30 | Accept: 'application/json', 31 | 'Content-Type': 'application/json' 32 | }) 33 | }), 34 | { 35 | method: 'GET' 36 | } 37 | ) 38 | }) 39 | 40 | it('should make a post request', async () => { 41 | const { httpClient } = setup() 42 | 43 | await httpClient.post('http://foo', { bar: 'baz' }) 44 | 45 | expect(window.fetch).toHaveBeenCalledWith( 46 | new Request('http://foo', { 47 | headers: new Headers({ 48 | Accept: 'application/json', 49 | 'Content-Type': 'application/json' 50 | }) 51 | }), 52 | { body: '{"bar":"baz"}', method: 'POST' } 53 | ) 54 | }) 55 | 56 | it('should make a put request', async () => { 57 | const { httpClient } = setup() 58 | 59 | await httpClient.put('http://foo', { bar: 'baz' }) 60 | 61 | expect(window.fetch).toHaveBeenCalledWith( 62 | new Request('http://foo', { 63 | headers: new Headers({ 64 | Accept: 'application/json', 65 | 'Content-Type': 'application/json' 66 | }) 67 | }), 68 | { body: '{"bar":"baz"}', method: 'PUT' } 69 | ) 70 | }) 71 | 72 | it('should make a delete request', async () => { 73 | const { httpClient } = setup() 74 | 75 | await httpClient.delete('http://foo') 76 | 77 | expect(window.fetch).toHaveBeenCalledWith( 78 | new Request('http://foo', { 79 | headers: new Headers({ 80 | Accept: 'application/json', 81 | 'Content-Type': 'application/json' 82 | }) 83 | }), 84 | { method: 'DELETE' } 85 | ) 86 | }) 87 | 88 | it('should execute after hook', async () => { 89 | const mock = jest.fn() 90 | const options = { hooks: { after: [mock] }, result: 'foo' } 91 | const { httpClient } = setup(options) 92 | 93 | await httpClient.get('http://foo') 94 | 95 | expect(mock).toHaveBeenCalledWith(expect.anything(), { 96 | hooks: { after: [mock], before: [] }, 97 | baseUrl: '', 98 | defaults: undefined 99 | }) 100 | }) 101 | 102 | it('should execute before hook', async () => { 103 | const mock = jest.fn() 104 | const options = { hooks: { before: [mock] }, result: 'foo' } 105 | const { httpClient } = setup(options) 106 | 107 | await httpClient.get('http://foo') 108 | 109 | expect(mock).toHaveBeenCalledWith(expect.anything(), { 110 | hooks: { after: [], before: [mock] }, 111 | baseUrl: '', 112 | defaults: undefined 113 | }) 114 | }) 115 | 116 | it('should handle empty responses', async () => { 117 | const { httpClient } = setup({ result: '' }) 118 | 119 | const response = await httpClient.get('http://foo') 120 | 121 | expect(response).toEqual({ 122 | headers: {}, 123 | options: { 124 | method: 'GET' 125 | }, 126 | result: '', 127 | status: undefined 128 | }) 129 | }) 130 | }) 131 | 132 | function setup(options?: HttpClientCreateOptions & Partial<{ result: T }>) { 133 | const fetchMock = jest.fn() 134 | fetchMock.mockImplementation(() => 135 | Promise.resolve({ 136 | text: () => Promise.resolve(JSON.stringify(options?.result ?? { foo: 'bar' })), 137 | ok: true, 138 | headers: new Headers() 139 | }) 140 | ) 141 | window.fetch = fetchMock 142 | 143 | return { 144 | httpClient: HttpClient.create(options) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /packages/utils/src/http-client/http-client.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from './http-error' 2 | import { HttpParams } from './http-params' 3 | import { HttpStatusCode } from './http-status-code' 4 | 5 | type Url = string 6 | 7 | export interface HttpClientResponse { 8 | result: Result 9 | status: HttpStatusCode 10 | headers: HttpClientHeaders 11 | options: RequestInit 12 | } 13 | 14 | export type HttpClientBeforeHook = (request: Request, options: HttpClientOptions) => void 15 | export type HttpClientAfterHook = (response: Response & { result: unknown }, options: HttpClientOptions) => void 16 | export type HttpClientHeaders = Record 17 | 18 | export interface HttpClientOptions { 19 | baseUrl: Url 20 | hooks: { before: HttpClientBeforeHook[]; after: HttpClientAfterHook[] } 21 | defaults?: RequestInit 22 | } 23 | 24 | export type HttpClientCreateOptions = Partial< 25 | Omit & { hooks: { before?: HttpClientBeforeHook[]; after?: HttpClientAfterHook[] } } 26 | > 27 | 28 | const defaultOptions = { 29 | baseUrl: '', 30 | hooks: { before: [], after: [] } 31 | } 32 | 33 | export class HttpClient { 34 | private readonly defaultHeaders = new Headers({ 35 | Accept: 'application/json', 36 | 'Content-Type': 'application/json' 37 | }) 38 | 39 | static create({ 40 | baseUrl = defaultOptions.baseUrl, 41 | hooks = { ...defaultOptions.hooks }, 42 | defaults 43 | }: HttpClientCreateOptions = defaultOptions) { 44 | const defaultBeforeHooks = hooks?.before ?? [] 45 | const defaultAfterHooks = hooks?.after ?? [] 46 | 47 | return new HttpClient({ 48 | baseUrl, 49 | hooks: { before: defaultBeforeHooks, after: defaultAfterHooks }, 50 | defaults 51 | }) 52 | } 53 | 54 | private constructor(private readonly options: HttpClientOptions) {} 55 | 56 | async get(url: Url, httpParams?: HttpParams): Promise> { 57 | return this.sendRequest(url, { method: 'GET' }, httpParams) 58 | } 59 | 60 | async post( 61 | url: string, 62 | body?: Body, 63 | httpParams?: HttpParams 64 | ): Promise> { 65 | return this.sendRequest(url, { method: 'POST', body: this.getParsedBody(body) }, httpParams) 66 | } 67 | 68 | async put( 69 | url: string, 70 | body?: Body, 71 | httpParams?: HttpParams 72 | ): Promise> { 73 | return this.sendRequest(url, { method: 'PUT', body: this.getParsedBody(body) }, httpParams) 74 | } 75 | 76 | async delete(url: string, httpParams?: HttpParams): Promise> { 77 | return this.sendRequest(url, { method: 'DELETE' }, httpParams) 78 | } 79 | 80 | private async sendRequest( 81 | url: string, 82 | options: RequestInit, 83 | httpParams?: HttpParams 84 | ): Promise> { 85 | const request = this.getRequest(url, httpParams) 86 | this.options.hooks.before.forEach(hook => hook(request, this.options)) 87 | const response = await fetch(request, { ...this.options.defaults, ...options }) 88 | const resultAsString = await response.text() 89 | const result = resultAsString.length !== 0 ? JSON.parse(resultAsString) : undefined 90 | this.options.hooks.after.forEach(hook => hook({ ...response, result }, this.options)) 91 | return this.getResponse(response, result, options) 92 | } 93 | 94 | private getRequest(url: string, httpParams: HttpParams | undefined) { 95 | return new Request(this.getUrl(url, httpParams), { headers: this.defaultHeaders }) 96 | } 97 | 98 | private getUrl(url: Url, params?: HttpParams) { 99 | let fullUrl = this.options.baseUrl === '' ? url : this.options.baseUrl + '/' + url 100 | 101 | if (params !== undefined) { 102 | fullUrl += `?${params.toString()}` 103 | } 104 | return new URL(fullUrl).toString() 105 | } 106 | 107 | private getParsedBody(body: Body) { 108 | return JSON.stringify(body) 109 | } 110 | 111 | private async getResponse( 112 | response: Response, 113 | result: Result, 114 | options: RequestInit 115 | ): Promise> { 116 | if (!response.ok) { 117 | throw new HttpError({ name: response.status.toString(), message: response.statusText }) 118 | } 119 | 120 | const headers: HttpClientHeaders = {} 121 | 122 | response.headers.forEach((value, key) => { 123 | headers[key] = value 124 | }) 125 | 126 | return { result: result as Result, headers, options, status: response.status } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /packages/utils/src/http-client/http-error.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedError } from '../extended-error/extended-error' 2 | 3 | export class HttpError extends ExtendedError { 4 | constructor({ name, message }: { name: string; message: string }) { 5 | super(`HTTP Error ${name}: ${message}`) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/utils/src/http-client/http-params.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpParams } from './http-params' 2 | 3 | describe('HttpParams', () => { 4 | it('should add a value', () => { 5 | const given = HttpParams.create().set('foo', 'bar').set('baz', 'qux') 6 | 7 | const actual = given.toString() 8 | 9 | expect(actual).toBe('foo=bar&baz=qux') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/utils/src/http-client/http-params.ts: -------------------------------------------------------------------------------- 1 | export class HttpParams { 2 | private constructor(private readonly param: URLSearchParams) {} 3 | 4 | static create() { 5 | return new HttpParams(new URLSearchParams()) 6 | } 7 | 8 | set(key: string, value: Value): HttpParams { 9 | this.param.set(key, value.toString()) 10 | return new HttpParams(this.param) 11 | } 12 | 13 | toString() { 14 | return this.param.toString() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/utils/src/http-client/http-status-code.ts: -------------------------------------------------------------------------------- 1 | export enum HttpStatusCode { 2 | Continue = 100, 3 | SwitchingProtocols = 101, 4 | Processing = 102, 5 | EarlyHints = 103, 6 | Ok = 200, 7 | Created = 201, 8 | Accepted = 202, 9 | NonAuthoritativeInformation = 203, 10 | NoContent = 204, 11 | ResetContent = 205, 12 | PartialContent = 206, 13 | MultiStatus = 207, 14 | AlreadyReported = 208, 15 | ImUsed = 226, 16 | MultipleChoices = 300, 17 | MovedPermanently = 301, 18 | Found = 302, 19 | SeeOther = 303, 20 | NotModified = 304, 21 | UseProxy = 305, 22 | Unused = 306, 23 | TemporaryRedirect = 307, 24 | PermanentRedirect = 308, 25 | BadRequest = 400, 26 | Unauthorized = 401, 27 | PaymentRequired = 402, 28 | Forbidden = 403, 29 | NotFound = 404, 30 | MethodNotAllowed = 405, 31 | NotAcceptable = 406, 32 | ProxyAuthenticationRequired = 407, 33 | RequestTimeout = 408, 34 | Conflict = 409, 35 | Gone = 410, 36 | LengthRequired = 411, 37 | PreconditionFailed = 412, 38 | PayloadTooLarge = 413, 39 | UriTooLong = 414, 40 | UnsupportedMediaType = 415, 41 | RangeNotSatisfiable = 416, 42 | ExpectationFailed = 417, 43 | ImATeapot = 418, 44 | MisdirectedRequest = 421, 45 | UnprocessableEntity = 422, 46 | Locked = 423, 47 | FailedDependency = 424, 48 | TooEarly = 425, 49 | UpgradeRequired = 426, 50 | PreconditionRequired = 428, 51 | TooManyRequests = 429, 52 | RequestHeaderFieldsTooLarge = 431, 53 | UnavailableForLegalReasons = 451, 54 | InternalServerError = 500, 55 | NotImplemented = 501, 56 | BadGateway = 502, 57 | ServiceUnavailable = 503, 58 | GatewayTimeout = 504, 59 | HttpVersionNotSupported = 505, 60 | VariantAlsoNegotiates = 506, 61 | InsufficientStorage = 507, 62 | LoopDetected = 508, 63 | NotExtended = 510, 64 | NetworkAuthenticationRequired = 511 65 | } 66 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Maybe } from './maybe/maybe' 2 | export type { Subject } from './observer/subject' 3 | export type { Observer } from './observer/observer' 4 | export { Datetime } from './datetime/datetime' 5 | export type { DatetimeOptions } from './datetime/datetime' 6 | export { Duration } from './datetime/duration' 7 | export type { Timestamp } from './datetime/timestamp' 8 | export type { TimeUnit } from './datetime/time-unit' 9 | export type { TimeUnits } from './datetime/time-units' 10 | export { MockDatetime } from './datetime/mock-datetime' 11 | export { isPromise } from './is-promise/is-promise' 12 | export { HttpClient } from './http-client/http-client' 13 | export type { 14 | HttpClientCreateOptions, 15 | HttpClientAfterHook, 16 | HttpClientBeforeHook, 17 | HttpClientOptions, 18 | HttpClientResponse 19 | } from './http-client/http-client' 20 | export { HttpParams } from './http-client/http-params' 21 | export { HttpStatusCode } from './http-client/http-status-code' 22 | export { HttpError } from './http-client/http-error' 23 | export { ExtendedError } from './extended-error/extended-error' 24 | export type { Class } from './types/class' 25 | export { range } from './range/range' 26 | export { Timer } from './timer/timer' 27 | -------------------------------------------------------------------------------- /packages/utils/src/is-promise/is-promise.ts: -------------------------------------------------------------------------------- 1 | export function isPromise(object: Promise | unknown): object is Promise { 2 | return typeof object !== 'undefined' && typeof (object as Promise).then === 'function' 3 | } 4 | -------------------------------------------------------------------------------- /packages/utils/src/maybe/callback-function.ts: -------------------------------------------------------------------------------- 1 | export type CallbackFunction = (...params: unknown[]) => T 2 | -------------------------------------------------------------------------------- /packages/utils/src/maybe/maybe-empty-error.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedError } from '../extended-error/extended-error' 2 | 3 | export class MaybeEmptyError extends ExtendedError { 4 | constructor() { 5 | super('Maybe is empty') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/utils/src/maybe/maybe.spec.ts: -------------------------------------------------------------------------------- 1 | import { Maybe } from './maybe' 2 | import { MaybeEmptyError } from './maybe-empty-error' 3 | 4 | describe('Maybe', () => { 5 | it('should handle an undefined value', () => { 6 | const maybe = Maybe.from(undefined) 7 | expect(maybe.getOrElse('test')).toBe('test') 8 | }) 9 | 10 | it('should a false value', () => { 11 | const maybe = Maybe.from(false) 12 | expect(maybe.getOrElse(true)).toBe(false) 13 | }) 14 | 15 | it('should handle a string value', () => { 16 | const maybe = Maybe.from('test') 17 | expect(maybe.getOrElse('')).toBe('test') 18 | }) 19 | 20 | it('should handle an empty string value', () => { 21 | const maybe = Maybe.from('') 22 | expect(maybe.getOrElse('test')).toBe('') 23 | }) 24 | 25 | it('should handle a null value', () => { 26 | const maybe = Maybe.from(null) 27 | expect(maybe.getOrElse('test')).toBe('test') 28 | }) 29 | 30 | it('should handle a numeric value', () => { 31 | const maybe = Maybe.from(42) 32 | expect(maybe.getOrElse(0)).toBe(42) 33 | }) 34 | 35 | it('should handle the zero value as valid', () => { 36 | const maybe = Maybe.from(0) 37 | expect(maybe.getOrElse(1)).toBe(0) 38 | }) 39 | 40 | it('should handle an empty maybe as invalid', () => { 41 | const maybe = Maybe.from(Maybe.none()) 42 | expect(maybe.has()).toBe(false) 43 | }) 44 | 45 | it('should tap a value', () => { 46 | const maybe = Maybe.from('value') 47 | let actual = false 48 | 49 | maybe.tap(() => { 50 | actual = true 51 | }) 52 | 53 | expect(actual).toBe(true) 54 | }) 55 | 56 | it('should not tap a value', () => { 57 | const maybe = Maybe.none() 58 | let actual = false 59 | 60 | maybe.tap(() => { 61 | actual = true 62 | }) 63 | 64 | expect(actual).toBe(false) 65 | }) 66 | 67 | it('should handle a callback as a default value', () => { 68 | const mock = jest.fn() 69 | const maybe = Maybe.from(null) 70 | maybe.getOrExecute(mock) 71 | expect(mock).toHaveBeenCalled() 72 | }) 73 | 74 | it('should check if it has a value', () => { 75 | const maybe = Maybe.from('hello') 76 | expect(maybe.has()).toBe(true) 77 | }) 78 | 79 | it('should check if it does not have a value', () => { 80 | const maybe = Maybe.from(null) 81 | expect(maybe.has()).toBe(false) 82 | }) 83 | 84 | it('should handle none value', () => { 85 | const maybe = Maybe.none() 86 | expect(maybe.getOrElse('test')).toBe('test') 87 | }) 88 | 89 | it('should handle or else value when the value does exist', () => { 90 | const maybe = Maybe.from('bar') 91 | 92 | const actual = maybe.orElse(Maybe.from('foo')) 93 | 94 | expect(actual).toEqual(Maybe.from('bar')) 95 | }) 96 | 97 | it('should handle or else value when the value does not exist', () => { 98 | const maybe = Maybe.none() 99 | 100 | const actual = maybe.orElse(Maybe.from('foo')) 101 | 102 | expect(actual).toEqual(Maybe.from('foo')) 103 | }) 104 | 105 | it('should get or throw', () => { 106 | const maybe = Maybe.none() 107 | expect(() => { 108 | maybe.getOrThrow(new Error('foo')) 109 | }).toThrowError('foo') 110 | }) 111 | 112 | it('should be able to map existing values', () => { 113 | const maybeMap = Maybe.from({ a: 'a' }) 114 | expect(maybeMap.map(e => e.a).getOrElse('b')).toBe('a') 115 | }) 116 | 117 | it('should be able to map non existing values', () => { 118 | type Type = { foo: Maybe<{ bar: string }> } 119 | const maybeMap = Maybe.from({ foo: Maybe.none() }) 120 | expect( 121 | maybeMap 122 | .getOrExecute(() => { 123 | throw new MaybeEmptyError() 124 | }) 125 | .foo.map(x => x.bar) 126 | ).toEqual(Maybe.none()) 127 | }) 128 | 129 | it('should be able to flat map existing values', () => { 130 | type Type = { foo: Maybe<{ bar: string }> } 131 | const maybeMap = Maybe.from({ foo: Maybe.from({ bar: 'qux' }) }) 132 | expect(maybeMap.flatMap(x => x.foo).map(x => x.bar)).toEqual(Maybe.from('qux')) 133 | }) 134 | 135 | it('should be able to flat map non existing values', () => { 136 | type Type = { foo: Maybe<{ bar: string }> } 137 | const maybeMap = Maybe.none() 138 | expect(maybeMap.flatMap(x => x.foo)).toEqual(Maybe.none()) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /packages/utils/src/maybe/maybe.ts: -------------------------------------------------------------------------------- 1 | import { CallbackFunction } from './callback-function' 2 | import { MaybeEmptyError } from './maybe-empty-error' 3 | 4 | export class Maybe { 5 | private constructor(private value: T | null) {} 6 | 7 | static none(): Maybe { 8 | return new Maybe(null) 9 | } 10 | 11 | static from(value: T | undefined | null): Maybe { 12 | return this.isValid(value) ? Maybe.some(value as T) : Maybe.none() 13 | } 14 | 15 | private static some(value: T): Maybe { 16 | if (!this.isValid(value)) { 17 | throw new MaybeEmptyError() 18 | } 19 | return new Maybe(value) 20 | } 21 | 22 | private static isValid(value: unknown | null | undefined | Maybe): boolean { 23 | return !(value === undefined || value === null || this.isEmptyMaybe(value)) 24 | } 25 | 26 | private static isEmptyMaybe(value: R): boolean { 27 | if (this.isMaybe(value)) { 28 | return !value.has() 29 | } 30 | 31 | return false 32 | } 33 | 34 | private static isMaybe(value: unknown | Maybe): value is Maybe { 35 | return value instanceof Maybe 36 | } 37 | 38 | has(): boolean { 39 | return this.value !== null 40 | } 41 | 42 | getOrElse(defaultValue: T): T { 43 | return this.value === null ? defaultValue : this.value 44 | } 45 | 46 | getOrOther(defaultValue: R): T | R { 47 | return this.value === null ? defaultValue : this.value 48 | } 49 | 50 | getOrExecute(defaultValue: CallbackFunction): T { 51 | return this.value === null ? defaultValue() : this.value 52 | } 53 | 54 | map(f: (wrapped: T) => R): Maybe { 55 | if (this.value === null) { 56 | return Maybe.none() 57 | } else { 58 | return Maybe.some(f(this.value)) 59 | } 60 | } 61 | 62 | tap(f: (wrapped: T) => void): Maybe { 63 | if (this.value !== null) { 64 | f(this.value) 65 | } 66 | 67 | return Maybe.from(this.value) 68 | } 69 | 70 | flatMap(f: (wrapped: T) => Maybe): Maybe { 71 | if (this.value === null) { 72 | return Maybe.none() 73 | } else { 74 | return f(this.value) 75 | } 76 | } 77 | 78 | getOrThrow(error?: Error): T { 79 | return this.value === null 80 | ? (() => { 81 | if (error !== undefined) { 82 | throw error 83 | } 84 | throw new MaybeEmptyError() 85 | })() 86 | : this.value 87 | } 88 | 89 | orElse(value: Maybe) { 90 | return this.value === null ? value : this 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/utils/src/observer/observer.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from './subject' 2 | 3 | export interface Observer { 4 | update(subject: Subject): void 5 | } 6 | -------------------------------------------------------------------------------- /packages/utils/src/observer/subject.ts: -------------------------------------------------------------------------------- 1 | import { Observer } from './observer' 2 | 3 | export interface Subject { 4 | observers: Observer[] 5 | register(observer: Observer): void 6 | unregister(observer: Observer): void 7 | publish(): void 8 | } 9 | -------------------------------------------------------------------------------- /packages/utils/src/range/range.spec.ts: -------------------------------------------------------------------------------- 1 | import { range } from './range' 2 | 3 | describe('range', () => { 4 | it('should return an array until the given number', () => { 5 | const result = range(5) 6 | expect(result).toEqual([0, 1, 2, 3, 4]) 7 | }) 8 | 9 | it('should return an array starting from 0', () => { 10 | const result = range(0, 5) 11 | expect(result).toEqual([0, 1, 2, 3, 4]) 12 | }) 13 | 14 | it('should return an array starting from 5', () => { 15 | const result = range(5, 10) 16 | expect(result).toEqual([5, 6, 7, 8, 9]) 17 | }) 18 | 19 | it('should return an array even when the starting number is higher than the ending number', () => { 20 | const result = range(5, 0) 21 | expect(result).toEqual([5, 4, 3, 2, 1]) 22 | }) 23 | 24 | it('should return an array even when the starting number is higher than the ending number on the middle', () => { 25 | const result = range(7, 3) 26 | expect(result).toEqual([7, 6, 5, 4]) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /packages/utils/src/range/range.ts: -------------------------------------------------------------------------------- 1 | export function range(start: number, end?: number) { 2 | let length: number 3 | let offset: number 4 | let shouldReverse = false 5 | 6 | if (end === undefined) { 7 | length = start 8 | offset = 0 9 | } else if (start > end) { 10 | length = start - end 11 | if (end === 0) { 12 | offset = 1 13 | } else { 14 | offset = start - end 15 | } 16 | shouldReverse = true 17 | } else { 18 | length = end - start 19 | offset = start 20 | } 21 | 22 | const numbers = Array.from({ length }, (_v, i) => i + offset) 23 | return shouldReverse ? numbers.slice().reverse() : numbers 24 | } 25 | -------------------------------------------------------------------------------- /packages/utils/src/timer/timer.spec.ts: -------------------------------------------------------------------------------- 1 | import { Timer } from './timer' 2 | 3 | jest.useFakeTimers() 4 | 5 | describe('Timer', () => { 6 | it('should create a timer', () => { 7 | let done = false 8 | 9 | Timer.create(() => { 10 | done = true 11 | }, 1000) 12 | 13 | jest.runAllTimers() 14 | expect(done).toBe(true) 15 | }) 16 | 17 | it('should create a timer and then stop it', () => { 18 | let done = false 19 | 20 | const timer = Timer.create(() => { 21 | done = true 22 | }, 1000) 23 | timer.pause() 24 | jest.runAllTimers() 25 | 26 | expect(done).toBe(false) 27 | }) 28 | 29 | it('should create a timer, stop it and resume it', () => { 30 | let done = false 31 | 32 | const timer = Timer.create(() => { 33 | done = true 34 | }, 1000) 35 | jest.advanceTimersByTime(500) 36 | timer.pause() 37 | jest.advanceTimersByTime(1000) 38 | expect(done).toBe(false) 39 | timer.resume() 40 | jest.advanceTimersByTime(500) 41 | 42 | expect(done).toBe(true) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /packages/utils/src/timer/timer.ts: -------------------------------------------------------------------------------- 1 | export class Timer { 2 | private timerId?: number | NodeJS.Timeout 3 | private start?: Date 4 | private remaining: number 5 | 6 | constructor(private readonly callback: () => void, delay: number) { 7 | this.remaining = delay 8 | } 9 | 10 | static create(callback: () => void, delay: number) { 11 | const timer = new Timer(callback, delay) 12 | timer.resume() 13 | return timer 14 | } 15 | 16 | resume() { 17 | this.start = new Date() 18 | clearTimeout(this.timerId as NodeJS.Timeout) 19 | this.timerId = setTimeout(this.callback, this.remaining) 20 | } 21 | 22 | pause() { 23 | clearTimeout(this.timerId as NodeJS.Timeout) 24 | this.remaining -= new Date().valueOf() - (this.start?.valueOf() ?? 0) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/utils/src/types/class.ts: -------------------------------------------------------------------------------- 1 | export type Class = new (...args: any[]) => T 2 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch' 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "composite": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "sourceMap": true, 8 | "module": "es2020", 9 | "target": "es2020", 10 | "lib": ["es2020", "dom"], 11 | "esModuleInterop": true, 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noUnusedParameters": true, 17 | "noUnusedLocals": true, 18 | "skipLibCheck": true, 19 | "noImplicitReturns": true, 20 | "experimentalDecorators": true, 21 | "emitDecoratorMetadata": true, 22 | "baseUrl": "src", 23 | "paths": { 24 | "@archimedes/*": ["./*/src"] 25 | } 26 | }, 27 | "references": [ 28 | { 29 | "path": "packages/arch" 30 | }, 31 | { 32 | "path": "packages/utils" 33 | }, 34 | { 35 | "path": "packages/components" 36 | }, 37 | { 38 | "path": "examples/with-angular" 39 | }, 40 | { 41 | "path": "examples/with-react" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "files": [], 4 | "references": [{ "path": "packages/components" }, { "path": "packages/utils" }, { "path": "packages/arch" }] 5 | } 6 | --------------------------------------------------------------------------------