├── .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 |
12 |
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 |
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 | }
--------------------------------------------------------------------------------