├── .gitignore ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── design └── app-assets.sketch ├── dist ├── fonts │ ├── OpenSans-Bold.ttf │ ├── OpenSans-BoldItalic.ttf │ ├── OpenSans-ExtraBold.ttf │ ├── OpenSans-ExtraBoldItalic.ttf │ ├── OpenSans-Italic.ttf │ ├── OpenSans-Light.ttf │ ├── OpenSans-LightItalic.ttf │ ├── OpenSans-Regular.ttf │ ├── OpenSans-SemiBold.ttf │ └── OpenSans-SemiBoldItalic.ttf └── index.html ├── package.json ├── pcss.config.js ├── rollup.config.js ├── rustfmt.toml ├── sketchdev.config.js ├── src-tauri ├── Cargo.lock ├── Cargo.toml ├── Tauri.toml ├── build.rs ├── icons │ └── app-icon.png └── src │ ├── ctx.rs │ ├── error.rs │ ├── event.rs │ ├── ipc │ ├── mod.rs │ ├── params.rs │ ├── project.rs │ ├── response.rs │ └── task.rs │ ├── main.rs │ ├── model │ ├── bmc_base.rs │ ├── mod.rs │ ├── model_store.rs │ ├── project.rs │ ├── seed_for_dev.rs │ ├── store │ │ ├── mod.rs │ │ ├── surreal_modql.rs │ │ ├── surreal_store.rs │ │ ├── try_froms.rs │ │ └── x_take_impl.rs │ └── task.rs │ ├── prelude.rs │ └── utils │ ├── mod.rs │ └── x_take.rs └── src-ui ├── pcss ├── base.pcss ├── d-ui.pcss ├── defaults.pcss ├── fonts.pcss ├── main.pcss ├── var-colors.pcss └── view │ ├── app-v.pcss │ ├── menu-c.pcss │ ├── nav-v.pcss │ ├── project-v.pcss │ └── tasks-dt.pcss ├── src ├── bindings │ ├── HubEvent.ts │ ├── ModelMutateResultData.ts │ ├── Project.ts │ ├── ProjectForCreate.ts │ ├── ProjectForUpdate.ts │ ├── Task.ts │ ├── TaskForCreate.ts │ ├── TaskForUpdate.ts │ ├── index.ts │ └── type_asserts.ts ├── event.ts ├── ipc.ts ├── main.ts ├── model │ └── index.ts ├── router.ts ├── svg-symbols.ts ├── type-enhancements.ts ├── utils.ts └── view │ ├── app-v.ts │ ├── index.ts │ ├── menu-c.ts │ ├── nav-v.ts │ ├── project-v.ts │ └── tasks-dt.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | _* 4 | 5 | # --- node & rust build 6 | node_modules/ 7 | target/ 8 | 9 | # For this template, ignore package-lock.json 10 | package-lock.json 11 | 12 | # --- tauri dist build assets 13 | dist/js/ 14 | dist/css/ 15 | 16 | # --- Awesome.toml 17 | # From the template app code, Awesome.toml is ignored as this should be generated 18 | # by 'awesome-app new` or 'awesome-app dev' if not present. 19 | # 20 | # However, in full application code, this could be committed (and line below commented), as it could be changed per project. 21 | Awesome.toml 22 | 23 | # --- No png except in dist or the app-icon. 24 | # For generating the app icons, npm run tauri icon src-tauri/icons/app-icon.png) 25 | *.png 26 | !dist/images/*.png 27 | !src-tauri/icons/app-icon.png 28 | 29 | # --- Safety net 30 | __pycache__/ 31 | npm-debug.log 32 | report.*.json 33 | *.parquet 34 | *.map 35 | *.zip 36 | *.gz 37 | *.tar 38 | *.tgz 39 | *.mov 40 | *.mp4 41 | 42 | # images 43 | *.ico 44 | *.icns 45 | *.jpeg 46 | *.jpg 47 | *.icns -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Jeremy Chone 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Jeremy Chone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust Desktop App Code Template (following AwesomeApp Blueprint) 2 | 3 | Base desktop application code with Tauri, Native Web Components, and SurrealDB (follow the VMES app architecture) 4 | 5 | **See [awesomeapp.dev](https://awesomeapp.dev) for more info** 6 | 7 | **[Troubleshooting](#troubleshooting)** | **[Changelog](https://awesomeapp.dev/changelog)** | **[Discord Awesome App](https://discord.gg/XuKWrNGKpC)** 8 | 9 | > Note: To enable persitent storage, edit the `src-tauri/Cargo.toml` to enable all `surrealdb` features. 10 | 11 | # Hot Reload dev 12 | 13 | For hot-reload UI and Tauri development, run the following in your VSCode from this root folder: 14 | 15 | ```sh 16 | awesome-app dev 17 | ``` 18 | 19 | > This assumes `awesome-app` was installed locally (e.g., `cargo install awesome-app`) 20 | 21 | > **IMPORTANT** - Requires **node.js v8 and above**. 22 | 23 | 24 | # How it works 25 | 26 | `awesome-app dev` will create an `Awesome.toml` which will be the list of commands it will run (format is self-explanatory). 27 | 28 | You can run the commands manually if you want, or see below for list of commands. 29 | 30 | We recommend using `awesome-app dev` but running each command manually might help troubleshoot. 31 | 32 | # Build manually 33 | 34 | IMPORTANT: Make sure to have **node.js latest of 16** or above. 35 | 36 | - `npm run tauri icon src-tauri/icons/app-icon.png` - This will build the application icons. 37 | 38 | - `npm run pcss` - This will build the postcss files (`src-ui/pcss/**/*.pcss`). 39 | 40 | - `npm run rollup` - This will build and package the typescript files (`src-ui/src/**/*.ts`). 41 | 42 | - `npm run localhost` - This will run a localhost server with the `dist/` folder as root (frontend hot reload) 43 | 44 | - In another terminal, `npm run tauri dev` - Will start the Tauri build and start the process. 45 | 46 |
47 | 48 | # Troubleshooting 49 | 50 | - Make sure to have **node.js 18** or above. 51 | 52 | - If some cryptic errors, run the command above one by one. 53 | 54 | - If `npm tauri dev` commands fail, try to do: 55 | - `cd src-tauri` 56 | - `cargo build` 57 | - This might be an important first step when using full surrealdb (i.e., with default features and not only kv-mem) 58 | 59 | - It failed to compile and came up with the error `failed to download replaced source registry crates-io`. **Deleting** the **cargo.lock** file and **package-lock.json** file fixed it. 60 | 61 | - Installing Tauri in case some issues: 62 | ```sh 63 | # install latest tauri in case there is none 64 | npm i -g @tauri-apps/cli @tauri-apps/api 65 | ``` 66 | 67 | 68 | 69 | ## Requirements on fedora 36: 70 | 71 | On Fedora, and probably linux, the following needs to be present on the system. 72 | 73 | ```sh 74 | dnf install gtk3-devel 75 | dnf install webkit2gtk3-jsc-devel 76 | dnf install libsoup-devel 77 | dnf install webkit2gtk3-devel.x86_64 78 | ``` 79 | 80 | ## Requirements on Ubuntu 20 81 | 82 | ```sh 83 | npm i 84 | npm i -g tauri 85 | sudo aptitude install -y \ 86 | build-essential \ 87 | libpango1.0-dev \ 88 | libsoup2.4-dev \ 89 | libjavascriptcoregtk-4.0-dev \ 90 | libgdk-pixbuf2.0-dev \ 91 | libgtk-3-dev \ 92 | libwebkit2gtk-4.0-dev 93 | npm run tauri dev 94 | ``` 95 | 96 |

97 | 98 | [This repo on GitHub](https://github.com/awesomeapp-dev/rust-desktop-app) 99 | 100 | -------------------------------------------------------------------------------- /design/app-assets.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/design/app-assets.sketch -------------------------------------------------------------------------------- /dist/fonts/OpenSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-Bold.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-BoldItalic.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-ExtraBold.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-ExtraBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-ExtraBoldItalic.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-Italic.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-Light.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-LightItalic.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-SemiBold.ttf -------------------------------------------------------------------------------- /dist/fonts/OpenSans-SemiBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/dist/fonts/OpenSans-SemiBoldItalic.ttf -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awesome-app-base", 3 | "version": "0.9.22", 4 | "type": "module", 5 | "scripts": { 6 | "tauri": "tauri", 7 | "localhost": "servor dist index.html 8080 --reload", 8 | "pcss": "pcss", 9 | "rollup": "rollup -c", 10 | "ui-build": "rollup -c & pcss", 11 | "ui-dev": "rollup -c -w & pcss -w & npm run localhost", 12 | "app-icons": "tauri icon src-tauri/icons/app-icon.png", 13 | "svg-symbols": "sketchdev" 14 | }, 15 | "dependencies": { 16 | "@dom-native/ui": "0.3.0-alpha.1", 17 | "@tauri-apps/api": "^1.2.0", 18 | "dom-native": "^0.11.2", 19 | "utils-min": "^0.2.1" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-node-resolve": "^15.0.2", 23 | "@rollup/plugin-typescript": "^11.1.0", 24 | "@tauri-apps/cli": "^1.2.3", 25 | "pcss-cli": "^0.2.9", 26 | "rollup": "^3.20.2", 27 | "servor": "^4.0.2", 28 | "sketchdev": "^0.7.4", 29 | "tslib": "^2.5.0", 30 | "typescript": "^5.0.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pcss.config.js: -------------------------------------------------------------------------------- 1 | const prefixer = (await import('autoprefixer')).default; 2 | const nested = (await import('postcss-nested')).default; 3 | const importer = (await import('postcss-import')).default; 4 | 5 | const plugins = [ 6 | prefixer, 7 | importer, 8 | nested 9 | ]; 10 | 11 | 12 | export default { 13 | // required. Support single string, or array, will be processed in order 14 | input: ['./src-ui/pcss/main.pcss'], 15 | 16 | // required. single css file supported for now. 17 | output: './dist/css/app-bundle.css', 18 | 19 | watchPath: ['./src-ui/pcss/**/*.pcss'], 20 | 21 | // postcss processor arrays 22 | plugins 23 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import rollup_nre from '@rollup/plugin-node-resolve'; 2 | import rollup_tsc from '@rollup/plugin-typescript'; 3 | 4 | export default [ 5 | { 6 | input: './src-ui/src/main.ts', 7 | output: { 8 | file: './dist/js/app-bundle.js', 9 | format: 'iife', 10 | name: 'bundle', 11 | sourcemap: true 12 | }, 13 | plugins: [ 14 | // rollup_cjs(), 15 | rollup_nre(), 16 | rollup_tsc({ 17 | tsconfig: './src-ui/tsconfig.json', 18 | compilerOptions: { 19 | declaration: false, 20 | declarationDir: null 21 | } 22 | }), 23 | // terser({ compress: false, mangle: false, format: { comments: false } }) 24 | ] 25 | } 26 | ] 27 | 28 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # delete or change following your personal/team preference. 2 | 3 | hard_tabs = true 4 | edition = "2021" 5 | 6 | # When recording 7 | # max_width = 95 8 | # chain_width = 80 9 | # fn_call_width = 80 10 | # single_line_if_else_max_width = 60 11 | # struct_lit_width = 24 -------------------------------------------------------------------------------- /sketchdev.config.js: -------------------------------------------------------------------------------- 1 | // NOTE - Does not need to be ran (svg-symbol.ts already generated). 2 | // But if on Mac and Sketch app installed, can generate icons automatically. 3 | 4 | export default { 5 | // change to your app-design.sketch file 6 | input: 'design/app-design.sketch', 7 | output: [{ 8 | type: 'svg', 9 | out: 'src-ui/src/svg-symbols.ts', 10 | artboard: /^ico\/.*/, 11 | flatten: '-' 12 | }] 13 | } -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "" 5 | authors = [""] 6 | license = "" 7 | repository = "" 8 | default-run = "app" 9 | edition = "2021" 10 | rust-version = "1.63" 11 | 12 | [build-dependencies] 13 | tauri-build = { version = "1", features = ["config-toml"] } 14 | 15 | [dependencies] 16 | tokio = { version = "1", features = ["full"] } 17 | ## -- Serde/Json 18 | serde_json = "1" 19 | serde = { version = "1", features = ["derive"] } 20 | serde_with_macros = "3" 21 | ## -- Tauri & SurrealDB 22 | tauri = { version = "1", features = [] } 23 | # For dev and to easy first time build experience, just have memory surrealdb for now. 24 | # Remove `default-feature=false, features = ...` to enable persistent storage. 25 | surrealdb = {version = "1.0.0-beta.9", default-features=false, features = ['kv-mem'] } 26 | ## -- Others 27 | parking_lot = "0.12" 28 | modql = "0.2.0" 29 | # NOTE: , features = ["format"] would be nice, but it is very heavy, and sometime have compiles issues with swc_ components 30 | ts-rs = { version = "6" } 31 | 32 | [dev-dependencies] 33 | anyhow = "1" 34 | 35 | [features] 36 | # by default Tauri runs in production mode 37 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 38 | default = [ "custom-protocol" ] 39 | # this feature is used used for production builds where `devPath` points to the filesystem 40 | # DO NOT remove this 41 | custom-protocol = [ "tauri/custom-protocol" ] 42 | -------------------------------------------------------------------------------- /src-tauri/Tauri.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | beforeBuildCommand = "" 3 | beforeDevCommand = "" 4 | devPath = "http://localhost:8080/" 5 | distDir = "../dist" 6 | 7 | [package] 8 | productName = "awesome-app" 9 | version = "0.1.0" 10 | 11 | [tauri.allowlist] 12 | all = false 13 | 14 | [tauri.bundle] 15 | active = true 16 | category = "DeveloperTool" 17 | copyright = "" 18 | externalBin = [ ] 19 | icon = [ 20 | "icons/32x32.png", 21 | "icons/128x128.png", 22 | "icons/128x128@2x.png", 23 | "icons/icon.icns", 24 | "icons/icon.ico" 25 | ] 26 | identifier = "my.awesome-app" 27 | longDescription = "" 28 | resources = [ ] 29 | shortDescription = "" 30 | targets = "all" 31 | 32 | [tauri.bundle.deb] 33 | depends = [ ] 34 | 35 | [tauri.bundle.macOS] 36 | exceptionDomain = "" 37 | frameworks = [ ] 38 | 39 | [tauri.bundle.windows] 40 | digestAlgorithm = "sha256" 41 | timestampUrl = "" 42 | 43 | [tauri.security] 44 | 45 | [tauri.updater] 46 | active = false 47 | 48 | [[tauri.windows]] 49 | title = "Awesome App" 50 | fullscreen = false 51 | resizable = true 52 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/icons/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomeapp-dev/rust-desktop-app/96af41cb9cf00adbef14f1b7c7e8df947c46115f/src-tauri/icons/app-icon.png -------------------------------------------------------------------------------- /src-tauri/src/ctx.rs: -------------------------------------------------------------------------------- 1 | //! Ctx is the context object passed through any IPC calls. 2 | //! It can be queried to get the necessary states/services to perform any steps of a request. 3 | //! 4 | //! Notes: 5 | //! - Simple implementation for now. 6 | //! - For cloud applications, this will be used for authorization. 7 | //! - Eventually, this will also be used for "full context" logging/tracing or even performance tracing. 8 | //! - For a single user, desktop application, this object is much simpler as authorization and logging requirements are much reduced. 9 | 10 | use crate::event::HubEvent; 11 | use crate::model::ModelStore; 12 | use crate::Result; 13 | use serde::Serialize; 14 | use std::sync::Arc; 15 | use tauri::{AppHandle, Manager, Wry}; 16 | 17 | pub struct Ctx { 18 | model_manager: Arc, 19 | app_handle: AppHandle, 20 | } 21 | 22 | impl Ctx { 23 | pub fn from_app(app: AppHandle) -> Result> { 24 | Ok(Arc::new(Ctx::new(app))) 25 | } 26 | } 27 | 28 | impl Ctx { 29 | pub fn new(app_handle: AppHandle) -> Self { 30 | Ctx { 31 | model_manager: (*app_handle.state::>()).clone(), 32 | app_handle, 33 | } 34 | } 35 | 36 | pub fn get_model_manager(&self) -> Arc { 37 | self.model_manager.clone() 38 | } 39 | 40 | pub fn emit_hub_event(&self, hub_event: HubEvent) { 41 | let _ = self.app_handle.emit_all("HubEvent", hub_event); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src-tauri/src/error.rs: -------------------------------------------------------------------------------- 1 | //! This is the main (and only for now) application Error type. 2 | //! It's using 'thiserror' as it reduces boilerplate error code while providing rich error typing. 3 | //! 4 | //! Notes: 5 | //! - The strategy is to start with one Error type for the whole application and then seggregate as needed. 6 | //! - Since everything is typed from the start, renaming and refactoring become relatively trivial. 7 | //! - By best practices, `anyhow` is not used in application code, but can be used in unit or integration test (will be in dev_dependencies when used) 8 | //! 9 | 10 | pub type Result = core::result::Result; 11 | 12 | #[derive(Debug)] 13 | pub enum Error { 14 | CtxFail, 15 | 16 | XValueNotOfType(&'static str), 17 | 18 | XPropertyNotFound(String), 19 | 20 | StoreFailToCreate(String), 21 | 22 | Modql(modql::Error), 23 | 24 | JsonSerde(serde_json::Error), 25 | 26 | ModqlOperatorNotSupported(String), 27 | 28 | Surreal(surrealdb::err::Error), 29 | 30 | IO(std::io::Error), 31 | } 32 | 33 | // region: --- Froms 34 | impl From for Error { 35 | fn from(val: modql::Error) -> Self { 36 | Error::Modql(val) 37 | } 38 | } 39 | impl From for Error { 40 | fn from(val: serde_json::Error) -> Self { 41 | Error::JsonSerde(val) 42 | } 43 | } 44 | impl From for Error { 45 | fn from(val: surrealdb::err::Error) -> Self { 46 | Error::Surreal(val) 47 | } 48 | } 49 | impl From for Error { 50 | fn from(val: std::io::Error) -> Self { 51 | Error::IO(val) 52 | } 53 | } 54 | // endregion: --- Froms 55 | 56 | // region: --- Error Boiler 57 | impl std::fmt::Display for Error { 58 | fn fmt(&self, fmt: &mut std::fmt::Formatter) -> core::result::Result<(), std::fmt::Error> { 59 | write!(fmt, "{self:?}") 60 | } 61 | } 62 | 63 | impl std::error::Error for Error {} 64 | // endregion: --- Error Boiler 65 | -------------------------------------------------------------------------------- /src-tauri/src/event.rs: -------------------------------------------------------------------------------- 1 | //! Event layer of the backend. 2 | //! 3 | //! Right now, very simple, just a HubEvent data type. 4 | //! 5 | 6 | use serde::Serialize; 7 | use ts_rs::TS; 8 | 9 | #[derive(TS, Serialize, Clone)] 10 | #[ts(export, export_to = "../src-ui/src/bindings/")] 11 | pub struct HubEvent { 12 | pub hub: String, 13 | pub topic: String, 14 | 15 | #[serde(skip_serializing_if = "Option::is_none")] 16 | pub label: Option, 17 | 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub data: Option, 20 | } 21 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/mod.rs: -------------------------------------------------------------------------------- 1 | //! `ipc` module and sub-modules are all Rust constructs necessary for the WebView to Rust Tauri IPC calls. 2 | //! 3 | //! At a high level it follows the "JSON-RPC 2.0" format 4 | //! - method_name - Will be the Tauri command function name) 5 | //! - params - Tauri commands will have one params argument by designed, called params (and state arguments) 6 | //! - response - Will be a IpcResponse with the JSON-RPC 2.0 result/error format back. 7 | //! 8 | //! The benefits of following the JSON-RPC 2.0 style is that it is simple, clean, and allows to wire the frontend to a 9 | //! JSON-RPC 2.0 cloud backend easily. 10 | //! 11 | //! Notes: 12 | //! - This module re-exports the appropriate sub-module constructs as their hierarchy is irrelevant to callers. 13 | 14 | mod params; 15 | mod project; 16 | mod response; 17 | mod task; 18 | 19 | // --- re-exports 20 | pub use params::*; 21 | pub use project::*; 22 | pub use response::*; 23 | pub use task::*; 24 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/params.rs: -------------------------------------------------------------------------------- 1 | //! Params types used in the IPC methods. 2 | //! 3 | //! The current best practice is to follow a single argument type, called "params" for all method (JSON-RPC's style). 4 | //! 5 | 6 | use serde::Deserialize; 7 | 8 | #[derive(Deserialize)] 9 | pub struct CreateParams { 10 | pub data: D, 11 | } 12 | 13 | #[derive(Deserialize)] 14 | pub struct UpdateParams { 15 | pub id: String, 16 | pub data: D, 17 | } 18 | 19 | #[derive(Deserialize)] 20 | pub struct ListParams { 21 | pub filter: Option, 22 | } 23 | 24 | #[derive(Deserialize)] 25 | pub struct GetParams { 26 | pub id: String, 27 | } 28 | 29 | #[derive(Deserialize)] 30 | pub struct DeleteParams { 31 | pub id: String, 32 | } 33 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/project.rs: -------------------------------------------------------------------------------- 1 | //! Tauri IPC commands to bridge Project Frontend Model Controller to Backend Model Controller 2 | //! 3 | 4 | use super::{CreateParams, DeleteParams, GetParams, IpcResponse, ListParams, UpdateParams}; 5 | use crate::ctx::Ctx; 6 | use crate::model::{ 7 | ModelMutateResultData, Project, ProjectBmc, ProjectForCreate, ProjectForUpdate, 8 | }; 9 | use crate::Error; 10 | use serde_json::Value; 11 | use tauri::{command, AppHandle, Wry}; 12 | 13 | #[command] 14 | pub async fn get_project(app: AppHandle, params: GetParams) -> IpcResponse { 15 | match Ctx::from_app(app) { 16 | Ok(ctx) => ProjectBmc::get(ctx, ¶ms.id).await.into(), 17 | Err(_) => Err(Error::CtxFail).into(), 18 | } 19 | } 20 | 21 | #[command] 22 | pub async fn create_project( 23 | app: AppHandle, 24 | params: CreateParams, 25 | ) -> IpcResponse { 26 | match Ctx::from_app(app) { 27 | Ok(ctx) => ProjectBmc::create(ctx, params.data).await.into(), 28 | Err(_) => Err(Error::CtxFail).into(), 29 | } 30 | } 31 | 32 | #[command] 33 | pub async fn update_project( 34 | app: AppHandle, 35 | params: UpdateParams, 36 | ) -> IpcResponse { 37 | match Ctx::from_app(app) { 38 | Ok(ctx) => ProjectBmc::update(ctx, ¶ms.id, params.data) 39 | .await 40 | .into(), 41 | Err(_) => Err(Error::CtxFail).into(), 42 | } 43 | } 44 | 45 | #[command] 46 | pub async fn delete_project( 47 | app: AppHandle, 48 | params: DeleteParams, 49 | ) -> IpcResponse { 50 | match Ctx::from_app(app) { 51 | Ok(ctx) => ProjectBmc::delete(ctx, ¶ms.id).await.into(), 52 | Err(_) => Err(Error::CtxFail).into(), 53 | } 54 | } 55 | 56 | #[command] 57 | pub async fn list_projects( 58 | app: AppHandle, 59 | params: ListParams, 60 | ) -> IpcResponse> { 61 | match Ctx::from_app(app) { 62 | Ok(ctx) => match params.filter.map(serde_json::from_value).transpose() { 63 | Ok(filter) => ProjectBmc::list(ctx, filter).await.into(), 64 | Err(err) => Err(Error::JsonSerde(err)).into(), 65 | }, 66 | Err(_) => Err(Error::CtxFail).into(), 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/response.rs: -------------------------------------------------------------------------------- 1 | //! Here we are following a "JSON-RPC 2.0" styleesponse with error or result. 2 | //! 3 | //! Notes: 4 | //! - For now, we do not handle the "request.id" of "JSON-RPC 2.0", and request batching 5 | //! but this could be added later. 6 | //! - The benefit of following the "JSON-RPC 2.0" scheme is that the frontend could be adapted to talk to a 7 | //! web server with minimum effort, and the JSON-RPC data format for request/response is simple, clean, and well thought out. 8 | 9 | use crate::Result; 10 | use serde::Serialize; 11 | 12 | #[derive(Serialize)] 13 | struct IpcError { 14 | message: String, 15 | } 16 | 17 | #[derive(Serialize)] 18 | pub struct IpcSimpleResult 19 | where 20 | D: Serialize, 21 | { 22 | pub data: D, 23 | } 24 | 25 | #[derive(Serialize)] 26 | pub struct IpcResponse 27 | where 28 | D: Serialize, 29 | { 30 | error: Option, 31 | result: Option>, 32 | } 33 | 34 | impl From> for IpcResponse 35 | where 36 | D: Serialize, 37 | { 38 | fn from(res: Result) -> Self { 39 | match res { 40 | Ok(data) => IpcResponse { 41 | error: None, 42 | result: Some(IpcSimpleResult { data }), 43 | }, 44 | Err(err) => IpcResponse { 45 | error: Some(IpcError { 46 | message: format!("{err}"), 47 | }), 48 | result: None, 49 | }, 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/src/ipc/task.rs: -------------------------------------------------------------------------------- 1 | //! Tauri IPC commands to bridge Task Frontend Model Controller to Backend Model Controller 2 | //! 3 | 4 | use crate::ctx::Ctx; 5 | use crate::ipc::{CreateParams, DeleteParams, GetParams, IpcResponse, ListParams, UpdateParams}; 6 | use crate::model::{ModelMutateResultData, Task, TaskBmc, TaskForCreate, TaskForUpdate}; 7 | use crate::Error; 8 | use serde_json::Value; 9 | use tauri::{command, AppHandle, Wry}; 10 | 11 | #[command] 12 | pub async fn get_task(app: AppHandle, params: GetParams) -> IpcResponse { 13 | match Ctx::from_app(app) { 14 | Ok(ctx) => TaskBmc::get(ctx, ¶ms.id).await.into(), 15 | Err(_) => Err(Error::CtxFail).into(), 16 | } 17 | } 18 | 19 | #[command] 20 | pub async fn create_task( 21 | app: AppHandle, 22 | params: CreateParams, 23 | ) -> IpcResponse { 24 | match Ctx::from_app(app) { 25 | Ok(ctx) => TaskBmc::create(ctx, params.data).await.into(), 26 | Err(_) => Err(Error::CtxFail).into(), 27 | } 28 | } 29 | 30 | #[command] 31 | pub async fn update_task( 32 | app: AppHandle, 33 | params: UpdateParams, 34 | ) -> IpcResponse { 35 | match Ctx::from_app(app) { 36 | Ok(ctx) => TaskBmc::update(ctx, ¶ms.id, params.data).await.into(), 37 | Err(_) => Err(Error::CtxFail).into(), 38 | } 39 | } 40 | 41 | #[command] 42 | pub async fn delete_task( 43 | app: AppHandle, 44 | params: DeleteParams, 45 | ) -> IpcResponse { 46 | match Ctx::from_app(app) { 47 | Ok(ctx) => TaskBmc::delete(ctx, ¶ms.id).await.into(), 48 | Err(_) => Err(Error::CtxFail).into(), 49 | } 50 | } 51 | 52 | #[command] 53 | pub async fn list_tasks(app: AppHandle, params: ListParams) -> IpcResponse> { 54 | // TODO: Needs to make error handling simpler (use ? rather than all into()) 55 | match Ctx::from_app(app) { 56 | Ok(ctx) => match params.filter.map(serde_json::from_value).transpose() { 57 | Ok(filter) => TaskBmc::list(ctx, filter).await.into(), 58 | Err(err) => Err(Error::JsonSerde(err)).into(), 59 | }, 60 | Err(_) => Err(Error::CtxFail).into(), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // #![allow(unused)] 2 | 3 | #![cfg_attr( 4 | all(not(debug_assertions), target_os = "windows"), 5 | windows_subsystem = "windows" 6 | )] 7 | 8 | // -- Re-Exports 9 | pub use error::{Error, Result}; 10 | 11 | // -- Imports 12 | use model::{seed_store_for_dev, ModelStore}; 13 | use std::sync::Arc; 14 | 15 | // -- Sub-Modules 16 | mod ctx; 17 | mod error; 18 | mod event; 19 | mod ipc; 20 | mod model; 21 | mod prelude; 22 | mod utils; 23 | 24 | #[tokio::main] 25 | async fn main() -> Result<()> { 26 | let model_manager = ModelStore::new().await?; 27 | let model_manager = Arc::new(model_manager); 28 | 29 | // for dev only 30 | seed_store_for_dev(model_manager.clone()).await?; 31 | 32 | tauri::Builder::default() 33 | .manage(model_manager) 34 | .invoke_handler(tauri::generate_handler![ 35 | // Project 36 | ipc::get_project, 37 | ipc::create_project, 38 | ipc::update_project, 39 | ipc::delete_project, 40 | ipc::list_projects, 41 | // Task 42 | ipc::get_task, 43 | ipc::create_task, 44 | ipc::update_task, 45 | ipc::delete_task, 46 | ipc::list_tasks, 47 | ]) 48 | .run(tauri::generate_context!()) 49 | .expect("error while running tauri application"); 50 | 51 | Ok(()) 52 | } 53 | -------------------------------------------------------------------------------- /src-tauri/src/model/bmc_base.rs: -------------------------------------------------------------------------------- 1 | //! Base and low level Backend Model Controller functions 2 | //! 3 | 4 | use super::store::{Creatable, Filterable, Patchable}; 5 | use super::{fire_model_event, ModelMutateResultData}; 6 | use crate::ctx::Ctx; 7 | use crate::{Error, Result}; 8 | use modql::ListOptions; 9 | use std::sync::Arc; 10 | use surrealdb::sql::Object; 11 | 12 | pub(super) async fn bmc_get(ctx: Arc, _entity: &'static str, id: &str) -> Result 13 | where 14 | E: TryFrom, 15 | { 16 | ctx.get_model_manager() 17 | .store() 18 | .exec_get(id) 19 | .await? 20 | .try_into() 21 | } 22 | 23 | pub(super) async fn bmc_create( 24 | ctx: Arc, 25 | entity: &'static str, 26 | data: D, 27 | ) -> Result 28 | where 29 | D: Creatable, 30 | { 31 | let id = ctx 32 | .get_model_manager() 33 | .store() 34 | .exec_create(entity, data) 35 | .await?; 36 | let result_data = ModelMutateResultData::from(id); 37 | 38 | fire_model_event(&ctx, entity, "create", result_data.clone()); 39 | 40 | Ok(result_data) 41 | } 42 | 43 | pub(super) async fn bmc_update( 44 | ctx: Arc, 45 | entity: &'static str, 46 | id: &str, 47 | data: D, 48 | ) -> Result 49 | where 50 | D: Patchable, 51 | { 52 | let id = ctx.get_model_manager().store().exec_merge(id, data).await?; 53 | 54 | let result_data = ModelMutateResultData::from(id); 55 | fire_model_event(&ctx, entity, "update", result_data.clone()); 56 | 57 | Ok(result_data) 58 | } 59 | 60 | pub(super) async fn bmc_delete( 61 | ctx: Arc, 62 | entity: &'static str, 63 | id: &str, 64 | ) -> Result { 65 | let id = ctx.get_model_manager().store().exec_delete(id).await?; 66 | let result_data = ModelMutateResultData::from(id); 67 | 68 | fire_model_event(&ctx, entity, "delete", result_data.clone()); 69 | 70 | Ok(result_data) 71 | } 72 | 73 | pub(super) async fn bmc_list( 74 | ctx: Arc, 75 | entity: &'static str, 76 | filter: Option, 77 | opts: ListOptions, 78 | ) -> Result> 79 | where 80 | E: TryFrom, 81 | F: Filterable + std::fmt::Debug, 82 | { 83 | // query for the Surreal Objects 84 | let objects = ctx 85 | .get_model_manager() 86 | .store() 87 | .exec_select(entity, filter.map(|f| f.filter_nodes(None)), opts) 88 | .await?; 89 | 90 | // then get the entities 91 | objects 92 | .into_iter() 93 | .map(|o| o.try_into()) 94 | .collect::>() 95 | } 96 | -------------------------------------------------------------------------------- /src-tauri/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! model module and sub-modules contain all of the model types and 2 | //! backend model controllers for the application. 3 | //! 4 | //! The application code call the model controllers, and the 5 | //! model controller calls the store and fire model events as appropriate. 6 | //! 7 | 8 | use crate::ctx::Ctx; 9 | use crate::event::HubEvent; 10 | use serde::Serialize; 11 | use store::SurrealStore; 12 | use ts_rs::TS; 13 | 14 | mod bmc_base; 15 | mod model_store; 16 | mod project; 17 | mod seed_for_dev; 18 | mod store; 19 | mod task; 20 | 21 | // --- Re-exports 22 | pub use model_store::*; 23 | pub use project::*; 24 | pub use task::*; 25 | // For dev only 26 | pub use seed_for_dev::seed_store_for_dev; 27 | 28 | // region: --- Model Event 29 | 30 | fn fire_model_event(ctx: &Ctx, entity: &str, action: &str, data: D) 31 | where 32 | D: Serialize + Clone, 33 | { 34 | ctx.emit_hub_event(HubEvent { 35 | hub: "Model".to_string(), 36 | topic: entity.to_string(), 37 | label: Some(action.to_string()), 38 | data: Some(data), 39 | }); 40 | } 41 | 42 | // endregion: --- Model Event 43 | 44 | // region: --- Common Model Result Data 45 | 46 | /// For now, all mutation queries will return an {id} struct. 47 | /// Note: Keep it light, and client can do a get if needed. 48 | #[derive(TS, Serialize, Clone)] 49 | #[ts(export, export_to = "../src-ui/src/bindings/")] 50 | pub struct ModelMutateResultData { 51 | pub id: String, 52 | } 53 | 54 | impl From for ModelMutateResultData { 55 | fn from(id: String) -> Self { 56 | Self { id } 57 | } 58 | } 59 | 60 | // endregion: --- Common Model Result Data 61 | 62 | // region: --- Tests 63 | #[cfg(test)] 64 | mod tests { 65 | use modql::filter::{FilterNodes, OpValString, OpValsString}; 66 | 67 | #[derive(Debug, FilterNodes)] 68 | struct ProjectFilter { 69 | id: Option, 70 | } 71 | 72 | #[test] 73 | fn test_simple() -> anyhow::Result<()> { 74 | let pf = ProjectFilter { 75 | id: Some(OpValString::Eq("hello".to_string()).into()), 76 | }; 77 | println!("{pf:?}"); 78 | Ok(()) 79 | } 80 | } 81 | // endregion: --- Tests 82 | -------------------------------------------------------------------------------- /src-tauri/src/model/model_store.rs: -------------------------------------------------------------------------------- 1 | //! ModelStore is just a Store wrapper so that Store does not have to be exposed outside the model module tree. 2 | //! 3 | //! This pattern allows to: 4 | //! 1) Expose only the "new" to outside the model module tree. 5 | //! 2) Access to the underlying store is allowed only for the model module tree. 6 | 7 | use super::SurrealStore; 8 | use crate::Result; 9 | 10 | pub struct ModelStore(SurrealStore); 11 | 12 | impl ModelStore { 13 | /// Create a new ModelStore instance and its corresponding SurrealStore 14 | pub async fn new() -> Result { 15 | Ok(ModelStore(SurrealStore::new().await?)) 16 | } 17 | 18 | pub(in crate::model) fn store(&self) -> &SurrealStore { 19 | &self.0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src-tauri/src/model/project.rs: -------------------------------------------------------------------------------- 1 | //! All model and controller for the Project type 2 | //! 3 | use super::bmc_base::{bmc_create, bmc_delete, bmc_get, bmc_list, bmc_update}; 4 | use super::store::{Creatable, Filterable, Patchable}; 5 | use super::ModelMutateResultData; 6 | use crate::ctx::Ctx; 7 | use crate::utils::XTake; 8 | use crate::{Error, Result}; 9 | use modql::filter::{FilterNodes, OpValsString}; 10 | use modql::ListOptions; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_with_macros::skip_serializing_none; 13 | use std::collections::BTreeMap; 14 | use std::sync::Arc; 15 | use surrealdb::sql::{Object, Value}; 16 | use ts_rs::TS; 17 | 18 | // region: --- Project 19 | 20 | #[derive(Serialize, TS, Debug)] 21 | #[ts(export, export_to = "../src-ui/src/bindings/")] 22 | pub struct Project { 23 | pub id: String, 24 | pub name: String, 25 | pub ctime: String, 26 | } 27 | 28 | impl TryFrom for Project { 29 | type Error = Error; 30 | fn try_from(mut val: Object) -> Result { 31 | let project = Project { 32 | id: val.x_take_val("id")?, 33 | name: val.x_take_val("name")?, 34 | ctime: val.x_take_val::("ctime")?.to_string(), 35 | }; 36 | 37 | Ok(project) 38 | } 39 | } 40 | 41 | // endregion: --- Project 42 | 43 | // region: --- ProjectForCreate 44 | 45 | #[skip_serializing_none] 46 | #[derive(Deserialize, TS, Debug)] 47 | #[ts(export, export_to = "../src-ui/src/bindings/")] 48 | pub struct ProjectForCreate { 49 | pub name: String, 50 | } 51 | 52 | impl From for Value { 53 | fn from(val: ProjectForCreate) -> Self { 54 | BTreeMap::from([ 55 | // Note: could have used map![.. => ..] as well 56 | ("name".into(), val.name.into()), 57 | ]) 58 | .into() 59 | } 60 | } 61 | 62 | impl Creatable for ProjectForCreate {} 63 | 64 | // endregion: --- ProjectForCreate 65 | 66 | // region: --- ProjectForUpdate 67 | 68 | #[skip_serializing_none] 69 | #[derive(Deserialize, TS, Debug)] 70 | #[ts(export, export_to = "../src-ui/src/bindings/")] 71 | pub struct ProjectForUpdate { 72 | pub name: Option, 73 | } 74 | 75 | impl From for Value { 76 | fn from(val: ProjectForUpdate) -> Self { 77 | let mut data = BTreeMap::new(); 78 | if let Some(name) = val.name { 79 | data.insert("name".into(), name.into()); 80 | } 81 | data.into() 82 | } 83 | } 84 | 85 | impl Patchable for ProjectForUpdate {} 86 | 87 | // endregion: --- ProjectForUpdate 88 | 89 | // region: --- ProjectFilter 90 | 91 | #[derive(FilterNodes, Deserialize, Debug)] 92 | pub struct ProjectFilter { 93 | pub id: Option, 94 | pub name: Option, 95 | } 96 | 97 | impl Filterable for ProjectFilter {} 98 | 99 | // endregion: --- ProjectFilter 100 | 101 | // region: --- ProjectBmc 102 | 103 | pub struct ProjectBmc; 104 | 105 | impl ProjectBmc { 106 | const ENTITY: &'static str = "project"; 107 | 108 | pub async fn get(ctx: Arc, id: &str) -> Result { 109 | bmc_get(ctx, Self::ENTITY, id).await 110 | } 111 | 112 | pub async fn create(ctx: Arc, data: ProjectForCreate) -> Result { 113 | bmc_create(ctx, Self::ENTITY, data).await 114 | } 115 | 116 | pub async fn update( 117 | ctx: Arc, 118 | id: &str, 119 | data: ProjectForUpdate, 120 | ) -> Result { 121 | bmc_update(ctx, Self::ENTITY, id, data).await 122 | } 123 | 124 | pub async fn delete(ctx: Arc, id: &str) -> Result { 125 | bmc_delete(ctx, Self::ENTITY, id).await 126 | } 127 | 128 | pub async fn list(ctx: Arc, filter: Option) -> Result> { 129 | bmc_list(ctx, Self::ENTITY, filter, ListOptions::default()).await 130 | } 131 | } 132 | 133 | // endregion: --- ProjectBmc 134 | -------------------------------------------------------------------------------- /src-tauri/src/model/seed_for_dev.rs: -------------------------------------------------------------------------------- 1 | use crate::model::{ProjectForCreate, TaskForCreate}; 2 | use crate::Result; 3 | use std::sync::Arc; 4 | 5 | use super::ModelStore; 6 | 7 | /// Only use while developing. Convenient when to seed the store on start of the application. 8 | pub async fn seed_store_for_dev(model_manager: Arc) -> Result<()> { 9 | let ps = ["A", "B"].into_iter().map(|k| { 10 | ( 11 | k, 12 | ProjectForCreate { 13 | name: format!("Project {k}"), 14 | }, 15 | ) 16 | }); 17 | 18 | for (k, project) in ps { 19 | let project_id = model_manager 20 | .store() 21 | .exec_create::("project", project) 22 | .await?; 23 | 24 | for i in 1..=200 { 25 | let done = i % 2 == 0; 26 | let task = TaskForCreate { 27 | project_id: project_id.clone(), 28 | title: format!("Task {k}.{i}"), 29 | desc: None, 30 | done: Some(done), 31 | }; 32 | 33 | model_manager 34 | .store() 35 | .exec_create::("task", task) 36 | .await?; 37 | } 38 | } 39 | 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src-tauri/src/model/store/mod.rs: -------------------------------------------------------------------------------- 1 | use modql::filter::IntoFilterNodes; 2 | use surrealdb::sql::Value; 3 | 4 | mod surreal_modql; 5 | mod surreal_store; 6 | mod try_froms; 7 | mod x_take_impl; 8 | 9 | // --- Re-export 10 | pub(super) use surreal_store::SurrealStore; 11 | 12 | // --- Marker traits for types that can be used for query. 13 | pub trait Creatable: Into {} 14 | pub trait Patchable: Into {} 15 | pub trait Filterable: IntoFilterNodes {} 16 | -------------------------------------------------------------------------------- /src-tauri/src/model/store/surreal_modql.rs: -------------------------------------------------------------------------------- 1 | //! ModQL implementation for the surrealdb store. 2 | //! 3 | //! For now the following is implemented: 4 | //! 5 | //! - FilterNodes with FilterGroups 6 | //! - ListOptions.offset 7 | //! - ListOptions.limit 8 | //! - ListOptions.order_by 9 | //! 10 | //! TODO: Implements the IncludeNodes when available in ModQL. 11 | //! 12 | 13 | use crate::prelude::*; 14 | use crate::{Error, Result}; 15 | use modql::filter::{FilterGroups, OpVal, OpValBool, OpValFloat64, OpValInt64, OpValString}; 16 | use modql::ListOptions; 17 | use std::collections::BTreeMap; 18 | use surrealdb::sql::Value; 19 | 20 | pub(super) fn build_select_query( 21 | tb: &str, 22 | or_groups: Option, 23 | list_options: ListOptions, 24 | ) -> Result<(String, BTreeMap)> { 25 | let mut sql = String::from("SELECT * FROM type::table($tb)"); 26 | 27 | let mut vars = BTreeMap::from([("tb".into(), tb.into())]); 28 | 29 | // --- Apply the filter 30 | if let Some(or_groups) = or_groups { 31 | let mut idx = 0; 32 | sql.push_str(" WHERE"); 33 | 34 | // For each OR group 35 | for (group_idx, filter_nodes) in or_groups.groups().iter().enumerate() { 36 | if group_idx > 0 { 37 | sql.push_str(" OR"); 38 | } 39 | 40 | // The AND filters 41 | sql.push_str(" ("); 42 | for (node_idx, filter_node) in filter_nodes.nodes().iter().enumerate() { 43 | let key = &filter_node.name; 44 | for opval in &filter_node.opvals { 45 | let var = f!("w{idx}"); 46 | if node_idx > 0 { 47 | sql.push_str(" AND"); 48 | } 49 | // fix me, needs to take it from op_val 50 | let (sql_el, val) = sqlize(opval.clone(), key, &var)?; 51 | sql.push_str(&f!(" {sql_el}")); 52 | vars.insert(var, val); 53 | 54 | idx += 1; 55 | } 56 | } 57 | sql.push_str(" )"); 58 | } 59 | } 60 | 61 | // --- Apply the orderby 62 | if let Some(order_bys) = list_options.order_bys { 63 | sql.push_str(" ORDER BY "); 64 | let obs = order_bys 65 | .order_bys() 66 | .into_iter() 67 | .map(|o| o.to_string()) 68 | .collect::>(); 69 | let obs = obs.join(","); 70 | sql.push_str(&obs); 71 | } 72 | 73 | // --- Apply the limit 74 | if let Some(limit) = list_options.limit { 75 | sql.push_str(&f!(" LIMIT {limit}")); 76 | } 77 | 78 | // --- Apply the offset 79 | if let Some(offset) = list_options.offset { 80 | sql.push_str(&f!(" START {offset}")); 81 | } 82 | 83 | Ok((sql, vars)) 84 | } 85 | 86 | /// Private helper to sqlize a a OpVal for SurrealDB 87 | /// 88 | /// # 89 | fn sqlize(opval: OpVal, prop_name: &str, var_idx: &str) -> Result<(String, Value)> { 90 | Ok(match opval { 91 | // Eq 92 | OpVal::String(OpValString::Eq(v)) => (f!("{prop_name} = ${var_idx}"), v.into()), 93 | OpVal::Int64(OpValInt64::Eq(v)) => (f!("{prop_name} = ${var_idx}"), v.into()), 94 | OpVal::Float64(OpValFloat64::Eq(v)) => (f!("{prop_name} = ${var_idx}"), v.into()), 95 | OpVal::Bool(OpValBool::Eq(v)) => (f!("{prop_name} = ${var_idx}"), v.into()), 96 | // Not 97 | OpVal::String(OpValString::Not(v)) => (f!("{prop_name} != ${var_idx}"), v.into()), 98 | OpVal::Int64(OpValInt64::Not(v)) => (f!("{prop_name} != ${var_idx}"), v.into()), 99 | OpVal::Float64(OpValFloat64::Not(v)) => (f!("{prop_name} != ${var_idx}"), v.into()), 100 | OpVal::Bool(OpValBool::Not(v)) => (f!("{prop_name} != ${var_idx}"), v.into()), 101 | // < 102 | OpVal::String(OpValString::Lt(v)) => (f!("{prop_name} < ${var_idx}"), v.into()), 103 | OpVal::Int64(OpValInt64::Lt(v)) => (f!("{prop_name} < ${var_idx}"), v.into()), 104 | OpVal::Float64(OpValFloat64::Lt(v)) => (f!("{prop_name} < ${var_idx}"), v.into()), 105 | // <= 106 | OpVal::String(OpValString::Lte(v)) => (f!("{prop_name} < ${var_idx}"), v.into()), 107 | OpVal::Int64(OpValInt64::Lte(v)) => (f!("{prop_name} < ${var_idx}"), v.into()), 108 | OpVal::Float64(OpValFloat64::Lte(v)) => (f!("{prop_name} < ${var_idx}"), v.into()), 109 | // > 110 | OpVal::String(OpValString::Gt(v)) => (f!("{prop_name} > ${var_idx}"), v.into()), 111 | OpVal::Int64(OpValInt64::Gt(v)) => (f!("{prop_name} > ${var_idx}"), v.into()), 112 | OpVal::Float64(OpValFloat64::Gt(v)) => (f!("{prop_name} > ${var_idx}"), v.into()), 113 | // >= 114 | OpVal::String(OpValString::Gte(v)) => (f!("{prop_name} > ${var_idx}"), v.into()), 115 | OpVal::Int64(OpValInt64::Gte(v)) => (f!("{prop_name} > ${var_idx}"), v.into()), 116 | OpVal::Float64(OpValFloat64::Gte(v)) => (f!("{prop_name} > ${var_idx}"), v.into()), 117 | 118 | // contains 119 | OpVal::String(OpValString::Contains(v)) => { 120 | (f!("{prop_name} CONTAINS ${var_idx}"), v.into()) 121 | } 122 | 123 | // startsWith 124 | OpVal::String(OpValString::StartsWith(v)) => { 125 | (f!("string::startsWith({prop_name}, ${var_idx}) "), v.into()) 126 | } 127 | 128 | // endsWith 129 | OpVal::String(OpValString::EndsWith(v)) => { 130 | (f!("string::endsWith({prop_name}, ${var_idx}) "), v.into()) 131 | } 132 | 133 | _ => return Err(Error::ModqlOperatorNotSupported(f!("{opval:?}"))), 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /src-tauri/src/model/store/surreal_store.rs: -------------------------------------------------------------------------------- 1 | //! Small store layer to talk to the SurrealDB. 2 | //! 3 | //! This module is to narrow and normalize the surrealdb API surface 4 | //! to the rest of the application code (.e.g, Backend Model Controllers) 5 | 6 | use crate::model::store::surreal_modql::build_select_query; 7 | use crate::model::store::{Creatable, Patchable}; 8 | use crate::prelude::*; 9 | use crate::utils::{map, XTake}; 10 | use crate::{Error, Result}; 11 | use modql::filter::FilterGroups; 12 | use modql::ListOptions; 13 | use surrealdb::dbs::Session; 14 | use surrealdb::kvs::Datastore; 15 | use surrealdb::sql::{thing, Array, Datetime, Object, Value}; 16 | 17 | // --- Store definition and implementation 18 | // Note: This is used to normalize the store access for what is 19 | // needed for this application. 20 | 21 | /// Store struct normalizing CRUD SurrealDB application calls 22 | pub(in crate::model) struct SurrealStore { 23 | ds: Datastore, 24 | ses: Session, 25 | } 26 | 27 | impl SurrealStore { 28 | pub(in crate::model) async fn new() -> Result { 29 | let ds = Datastore::new("memory").await?; 30 | let ses = Session::for_db("appns", "appdb"); 31 | Ok(SurrealStore { ds, ses }) 32 | } 33 | 34 | pub(in crate::model) async fn exec_get(&self, tid: &str) -> Result { 35 | let sql = "SELECT * FROM $th"; 36 | 37 | let vars = map!["th".into() => thing(tid)?.into()]; 38 | 39 | let ress = self.ds.execute(sql, &self.ses, Some(vars), true).await?; 40 | 41 | let first_res = ress.into_iter().next().expect("Did not get a response"); 42 | 43 | W(first_res.result?.first()).try_into() 44 | } 45 | 46 | pub(in crate::model) async fn exec_create( 47 | &self, 48 | tb: &str, 49 | data: T, 50 | ) -> Result { 51 | let sql = "CREATE type::table($tb) CONTENT $data RETURN id"; 52 | 53 | let mut data: Object = W(data.into()).try_into()?; 54 | let now = Datetime::default().timestamp_nanos(); 55 | data.insert("ctime".into(), now.into()); 56 | 57 | let vars = map![ 58 | "tb".into() => tb.into(), 59 | "data".into() => Value::from(data)]; 60 | 61 | let ress = self.ds.execute(sql, &self.ses, Some(vars), false).await?; 62 | let first_val = ress 63 | .into_iter() 64 | .next() 65 | .map(|r| r.result) 66 | .expect("id not returned")?; 67 | 68 | if let Value::Object(mut val) = first_val.first() { 69 | val.x_take_val::("id") 70 | .map_err(|ex| Error::StoreFailToCreate(f!("exec_create {tb} {ex}"))) 71 | } else { 72 | Err(Error::StoreFailToCreate(f!( 73 | "exec_create {tb}, nothing returned." 74 | ))) 75 | } 76 | } 77 | 78 | pub(in crate::model) async fn exec_merge( 79 | &self, 80 | tid: &str, 81 | data: T, 82 | ) -> Result { 83 | let sql = "UPDATE $th MERGE $data RETURN id"; 84 | 85 | let vars = map![ 86 | "th".into() => thing(tid)?.into(), 87 | "data".into() => data.into()]; 88 | 89 | let ress = self.ds.execute(sql, &self.ses, Some(vars), true).await?; 90 | 91 | let first_res = ress.into_iter().next().expect("id not returned"); 92 | 93 | let result = first_res.result?; 94 | 95 | if let Value::Object(mut val) = result.first() { 96 | val.x_take_val("id") 97 | } else { 98 | Err(Error::StoreFailToCreate(f!( 99 | "exec_merge {tid}, nothing returned." 100 | ))) 101 | } 102 | } 103 | 104 | pub(in crate::model) async fn exec_delete(&self, tid: &str) -> Result { 105 | let sql = "DELETE $th"; 106 | 107 | let vars = map!["th".into() => thing(tid)?.into()]; 108 | 109 | let ress = self.ds.execute(sql, &self.ses, Some(vars), false).await?; 110 | 111 | let first_res = ress.into_iter().next().expect("Did not get a response"); 112 | 113 | // Return the error if result failed 114 | first_res.result?; 115 | 116 | // return success 117 | Ok(tid.to_string()) 118 | } 119 | 120 | pub(in crate::model) async fn exec_select>( 121 | &self, 122 | tb: &str, 123 | filter_groups: Option, 124 | list_options: ListOptions, 125 | ) -> Result> { 126 | let filter_or_groups = filter_groups.map(|v| v.into()); 127 | let (sql, vars) = build_select_query(tb, filter_or_groups, list_options)?; 128 | 129 | let ress = self.ds.execute(&sql, &self.ses, Some(vars), false).await?; 130 | 131 | let first_res = ress.into_iter().next().expect("Did not get a response"); 132 | 133 | // Get the result value as value array (fail if it is not) 134 | let array: Array = W(first_res.result?).try_into()?; 135 | 136 | // build the list of objects 137 | array.into_iter().map(|value| W(value).try_into()).collect() 138 | } 139 | } 140 | 141 | // region: --- Tests 142 | #[cfg(test)] 143 | mod tests { 144 | use modql::filter::*; 145 | use std::sync::Arc; 146 | use tokio::sync::OnceCell; 147 | 148 | use crate::model::ModelStore; 149 | use crate::utils::XTake; 150 | use modql::ListOptions; 151 | 152 | static STORE_ONCE: OnceCell> = OnceCell::const_new(); 153 | 154 | /// Initialize store once for this unit test group. 155 | /// Will panic if can't create store. 156 | async fn get_shared_test_store() -> Arc { 157 | STORE_ONCE 158 | .get_or_init(|| async { 159 | // create and seed the store 160 | let model_manager = ModelStore::new().await.unwrap(); 161 | let model_manager = Arc::new(model_manager); 162 | 163 | crate::model::seed_store_for_dev(model_manager.clone()) 164 | .await 165 | .unwrap(); 166 | model_manager 167 | }) 168 | .await 169 | .clone() 170 | } 171 | 172 | #[derive(Debug, FilterNodes)] 173 | struct ProjectFilter { 174 | pub id: Option, 175 | pub name: Option, 176 | pub some_other: Option, 177 | } 178 | 179 | #[derive(Debug, FilterNodes)] 180 | struct TaskFilter { 181 | pub project_id: Option, 182 | pub title: Option, 183 | pub done: Option, 184 | pub desc: Option, 185 | } 186 | 187 | #[test] 188 | fn test_surreal_build_select_query() -> anyhow::Result<()> { 189 | let filter = ProjectFilter { 190 | id: Some(OpValInt64::Lt(1).into()), 191 | name: Some(OpValString::Eq("Hello".to_string()).into()), 192 | some_other: None, 193 | }; 194 | let filter_nodes: Vec = filter.into(); 195 | 196 | let (sql, vars) = super::build_select_query( 197 | "project", 198 | Some(filter_nodes.into()), 199 | ListOptions::default(), 200 | )?; 201 | 202 | assert!(sql.contains("id <"), "should contain id <"); 203 | assert!(sql.contains("name ="), "should contain name ="); 204 | assert!(sql.contains("$w1"), "should contain $w1"); 205 | // should have 3 vars, one for the $tb, and one per var 206 | assert_eq!(vars.len(), 3, "should have e vars"); 207 | 208 | Ok(()) 209 | } 210 | 211 | #[tokio::test] 212 | async fn test_surreal_simple_project_select() -> anyhow::Result<()> { 213 | // --- FIXTURE 214 | let model_manager = get_shared_test_store().await; 215 | let filter = ProjectFilter { 216 | id: None, 217 | name: Some(OpValString::Eq("Project A".to_string()).into()), 218 | some_other: None, 219 | }; 220 | 221 | // --- EXEC 222 | let mut rs = model_manager 223 | .store() 224 | .exec_select("project", Some(filter), ListOptions::default()) 225 | .await?; 226 | 227 | // --- CHECKS 228 | assert_eq!(rs.len(), 1, "number of projects returned"); 229 | let mut obj = rs.pop().unwrap(); 230 | assert_eq!(obj.x_take::("name")?.unwrap(), "Project A"); 231 | 232 | Ok(()) 233 | } 234 | 235 | #[tokio::test] 236 | async fn test_surreal_simple_task_select() -> anyhow::Result<()> { 237 | // --- FIXTURE 238 | let model_manager = get_shared_test_store().await; 239 | 240 | // get the "Project A" project_id 241 | let project_filter_node = FilterNode::from(("name", "Project A")); 242 | let mut rs = model_manager 243 | .store() 244 | .exec_select("project", Some(project_filter_node), ListOptions::default()) 245 | .await?; 246 | let project_id = rs.pop().unwrap().x_take_val::("id")?; 247 | 248 | let filter = TaskFilter { 249 | project_id: Some(OpValString::from(project_id).into()), 250 | title: None, 251 | done: Some(OpValBool::Eq(true).into()), 252 | desc: None, 253 | }; 254 | 255 | // --- EXEC 256 | let rs = model_manager 257 | .store() 258 | .exec_select("task", Some(filter), ListOptions::default()) 259 | .await?; 260 | 261 | // --- CHECKS 262 | assert_eq!( 263 | rs.len(), 264 | 100, 265 | "Result length (for Project A & done: true tasks" 266 | ); 267 | 268 | Ok(()) 269 | } 270 | 271 | #[tokio::test] 272 | async fn test_surreal_select_contains() -> anyhow::Result<()> { 273 | // --- FIXTURE 274 | let model_manager = get_shared_test_store().await; 275 | let filter_node = FilterNode::from(("title", OpValString::Contains("200".into()))); 276 | 277 | // --- EXEC 278 | let mut rs = model_manager 279 | .store() 280 | .exec_select( 281 | "task", 282 | Some(filter_node), 283 | ListOptions { 284 | order_bys: Some("title".into()), 285 | ..Default::default() 286 | }, 287 | ) 288 | .await?; 289 | 290 | // --- CHECK 291 | assert_eq!( 292 | "Task B.200", 293 | rs.pop().unwrap().x_take_val::("title")? 294 | ); 295 | assert_eq!( 296 | "Task A.200", 297 | rs.pop().unwrap().x_take_val::("title")? 298 | ); 299 | 300 | Ok(()) 301 | } 302 | 303 | #[tokio::test] 304 | async fn test_surreal_select_starts_with() -> anyhow::Result<()> { 305 | // --- FIXTURE 306 | let model_manager = get_shared_test_store().await; 307 | let filter_node = FilterNode::from(("title", OpValString::StartsWith("Task A.1".into()))); 308 | 309 | // --- EXEC 310 | let rs = model_manager 311 | .store() 312 | .exec_select("task", Some(filter_node), ListOptions::default()) 313 | .await?; 314 | 315 | // --- CHECK 316 | assert_eq!(rs.len(), 111, "Number of tasks starting with 'Task A.1'"); 317 | 318 | Ok(()) 319 | } 320 | 321 | #[tokio::test] 322 | async fn test_surreal_select_ends_with() -> anyhow::Result<()> { 323 | // --- FIXTURE 324 | let model_manager = get_shared_test_store().await; 325 | let filter_node = FilterNode::from(("title", OpValString::EndsWith("11".into()))); 326 | 327 | // --- EXEC 328 | let rs = model_manager 329 | .store() 330 | .exec_select("task", Some(filter_node), ListOptions::default()) 331 | .await?; 332 | 333 | // --- CHECK 334 | assert_eq!(rs.len(), 4, "Number of tasks ending with '11'"); 335 | 336 | Ok(()) 337 | } 338 | 339 | #[tokio::test] 340 | async fn test_surreal_select_or() -> anyhow::Result<()> { 341 | // --- FIXTURE 342 | let model_manager = get_shared_test_store().await; 343 | let filter_nodes_1: Vec = vec![FilterNode::from(( 344 | "title", 345 | OpValString::EndsWith("11".into()), 346 | ))]; 347 | let filter_nodes_2: Vec = vec![FilterNode::from(( 348 | "title", 349 | OpValString::EndsWith("22".into()), 350 | ))]; 351 | 352 | // --- EXEC 353 | let rs = model_manager 354 | .store() 355 | .exec_select( 356 | "task", 357 | Some(vec![filter_nodes_1, filter_nodes_2]), 358 | ListOptions::default(), 359 | ) 360 | .await?; 361 | 362 | // --- CHECK 363 | assert_eq!(rs.len(), 8, "Number of tasks ending with '11' OR '22'"); 364 | 365 | Ok(()) 366 | } 367 | 368 | #[tokio::test] 369 | async fn test_surreal_select_order_bys() -> anyhow::Result<()> { 370 | // --- FIXTURE 371 | let model_manager = get_shared_test_store().await; 372 | let filter_nodes_1 = vec![FilterNode::from(( 373 | "title", 374 | OpValString::EndsWith("11".into()), 375 | ))]; 376 | let filter_nodes_2 = vec![FilterNode::from(( 377 | "title", 378 | OpValString::EndsWith("22".into()), 379 | ))]; 380 | 381 | let list_options = ListOptions { 382 | order_bys: Some(vec!["done", "!title"].into()), 383 | ..Default::default() 384 | }; 385 | 386 | // --- EXEC 387 | let rs = model_manager 388 | .store() 389 | .exec_select( 390 | "task", 391 | Some(vec![filter_nodes_1, filter_nodes_2]), 392 | list_options, 393 | ) 394 | .await?; 395 | 396 | // --- CHECK 397 | assert_eq!(rs.len(), 8, "Number of tasks ending with '11' OR '22'"); 398 | // TODO: Need to check the order 399 | 400 | // for mut obj in rs.into_iter() { 401 | // println!( 402 | // "{:?} {:?}", 403 | // obj.x_take_val::("title")?, 404 | // obj.x_take_val::("done")? 405 | // ); 406 | // } 407 | 408 | Ok(()) 409 | } 410 | 411 | #[tokio::test] 412 | async fn test_surreal_select_offset_limit() -> anyhow::Result<()> { 413 | // --- FIXTURE 414 | let model_manager = get_shared_test_store().await; 415 | let filter_nodes_1 = vec![FilterNode::from(( 416 | "title", 417 | OpValString::EndsWith("11".into()), 418 | ))]; 419 | let filter_nodes_2 = vec![FilterNode::from(( 420 | "title", 421 | OpValString::EndsWith("22".into()), 422 | ))]; 423 | 424 | let list_options = ListOptions { 425 | order_bys: Some(vec!["done", "title"].into()), 426 | limit: Some(2), 427 | offset: Some(1), 428 | }; 429 | 430 | // --- EXEC 431 | let mut rs = model_manager 432 | .store() 433 | .exec_select( 434 | "task", 435 | Some(vec![filter_nodes_1, filter_nodes_2]), 436 | list_options, 437 | ) 438 | .await?; 439 | 440 | // --- CHECK 441 | assert_eq!(rs.len(), 2, "Number of tasks when Limit = 2"); 442 | // Check tasks 443 | // Note: This will reverse order checked as we are usin pop. 444 | assert_eq!( 445 | "Task B.11", 446 | rs.pop().unwrap().x_take_val::("title")? 447 | ); 448 | assert_eq!( 449 | "Task A.111", 450 | rs.pop().unwrap().x_take_val::("title")? 451 | ); 452 | 453 | // --- Visualy check results 454 | // for mut obj in rs.into_iter() { 455 | // println!( 456 | // "{:?} {:?}", 457 | // obj.x_take_val::("title")?, 458 | // obj.x_take_val::("done")? 459 | // ); 460 | // } 461 | 462 | Ok(()) 463 | } 464 | } 465 | // endregion: --- Tests 466 | -------------------------------------------------------------------------------- /src-tauri/src/model/store/try_froms.rs: -------------------------------------------------------------------------------- 1 | //! TryFrom implementations for store related types 2 | 3 | use crate::prelude::*; 4 | use crate::{Error, Result}; 5 | use surrealdb::sql::{Array, Object, Value}; 6 | 7 | impl TryFrom> for Object { 8 | type Error = Error; 9 | fn try_from(val: W) -> Result { 10 | match val.0 { 11 | Value::Object(obj) => Ok(obj), 12 | _ => Err(Error::XValueNotOfType("Object")), 13 | } 14 | } 15 | } 16 | 17 | impl TryFrom> for Array { 18 | type Error = Error; 19 | fn try_from(val: W) -> Result { 20 | match val.0 { 21 | Value::Array(obj) => Ok(obj), 22 | _ => Err(Error::XValueNotOfType("Array")), 23 | } 24 | } 25 | } 26 | 27 | impl TryFrom> for i64 { 28 | type Error = Error; 29 | fn try_from(val: W) -> Result { 30 | match val.0 { 31 | Value::Number(obj) => Ok(obj.as_int()), 32 | _ => Err(Error::XValueNotOfType("i64")), 33 | } 34 | } 35 | } 36 | 37 | impl TryFrom> for bool { 38 | type Error = Error; 39 | fn try_from(val: W) -> Result { 40 | match val.0 { 41 | Value::False => Ok(false), 42 | Value::True => Ok(true), 43 | _ => Err(Error::XValueNotOfType("bool")), 44 | } 45 | } 46 | } 47 | 48 | impl TryFrom> for String { 49 | type Error = Error; 50 | fn try_from(val: W) -> Result { 51 | match val.0 { 52 | Value::Strand(strand) => Ok(strand.as_string()), 53 | Value::Thing(thing) => Ok(thing.to_string()), 54 | _ => Err(Error::XValueNotOfType("String")), 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src-tauri/src/model/store/x_take_impl.rs: -------------------------------------------------------------------------------- 1 | //! XTakeImpl implementations for the surrealdb Object types. 2 | //! 3 | //! Note: Implement the `XTakeImpl' trait on objects, will provide the 4 | //! `XTake` trait (by blanket implementation)with `.x_take(key)` 5 | //! and `.x_take_val(key)`. 6 | 7 | use crate::prelude::*; 8 | use crate::utils::XTakeImpl; 9 | use crate::Result; 10 | use surrealdb::sql::Object; 11 | 12 | impl XTakeImpl for Object { 13 | fn x_take_impl(&mut self, k: &str) -> Result> { 14 | let v = self.remove(k).map(|v| W(v).try_into()); 15 | match v { 16 | None => Ok(None), 17 | Some(Ok(val)) => Ok(Some(val)), 18 | Some(Err(ex)) => Err(ex), 19 | } 20 | } 21 | } 22 | 23 | impl XTakeImpl for Object { 24 | fn x_take_impl(&mut self, k: &str) -> Result> { 25 | let v = self.remove(k).map(|v| W(v).try_into()); 26 | match v { 27 | None => Ok(None), 28 | Some(Ok(val)) => Ok(Some(val)), 29 | Some(Err(ex)) => Err(ex), 30 | } 31 | } 32 | } 33 | 34 | impl XTakeImpl for Object { 35 | fn x_take_impl(&mut self, k: &str) -> Result> { 36 | Ok(self.remove(k).map(|v| v.is_true())) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src-tauri/src/model/task.rs: -------------------------------------------------------------------------------- 1 | //! All model and controller for the Item type 2 | //! 3 | 4 | use super::bmc_base::{bmc_create, bmc_delete, bmc_get, bmc_list, bmc_update}; 5 | use super::store::{Creatable, Filterable, Patchable}; 6 | use super::ModelMutateResultData; 7 | use crate::ctx::Ctx; 8 | use crate::utils::{map, XTake}; 9 | use crate::{Error, Result}; 10 | use modql::filter::{FilterNodes, OpValsString}; 11 | use modql::ListOptions; 12 | use serde::{Deserialize, Serialize}; 13 | use serde_with_macros::skip_serializing_none; 14 | use std::collections::BTreeMap; 15 | use std::sync::Arc; 16 | use surrealdb::sql::{Object, Value}; 17 | use ts_rs::TS; 18 | 19 | // region: --- Task 20 | 21 | #[skip_serializing_none] 22 | #[derive(Serialize, TS, Debug)] 23 | #[ts(export, export_to = "../src-ui/src/bindings/")] 24 | pub struct Task { 25 | pub id: String, 26 | pub ctime: String, 27 | pub project_id: String, 28 | 29 | pub done: bool, 30 | pub title: String, 31 | pub desc: Option, 32 | } 33 | 34 | impl TryFrom for Task { 35 | type Error = Error; 36 | fn try_from(mut val: Object) -> Result { 37 | let task = Task { 38 | id: val.x_take_val("id")?, 39 | ctime: val.x_take_val::("ctime")?.to_string(), 40 | project_id: val.x_take_val("project_id")?, 41 | done: val.x_take_val("done")?, 42 | title: val.x_take_val("title")?, 43 | desc: val.x_take("desc")?, 44 | }; 45 | 46 | Ok(task) 47 | } 48 | } 49 | 50 | // endregion: --- Task 51 | 52 | // region: --- TaskForCreate 53 | 54 | #[skip_serializing_none] 55 | #[derive(Deserialize, TS, Debug)] 56 | #[ts(export, export_to = "../src-ui/src/bindings/")] 57 | pub struct TaskForCreate { 58 | pub project_id: String, 59 | pub title: String, 60 | pub done: Option, 61 | pub desc: Option, 62 | } 63 | 64 | impl From for Value { 65 | fn from(val: TaskForCreate) -> Self { 66 | let mut data = map![ 67 | "project_id".into() => val.project_id.into(), 68 | "title".into() => val.title.into(), 69 | ]; 70 | 71 | // default for done is false 72 | data.insert("done".into(), val.done.unwrap_or(false).into()); 73 | 74 | if let Some(desc) = val.desc { 75 | data.insert("desc".into(), desc.into()); 76 | } 77 | Value::Object(data.into()) 78 | } 79 | } 80 | 81 | impl Creatable for TaskForCreate {} 82 | 83 | // endregion: --- TaskForCreate 84 | 85 | // region: --- TaskForUpdate 86 | 87 | #[skip_serializing_none] 88 | #[derive(Deserialize, TS, Debug)] 89 | #[ts(export, export_to = "../src-ui/src/bindings/")] 90 | pub struct TaskForUpdate { 91 | pub title: Option, 92 | pub done: Option, 93 | pub desc: Option, 94 | } 95 | 96 | impl From for Value { 97 | fn from(val: TaskForUpdate) -> Self { 98 | let mut data = BTreeMap::new(); 99 | if let Some(title) = val.title { 100 | data.insert("title".into(), title.into()); 101 | } 102 | if let Some(done) = val.done { 103 | data.insert("done".into(), done.into()); 104 | } 105 | if let Some(desc) = val.desc { 106 | data.insert("desc".into(), desc.into()); 107 | } 108 | Value::Object(data.into()) 109 | } 110 | } 111 | 112 | impl Patchable for TaskForUpdate {} 113 | 114 | // endregion: --- TaskForUpdate 115 | 116 | // region: --- TaskFilter 117 | 118 | #[derive(FilterNodes, Deserialize, Debug)] 119 | pub struct TaskFilter { 120 | pub project_id: Option, 121 | pub title: Option, 122 | } 123 | 124 | impl Filterable for TaskFilter {} 125 | 126 | // endregion: --- TaskFilter 127 | 128 | // region: --- TaskBmc 129 | 130 | pub struct TaskBmc; 131 | 132 | impl TaskBmc { 133 | const ENTITY: &'static str = "task"; 134 | 135 | pub async fn get(ctx: Arc, id: &str) -> Result { 136 | bmc_get::(ctx, Self::ENTITY, id).await 137 | } 138 | 139 | pub async fn create(ctx: Arc, data: TaskForCreate) -> Result { 140 | bmc_create(ctx, Self::ENTITY, data).await 141 | } 142 | 143 | pub async fn update( 144 | ctx: Arc, 145 | id: &str, 146 | data: TaskForUpdate, 147 | ) -> Result { 148 | bmc_update(ctx, Self::ENTITY, id, data).await 149 | } 150 | 151 | pub async fn delete(ctx: Arc, id: &str) -> Result { 152 | bmc_delete(ctx, Self::ENTITY, id).await 153 | } 154 | 155 | pub async fn list(ctx: Arc, filter: Option) -> Result> { 156 | let opts = ListOptions { 157 | limit: None, 158 | offset: None, 159 | order_bys: Some("!ctime".into()), 160 | }; 161 | bmc_list(ctx, Self::ENTITY, filter, opts).await 162 | } 163 | } 164 | 165 | // endregion: --- TaskBmc 166 | -------------------------------------------------------------------------------- /src-tauri/src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! Key default types for this application designed to be imported in most crate modules. 2 | //! 3 | //! Notes: 4 | //! - The best practice is to have a narrow crate prelude to normalize the key types throughout the application code. 5 | //! - We keep this as small as possible, and try to limit generic name beside Result and Error (which is re-exported from this module) 6 | //! - The `f!` macro alias of `format!` (personal preference) 7 | //! 8 | 9 | // Generic Wrapper tuple struct for newtype pattern, mostly for external type to type From/TryFrom conversions 10 | pub struct W(pub T); 11 | 12 | // Personal preference. 13 | pub use std::format as f; 14 | -------------------------------------------------------------------------------- /src-tauri/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | //! Application wide utilities. Most will be re-exported. 2 | //! 3 | 4 | mod x_take; 5 | 6 | // --- re-exports 7 | pub use self::x_take::*; 8 | 9 | // from: https://github.com/surrealdb/surrealdb.wasm/blob/main/src/mac/mod.rs 10 | macro_rules! map { 11 | ($($k:expr => $v:expr),* $(,)?) => {{ 12 | let mut m = ::std::collections::BTreeMap::new(); 13 | $(m.insert($k, $v);)+ 14 | m 15 | }}; 16 | } 17 | pub(crate) use map; // export macro for crate 18 | -------------------------------------------------------------------------------- /src-tauri/src/utils/x_take.rs: -------------------------------------------------------------------------------- 1 | //! XTake trait is about taking a value from an object for a given key. 2 | //! 3 | //! The trait to implement for a type is the `XTakeImpl` which has only one function. 4 | //! 5 | //! `x_take_impl(&mut self, k: &str) -> Result>` 6 | //! 7 | //! Then, XTake is a blanket implementation (Do not implement it) with 8 | //! - `x_take` that returns a `Result>` 9 | //! - `x_take_val` that returns `Result` (i.e. fails if no value for key) 10 | //! 11 | 12 | use crate::{Error, Result}; 13 | 14 | /// Remove and return the Option for a given type and key. 15 | /// If no value for this key, return Result. 16 | /// If type missmatch, return a Error. 17 | pub trait XTakeImpl { 18 | fn x_take_impl(&mut self, k: &str) -> Result>; 19 | } 20 | 21 | /// For turbofish friendly version of XTakeInto with blanket implementation. 22 | /// Note: Has a blanket implementation. Not to be implemented directly. 23 | /// XTakeInto is the to be implemented trait 24 | pub trait XTake { 25 | fn x_take(&mut self, k: &str) -> Result> 26 | where 27 | Self: XTakeImpl; 28 | 29 | fn x_take_val(&mut self, k: &str) -> Result 30 | where 31 | Self: XTakeImpl; 32 | } 33 | 34 | /// Blanket implementation 35 | impl XTake for O { 36 | fn x_take(&mut self, k: &str) -> Result> 37 | where 38 | Self: XTakeImpl, 39 | { 40 | XTakeImpl::x_take_impl(self, k) 41 | } 42 | 43 | fn x_take_val(&mut self, k: &str) -> Result 44 | where 45 | Self: XTakeImpl, 46 | { 47 | let val: Option = XTakeImpl::x_take_impl(self, k)?; 48 | val.ok_or_else(|| Error::XPropertyNotFound(k.to_string())) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src-ui/pcss/base.pcss: -------------------------------------------------------------------------------- 1 | :root { 2 | font-size: 16px; /* Set the 1rem = 16px by default */ 3 | 4 | color-scheme: dark; 5 | } 6 | 7 | /* best practice for application */ 8 | * { 9 | box-sizing: border-box; 10 | 11 | /* To allow flex/grid box to shrink smaller than content. 12 | see: https://stackoverflow.com/a/36247448/686724 */ 13 | min-width: 0; 14 | min-height: 0; 15 | 16 | /* layout normalization */ 17 | margin: 0; 18 | padding: 0; 19 | 20 | /* font smoothing */ 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | 24 | /* App feel */ 25 | user-select: none; 26 | } 27 | 28 | html, 29 | body { 30 | height: 100%; 31 | } 32 | 33 | html { 34 | /* prevent the browser 'bounce' */ 35 | overflow: hidden; 36 | } 37 | 38 | body { 39 | font-family: 'Open Sans', sans-serif; 40 | font-size: 1rem; 41 | display: grid; 42 | color: var(--txt); 43 | background: var(--bkg-app); 44 | } -------------------------------------------------------------------------------- /src-ui/pcss/d-ui.pcss: -------------------------------------------------------------------------------- 1 | /** Customization of the @dom-native/ui look and feel */ 2 | 3 | 4 | :root { 5 | --d-field-bkg: var(--bkg-60); 6 | --d-field-bdr: none; 7 | 8 | --d-field-input: var(--txt-60); 9 | } 10 | 11 | 12 | 13 | d-input { 14 | min-height: 2.75rem; 15 | 16 | &.no-label { 17 | grid-template-rows: 0 1fr; 18 | } 19 | 20 | &::part(box) { 21 | border-radius: 6px; 22 | } 23 | } 24 | 25 | d-check svg { 26 | fill: var(--ico) !important; 27 | } -------------------------------------------------------------------------------- /src-ui/pcss/defaults.pcss: -------------------------------------------------------------------------------- 1 | h1 { 2 | font-size: 1.25rem; 3 | } 4 | 5 | h2 { 6 | font-size: 1.125rem; 7 | } 8 | 9 | h3 { 10 | font-size: 1rem; 11 | font-weight: 300; 12 | } 13 | 14 | /* For this app, the default size will be 1.25rem */ 15 | d-ico { 16 | height: 1.25rem; 17 | width: 1.25rem; 18 | fill: var(--ico); 19 | 20 | &.action { 21 | cursor: pointer; 22 | &:hover { 23 | fill: var(--ico-prime); 24 | } 25 | &:active { 26 | filter: brightness(1.2); 27 | } 28 | } 29 | } 30 | 31 | label.delete { 32 | color: var(--txt-red); 33 | } -------------------------------------------------------------------------------- /src-ui/pcss/fonts.pcss: -------------------------------------------------------------------------------- 1 | /* Note - Font files needs to be in dist/fonts/ folder */ 2 | 3 | @font-face { 4 | font-family: 'Open Sans'; 5 | src: url('../fonts/OpenSans-Black.ttf') format('truetype'); 6 | font-weight: 900; 7 | font-style: normal; 8 | } 9 | 10 | @font-face { 11 | font-family: 'Open Sans'; 12 | src: url('../fonts/OpenSans-BlackItalic.ttf') format('truetype'); 13 | font-weight: 900; 14 | font-style: italic; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Open Sans'; 19 | src: url('../fonts/OpenSans-Bold.ttf') format('truetype'); 20 | font-weight: 700; 21 | font-style: normal; 22 | } 23 | 24 | @font-face { 25 | font-family: 'Open Sans'; 26 | src: url('../fonts/OpenSans-BoldItalic.ttf') format('truetype'); 27 | font-weight: 700; 28 | font-style: italic; 29 | } 30 | 31 | @font-face { 32 | font-family: 'Open Sans'; 33 | src: url('../fonts/OpenSans-Medium.ttf') format('truetype'); 34 | font-weight: 500; 35 | font-style: normal; 36 | } 37 | 38 | @font-face { 39 | font-family: 'Open Sans'; 40 | src: url('../fonts/OpenSans-MediumItalic.ttf') format('truetype'); 41 | font-weight: 500; 42 | font-style: italic; 43 | } 44 | 45 | @font-face { 46 | font-family: 'Open Sans'; 47 | src: url('../fonts/OpenSans-Regular.ttf') format('truetype'); 48 | font-weight: 400; 49 | font-style: normal; 50 | } 51 | 52 | @font-face { 53 | font-family: 'Open Sans'; 54 | src: url('../fonts/OpenSans-Italic.ttf') format('truetype'); 55 | font-weight: 400; 56 | font-style: italic; 57 | } 58 | 59 | @font-face { 60 | font-family: 'Open Sans'; 61 | src: url('../fonts/OpenSans-Light.ttf') format('truetype'); 62 | font-weight: 300; 63 | font-style: normal; 64 | } 65 | 66 | @font-face { 67 | font-family: 'Open Sans'; 68 | src: url('../fonts/OpenSans-LightItalic.ttf') format('truetype'); 69 | font-weight: 300; 70 | font-style: italic; 71 | } 72 | -------------------------------------------------------------------------------- /src-ui/pcss/main.pcss: -------------------------------------------------------------------------------- 1 | @import './var-colors.pcss'; 2 | @import './base.pcss'; 3 | @import './defaults.pcss'; 4 | 5 | @import '@dom-native/ui/pcss/all.pcss'; 6 | @import './d-ui.pcss'; 7 | 8 | /* --- Components */ 9 | @import './view/menu-c.pcss'; 10 | 11 | /* --- Views */ 12 | @import './view/app-v.pcss'; 13 | @import './view/nav-v.pcss'; 14 | @import './view/project-v.pcss'; 15 | @import './view/tasks-dt.pcss'; 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src-ui/pcss/var-colors.pcss: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Reference colors from google material. 000 to 999 format. 4 | * 5 | * https: //material.io/archive/guidelines/style/color.html#color-color-palette 6 | */ 7 | :root { 8 | 9 | --clr-white: #fff; 10 | 11 | /* google material 'Grey' */ 12 | --clr-mono-050: #FAFAFA; 13 | --clr-mono-100: #F5F5F5; 14 | --clr-mono-200: #EEEEEE; 15 | --clr-mono-300: #E0E0E0; 16 | --clr-mono-400: #BDBDBD; 17 | --clr-mono-500: #9E9E9E; 18 | --clr-mono-600: #757575; 19 | --clr-mono-700: #616161; 20 | --clr-mono-750: #565656; 21 | --clr-mono-800: #424242; 22 | --clr-mono-850: #303030; 23 | --clr-mono-900: #212121; 24 | --clr-mono-950: #171717; 25 | --clr-mono-990: #0D0D0D; 26 | 27 | /* google material 'Blue' */ 28 | --clr-prime-050: #E3F2FD; 29 | --clr-prime-100: #BBDEFB; 30 | --clr-prime-200: #90CAF9; 31 | --clr-prime-300: #64B5F6; 32 | --clr-prime-400: #42A5F5; 33 | --clr-prime-500: #2196F3; 34 | --clr-prime-600: #1E88E5; 35 | --clr-prime-700: #1976D2; 36 | --clr-prime-800: #1565C0; 37 | --clr-prime-900: #0D47A1; 38 | 39 | --clr-prime-A100: hsl(217, 100%, 75%); 40 | --clr-prime-A200: #448AFF; 41 | --clr-prime-A400: #2979FF; 42 | --clr-prime-A700: #2962FF; 43 | 44 | --clr-red: rgb(255, 32, 32); 45 | --clr-green: rgb(0, 180, 0); 46 | } 47 | 48 | /** 49 | * Application colors 50 | * Based on 10..90 emphasis levels. 51 | * 50 is the default, and not suffixed. 52 | */ 53 | :root { 54 | 55 | /* #region Text Colors */ 56 | --txt-30: var(--clr-mono-500); 57 | --txt-40: var(--clr-mono-400); 58 | --txt-50: var(--clr-mono-300); 59 | --txt-60: var(--clr-mono-200); 60 | --txt-70: var(--clr-mono-100); 61 | 62 | --txt: var(--txt-50); 63 | 64 | --txt-red: rgb(255, 92, 92); 65 | 66 | --txt-title: var(--clr-mono-200); 67 | --txt-prime: var(--clr-prime-400); 68 | /* #endregion Text Colors */ 69 | 70 | /* #region Ico Colors */ 71 | --ico-30: var(--clr-mono-900); 72 | --ico-40: var(--clr-mono-750); 73 | --ico: var(--clr-mono-700); 74 | --ico-60: var(--clr-mono-500); 75 | --ico-70: var(--clr-mono-500); 76 | 77 | --ico-prime: var(--clr-prime-400); 78 | /* #endregion Ico Colors */ 79 | 80 | /* #region Border Colors */ 81 | --bdr-40: var(--clr-mono-900); 82 | --bdr: var(--clr-mono-850); 83 | --bdr-60: var(--clr-mono-800); 84 | --bdr-70: var(--clr-mono-700); 85 | 86 | --bdr-sel: var(--clr-prime-500); 87 | /* #endregion Border Colors */ 88 | 89 | /* #region Backbround Colors */ 90 | --bkg-20: var(--clr-mono-990); 91 | --bkg-30: var(--clr-mono-950); 92 | --bkg-40: var(--clr-mono-900); 93 | --bkg: var(--clr-mono-850); 94 | --bkg-60: var(--clr-mono-800); 95 | --bkg-70: var(--clr-mono-700); 96 | 97 | --bkg-hover: var(--clr-mono-700); 98 | 99 | --bkg-sel: var(--bkg-60); 100 | --bkg-pressed: var(--clr-mono-600); 101 | 102 | --bkg-app: var(--bkg-20); 103 | /* #endregion Backbround Colors */ 104 | } -------------------------------------------------------------------------------- /src-ui/pcss/view/app-v.pcss: -------------------------------------------------------------------------------- 1 | app-v { 2 | --app-header-height: 6rem; 3 | 4 | background: var(--bkg-app); 5 | display: grid; 6 | grid-template-columns: 12rem 1fr; 7 | grid-template-rows: var(--app-header-height) 1fr; 8 | 9 | &.min-nav { 10 | grid-template-columns: 0rem 1fr; 11 | } 12 | 13 | & > header { 14 | grid-column: 1/2; 15 | 16 | padding: 0 .5rem; 17 | background: var(--bkg-40); 18 | 19 | display: grid; 20 | grid-template-columns: auto 1fr; 21 | gap: .5rem; 22 | align-items: center; 23 | font-size: 1.25rem; 24 | font-weight: 200; 25 | 26 | & > h1 { 27 | text-transform: uppercase; 28 | font-size: 1.125rem; 29 | font-weight: 400; 30 | letter-spacing: .05em; 31 | color: var(--txt-70); 32 | 33 | span.prime { 34 | margin-left: .3em; 35 | color: var(--txt-prime); 36 | } 37 | } 38 | 39 | } 40 | 41 | & > nav-v { 42 | grid-column: 1; 43 | grid-row: 2; 44 | 45 | border-top: solid 1px var(--bkg-30); 46 | } 47 | 48 | 49 | & > main { 50 | grid-row: 1/3; 51 | grid-column: 2; 52 | display: grid; 53 | } 54 | 55 | } 56 | 57 | -------------------------------------------------------------------------------- /src-ui/pcss/view/menu-c.pcss: -------------------------------------------------------------------------------- 1 | menu-c { 2 | position: absolute; 3 | z-index: 100; 4 | 5 | background: var(--bkg); 6 | border: solid 1px var(--bdr-60); 7 | box-shadow: var(--d-elev-03-shadow); 8 | border-radius: 5px; 9 | display: grid; 10 | 11 | grid-auto-rows: minmax(2rem, auto); 12 | align-items: center; 13 | 14 | & > li { 15 | cursor: pointer; 16 | display: grid; 17 | padding: .25rem 1rem; 18 | 19 | &:hover { 20 | background: var(--bkg-hover) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src-ui/pcss/view/nav-v.pcss: -------------------------------------------------------------------------------- 1 | nav-v { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1rem; 5 | background: var(--bkg-40); 6 | 7 | padding-top: 2rem; 8 | 9 | header { 10 | 11 | display: grid; 12 | grid-template-columns: 1fr 2rem; 13 | grid-template-rows: 2rem; 14 | align-items: center; 15 | 16 | 17 | label { 18 | padding-left: 1rem; 19 | color: var(--txt); 20 | font-weight: 400; 21 | font-size: 1rem; 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | c-ico { 27 | width: 1.25rem; 28 | height: 1.25rem; 29 | } 30 | 31 | project-new-ipt { 32 | grid-column: 1/3; 33 | margin: 1rem .5rem; 34 | 35 | d-input::part(ctrl) { 36 | width: 100%; 37 | } 38 | } 39 | } 40 | 41 | 42 | section { 43 | flex: 1; 44 | display: flex; 45 | flex-direction: column; 46 | gap: .5rem; 47 | a { 48 | cursor: pointer; 49 | padding: .5rem; 50 | padding-left: 1rem; 51 | border-left: solid 4px rgba(0,0,0,0); 52 | overflow: hidden; 53 | white-space: nowrap; 54 | text-overflow: ellipsis; 55 | 56 | 57 | &.sel, &:hover { 58 | background: var(--bkg-sel); 59 | } 60 | 61 | &:active { 62 | background: var(--bkg-pressed); 63 | } 64 | 65 | &.sel { 66 | border-left: solid 4px var(--bdr-sel); 67 | font-weight: 600; 68 | } 69 | } 70 | } 71 | } 72 | 73 | c-nav-item { 74 | display: grid; 75 | } -------------------------------------------------------------------------------- /src-ui/pcss/view/project-v.pcss: -------------------------------------------------------------------------------- 1 | project-v { 2 | margin: 0 0 1rem 0; 3 | display: grid; 4 | grid-template-rows: var(--app-header-height) 3rem 1fr; 5 | 6 | & > header { 7 | padding: 0 0 0 3rem; 8 | font-size: 1.25rem; 9 | font-weight: 600; 10 | 11 | display: grid; 12 | background: var(--bkg-30); 13 | 14 | align-items: center; 15 | grid-template-columns: max-content 20rem; 16 | gap: 2rem; 17 | 18 | h1 { 19 | color: var(--txt-50); 20 | font-weight: 600; 21 | text-transform: uppercase; 22 | letter-spacing: .04em; 23 | } 24 | 25 | } 26 | 27 | .search-task { 28 | margin: 1rem; 29 | width: 30rem; 30 | align-self: flex-start; 31 | justify-self: right; 32 | } 33 | 34 | & > section { 35 | padding: 1rem 2rem; 36 | display: grid; 37 | } 38 | } -------------------------------------------------------------------------------- /src-ui/pcss/view/tasks-dt.pcss: -------------------------------------------------------------------------------- 1 | tasks-dt { 2 | margin-top: 1rem; 3 | /* Note: Here we define the bkg in variable because we use it in the .th 4 | so that we can use the css-grid for the header and rows of the datagrid 5 | */ 6 | --content-bkg: var(--bkg-app); 7 | background: var(--content-bkg); 8 | 9 | overflow: auto; 10 | display: grid; 11 | grid-template-columns: 2fr 1fr 5rem 2rem; 12 | grid-auto-rows: min-content; 13 | color: var(--txt-70); 14 | 15 | 16 | & > div.th { 17 | position: sticky; 18 | top: 0; 19 | height: 2.5rem; 20 | background: var(--content-bkg); 21 | padding-left: 1rem; 22 | border-bottom: solid 1px var(--bdr); 23 | 24 | font-weight: 600; 25 | text-transform: uppercase; 26 | letter-spacing: .1em; 27 | color: var(--txt-30); 28 | display: grid; 29 | align-items: flex-start; 30 | 31 | &.done { 32 | padding-left: 0; 33 | justify-content: center; 34 | } 35 | } 36 | 37 | d-ico[name="ico-more"] { 38 | cursor: pointer; 39 | width: 1.25rem; 40 | height: 1.25rem; 41 | justify-self: center; 42 | align-self: center; 43 | } 44 | 45 | /* Little visual trick for the first row */ 46 | task-row:first-of-type>* { 47 | margin-top: .5rem; 48 | } 49 | 50 | task-row { 51 | display: contents; 52 | 53 | & > * { 54 | padding: 0 0.5rem 0 1rem; 55 | height: 3rem; 56 | display: grid; 57 | align-items: center; 58 | } 59 | 60 | &.anim-delete { 61 | & > * { 62 | color: var(--txt-red); 63 | opacity: .5; 64 | transform: scale(0); 65 | transform-origin: left; 66 | transition-property: transform, opacity; 67 | transition-duration: .5s; 68 | } 69 | } 70 | 71 | & > .info { 72 | overflow: hidden; 73 | white-space: nowrap; 74 | text-overflow: ellipsis; 75 | display: block; 76 | align-self: center; 77 | height: auto; 78 | } 79 | 80 | & > d-ico, 81 | & > d-check { 82 | padding: 0; 83 | } 84 | 85 | & d-check { 86 | justify-self: center; 87 | 88 | &[checked] svg { 89 | fill: var(--clr-green) !important; 90 | } 91 | } 92 | 93 | } 94 | 95 | } -------------------------------------------------------------------------------- /src-ui/src/bindings/HubEvent.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface HubEvent { hub: string, topic: string, label?: string, data?: D, } -------------------------------------------------------------------------------- /src-ui/src/bindings/ModelMutateResultData.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface ModelMutateResultData { id: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/Project.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface Project { id: string, name: string, ctime: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/ProjectForCreate.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface ProjectForCreate { name: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/ProjectForUpdate.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface ProjectForUpdate { name?: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/Task.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface Task { id: string, ctime: string, project_id: string, done: boolean, title: string, desc?: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/TaskForCreate.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface TaskForCreate { project_id: string, title: string, done?: boolean, desc?: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/TaskForUpdate.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface TaskForUpdate { title?: string, done?: boolean, desc?: string, } -------------------------------------------------------------------------------- /src-ui/src/bindings/index.ts: -------------------------------------------------------------------------------- 1 | //! Manualy maintained (for now) 2 | //! Re-export all bindings for import convenience 3 | 4 | export * from './HubEvent.js'; 5 | export * from './ModelMutateResultData.js'; 6 | export * from './Project.js'; 7 | export * from './ProjectForCreate.js'; 8 | export * from './ProjectForUpdate.js'; 9 | export * from './Task.js'; 10 | export * from './TaskForCreate.js'; 11 | export * from './TaskForUpdate.js'; 12 | 13 | -------------------------------------------------------------------------------- /src-ui/src/bindings/type_asserts.ts: -------------------------------------------------------------------------------- 1 | //! For now, manually written. Eventually could be automated. 2 | 3 | import { ModelMutateResultData } from './index.js'; 4 | 5 | export function ensure_ModelMutateResultData(obj: any): ModelMutateResultData { 6 | const keys = Object.keys(obj); 7 | if (keys.length != 1 || keys[0] != "id" || typeof obj["id"] !== "string") { 8 | throw new Error("assert ModelMutateResultData failed {obj}"); 9 | } 10 | return obj; 11 | } -------------------------------------------------------------------------------- /src-ui/src/event.ts: -------------------------------------------------------------------------------- 1 | import { Event as TauriEvent, listen } from '@tauri-apps/api/event'; 2 | import { hub } from 'dom-native'; 3 | import type { HubEvent } from './bindings/index.js'; 4 | 5 | // --- Bridge Tauri HubEvent events to dom-native hub/pub/sub event 6 | // (optional, but allows to use hub("Data").sub(..) or 7 | // @onHub("Data", topic, label) on BaseHTMLElement custom elements) 8 | listen("HubEvent", function (evt: TauriEvent>) { 9 | const hubEvent = evt.payload; 10 | 11 | // Get or create the Hub by name (from dom-native) 12 | // (a Hub is a event bus namespace silo) 13 | let _hub = hub(hubEvent.hub); 14 | 15 | // Publish event to the given Hub 16 | if (hubEvent.label != null) { 17 | _hub.pub(hubEvent.topic, hubEvent.label, hubEvent.data); 18 | } else { 19 | _hub.pub(hubEvent.topic, hubEvent.data); 20 | } 21 | }) -------------------------------------------------------------------------------- /src-ui/src/ipc.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api"; 2 | import { deepFreeze } from 'utils-min'; 3 | 4 | /** 5 | * Small wrapper on top of tauri api invoke 6 | * 7 | * best-practice: Light and narrow external api abstraction. 8 | */ 9 | export async function ipc_invoke(method: string, params?: object): Promise { 10 | const response: any = await invoke(method, { params }); 11 | if (response.error != null) { 12 | console.log('ERROR - ipc_invoke - ipc_invoke error', response); 13 | throw new Error(response.error); 14 | } else { 15 | return deepFreeze(response.result); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src-ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import '@dom-native/ui'; 2 | import { loadDefaultIcons } from '@dom-native/ui'; 3 | import { html } from 'dom-native'; 4 | import './event.js'; 5 | import { SYMBOLS } from './svg-symbols.js'; 6 | 7 | // important, this will load the customElements 8 | import './view/index.js'; 9 | 10 | // load the default icons from @dom-native/ui 11 | loadDefaultIcons(); 12 | 13 | // --- Initialize some assets on DOMContentLoaded 14 | document.addEventListener("DOMContentLoaded", async function (event) { 15 | 16 | // Append the app custom icons 17 | // (similar to what loadDefaultIcons does for @dom-native/ui icons) 18 | // (this allows to use the and update fill from css) 19 | const svgEl = html(SYMBOLS).firstElementChild!; 20 | svgEl.setAttribute('style', 'display: none'); // in case dom engine move it to body 21 | document.head.appendChild(svgEl); 22 | }); 23 | 24 | 25 | -------------------------------------------------------------------------------- /src-ui/src/model/index.ts: -------------------------------------------------------------------------------- 1 | import { pruneEmpty } from 'utils-min'; 2 | import { ModelMutateResultData, Project, ProjectForCreate, ProjectForUpdate, Task, TaskForCreate, TaskForUpdate } from '../bindings/index.js'; 3 | import { ensure_ModelMutateResultData } from '../bindings/type_asserts.js'; 4 | import { ipc_invoke } from '../ipc.js'; 5 | 6 | /** 7 | * Base Frontend Model Controller class with basic CRUD except `list` which will be per subclass for now. 8 | * 9 | * - M - For the Enity model type (e.g., Project) 10 | * - C - For the Create data type (e.g., ProjectForCreate) 11 | * - U - For the update data type (e.g., ProjectForUpdate) 12 | */ 13 | class BaseFmc { 14 | #cmd_suffix: string 15 | get cmd_suffix() { return this.#cmd_suffix; } 16 | 17 | constructor(cmd_suffix: string) { 18 | this.#cmd_suffix = cmd_suffix; 19 | } 20 | 21 | async get(id: string): Promise { 22 | return ipc_invoke(`get_${this.#cmd_suffix}`, { id }).then(res => res.data); 23 | } 24 | 25 | async create(data: C): Promise { 26 | return ipc_invoke(`create_${this.#cmd_suffix}`, { data }).then(res => { 27 | return ensure_ModelMutateResultData(res.data); 28 | }); 29 | } 30 | 31 | async update(id: string, data: U): Promise { 32 | return ipc_invoke(`update_${this.#cmd_suffix}`, { id, data }).then(res => { 33 | return ensure_ModelMutateResultData(res.data); 34 | }); 35 | } 36 | 37 | async delete(id: string): Promise { 38 | return ipc_invoke(`delete_${this.#cmd_suffix}`, { id }).then(res => res.data); 39 | } 40 | } 41 | 42 | // #region --- ProjectFmc 43 | class ProjectFmc extends BaseFmc { 44 | constructor() { 45 | super("project"); 46 | } 47 | 48 | async list(): Promise { 49 | // Note: for now, we just add a 's' for list, might might get rid of plurals 50 | return ipc_invoke(`list_${this.cmd_suffix}s`, {}).then(res => res.data); 51 | } 52 | } 53 | export const projectFmc = new ProjectFmc(); 54 | // #endregion --- ProjectFmc 55 | 56 | // #region --- TaskBmc 57 | class TaskFmc extends BaseFmc { 58 | constructor() { 59 | super("task"); 60 | } 61 | 62 | async list(filter: any): Promise { 63 | // prune the empty string so that the UI does not have to do too much. 64 | filter = pruneEmpty(filter); 65 | // Note: for now, we just add a 's' for list, might might get rid of plurals 66 | return ipc_invoke(`list_${this.cmd_suffix}s`, { filter }).then(res => res.data); 67 | } 68 | } 69 | export const taskFmc = new TaskFmc(); 70 | 71 | // #endregion --- TaskBmc 72 | 73 | -------------------------------------------------------------------------------- /src-ui/src/router.ts: -------------------------------------------------------------------------------- 1 | import { hub } from 'dom-native'; 2 | 3 | const route_hub = hub("Route"); 4 | 5 | /** 6 | * Route states for the whole application. 7 | * 8 | * Currently, the best practice is to keep the Route states as simple 9 | * as possible, meaning, flat and just "ids" like names/values. 10 | * 11 | **/ 12 | interface Route { 13 | project_id?: string 14 | } 15 | 16 | class Router { 17 | 18 | #current_route: Route = {}; 19 | 20 | update_state(state: Partial) { 21 | // Note: DeepClone when Route state cannot be assumed to be flat anymore. 22 | Object.assign(this.#current_route, state); 23 | route_hub.pub("change", null); 24 | } 25 | 26 | get_current(): Route { 27 | // clone for safety (shallow enough as route is designed to be flat) 28 | return { ...this.#current_route }; 29 | } 30 | 31 | 32 | } 33 | 34 | export const router = new Router(); -------------------------------------------------------------------------------- /src-ui/src/svg-symbols.ts: -------------------------------------------------------------------------------- 1 | 2 | // GENERATED BY SKETCHDEV 3 | 4 | export const SYMBOLS = ` 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | `; 20 | -------------------------------------------------------------------------------- /src-ui/src/type-enhancements.ts: -------------------------------------------------------------------------------- 1 | // NOTE - Some TypeScript type enhancements. 2 | // Does not have to be imported anywhere, TypeScript will pick it up. 3 | 4 | export { }; // make this file a module 5 | 6 | declare global { 7 | // Cloning a DocumentFragment returns a DocumentFragment 8 | // Note: This is not needed in this code base as we use importNode, 9 | // but this is showing how the global types can be extends/tuned. 10 | interface DocumentFragment { 11 | cloneNode(deep?: boolean): DocumentFragment; 12 | } 13 | } -------------------------------------------------------------------------------- /src-ui/src/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * Narrow utility to make a string css class "compatible". 5 | * TODO: Need to make it more exhaustive. 6 | */ 7 | export function classable(str: string): string { 8 | return str.replace(":", "_"); 9 | } -------------------------------------------------------------------------------- /src-ui/src/view/app-v.ts: -------------------------------------------------------------------------------- 1 | //! Main Application View which will initialize the application and display the appropriate 2 | //! 3 | //! Notes: 4 | //! - Will listen to Route.change event, and update the main view 5 | //! - The Nav View `nav-v` will manage it's routing update. 6 | //! 7 | //! TODO: Needs to implement the menu click (min-nav action) 8 | //! 9 | 10 | import { BaseHTMLElement, customElement, elem, first, getFirst, html, onEvent, onHub } from 'dom-native'; 11 | import { projectFmc } from '../model'; 12 | import { router } from '../router'; 13 | 14 | // dom-native JS Tagged templates to create a DocumentFragment (parse once) 15 | const HTML = html` 16 |
17 | 18 |

Awesome App

19 |
20 | 21 |
22 | ` 23 | 24 | @customElement('app-v') // same as customElements.define('app-v', AppView) 25 | export class AppView extends BaseHTMLElement { // extends HTMLElement 26 | // #region --- Key Els 27 | #mainEl!: HTMLElement 28 | // #endregion --- Key Els 29 | 30 | // #region --- App Events 31 | @onHub("Route", "change") // @onHub(hubName, topic, label?) 32 | async onRouteChange() { 33 | const { project_id } = router.get_current(); 34 | if (project_id != null) { 35 | const project = await projectFmc.get(project_id); 36 | const projectEl = elem('project-v', { $: { project } }); 37 | this.#mainEl.replaceChildren(projectEl); 38 | } else { 39 | this.#mainEl.textContent = "Welcome select project"; 40 | } 41 | } 42 | // #endregion --- App Events 43 | 44 | // #region --- UI Events 45 | @onEvent("pointerup", "header > c-ico.menu") // @onEvent(eventType, elementSelectorFromThis) 46 | onMenuClick(evt: PointerEvent) { 47 | this.classList.toggle("min-nav"); 48 | } 49 | // #endregion --- UI Events 50 | 51 | init() { // Will be called by BaseHTMLElement once on first connectedCallback 52 | // clone the HTML documentFragment and get the key elements (to be used later) 53 | let content = document.importNode(HTML, true); 54 | 55 | this.#mainEl = getFirst(content, "main"); 56 | 57 | // beautify the header h1 58 | const h1 = first(content, 'header > h1'); 59 | if (h1) { 60 | if (h1.firstElementChild == null) { 61 | const text = h1.textContent?.split(/[-_ ](.+)/) ?? ["NO", "NAME"]; 62 | h1.replaceChildren(html`${text[0]}${text[1]}`) 63 | } 64 | } 65 | 66 | // replace the children 67 | this.replaceChildren(content); 68 | } 69 | } 70 | declare global { // trick to augment the global TagName with this component 71 | interface HTMLElementTagNameMap { 72 | 'app-v': AppView; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src-ui/src/view/index.ts: -------------------------------------------------------------------------------- 1 | //! index of all of the views and components. 2 | //! 3 | //! Notes: 4 | //! - The responsibility of this module is to export/import all of the sub views/components 5 | //! as they must be preloaded to activate the HTML custom elements. 6 | //! - This file will be imported by `main.ts` without the need to know the specifics of the views and components. 7 | //! - Component notation follows "[domain_space]-[component_type]" where the domain_space is the entity or function of the components, 8 | //! for example, `app` or `tasks`, and the component_type reflect the type of the component, such as `v` for **view** or `c` for **component** 9 | //! or `dt` for **data table**. 10 | //! 11 | //! The differences between "Views" and "Components" are more on the semantic side than implementations 12 | //! - Views are a bigger part of the application, usually big composites of components and light elements. 13 | //! Typically manage the UI Events, Model Events, and routing as needed. 14 | //! - Components are smaller UI Elements, usually not model specific. They are designed to be as data "unintelligent" as possible. 15 | //! - Composites are between views and components and tend to be used for medium sized reusable system parts. Like a "Task Data Table" (e.g., `tasks-dt`) 16 | //! 17 | 18 | export * from './app-v.js'; 19 | export * from './menu-c.js'; 20 | export * from './nav-v.js'; 21 | export * from './project-v.js'; 22 | export * from './tasks-dt.js'; 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src-ui/src/view/menu-c.ts: -------------------------------------------------------------------------------- 1 | import { BaseHTMLElement, customElement, elem, onDoc, onEvent, OnEvent, trigger } from 'dom-native'; 2 | 3 | type Options = { [k: string]: string | HTMLElement }; 4 | 5 | 6 | @customElement('menu-c') 7 | export class MenuComponent extends BaseHTMLElement { // extends HTMLElement 8 | // #region --- Data 9 | // This data is disposable, no need to keep, and the key is stored as children attribute. 10 | set options(v: Options) { this.update(v) } 11 | // #endregion --- Data 12 | 13 | // #region --- UI Events 14 | @onEvent('pointerup', 'li') 15 | onLiClick(evt: OnEvent) { 16 | const key = evt.selectTarget.getAttribute("data-key"); 17 | trigger(this, "SELECT", { detail: key }); 18 | this.remove(); 19 | } 20 | 21 | @onDoc('pointerup', { nextFrame: true }) 22 | onDocClick(evt: Event) { 23 | if (!this.contains(evt.target as Node)) { 24 | this.remove(); 25 | } 26 | } 27 | // #endregion --- UI Events 28 | 29 | // Note: For this component, no need to check if same data, just refresh. 30 | // And the key is stored in the data-key, so, nothing else to store. 31 | // Less is simpler. 32 | // The frozen is not really needed here as we do not store it. 33 | // However, just for consistency. 34 | update(options: Options) { 35 | // and replace the content 36 | const els = Object.entries(options).map(([k, v]) => { 37 | const el = elem('li', { "data-key": k }); 38 | if (typeof v == "string") { 39 | el.textContent = v; 40 | } else { 41 | el.appendChild(v); 42 | } 43 | return el; 44 | }); 45 | this.replaceChildren(...els); 46 | } 47 | } 48 | declare global { 49 | interface HTMLElementTagNameMap { 50 | 'menu-c': MenuComponent; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src-ui/src/view/nav-v.ts: -------------------------------------------------------------------------------- 1 | import { DInputElement } from '@dom-native/ui'; 2 | import { all, BaseHTMLElement, customElement, elem, first, frag, html, on, OnEvent, onEvent, onHub, scanChild } from "dom-native"; 3 | import { Project } from '../bindings/Project.js'; 4 | import { projectFmc } from '../model/index.js'; 5 | import { router } from '../router.js'; 6 | 7 | const HTML = html` 8 |
9 | 10 | 11 |
12 |
13 |
14 | `; 15 | 16 | @customElement('nav-v') 17 | export class NavView extends BaseHTMLElement { // extends HTMLElement 18 | // #region --- Key Els 19 | #headerEl!: HTMLElement 20 | #contentEl!: HTMLElement 21 | // #endregion --- Key Els 22 | 23 | // #region --- App Events 24 | @onHub("Model", "project", "create") 25 | async onProjectCreate(data: any) { 26 | this.refreshContent(); 27 | router.update_state({ 28 | project_id: data.id 29 | }); 30 | } 31 | 32 | @onHub("Route", "change") 33 | onRouteChange() { 34 | this.updateContentSel(); 35 | } 36 | // #endregion --- App Events 37 | 38 | // #region --- UI Events 39 | @onEvent("pointerdown", "header > .show-add-project") 40 | onShowAddProject() { 41 | let inputEl = first(this.#headerEl, "project-new-ipt"); 42 | 43 | // if already showing, we toggle it off (cancel) 44 | if (inputEl != null) { 45 | inputEl.remove(); 46 | return; 47 | } 48 | // otherwise, we add the d-input 49 | else { 50 | const inputEl = this.#headerEl.appendChild(elem("project-new-ipt"))!; 51 | inputEl.focus(); 52 | on(inputEl, "CHANGE", (evt: OnEvent<{ name: string | null, value: string }>) => { 53 | const val = evt.detail.value; 54 | if (val.length > 0) { 55 | projectFmc.create({ name: val }); 56 | inputEl.clear(); // this will triggern a CHANGE with value "" 57 | } else { 58 | inputEl.remove(); 59 | } 60 | }); 61 | } 62 | } 63 | 64 | @onEvent("pointerdown", "section > a") 65 | selNav(evt: Event & OnEvent) { 66 | 67 | const project_id = evt.selectTarget.getAttribute("data-id")!; 68 | 69 | router.update_state({ project_id }); 70 | } 71 | // #endregion --- UI Events 72 | 73 | init() { 74 | const content = document.importNode(HTML, true); 75 | [this.#headerEl, this.#contentEl] = scanChild(content, 'header', 'section'); 76 | 77 | this.replaceChildren(content); 78 | 79 | this.refreshContent(true); 80 | } 81 | 82 | async refreshContent(first_refresh?: boolean) { 83 | 84 | const projects = await projectFmc.list(); 85 | 86 | // Create the content DocumentFragment from the projects and replace children 87 | const content = frag(projects, (prj: Project) => 88 | elem('a', { "data-id": prj.id, $: { textContent: prj.name } })); 89 | this.#contentEl.replaceChildren(content); 90 | 91 | // Update selction 92 | this.updateContentSel(); 93 | 94 | // If first refresh, select first project (update router) 95 | if (first_refresh && projects.length > 0) { 96 | router.update_state({ project_id: projects[0].id }) 97 | } 98 | } 99 | 100 | updateContentSel() { 101 | let { project_id } = router.get_current(); 102 | all(this, `section > a.sel`).forEach(el => el.classList.remove("sel")); 103 | if (project_id != null) { 104 | const el = first(`section > a[data-id="${project_id}"]`); 105 | el?.classList.add("sel"); 106 | } 107 | } 108 | 109 | } 110 | declare global { 111 | interface HTMLElementTagNameMap { 112 | 'nav-v': NavView; 113 | } 114 | } 115 | 116 | @customElement('project-new-ipt') 117 | class ProjectNewInput extends BaseHTMLElement { // extends HTMLElement 118 | // #region --- Key Els 119 | #d_input!: DInputElement; 120 | // #endregion --- Key Els 121 | 122 | // #region --- UI Events 123 | // Note: here we need keydown and preventDefault if we want to avoid the "ding" sound. 124 | @onEvent("keydown") 125 | onExecKey(evt: KeyboardEvent) { 126 | if (evt.key == "Escape") { // we cancel 127 | this.remove(); 128 | evt.preventDefault(); 129 | } 130 | } 131 | // #endregion --- UI Events 132 | 133 | init() { 134 | this.#d_input = elem("d-input", { placeholder: "Project name (press Enter)" }); 135 | this.replaceChildren(this.#d_input); 136 | } 137 | 138 | focus() { 139 | // Note: This is a little trick to make sure the focus command does not get loss 140 | requestAnimationFrame(() => { 141 | this.#d_input.focus(); 142 | }); 143 | 144 | } 145 | 146 | clear() { 147 | this.#d_input.value = ""; 148 | } 149 | } 150 | declare global { 151 | interface HTMLElementTagNameMap { 152 | 'project-new-ipt': ProjectNewInput; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src-ui/src/view/project-v.ts: -------------------------------------------------------------------------------- 1 | import { DInputElement } from '@dom-native/ui'; 2 | import { BaseHTMLElement, customElement, elem, getFirst, html, onEvent, OnEvent } from 'dom-native'; 3 | import { Project } from '../bindings/index.js'; 4 | import { taskFmc } from '../model/index.js'; 5 | 6 | const HTML = html` 7 |
8 |

9 | 10 |
11 | 12 |
13 | `; 14 | 15 | @customElement('project-v') 16 | export class ProjectView extends BaseHTMLElement { // extends HTMLElement 17 | // #region --- Data 18 | #project!: Project 19 | set project(p: Project) { this.#project = p; this.update(); } 20 | // #endregion --- Data 21 | 22 | // #region --- Key Els 23 | #titleEl!: HTMLElement 24 | #contentEl!: HTMLElement 25 | #newTaskDInputEl!: DInputElement 26 | #searchTaskDInputEl!: DInputElement 27 | // #endregion --- Key Els 28 | 29 | // #region --- UI Events 30 | @onEvent("CHANGE", "d-input.new-task") 31 | onNewTaskInput(evt: OnEvent) { 32 | let title = (evt.selectTarget).value.trim(); 33 | if (title.length > 0) { 34 | 35 | // Create the task 36 | const project_id = this.#project.id; 37 | taskFmc.create({ project_id, title }); 38 | 39 | // Clear the input 40 | // Note: Here we could also do an await on create, before clearing the input. 41 | // Or listening the create event back on task (which is debetable). 42 | this.#newTaskDInputEl.value = ''; 43 | } 44 | } 45 | 46 | @onEvent("CHANGE", "d-input.search-task") 47 | onSearchChange(evt: OnEvent) { 48 | let search = (evt.selectTarget).value.trim() as string; 49 | if (search.length > 0) { 50 | this.update({ title: { $contains: search } }); 51 | } else { 52 | this.update(); 53 | } 54 | } 55 | 56 | @onEvent("EMPTY", "tasks-dt") 57 | onTasksIsEmpty() { 58 | this.#newTaskDInputEl.focus(); 59 | } 60 | // #endregion --- UI Events 61 | 62 | init() { 63 | const content = document.importNode(HTML, true); 64 | 65 | [this.#titleEl, this.#contentEl, this.#newTaskDInputEl, this.#searchTaskDInputEl] = 66 | getFirst(content, "h1", "section", "d-input.new-task", "d-input.search-task") as [HTMLHeadingElement, HTMLElement, DInputElement, DInputElement]; 67 | 68 | this.replaceChildren(content); 69 | 70 | this.update() 71 | } 72 | 73 | async update(filter?: any) { 74 | if (this.#contentEl && this.#titleEl) { 75 | this.#titleEl.textContent = this.#project.name; 76 | 77 | const taskDt = elem('tasks-dt', { $: { project_id: this.#project.id, filter } }); 78 | this.#contentEl.replaceChildren(taskDt); 79 | } 80 | } 81 | } 82 | declare global { 83 | interface HTMLElementTagNameMap { 84 | 'project-v': ProjectView; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src-ui/src/view/tasks-dt.ts: -------------------------------------------------------------------------------- 1 | import { DCheckElement } from '@dom-native/ui'; 2 | import { all, BaseHTMLElement, customElement, elem, first, frag, html, on, OnEvent, onEvent, onHub, position, scanChild, trigger } from 'dom-native'; 3 | import { ModelMutateResultData, Task } from '../bindings/index.js'; 4 | import { taskFmc } from '../model/index.js'; 5 | import { classable } from '../utils.js'; 6 | 7 | const TASK_HEADER = html` 8 |
Title
9 |
Info
10 |
Done
11 |
 
12 | ` 13 | 14 | const TASK_ROW_HTML = html` 15 | 16 | 17 | 18 | 19 | `; 20 | 21 | @customElement('tasks-dt') 22 | export class TasksDataTable extends BaseHTMLElement { // extends HTMLElement 23 | // #region --- Data 24 | #project_id!: string; 25 | set project_id(v: string) { this.#project_id = v; this.update() } 26 | 27 | #filter?: any 28 | set filter(f: any) { this.#filter = f; this.update() } 29 | // #endregion --- Data 30 | 31 | // #region --- App Event 32 | // Create will refresh the full datagrid, in case of sort by name and such 33 | @onHub("Model", "task", "create") 34 | onTaskCreate() { 35 | this.update(); 36 | } 37 | 38 | // Delete can be more selective in this case, will delete the row 39 | @onHub("Model", "task", "delete") 40 | onTaskDelete(data: ModelMutateResultData) { 41 | all(this, `task-row.${classable(data.id)}`).forEach(taskRowEl => { 42 | // Note: This will add the class in the taskRow, but the animations are on the cells 43 | // as the task-row as the display: contents in the css 44 | // (to be transparent to the grid layout, hence, can't style it) 45 | taskRowEl.classList.add('anim-delete'); 46 | 47 | // Note: Trick to start the dom deletion before the animation terminate to make it snapier 48 | setTimeout(() => { 49 | taskRowEl.remove(); 50 | }, 100); 51 | 52 | 53 | // Note: This is sementically correct way to delete it, on first transition end. 54 | // taskRowEl.addEventListener('transitionend', (evt) => { 55 | // // Note: Here we will get many events back (one per animated element and property) 56 | // // So, just delete on first. 57 | // if (taskRowEl.isConnected) { 58 | // taskRowEl.remove() 59 | // } 60 | // }); 61 | }); 62 | } 63 | 64 | @onHub("Model", "task", "update") 65 | async onTaskUpdate(data: ModelMutateResultData) { 66 | const newTask = await taskFmc.get(data.id); 67 | all(this, `task-row.${classable(data.id)}`).forEach((taskEl) => (taskEl).task = newTask); 68 | } 69 | // #endregion --- App Event 70 | 71 | // #region --- UI Events 72 | @onEvent("pointerup", "task-row .show-more") 73 | onTaskShowMore(evt: OnEvent) { 74 | const MENU_CLASS = 'task-row-more-menu'; 75 | 76 | // if already showing (will auto remove, but we do not want to popup it again) 77 | if (first(`body > menu-c.${MENU_CLASS}`)) return; 78 | 79 | const showMoreEl = evt.selectTarget; 80 | const task = showMoreEl.closest('task-row')!.task; 81 | 82 | const options = { 83 | 'toggle': (task.done) ? "Mark Undone" : "Mark Done", 84 | 'delete': elem("label", { class: "delete", $: { textContent: "Delete" } }), 85 | }; 86 | 87 | // Show the meunu 88 | const menuEl = elem("menu-c", { "class": MENU_CLASS, $: { options } }); 89 | document.body.appendChild(menuEl); 90 | on(menuEl, "SELECT", (evt: OnEvent) => { 91 | if (evt.detail == 'delete') { 92 | taskFmc.delete(task.id); 93 | } else if (evt.detail == 'toggle') { 94 | taskFmc.update(task.id, { done: !task.done }); 95 | } 96 | 97 | }); 98 | position(menuEl, showMoreEl, { refPos: "BR", pos: "BL", gap: 4 }); 99 | } 100 | 101 | @onEvent("CHANGE", "task-row d-check") 102 | onTaskCheckClick(evt: OnEvent<{ value: boolean }>) { 103 | let taskEl = evt.selectTarget.closest("task-row")!; 104 | let task_id = taskEl.task.id; 105 | let newDone = evt.detail.value; 106 | 107 | // Make sure to avoid infine loop 108 | // (will get this event when changed by other mean as well) 109 | if (newDone !== taskEl.task.done) { 110 | taskFmc.update(task_id, { done: evt.detail.value }); 111 | } 112 | } 113 | // #endregion --- UI Events 114 | 115 | postDisplay() { 116 | this.update(); 117 | } 118 | 119 | async update() { 120 | if (this.initialized) { 121 | const filter = { 122 | project_id: this.#project_id, 123 | ...this.#filter 124 | } 125 | const tasks = await taskFmc.list(filter); 126 | 127 | const content = frag(tasks, task => elem('task-row', { $: { task } })); 128 | 129 | content.prepend(document.importNode(TASK_HEADER, true)); 130 | 131 | this.replaceChildren(content); 132 | 133 | if (tasks.length == 0) { 134 | trigger(this, "EMPTY"); 135 | } 136 | } 137 | 138 | } 139 | } 140 | declare global { 141 | interface HTMLElementTagNameMap { 142 | 'tasks-dt': TasksDataTable; 143 | } 144 | } 145 | 146 | // #region --- task-row 147 | @customElement('task-row') 148 | export class TaskRow extends BaseHTMLElement { // extends HTMLElement 149 | // #region --- Data 150 | #task!: Task; 151 | set task(newTask: Task) { 152 | const oldTask = this.#task as Task | undefined; 153 | if (oldTask !== newTask) { 154 | this.#task = newTask; 155 | this.update(newTask, oldTask); 156 | } 157 | } 158 | get task() { return this.#task } 159 | // #endregion --- Data 160 | 161 | // #region --- Key Els 162 | #checkEl!: DCheckElement; 163 | #titleEl!: HTMLElement; 164 | #infoEl!: HTMLElement; 165 | // #endregion --- Key Els 166 | 167 | init() { 168 | 169 | super.init(); 170 | let content = document.importNode(TASK_ROW_HTML, true); 171 | // Note: dom-native scanChild is a strict one fast pass child scanner. 172 | // Use all/first if needs to be more flexible. 173 | [this.#titleEl, this.#infoEl, this.#checkEl] = scanChild(content, 'span', 'span', 'd-check'); 174 | 175 | // FIXME: Check that order does not matter here. 176 | this.replaceChildren(content); 177 | this.update(this.#task); 178 | } 179 | 180 | update(newTask: Task, oldTask?: Task) { 181 | 182 | if (oldTask) { 183 | this.classList.remove(`${classable(oldTask.id)}`) 184 | } 185 | 186 | // if ready to be injected, we do the job 187 | if (newTask && this.#titleEl != null) { 188 | 189 | this.classList.add(`${classable(newTask.id)}`); 190 | this.#checkEl.checked = newTask.done; 191 | 192 | this.#titleEl.textContent = newTask.title; 193 | let info = newTask.ctime; 194 | info = info.substring(info.length - 5); 195 | this.#infoEl.textContent = `(ctime: ${info})`; 196 | } 197 | 198 | } 199 | } 200 | declare global { 201 | interface HTMLElementTagNameMap { 202 | 'task-row': TaskRow; 203 | } 204 | } 205 | // #endregion --- task-row 206 | 207 | -------------------------------------------------------------------------------- /src-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // For modern runtimes & rollup 4 | "target": "ES2018", 5 | "module": "ES2022", 6 | 7 | // For interop 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | 11 | // Full ts mode 12 | "allowJs": false, 13 | "checkJs": false, 14 | "strict": true, 15 | 16 | // Fev info 17 | "declaration": true, 18 | "sourceMap": true, 19 | 20 | // Use native class fields 21 | "useDefineForClassFields": true, 22 | 23 | // Allows TS Decorators 24 | "experimentalDecorators": true, 25 | 26 | // Disallow inconsistently-cased references to the same file. 27 | "forceConsistentCasingInFileNames": true, 28 | 29 | // Paths info 30 | "outDir": ".out/", // for checkin tsc output only. Rollup is used for runtime 31 | "baseUrl": ".", 32 | 33 | // Speedup compile 34 | "skipLibCheck": true 35 | }, 36 | 37 | // We want more control about which code we will compile and exclude 38 | "include": [ 39 | "./src/**/*.ts" 40 | ], 41 | 42 | "exclude": [ 43 | "node_modules" 44 | ] 45 | } --------------------------------------------------------------------------------