├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── angular.json ├── docs ├── Makefile ├── advanced.rst ├── anim.gif ├── basic-usage.rst ├── conf.py ├── deployment.rst ├── development.rst ├── index.rst ├── make.bat ├── principles.rst ├── reference │ ├── components.rst │ ├── directives.rst │ ├── index.rst │ ├── services.rst │ ├── traversal.rst │ └── views.rst └── setup.rst ├── mrs.developer.json ├── package-lock.json ├── package.json ├── projects └── plone-restapi-angular │ ├── README.md │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── components │ │ │ ├── breadcrumbs.spec.ts │ │ │ ├── breadcrumbs.ts │ │ │ ├── comments.spec.ts │ │ │ ├── comments.ts │ │ │ ├── global.navigation.spec.ts │ │ │ ├── global.navigation.ts │ │ │ ├── navigation.level.ts │ │ │ ├── navigation.ts │ │ │ └── workflow.ts │ │ ├── directives │ │ │ ├── download.directive.spec.ts │ │ │ └── download.directive.ts │ │ ├── index.ts │ │ ├── interfaces.ts │ │ ├── module.ts │ │ ├── services │ │ │ ├── api.service.spec.ts │ │ │ ├── api.service.ts │ │ │ ├── authentication.service.spec.ts │ │ │ ├── authentication.service.ts │ │ │ ├── cache.service.spec.ts │ │ │ ├── cache.service.ts │ │ │ ├── comments.service.spec.ts │ │ │ ├── comments.service.ts │ │ │ ├── configuration.service.spec.ts │ │ │ ├── configuration.service.ts │ │ │ ├── index.ts │ │ │ ├── loading.service.spec.ts │ │ │ ├── loading.service.ts │ │ │ ├── navigation.service.spec.ts │ │ │ ├── navigation.service.ts │ │ │ ├── resource.service.spec.ts │ │ │ ├── resource.service.ts │ │ │ └── services.ts │ │ ├── traversal.spec.ts │ │ ├── traversal.ts │ │ ├── traversing.ts │ │ ├── views │ │ │ ├── add.ts │ │ │ ├── edit.spec.ts │ │ │ ├── edit.ts │ │ │ ├── login.spec.ts │ │ │ ├── login.ts │ │ │ ├── password-reset.ts │ │ │ ├── request-password-reset.ts │ │ │ ├── search.spec.ts │ │ │ ├── search.ts │ │ │ ├── sitemap.ts │ │ │ ├── view.spec.ts │ │ │ └── view.ts │ │ └── vocabularies.ts │ └── public-api.ts │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ └── tslint.json ├── src ├── .browserslistrc ├── app │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── components │ │ ├── search.html │ │ └── search.ts │ └── custom │ │ ├── breadcrumbs.html │ │ ├── index.ts │ │ ├── navigation.html │ │ └── view.html ├── assets │ └── .gitkeep ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── jestGlobalMocks.ts ├── main.ts ├── polyfills.ts ├── setupJest.ts ├── styles.css ├── tsconfig.app.json ├── tsconfig.spec.json └── tslint.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /tmp 5 | dist/ 6 | src/**/*.js 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | *.launch 16 | .settings/ 17 | .vscode/ 18 | 19 | # misc 20 | /.sass-cache 21 | /connect.lock 22 | /coverage/* 23 | /libpeerconnection.log 24 | npm-debug.log 25 | testem.log 26 | /typings 27 | 28 | # e2e 29 | e2e/*.js 30 | e2e/*.map 31 | 32 | #System Files 33 | .DS_Store 34 | Thumbs.db 35 | 36 | # test sub-projects and demo 37 | /demo 38 | /src/develop 39 | 40 | /venv 41 | /docs/_build 42 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ngFactory.ts 2 | /node_modules 3 | /src 4 | /tests 5 | /tmp 6 | /demo 7 | 8 | # IDEs and editors 9 | /.idea 10 | .project 11 | .classpath 12 | *.launch 13 | .settings/ 14 | .editorconfig 15 | .vscode 16 | 17 | # misc 18 | /.sass-cache 19 | /connect.lock 20 | /coverage/* 21 | /libpeerconnection.log 22 | npm-debug.log 23 | testem.log 24 | /typings 25 | .travis.yml 26 | .npmignore 27 | 28 | # e2e 29 | /e2e/*.js 30 | /e2e/*.map 31 | 32 | #System Files 33 | .DS_Store 34 | Thumbs.db -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12.4.0 4 | cache: 5 | directories: 6 | - node_modules 7 | dist: trusty 8 | install: 9 | - npm install 10 | - missdev --https 11 | script: 12 | - npm run test 13 | - npm run build 14 | notifications: 15 | email: 16 | - ebrehault@gmail.com, ramon.nb@gmail.com, thomas.desvenain@gmail.com 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.2.0 (Unreleased) 2 | 3 | - Change project structure to use ng cli [Mathilde Pellerin] 4 | - Remove rxjs-compat [Mathilde Pellerin] 5 | - Upgrade to angular 7.2 [Mathilde Pellerin and Eric Brehault] 6 | 7 | # 2.1.0 (Unreleased) 8 | 9 | ## New features 10 | 11 | - Json deserialize dexterity validation errors. [Thomas Desvenain] 12 | - add PUT method support [Eric Brehault] 13 | - support Guillotina registry [Eric Brehault] 14 | - support Guillotina delete endpoints [Eric Brehault] 15 | - support Guillotina addons endpoint [Eric Brehault] 16 | - support Guillotina sharing [Eric Brehault] 17 | - add pending status to Authentication [Eric Brehault] 18 | - support Guillotina behaviors [Eric Brehault] 19 | - support basic authentication [Eric Brehault] 20 | 21 | ## API 22 | 23 | - works with Angular 6 and RxJS 6 + rxjs-compat [Thomas Desvenain] 24 | 25 | ## Bug fixes 26 | 27 | - support backend running on same domain [Eric Brehault] 28 | - default to basic auth if no JWT [Eric Brehault] 29 | - clean basic auth on logout [Eric Brehault] 30 | - emit when not authorized [Eric Brehault] 31 | - export password views [Thomas Desvenain] 32 | - export Services object from root. [Thomas Desvenain] 33 | - angular-traversal is a peer dependency [Thomas Desvenain] 34 | 35 | 36 | # 2.0.0 (2018-05-07) 37 | 38 | ## BREAKING CHANGES 39 | 40 | - `login` method is now an observable. 41 | If you don't subscribe to it, request is not send. 42 | 43 | - `getUserInfo` method has been removed. 44 | 45 | - By default, patch request returns a 200 with full modified content representation. 46 | No effect with plone.restapi <= 1.0a25 47 | 48 | - Since angular2-schema-form dependency has been removed, 49 | edit forms are not anymore auto-generated by angular2-schema-form by default. 50 | 51 | - `review_state` is not a possible search *option* anymore. It is now a possible search criterion only. 52 | 53 | 54 | ## New features 55 | 56 | - Add plone-workflow component. [Thomas Desvenain] 57 | 58 | - We can set a comment on workflow transition. [Thomas Desvenain] 59 | 60 | - Add `workflow` method to get available transitions and history. [Thomas Desvenain] 61 | 62 | - Add `username` to isAuthenticated behavior subject. [Thomas Desvenain] 63 | 64 | - `login` method of `authentication` service now returns an observable. [Thomas Desvenain] 65 | 66 | - Normalized many error responses. Added unit tests on this. [Thomas Desvenain] 67 | 68 | - Add `vocabulary` method to retrieve zope vocabularies. [Thomas Desvenain] 69 | 70 | - We can configure request retries and auth token expiration delay. [Thomas Desvenain] 71 | 72 | - Add interface and helper for file field value upload. [Thomas Desvenain] 73 | 74 | - Compatibility with plone.restapi 1.0b1 and later: url has been renamed to @id on @navigation and @breadcrumb endpoints. Keep the url property on the NavLink interface to be backwards compatible. [Sune Wøller] 75 | 76 | ## Bug fixes 77 | 78 | - Dont fail in normalising urls if path is null [Sune Wøller] 79 | 80 | - Do not prefix url once it starts with http: or https: 81 | 82 | - url was missing from navlink interface. [Thomas Desvenain] 83 | 84 | - Fixed Date search criteria. [Thomas Desvenain] 85 | 86 | - Renamed Comment interface to CommentItem to prevent name collision with component. 87 | 88 | ## Refactor 89 | 90 | - All services injected are protected to ease overloading [Thomas Desvenain] 91 | 92 | - Remove dependency on angular2-schema-form. [Eric Brehault] 93 | 94 | - Add an edit view with angular2-schema-form on test app. [Thomas Desvenain] 95 | 96 | 97 | # 1.3.1 (2017-11-08) 98 | 99 | ## Bug fixes 100 | 101 | - Move from @components plone.restapi endpoint to @navigation and @breadcrumbs [Sune Brøndum Wøller] 102 | 103 | - More robust error handling [Thomas Desvenain] 104 | 105 | # 1.3.0 (2017-11-04) 106 | 107 | ## New features 108 | 109 | - New LoadingService to manage loading status. 110 | Global loading status is now robust on parallel requests. 111 | [Thomas Desvenain] 112 | 113 | - Angular 5 compliancy [Eric Bréhault] 114 | 115 | ## Bug fixes 116 | 117 | - Fixed password reset for browsers that does not have UrlSearchParams. 118 | 119 | ## Refactoring 120 | 121 | - All services are moved to a services subfolder. 122 | 123 | # 1.2.4 (2017-10-11) 124 | 125 | ## New features 126 | 127 | - New download directive attribute [Thomas Desvenain] 128 | 129 | ## Bug fixes 130 | 131 | - Fix missing active links in global navigation, bug introduced in 1.2.0 [Sune Brøndum Wøller] 132 | - Improve test project set up [Sune Brøndum Wøller] 133 | 134 | # 1.2.3 (2017-09-29) 135 | 136 | ## Bug fixes 137 | 138 | - fix error handling 139 | 140 | # 1.2.2 (2017-09-29) 141 | 142 | ## Bug fixes 143 | 144 | - fix error handling 145 | 146 | # 1.2.1 (2017-09-29) 147 | 148 | ## Bug fixes 149 | 150 | - restore peerDependencies instead of dependencies to avoid compilation issues 151 | 152 | # 1.2.0 (2017-09-29) 153 | 154 | ## New features 155 | 156 | - Proper interfaces instead of any types [Thomas Desvenain] 157 | - Password reset feature [Thomas Desvenain] 158 | - Make local and global nav reactive [Thomas Desvenain] 159 | - Migrate to @angular/common/http [Fulvio Casali] 160 | - Cache management [Thomas Desvenain] 161 | 162 | # 1.1.0 (2017-09-12) 163 | 164 | ## New features 165 | 166 | - api.download() allows the user to access a file from the backend via a local blob 167 | 168 | ## Bug fixes 169 | 170 | - Fix api call for workflow transition 171 | 172 | # 1.0.1 (2017-09-08) 173 | 174 | ## New features 175 | 176 | - Support local URLs for backend 177 | 178 | # 1.0.0-alpha.28 (2017-08-03) 179 | 180 | ## New features 181 | 182 | - Allow to use Plone REST API expansion (provided by 1.0a19). 183 | 184 | # 1.0.0-alpha.27 (2017-07-06) 185 | 186 | ## Breaking changes 187 | 188 | - Inject all services in a single service for all the components. 189 | 190 | ## New features 191 | 192 | - Scroll to top after traversing. 193 | - Display subcontents in default view. 194 | 195 | # 1.0.0-alpha.26 (2017-06-30) 196 | 197 | ## Bug fixes 198 | 199 | - Fix navigation request parameter 200 | 201 | # 1.0.0-alpha.25 (2017-06-28) 202 | 203 | ## Breaking changes 204 | 205 | - API service `loading` observable is now replaced by a `status` observable. 206 | 207 | ## New features 208 | 209 | - Handle errors in API service 210 | 211 | ## Bug fixes 212 | 213 | - Fix navigation request parameter 214 | - Fix navigation tree 215 | 216 | # 1.0.0-alpha.24 (2017-06-28) 217 | 218 | ## New features 219 | 220 | - Comments read/add 221 | 222 | # 1.0.0-alpha.23 (2017-06-27) 223 | 224 | ## New features 225 | 226 | - When search results contain File contents, return an actual link, not a traverse 227 | - Sitemap view 228 | 229 | # 1.0.0-alpha.22 (2017-06-26) 230 | 231 | ## Breaking changes 232 | 233 | - The `isAuthenticated` observable is not a boolean anymore but an object (state + error) 234 | 235 | # 1.0.0-alpha.21 (2017-06-16) 236 | 237 | ## Breaking changes 238 | 239 | - The resource service find() method signature has changed 240 | - Requires Plone RESTAPI >= 1.0a18 241 | 242 | ## New features 243 | 244 | - find(): all search options passed as a unique dictionary 245 | - find(): add fullobjects option to retrieve full objects 246 | 247 | # 1.0.0-alpha.20 (2017-06-15) 248 | 249 | ## New features 250 | 251 | - Can subscribe to APIService.loading to knwo when loading is done or not. 252 | 253 | ## Bug fixes 254 | 255 | - Fix find() method when criteria are lists. 256 | 257 | # 1.0.0-alpha.19 (2017-06-02) 258 | 259 | ## New features 260 | 261 | - Allow 0 as root level (i.e. current folder) for local navigation. 262 | 263 | ## Bug fixes 264 | 265 | - Fix local navigation. 266 | 267 | # 1.0.0-alpha.18 (2017-06-01) 268 | 269 | ## New features 270 | 271 | - Use TypeMarker as default marker for traversing view registration. 272 | 273 | ## Bug fixes 274 | 275 | - Fix sort_order in find() method. 276 | 277 | # 1.0.0-alpha.17 (2017-05-25) 278 | 279 | ## New features 280 | 281 | - search view 282 | - redirect to login page if not authorized 283 | - Angular Universal support 284 | - expose title and description meta in HEAD 285 | 286 | # 1.0.0-alpha.16 (2017-04-25) 287 | 288 | ## New features 289 | 290 | - specific service for HTTP calls 291 | - login view 292 | - edit view 293 | 294 | ## Bug fixes 295 | 296 | - manage unsubscribe for all traversing aware components 297 | 298 | # 1.0.0-alpha.15 (2017-04-17) 299 | 300 | ## Bug fixes 301 | 302 | - fix AOT support 303 | - fix src/href replace in body text 304 | 305 | # 1.0.0-alpha.14 (2017-04-01) 306 | 307 | ## New features 308 | 309 | - add start and size options in search 310 | - fully functional navigation service and component 311 | 312 | ## Bug fixes 313 | 314 | - encode metadata_fields and booleans properly in search 315 | 316 | # 1.0.0-alpha.13 (2017-03-30) 317 | 318 | ## New feature 319 | 320 | - convert images and files to full backend path in rich text content 321 | 322 | ## Bug fixes 323 | 324 | - fix active link in navigation 325 | 326 | # 1.0.0-alpha.12 (2017-03-30) 327 | 328 | ## New feature 329 | 330 | - upgrade angular-traversal to allow full path traversing 331 | 332 | # 1.0.0-alpha.11 (2017-03-29) 333 | 334 | ## New feature 335 | 336 | - manage active link in local navigation 337 | - display parent navigation if context is not a folder 338 | 339 | ## Bug fixes 340 | 341 | - rename component.service properly and export it 342 | - set target to es5 for tests (so we can clean up custom components) 343 | 344 | # 1.0.0-alpha.10 (2017-03-24) 345 | 346 | ## New feature 347 | 348 | Upgrade to Angular 4.0 349 | 350 | # 1.0.0-alpha.6 to .9 (2017-03-22) 351 | 352 | ## New feature 353 | 354 | - Set active link in global navigation 355 | 356 | # 1.0.0-alpha.6 (2017-03-22) 357 | 358 | ## Bug fixes 359 | 360 | - Fix package exported classes 361 | 362 | # 1.0.0-alpha.5 (2017-03-22) 363 | 364 | ## New features 365 | 366 | - Add global navigation component 367 | 368 | # 1.0.0-alpha.4 (2017-03-19) 369 | 370 | ## Bug Fixes 371 | 372 | - Move dependencies to peer dependencies 373 | 374 | # 1.0.0-alpha.3 (2017-03-19) 375 | 376 | ## Bug Fixes 377 | 378 | - Fix TypeScript compilation 379 | 380 | # 1.0.0-alpha.2 (2017-03-16) 381 | 382 | ## Bug Fixes 383 | 384 | - Clean up package content 385 | 386 | # 1.0.0-alpha.1 (2017-03-16) 387 | 388 | Initial release 389 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Plone Foundation 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Plone Angular SDK 2 | 3 | [![Build Status](https://travis-ci.org/plone/plone.restapi-angular.svg?branch=master)](https://travis-ci.org/plone/plone.restapi-angular) 4 | [![Coverage Status](https://coveralls.io/repos/github/plone/plone.restapi-angular/badge.svg?branch=master)](https://coveralls.io/github/plone/plone.restapi-angular?branch=master) 5 | [![Documentation Status](https://readthedocs.org/projects/plonerestapi-angular/badge/?version=latest)](http://plonerestapi-angular.readthedocs.io/en/latest/?badge=latest) 6 | 7 | **A simple Angular SDK to build web sites easily on top of the Plone RESTAPI.** 8 | 9 | This package aims to provide the services and components needed to build an Angular applications based on the [Plone REST API](http://plonerestapi.readthedocs.io/en/latest/). 10 | 11 | Plone is a flexible and powerful backend, it provides: 12 | 13 | - hierachical storage 14 | - customizable content types 15 | - granular access control 16 | - workflows 17 | - a rich management interface 18 | 19 | The Plone Angular SDK provides ready-to-use components to build a wide range of applications. 20 | 21 | ![Animation](https://github.com/plone/plone.restapi-angular/raw/master/docs/anim.gif) 22 | 23 | ## Documentation 24 | 25 | See [documentation](http://plonerestapi-angular.readthedocs.io). 26 | 27 | ## Example 28 | 29 | This repository contains a very simple example: [https://github.com/collective/plone-angular-demo](https://github.com/collective/plone-angular-demo). 30 | 31 | To initialize it, run: 32 | ``` 33 | yarn install 34 | ``` 35 | 36 | Make sure you have a Plone server running on localhost:8080 with Plone RESTAPI installed. 37 | 38 | Then launch: 39 | ``` 40 | ng serve 41 | ``` 42 | and visit http://locahost:4200/. 43 | 44 | By checking out this [commit](https://github.com/collective/plone-angular-demo/commit/152068ef3db2362da52e36ae7fe753992dd3bf42), you will get a site displaying the current content plus a navigation bar, with no customization. 45 | 46 | If you checkout this [commit](https://github.com/collective/plone-angular-demo/commit/3881c003d1d253208d2db4a14c2bbec6dbe1b484), you will have bootstrap style (you will need to run `yarn install` in order to update your node modules) and a custom navigation. 47 | 48 | ## Run tests 49 | 50 | cd tests 51 | yarn install 52 | cd .. 53 | yarn test 54 | 55 | ## Contribute 56 | 57 | - Issue Tracker: https://github.com/plone/plone.restapi-angular/issues 58 | - Source Code: https://github.com/plone/plone.restapi-angular 59 | - Documentation: https://github.com/plone/plone.restapi-angular/README.md 60 | 61 | ## Support 62 | 63 | If you are having issues, please let us know. 64 | 65 | Use thissue tracker https://github.com/plone/plone.restapi-angular/issues 66 | 67 | ## License 68 | 69 | The project is licensed under the MIT license. 70 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "restapi-angular": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/restapi-angular", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "src/tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "src/styles.css" 27 | ], 28 | "scripts": [], 29 | "es5BrowserSupport": true 30 | }, 31 | "configurations": { 32 | "production": { 33 | "fileReplacements": [ 34 | { 35 | "replace": "src/environments/environment.ts", 36 | "with": "src/environments/environment.prod.ts" 37 | } 38 | ], 39 | "optimization": true, 40 | "outputHashing": "all", 41 | "sourceMap": false, 42 | "extractCss": true, 43 | "namedChunks": false, 44 | "aot": true, 45 | "extractLicenses": true, 46 | "vendorChunk": false, 47 | "buildOptimizer": true, 48 | "budgets": [ 49 | { 50 | "type": "initial", 51 | "maximumWarning": "2mb", 52 | "maximumError": "5mb" 53 | } 54 | ] 55 | } 56 | } 57 | }, 58 | "serve": { 59 | "builder": "@angular-devkit/build-angular:dev-server", 60 | "options": { 61 | "browserTarget": "restapi-angular:build" 62 | }, 63 | "configurations": { 64 | "production": { 65 | "browserTarget": "restapi-angular:build:production" 66 | } 67 | } 68 | }, 69 | "extract-i18n": { 70 | "builder": "@angular-devkit/build-angular:extract-i18n", 71 | "options": { 72 | "browserTarget": "restapi-angular:build" 73 | } 74 | }, 75 | "test": { 76 | "builder": "@angular-devkit/build-angular:karma", 77 | "options": { 78 | "main": "src/test.ts", 79 | "polyfills": "src/polyfills.ts", 80 | "tsConfig": "src/tsconfig.spec.json", 81 | "karmaConfig": "src/karma.conf.js", 82 | "styles": [ 83 | "src/styles.css" 84 | ], 85 | "scripts": [], 86 | "assets": [ 87 | "src/favicon.ico", 88 | "src/assets" 89 | ] 90 | } 91 | }, 92 | "lint": { 93 | "builder": "@angular-devkit/build-angular:tslint", 94 | "options": { 95 | "tsConfig": [ 96 | "src/tsconfig.app.json", 97 | "src/tsconfig.spec.json" 98 | ], 99 | "exclude": [ 100 | "**/node_modules/**" 101 | ] 102 | } 103 | } 104 | } 105 | }, 106 | "restapi-angular-e2e": { 107 | "root": "e2e/", 108 | "projectType": "application", 109 | "prefix": "", 110 | "architect": { 111 | "e2e": { 112 | "builder": "@angular-devkit/build-angular:protractor", 113 | "options": { 114 | "protractorConfig": "e2e/protractor.conf.js", 115 | "devServerTarget": "restapi-angular:serve" 116 | }, 117 | "configurations": { 118 | "production": { 119 | "devServerTarget": "restapi-angular:serve:production" 120 | } 121 | } 122 | }, 123 | "lint": { 124 | "builder": "@angular-devkit/build-angular:tslint", 125 | "options": { 126 | "tsConfig": "e2e/tsconfig.e2e.json", 127 | "exclude": [ 128 | "**/node_modules/**" 129 | ] 130 | } 131 | } 132 | } 133 | }, 134 | "plone-restapi-angular": { 135 | "root": "projects/plone-restapi-angular", 136 | "sourceRoot": "projects/plone-restapi-angular/src", 137 | "projectType": "library", 138 | "prefix": "lib", 139 | "architect": { 140 | "build": { 141 | "builder": "@angular-devkit/build-ng-packagr:build", 142 | "options": { 143 | "tsConfig": "projects/plone-restapi-angular/tsconfig.lib.json", 144 | "project": "projects/plone-restapi-angular/ng-package.json" 145 | } 146 | }, 147 | "test": { 148 | "builder": "@angular-devkit/build-angular:karma", 149 | "options": { 150 | "main": "projects/plone-restapi-angular/src/test.ts", 151 | "tsConfig": "projects/plone-restapi-angular/tsconfig.spec.json", 152 | "karmaConfig": "projects/plone-restapi-angular/karma.conf.js" 153 | } 154 | }, 155 | "lint": { 156 | "builder": "@angular-devkit/build-angular:tslint", 157 | "options": { 158 | "tsConfig": [ 159 | "projects/plone-restapi-angular/tsconfig.lib.json", 160 | "projects/plone-restapi-angular/tsconfig.spec.json" 161 | ], 162 | "exclude": [ 163 | "**/node_modules/**" 164 | ] 165 | } 166 | } 167 | } 168 | } 169 | }, 170 | "defaultProject": "restapi-angular" 171 | } -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = PloneAngularSDK 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced 2 | ======== 3 | 4 | Configuration options 5 | --------------------- 6 | 7 | The CONFIGURATION provider gets some values: 8 | 9 | - `BACKEND_URL`: the url of the backed 10 | - `CLIENT_TIMEOUT` the time (in ms) client waits for a backend response before it raises a timeout error. Defaults to 15000. 11 | 12 | 13 | Registering a custom marker for view registration 14 | ------------------------------------------------- 15 | 16 | TBD 17 | -------------------------------------------------------------------------------- /docs/anim.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.restapi-angular/b65c901a8f2efa673345cddc0e005edc0333acb8/docs/anim.gif -------------------------------------------------------------------------------- /docs/basic-usage.rst: -------------------------------------------------------------------------------- 1 | Basic usage 2 | =========== 3 | 4 | In ``src/app.module.ts``, load the module and set the backend URL: 5 | 6 | .. code-block:: javascript 7 | 8 | import { RESTAPIModule } from '@plone/restapi-angular'; 9 | 10 | ... 11 | 12 | @NgModule({ 13 | ... 14 | imports: [ 15 | ... 16 | RESTAPIModule, 17 | ], 18 | providers: [ 19 | { 20 | provide: 'CONFIGURATION', useValue: { 21 | BACKEND_URL: 'http://localhost:8080/Plone', 22 | CLIENT_TIMEOUT: 5000, 23 | } 24 | }, 25 | ], 26 | ... 27 | 28 | And you have to set up the Plone views for traversal in ``src/app.component.ts``: 29 | 30 | .. code-block:: javascript 31 | 32 | import { Component } from '@angular/core'; 33 | import { Traverser } from 'angular-traversal'; 34 | import { PloneViews } from '@plone/restapi-angular'; 35 | 36 | @Component({ 37 | ... 38 | }) 39 | export class AppComponent { 40 | 41 | constructor( 42 | private views:PloneViews, 43 | private traverser: Traverser, 44 | ) { 45 | this.views.initialize(); 46 | } 47 | } 48 | 49 | Now you can use the Plone components in your templates, for example in ``src/app.component.html``: 50 | 51 | .. code-block:: html 52 | 53 | 54 | 55 | 56 | Customize components 57 | --------------------- 58 | 59 | **WORK IN PROGRESS** (we will propose a better customization story) 60 | 61 | If you want to change the component's rendering, you can provide your own template by extending the original Plone component. 62 | 63 | In this example we will override the template used by the ``Navigation`` component in order to use `Material Design `_ styling. The navigation menu is actually provided by two separate components, |Navigation|_ and |NavigationLevel|_. The actual customization will happen in the latter, but we also need a custom ``Navigation`` in order to refer to our custom ``NavigationLevel``. 64 | 65 | .. |Navigation| replace:: ``Navigation`` 66 | .. _Navigation: https://github.com/plone/plone.restapi-angular/blob/master/src/components/navigation.ts 67 | 68 | .. |NavigationLevel| replace:: ``NavigationLevel`` 69 | .. _NavigationLevel: https://github.com/plone/plone.restapi-angular/blob/master/src/components/navigation.level.ts 70 | 71 | Let's use Angular CLI to create our custom components: 72 | 73 | .. code-block:: bash 74 | 75 | ng generate component custom-navigation 76 | ng generate component custom-navigation-level 77 | 78 | This will create two new folders: ``./src/app/custom-navigation`` and ``./src/app/custom-navigation-level``. 79 | 80 | We will start with ``./src/app/custom-navigation/custom-navigation.component.ts``: 81 | 82 | .. code-block:: javascript 83 | 84 | import { Component } from '@angular/core'; 85 | import { Navigation } from '@plone/restapi-angular'; 86 | 87 | @Component({ 88 | selector: 'custom-navigation', 89 | template: `` 90 | }) 91 | export class CustomNavigationComponent extends Navigation {} 92 | 93 | - We add an ``import`` for the default ``Navigation``. 94 | - Rename the ``selector``. 95 | - Put the ``template`` inline (using backticks) instead of using an external ``templateUrl``, since the template is very short. 96 | - Replace ``implements`` with ``extends`` and extend from ``Navigation``. 97 | - Delete the ``constructor`` and ``ngOnInit``. 98 | 99 | Let us now turn to ``./src/app/custom-navigation-level/custom-navigation-level.component.ts``: 100 | 101 | .. code-block:: javascript 102 | 103 | import { Component } from '@angular/core'; 104 | import { NavigationLevel } from '@plone/restapi-angular'; 105 | 106 | @Component({ 107 | selector: 'custom-navigation-level', 108 | templateUrl: './custom-navigation-level.component.html', 109 | }) 110 | export class CustomNavigationLevelComponent extends NavigationLevel { 111 | } 112 | 113 | This is very similar to the custom navigation component, except that we point to a ``templateUrl``, because in this case the template (``./src/app/custom-navigation-level/custom-navigation-level.component.html``) is a little more involved. 114 | 115 | .. code-block:: javascript 116 | 117 | 118 | 119 | 120 | {{ link.properties.title }} 121 | 122 | 125 | 126 | 127 | 128 | Note that we are using the same structure as in the |defaultNavigationLeveltemplate|_, only using markup from Angular Material. Before we can call this done, we also need to install the dependencies (see `the setup here `_): 129 | 130 | .. |defaultNavigationLeveltemplate| replace:: default ``NavigationLevel`` template 131 | .. _defaultNavigationLeveltemplate: https://github.com/plone/plone.restapi-angular/blob/master/src/components/navigation.level.ts#L5 132 | 133 | .. code-block:: bash 134 | 135 | npm install --save @angular/material 136 | npm install --save @angular/animations 137 | 138 | Finally, edit your app module (``./src/app/app.module.ts``): 139 | 140 | .. code-block:: javascript 141 | 142 | ... 143 | import { CustomNavigation } from './src/custom-navigation/custom-navigation.component'; 144 | ... 145 | @NgModule({ 146 | declarations: [ 147 | ... 148 | CustomNavigation, 149 | ], 150 | ... 151 | 152 | And load the CSS for Angular Material in the "main template" ``./src/index.html``: 153 | 154 | .. code-block:: html 155 | 156 | 157 | 158 | Now you can use your ```` component in templates, for example by using it instead of ````. 159 | 160 | Customize views 161 | --------------------- 162 | 163 | Customizing a view is quite similar to component customization, the only extra step is to declare it for traversal. 164 | In this example we will modify the default view so that it will display the context's summary under its title. 165 | 166 | Let's use Angular CLI to create our custom view: 167 | 168 | .. code-block:: bash 169 | 170 | ng generate component custom-view 171 | 172 | This will create a new folder: ``./src/app/custom-view``. 173 | 174 | Edit ``./src/app/custom-view/custom-view.component.ts``: 175 | 176 | .. code-block:: javascript 177 | 178 | import { Component } from '@angular/core'; 179 | import { ViewView } from '@plone/restapi-angular'; 180 | 181 | @Component({ 182 | selector: 'custom-view', 183 | template: `

{{ context.title }}

{{ context.description }}

`, 184 | }) 185 | export class CustomViewView extends ViewView {} 186 | 187 | You can see in the inline template that we added the ``context.description``. 188 | 189 | In ``app.module.ts``, you will need to put our custom view in ``declarations`` and in ``entryComponents``: 190 | 191 | .. code-block:: javascript 192 | 193 | import { CustomViewView } from './custom-view/custom-view.component'; 194 | @NgModule({ 195 | declarations: [ 196 | AppComponent, 197 | CustomViewView, 198 | ], 199 | entryComponents: [ 200 | CustomViewView, 201 | ], 202 | ... 203 | 204 | And in ``app.component.ts``, you will need to register it for traversal this way: 205 | 206 | .. code-block:: javascript 207 | 208 | ... 209 | import { CustomViewView } from './custom-view/custom-view.component'; 210 | 211 | ... 212 | export class AppComponent { 213 | 214 | constructor( 215 | private views:PloneViews, 216 | private traverser: Traverser, 217 | ) { 218 | this.views.initialize(); 219 | this.traverser.addView('view', '*', CustomViewView); 220 | } 221 | } 222 | 223 | Now your custom view will replace the original one. 224 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Plone Angular SDK documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Nov 16 10:48:57 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc'] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = '.rst' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = u'Plone Angular SDK' 49 | copyright = u'2017, ebrehault sunew tdesvenain fulv' 50 | author = u'ebrehault sunew tdesvenain fulv' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = u'1.3.1' 58 | # The full version, including alpha/beta/rc tags. 59 | release = u'1.3.1' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This patterns also effect to html_static_path and html_extra_path 71 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = 'sphinx' 75 | 76 | # If true, `todo` and `todoList` produce output, else they produce nothing. 77 | todo_include_todos = False 78 | 79 | 80 | # -- Options for HTML output ---------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | html_theme = 'sphinx_rtd_theme' 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | 93 | # Add any paths that contain custom static files (such as style sheets) here, 94 | # relative to this directory. They are copied after the builtin static files, 95 | # so a file named "default.css" will overwrite the builtin "default.css". 96 | html_static_path = ['_static'] 97 | 98 | # Custom sidebar templates, must be a dictionary that maps document names 99 | # to template names. 100 | # 101 | # This is required for the alabaster theme 102 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 103 | html_sidebars = { 104 | '**': [ 105 | 'relations.html', # needs 'show_related': True theme option to display 106 | 'searchbox.html', 107 | ] 108 | } 109 | 110 | 111 | # -- Options for HTMLHelp output ------------------------------------------ 112 | 113 | # Output file base name for HTML help builder. 114 | htmlhelp_basename = 'PloneAngularSDKdoc' 115 | 116 | 117 | # -- Options for LaTeX output --------------------------------------------- 118 | 119 | latex_elements = { 120 | # The paper size ('letterpaper' or 'a4paper'). 121 | # 122 | # 'papersize': 'letterpaper', 123 | 124 | # The font size ('10pt', '11pt' or '12pt'). 125 | # 126 | # 'pointsize': '10pt', 127 | 128 | # Additional stuff for the LaTeX preamble. 129 | # 130 | # 'preamble': '', 131 | 132 | # Latex figure (float) alignment 133 | # 134 | # 'figure_align': 'htbp', 135 | } 136 | 137 | # Grouping the document tree into LaTeX files. List of tuples 138 | # (source start file, target name, title, 139 | # author, documentclass [howto, manual, or own class]). 140 | latex_documents = [ 141 | (master_doc, 'PloneAngularSDK.tex', u'Plone Angular SDK Documentation', 142 | u'ebrehault sunew tdesvenain fulv', 'manual'), 143 | ] 144 | 145 | 146 | # -- Options for manual page output --------------------------------------- 147 | 148 | # One entry per manual page. List of tuples 149 | # (source start file, name, description, authors, manual section). 150 | man_pages = [ 151 | (master_doc, 'ploneangularsdk', u'Plone Angular SDK Documentation', 152 | [author], 1) 153 | ] 154 | 155 | 156 | # -- Options for Texinfo output ------------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | (master_doc, 'PloneAngularSDK', u'Plone Angular SDK Documentation', 163 | author, 'PloneAngularSDK', 'One line description of project.', 164 | 'Miscellaneous'), 165 | ] 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /docs/deployment.rst: -------------------------------------------------------------------------------- 1 | Deployment 2 | ========== 3 | 4 | Basic Deployment 5 | ---------------- 6 | 7 | Deployment can be achieved in two very basic steps: 8 | 9 | - build the app: `ng build --prod`, 10 | - push the resulting `./dist` folder to any HTTP server. 11 | 12 | But we need to tell the HTTP server to not worry about traversed URL. 13 | Basically any requested URL must be redirected to `index.html`, so Angular Traversal 14 | takes care about the requested path. 15 | 16 | If you use Nginx, it can be achieved with this very simple configuration:: 17 | 18 | location / { 19 | try_files $uri $uri/ /index.html; 20 | } 21 | 22 | Basically any existing file (like index.html, JS or CSS bundles, etc.) will be 23 | served directly, and anything else is redirected to index.html. 24 | 25 | Server-side rendering 26 | --------------------- 27 | 28 | For a single page app, it might be interesting to be able to render pages on the server-side: 29 | 30 | - it improves the first-page display time, 31 | - it improves SEO, 32 | - it makes social network sharing more accurate. 33 | 34 | Angular provides a server-side rendering solution named `Universal `_. 35 | Universal uses NodeJS to render the requested page as plain HTML which is delivered to the client directly. 36 | But once the first page is delivered, the page is rehydrated, meaning the JavaScript application 37 | is loaded on the background and takes the control back smoothly, so when the user clicks on 38 | any link or performs any action offered by the UI, it is processed on the client-side. 39 | 40 | @plone/restapi-angular is Universal compliant. 41 | 42 | A little extra configuration is needed to allow it in a regular Angular CLI project, 43 | and an example will be provided soon. -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | 5 | To make development and debugging of this library easy, you can run on a linked git clone when using it from an angular-cli based app. 6 | 7 | 8 | Goals: 9 | 10 | - Run on a git clone, not just on a released version of the library in node_modules. Making it possible to run on a branch, (master, feature branch for a later pull request...) 11 | - Sourcemaps of the library Typescript code in the browsers developer tools. 12 | - ``debugger;``-statements can be placed in the typescript sourcecode of the library, as well as of the app. 13 | - instant recompile and reload of both app and library code changes when using ``ng serve``. 14 | - keep imports the same: ``import { RESTAPIModule } from '@plone/restapi-angular';`` should work both when we run on a release in node_modules or on a git clone. 15 | 16 | Prerequisites: 17 | 18 | You have created an app with angular-cli. 19 | 20 | 21 | Setting up development 22 | ---------------------- 23 | 24 | The method is: 25 | 26 | 1. clone the library (or libraries). 27 | 2. symlink the src-folder of the library into a packages-folder in your apps src-folder. 28 | 3. configure the module resolution 29 | 4. configure angular-cli build to follow symlinks 30 | 31 | This method will build the library with the methods and configuration of your app. Production releases can behave differently. 32 | 33 | 1 and 2: The following script clones two libraries - plone.restapi-angular and angular-traversal, and symlinks them into src/packages 34 | 35 | Run it from inside your app. 36 | 37 | .. code-block:: shell 38 | 39 | #!/bin/sh 40 | # Run me from project root 41 | mkdir develop 42 | cd develop 43 | git clone git@github.com:plone/plone.restapi-angular.git 44 | git clone https://github.com/makinacorpus/angular-traversal.git 45 | cd .. 46 | 47 | mkdir src/packages 48 | mkdir src/packages/@plone 49 | ln -sT ../../../develop/plone.restapi-angular/src ./src/packages/@plone/restapi-angular 50 | ln -sT ../../develop/angular-traversal/src ./src/packages/angular-traversal 51 | 52 | 53 | For ``@plone/restapi-angular``, we need to create the full namespace folder hierarchy (``@plone``). 54 | 55 | 3: Module resolution: We want to keep being able to import from ``@plone/restapi-angular``, just as when running on a released version of the library:: 56 | 57 | import { RESTAPIModule } from '@plone/restapi-angular'; 58 | 59 | In ``tsconfig.json`` it is possible to configure a ``paths``-mapping of module names to locations, relative to the baseUrl (the location of your apps main entry point). 60 | 61 | See https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping 62 | 63 | Add the paths mapping to the ``compilerOptions`` in the ``tsconfig.app.json`` of your app, (I assume you have the layout of an angular-cli standard project), and make sure the location matches with your ``baseUrl``-setting. 64 | 65 | .. code-block:: javascript 66 | 67 | "baseUrl": "./", 68 | "paths": { 69 | "@plone/restapi-angular": ["packages/@plone/restapi-angular"], 70 | "angular-traversal": ["packages/angular-traversal"] 71 | } 72 | 73 | With some IDEs, like IntelliJ, you will have to put those settings into root ``tsconfig.json``. 74 | Note that the baseUrl will be your source directory (probably ``./src``) there. 75 | 76 | .. code-block:: javascript 77 | 78 | "baseUrl": "./src", 79 | "paths": { 80 | "@plone/restapi-angular": ["packages/@plone/restapi-angular"], 81 | "angular-traversal": ["packages/angular-traversal"] 82 | }, 83 | 84 | 4: Add the following to the ``defaults`` section of your ``.angular-cli.json``:: 85 | 86 | "defaults": { 87 | "build": { 88 | "preserveSymlinks": true 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Plone Angular SDK documentation master file, created by 2 | sphinx-quickstart on Thu Nov 16 10:48:57 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Plone Angular SDK's documentation! 7 | ============================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | basic-usage 14 | setup 15 | principles 16 | deployment 17 | development 18 | advanced 19 | reference/index 20 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=PloneAngularSDK 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/principles.rst: -------------------------------------------------------------------------------- 1 | Principles 2 | ========== 3 | -------------------------------------------------------------------------------- /docs/reference/components.rst: -------------------------------------------------------------------------------- 1 | Components 2 | ========== 3 | 4 | Breadcrumbs 5 | ----------- 6 | 7 | .. code-block:: html 8 | 9 | 10 | 11 | Displays the breadcrumbs links for the current context. 12 | 13 | Forms 14 | ----- 15 | 16 | Based on `Angular2 Schema Form `_. 17 | 18 | Global navigation 19 | ----------------- 20 | 21 | .. code-block:: html 22 | 23 | 24 | 25 | Displays the first level links. 26 | 27 | Navigation 28 | ---------- 29 | 30 | .. code-block:: html 31 | 32 | 33 | 34 | Display navigation links. 35 | 36 | ``root`` can be either a string (to specify a static path like ``/news``) or a null or negative number to specify an ancestor of the current page (0 means current folder). 37 | 38 | ``depth`` defines the tree depth. 39 | 40 | Note: be careful, in Angular templates, inputs are considered as string unless they are interpolated, so ``root="/events"`` returns the string ``"/events"`` and it works. It is equivalent to ``[root]="'/events'"``. 41 | But ``root="-1"`` is wrong, as it would return the string ``"-1"`` which is not a number. To get an actual number, interpolation is mandatory: ``[root]="-1"``. 42 | 43 | Comments 44 | -------- 45 | 46 | .. code-block:: html 47 | 48 | 49 | 50 | Display the existing comments and allow to add new ones. 51 | 52 | Workflow 53 | -------- 54 | 55 | .. code-block:: html 56 | 57 | 58 | 59 | Display workflow history and actionable list of available transitions. 60 | 61 | 62 | Toolbar 63 | ------- 64 | 65 | .. code-block:: html 66 | 67 | 68 | 69 | TO BE IMPLEMENTED 70 | -------------------------------------------------------------------------------- /docs/reference/directives.rst: -------------------------------------------------------------------------------- 1 | Directives 2 | ========== 3 | 4 | 5 | Download directive 6 | ------------------ 7 | 8 | Download directive makes the component to start a file download at click. 9 | 10 | You have to provide a NamedFile object to the directive:: 11 | 12 | Click here to download {{ context.thefile.filename }} 13 | 14 | This works with any html element:: 15 | 16 | 17 | 18 | The directive has three outputs, 19 | 20 | - `onBeforeDownloadStarted`, 21 | - `onDownloadSucceeded`, 22 | - `onDownloadFailed` 23 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | References 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: References: 7 | 8 | components 9 | directives 10 | services 11 | traversal 12 | views 13 | -------------------------------------------------------------------------------- /docs/reference/services.rst: -------------------------------------------------------------------------------- 1 | Services 2 | ======== 3 | 4 | Services injection 5 | ------------------ 6 | 7 | To make injection easier, all the following services are available in a unique service named `Services`. Example: 8 | 9 | .. code-block:: javascript 10 | 11 | import { Services } from '@plone/restapi-angular'; 12 | ... 13 | 14 | constructor(public services: Services) { } 15 | 16 | ... 17 | 18 | this.services.resource.find(...); 19 | 20 | Configuration 21 | ------------- 22 | 23 | It manages the following configuration values: 24 | 25 | - `AUTH_TOKEN_EXPIRES`: the expiration delay of the authentication token stored in local storage, in milliseconds (1 day by default). 26 | - `BACKEND_URL`: the URL of the backend searver exposing a valid Plone REST API 27 | - `PATCH_RETURNS_REPRESENTATION`: if true (by default), successful patch requests return a 200 28 | with full modified content representation as body. 29 | If false, it returns a 204 response with no content. 30 | - `RETRY_REQUEST_ATTEMPTS`: the number of times client will try a request when server is unavailable. (3 by default). 31 | - `RETRY_REQUEST_DELAY`: the retry delay in milliseconds (2000 by default). 32 | 33 | Methods: 34 | 35 | `get(key: string)`: returns the configuration value for the given key. 36 | 37 | `urlToPath(url: string): string`: converts a full backend URL into a locally traversable path. 38 | 39 | Authentication 40 | -------------- 41 | 42 | Properties: 43 | 44 | `isAuthenticated`: observable indicating the current authentication status. 45 | The `state` property is a boolean indicating if the user is logged or not, and the `error` property indicates the error if any. 46 | The `username` property is the name of the logged in user, if any. 47 | 48 | Methods: 49 | 50 | `getUserInfo()`: returns an object containing the current user information. 51 | 52 | `login(login: string, password: string)`: authenticate to the backend using the provided credentials, 53 | the resulting authentication token and user information will be stored in localstorage. 54 | It returns an observable. 55 | 56 | `logout()`: delete the current authentication token. 57 | 58 | Comments 59 | -------- 60 | 61 | Methods: 62 | 63 | `add(path: string, data: any)`: add a new comment in the content corresponding to the path. 64 | 65 | `delete(path: string)`: delete the comment corresponding to the path. 66 | 67 | `get(path: string)`: get all the comments of the content corresponding to the path. 68 | 69 | `update(path: string, data: any)`: update the comment corresponding to the path. 70 | 71 | Resources 72 | --------- 73 | 74 | This service gives access to all the Plone RESTAPI endpoints to manage resourcezs (i.e contents). 75 | 76 | Properties: 77 | 78 | `defaultExpand`: array of string indicating the default expansions that will be asked to the backend when we call `get`. 79 | 80 | Methods: 81 | 82 | `breadcrumbs(path: string)`: return the breadcrumbs links for the specified content. 83 | 84 | `copy(sourcePath: string, targetPath: string)`: copy the resource to another location. Returns an observable. 85 | 86 | `create(path: string, model: any)`: create a new resource in the container indicated by the path. Returns an observable. 87 | 88 | `delete(path: string)`: remove the requested resource as an observable. Returns an observable. 89 | 90 | `find(query: any, path: string='/', options: SearchOptions={})`: returns the search results as an observable. 91 | 92 | See `http://plonerestapi.readthedocs.io/en/latest/searching.html#search `_. 93 | The `options` parameter can contain the following attributes: 94 | 95 | - sort_on: string, name of the index used to sort the result. 96 | - metadata_fields: string[], list of extra metadata fields to retrieve 97 | - start: number, rank of the first item (used for batching, default is 0), 98 | - size: number, length of the batching (default is 20) 99 | - sort_order: string, `'reverse'` to get a reversed order, 100 | - fullobjects: boolean, if `True`, the result will be fully serialized objects, not just metadata. 101 | 102 | `getSearchQueryString`: (static) get a query string from a criterion/value(s) mapping and options object. Used by `find` method. 103 | 104 | `get(path: string, expand?: string[])`: returns the requested resource as an observable. `expand` allow to specify extra expansion (they will be added to `defaultExpand`). 105 | 106 | `lightFileRead(file: File): Observable`: (static) get a plone file field from a javascript File object. Not suitable for big files. 107 | 108 | `move(sourcePath: string, targetPath: string)`: move the resource to another location. Returns an observable. 109 | 110 | `navigation()`: get the global navigation links. Returns an observable. 111 | 112 | `transition(path: string, transition: string, options: WorkflowTransitionOptions)`: perform the transition on the resource. You can set a workflow comment. Returns an observable of the last action information. 113 | 114 | `workflow(path: string)`: get the workflow history and the available transitions on the content. Returns an observable. 115 | 116 | `update(path: string, model: any)`: update the resource by storing the provided model content (existing attibutes are not overidden). Returns an observable. 117 | 118 | `save(path: string, model: any)`: update the resource by replacing its model with the provided model content. Returns an observable. 119 | 120 | `type(typeId)`: return the JSON schema of the specified resource type. 121 | 122 | `vocabulary(vocabularyId)`: return the specified vocabulary object. Returns an observable. 123 | 124 | API service 125 | ----------- 126 | 127 | This service allows to call regular HTTP verbs (for instance to call non-standard endpoints implemented on our backend): 128 | 129 | - `get(path)` 130 | - `post(path, data)` 131 | - `patch(path, data)` 132 | - `delete(path)` 133 | 134 | They all takes care to add the appropriate headers (like authentication token), and return an observable. 135 | 136 | In addition, it provides a specific method to download a file as a blob: 137 | 138 | `download(path)` returns an observable containing a `Blob object `_. 139 | 140 | A Blob object can be turned into an URL like this: 141 | 142 | .. code-block:: javascript 143 | 144 | import { DomSanitizer } from '@angular/platform-browser'; 145 | 146 | constructor( 147 | ... 148 | public sanitizer: DomSanitizer, 149 | ) { } 150 | 151 | ... 152 | this.services.api.download(path).subscribe(blob => { 153 | this.downloadURL = this.sanitizer.bypassSecurityTrustUrl( 154 | window.URL.createObjectURL(blob)); 155 | }); 156 | 157 | It also exposes a `status` observable which returns an object containing: 158 | 159 | - `loading`, boolean, true if call is pending, false if finished 160 | - `error`, the HTTP error if any. 161 | 162 | It exposes a `backendAvailable` observable that emits `false` when backend server can't be reached or consistently responds 502, 503 or 504. 163 | 164 | 165 | Cache service 166 | ------------- 167 | 168 | The CacheService service provides a `get` method which wraps `get` method from Api service with caching features. 169 | 170 | The http request observable is piped into a Subject that repeats the same response during a delay. This delay can be set while providing `CACHE_REFRESH_DELAY` property of `CONFIGURATION` provider. 171 | 172 | You can clear the cache emitting the `revoke` event of the service. It revokes all the cache if you give no argument to the emission. It revokes cache for a single path if you give it a string. 173 | 174 | .. code-block:: javascript 175 | 176 | this.cache.revoke.emit('http://example.com/home') 177 | 178 | The cache can't store more than as many entries as set on `CACHE_MAX_SIZE` property. 179 | 180 | A `hits` property contains the hits statistics (number of hits by path). 181 | 182 | Cache service is massively used by `resource` and `comments` service. All get requests are cached and all create/update/delete requests revokes cache. 183 | 184 | 185 | Loading service 186 | --------------- 187 | 188 | Loading service stores ids for what is currently loading. You declare here which loadings have begun and finished. 189 | 190 | The service provides observables that emits when loading status changes. This is useful when you want to display a reactive loader. 191 | 192 | You give an id to each 'thing' you mark as loaded using the `begin` method. You mark loading as finished using the `finish` method. 193 | 194 | `status` behavior subject changes when there is nothing left to load or if there is at least one thing loading. 195 | 196 | `isLoading` method provides an observable that emits the loading status for a specific id. 197 | 198 | 199 | .. code-block:: javascript 200 | 201 | loading.status.subscribe((isLoading) => { 202 | this.somethingIsLoading = isLoading; 203 | }); 204 | 205 | loading.isLoading('the-data').subscribe((isLoading: boolean) => { 206 | this.dataIsLoading = isLoading; 207 | }); 208 | 209 | loading.begin('the-data') // mark 'the-data' as loading 210 | dataService.getData().subscribe((data: string[]) => { 211 | loading.finish('the-data'); 212 | this.data = data; 213 | }, (error) => { 214 | loading.finish('the-data'); 215 | this.data = []; 216 | this.error = error; 217 | }); 218 | 219 | 220 | This service is used by LoadingInterceptor http interceptor that marks a loading status when any http request is done. 221 | -------------------------------------------------------------------------------- /docs/reference/traversal.rst: -------------------------------------------------------------------------------- 1 | Traversal 2 | ========= 3 | 4 | Based on `Angular traversal `_. 5 | 6 | The Traversal service replaces the default Angular routing. It uses the current location to determine the backend resource (the **context**) and the desired rendering (the **view**). 7 | 8 | The view is the last part of the current location and is prefiexd by `@@`. 9 | If no view is specified, it defaults to `view`. 10 | 11 | The rest of the location is the resource URL. 12 | 13 | Example: `/news/what-about-traversal/@@edit` 14 | 15 | When traversing to the location, the resource will be requested to the backend, and the result will become the current context, accessible from any component in the app. 16 | 17 | According the values in the `@type` property of the context, the appropriate component will be used to render the view. 18 | 19 | Note: We can also use another criteria than `@type` by registring a custom marker (the package comes with an `InterfaceMarker` which marks context according the `interfaces` attribute, which is supposed to be a list. At the moment, the Plone REST API does not expose this attribute). 20 | 21 | Outlet: 22 | 23 | .. code-block:: html 24 | 25 | 26 | 27 | 28 | It allows to position the view rendeirng in the main layout. 29 | 30 | Directive: 31 | 32 | `traverseTo` allows to create a link to a given location. 33 | 34 | Example: 35 | .. code-block:: html 36 | 37 | See the sprint event 38 | 39 | 40 | Methods: 41 | 42 | `addView(name: string, marker: string, component: any)`: register a component as a view for a given marker value. By default, we use the context's `@type` value as marker. 43 | 44 | `traverse(path: string, navigate: boolean = true)`: traverse to the given path. If `navigate` is false, the location will not be changed (useful if the browser location was already set before we traverse). 45 | -------------------------------------------------------------------------------- /docs/reference/views.rst: -------------------------------------------------------------------------------- 1 | Views 2 | ===== 3 | 4 | @@add 5 | ----- 6 | 7 | Example: `http://localhost:4200/site/folder1/@@add?type=Document` 8 | 9 | Display the form to add a new content in the current context folder. The content-type is specified in the query string. 10 | 11 | @@edit 12 | ------ 13 | 14 | Example: `http://localhost:4200/site/folder1/@@edit` 15 | 16 | Display the current context in an edit form. 17 | 18 | @@layout 19 | -------- 20 | 21 | Example: `http://localhost:4200/site/folder1/@@layout` 22 | 23 | Display the layout editor for current context. 24 | 25 | TO BE IMPLEMENTED 26 | 27 | @@login 28 | ------- 29 | 30 | Example: `http://localhost:4200/site/@@login` 31 | 32 | Display the login form. 33 | 34 | @@search 35 | -------- 36 | 37 | Example: `http://localhost:4200/site/@@search?SearchableText=RESTAPI` 38 | 39 | Display the search results for the specified criteria. 40 | 41 | @@sharing 42 | --------- 43 | 44 | Example: `http://localhost:4200/site/folder1/@@sharing` 45 | 46 | Display the sharing form for the current context. 47 | 48 | TO BE IMPLEMENTED 49 | 50 | @@view 51 | ------ 52 | 53 | Example: `http://localhost:4200/site/folder1` or `http://localhost:4200/site/folder1/@@view` 54 | 55 | Display the current context. 56 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | NodeJs 5 | ------ 6 | 7 | We will need NodeJS 6.10+. 8 | 9 | We recommend using NVM to install NodeJS. 10 | 11 | Install nvm on our system using the instructions and provided script at: 12 | 13 | https://github.com/creationix/nvm#install-script 14 | 15 | Using ``nvm`` we will look up the latest LTS version of node.js and install it:: 16 | 17 | $ nvm ls-remote --lts 18 | $ nvm install 6.10 19 | 20 | Then, each time we want to use this version of NodeJS, we just type:: 21 | 22 | $ nvm use 6.10 23 | 24 | Angular CLI 25 | ----------- 26 | 27 | `Angular CLI `_ is the commande line interface provided by Angular. 28 | 29 | .. note:: 30 | 31 | We need CLI 1.0.0+ 32 | 33 | We install it with NPM:: 34 | 35 | $ npm install -g @angular/cli 36 | 37 | The ``-g`` option install the CLI globally, meaning it is available wherever we activate our NVM. 38 | 39 | ``ng`` will be available from the command line and we are ready to bootstrap an application. 40 | 41 | Backend 42 | ------- 43 | 44 | We need a running instance providing the Plone REST API. 45 | 46 | TODO: provide deployment options here. 47 | 48 | Setup a new Angular project 49 | --------------------------- 50 | 51 | Enter the command:: 52 | 53 | $ ng new myapp 54 | 55 | It will setup a standard Angular project structure and install all the default dependencies. 56 | 57 | The app can be served locally with:: 58 | 59 | $ ng serve 60 | 61 | The result can be seen on http://localhost:4200, and any change in the project code triggers an automatic reload of the page. 62 | 63 | Add the @plone/restapi-angular dependency 64 | ----------------------------------------- 65 | 66 | Stop the local server and type:: 67 | 68 | $ npm install @plone/restapi-angular --save 69 | 70 | Note: the ``--save`` option ensures the dependency is added in our ``package.json``. 71 | 72 | We are now ready to use Plone Angular SDK. 73 | -------------------------------------------------------------------------------- /mrs.developer.json: -------------------------------------------------------------------------------- 1 | { 2 | "ngx-schema-form": { 3 | "path": "/projects/schema-form/src/lib", 4 | "url": "git@github.com:guillotinaweb/ngx-schema-form.git", 5 | "https": "https://github.com/guillotinaweb/ngx-schema-form.git", 6 | "branch": "angular7-2" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "restapi-angular", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "jest --no-cache", 9 | "test:watch": "jest --watch", 10 | "test:ci": "jest --runInBand --ci", 11 | "lint": "ng lint", 12 | "e2e": "ng e2e" 13 | }, 14 | "private": true, 15 | "dependencies": { 16 | "@angular/animations": "~7.2.0", 17 | "@angular/common": "~7.2.0", 18 | "@angular/compiler": "~7.2.0", 19 | "@angular/core": "~7.2.0", 20 | "@angular/forms": "~7.2.0", 21 | "@angular/http": "^7.2.15", 22 | "@angular/platform-browser": "~7.2.0", 23 | "@angular/platform-browser-dynamic": "~7.2.0", 24 | "@angular/router": "~7.2.0", 25 | "angular-traversal": "^1.1.0", 26 | "core-js": "^2.5.4", 27 | "rxjs": "~6.3.3", 28 | "tslib": "^1.9.0", 29 | "zone.js": "~0.8.26", 30 | "z-schema": "^3.24.1" 31 | }, 32 | "devDependencies": { 33 | "@angular-devkit/build-angular": "~0.13.0", 34 | "@angular-devkit/build-ng-packagr": "~0.13.0", 35 | "@angular/cli": "~7.3.6", 36 | "@angular/compiler-cli": "~7.2.0", 37 | "@angular/language-service": "~7.2.0", 38 | "@types/jasmine": "~2.8.8", 39 | "@types/jasminewd2": "~2.0.3", 40 | "@types/jest": "^24.0.11", 41 | "@types/node": "~8.9.4", 42 | "codelyzer": "~4.5.0", 43 | "jest": "^24.8.0", 44 | "jest-preset-angular": "^7.0.1", 45 | "mrs-developer": "^1.1.0", 46 | "ng-packagr": "^4.2.0", 47 | "protractor": "~5.4.0", 48 | "ts-node": "~7.0.0", 49 | "tsickle": ">=0.34.0", 50 | "tslib": "^1.9.3", 51 | "tslint": "~5.11.0", 52 | "typescript": "~3.2.2" 53 | }, 54 | "jest": { 55 | "preset": "jest-preset-angular", 56 | "setupFilesAfterEnv": [ 57 | "/src/setupJest.ts" 58 | ], 59 | "testPathIgnorePatterns": [ 60 | "/node_modules/", 61 | "test.ts" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/README.md: -------------------------------------------------------------------------------- 1 | # PloneRestapiAngular 2 | 3 | This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.2.0. 4 | 5 | ## Code scaffolding 6 | 7 | Run `ng generate component component-name --project plone-restapi-angular` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project plone-restapi-angular`. 8 | > Note: Don't forget to add `--project plone-restapi-angular` or else it will be added to the default project in your `angular.json` file. 9 | 10 | ## Build 11 | 12 | Run `ng build plone-restapi-angular` to build the project. The build artifacts will be stored in the `dist/` directory. 13 | 14 | ## Publishing 15 | 16 | After building your library with `ng build plone-restapi-angular`, go to the dist folder `cd dist/plone-restapi-angular` and run `npm publish`. 17 | 18 | ## Running unit tests 19 | 20 | Run `ng test plone-restapi-angular` to execute the unit tests via [Karma](https://karma-runner.github.io). 21 | 22 | ## Further help 23 | 24 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 25 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/plone-restapi-angular", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/plone-restapi-angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@plone/restapi-angular", 3 | "version": "2.2.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/plone/plone.restapi-angular.git" 7 | }, 8 | "author": "Plone Community", 9 | "license": "MIT", 10 | "bugs": { 11 | "url": "https://github.com/plone/plone.restapi-angular/issues" 12 | }, 13 | "homepage": "https://github.com/plone/plone.restapi-angular#readme", 14 | "peerDependencies": { 15 | "@angular/common": "^7.2.0", 16 | "@angular/core": "^7.2.0", 17 | "angular-traversal": "^1.1.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/breadcrumbs.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { 4 | HttpClientTestingModule 5 | } from '@angular/common/http/testing'; 6 | import { Injectable, EventEmitter } from '@angular/core'; 7 | import { APP_BASE_HREF } from '@angular/common'; 8 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer, Target } from 'angular-traversal'; 9 | import { 10 | TypeMarker, 11 | PloneViews, 12 | RESTAPIResolver, 13 | FullPathNormalizer, 14 | } from '../traversal'; 15 | 16 | import { ConfigurationService } from '../services/configuration.service'; 17 | import { APIService } from '../services/api.service'; 18 | import { CommentsService } from '../services/comments.service'; 19 | import { NavigationService } from '../services/navigation.service'; 20 | import { AuthenticationService } from '../services/authentication.service'; 21 | import { ResourceService } from '../services/resource.service'; 22 | import { Services } from '../services'; 23 | import { Breadcrumbs } from './breadcrumbs'; 24 | import { LoadingService } from '../services/loading.service'; 25 | import { CacheService } from '../services/cache.service'; 26 | import { of } from 'rxjs'; 27 | 28 | @Injectable() 29 | class MockResourceService { 30 | 31 | resourceModified = new EventEmitter(); 32 | breadcrumbs(path: string) { 33 | return of([ 34 | { 35 | "title": "A folder", 36 | "url": "http://fake/Plone/a-folder" 37 | }, 38 | { 39 | "title": "test", 40 | "url": "http://fake/Plone/a-folder/test" 41 | } 42 | ]); 43 | } 44 | } 45 | 46 | describe('Breadcrumbs', () => { 47 | let component: Breadcrumbs; 48 | let fixture: ComponentFixture; 49 | 50 | beforeEach(async(() => { 51 | TestBed.configureTestingModule({ 52 | declarations: [Breadcrumbs], 53 | imports: [HttpClientTestingModule, TraversalModule], 54 | providers: [ 55 | APIService, 56 | AuthenticationService, 57 | ConfigurationService, 58 | { 59 | provide: 'CONFIGURATION', useValue: { 60 | BACKEND_URL: 'http://fake/Plone', 61 | } 62 | }, 63 | CacheService, 64 | CommentsService, 65 | LoadingService, 66 | NavigationService, 67 | TypeMarker, 68 | RESTAPIResolver, 69 | PloneViews, 70 | Services, 71 | Traverser, 72 | { provide: Resolver, useClass: RESTAPIResolver }, 73 | { provide: Marker, useClass: TypeMarker }, 74 | { provide: APP_BASE_HREF, useValue: '/' }, 75 | { provide: Normalizer, useClass: FullPathNormalizer }, 76 | { provide: ResourceService, useClass: MockResourceService }, 77 | ], 78 | }) 79 | .compileComponents(); 80 | })); 81 | 82 | beforeEach(() => { 83 | fixture = TestBed.createComponent(Breadcrumbs); 84 | component = fixture.componentInstance; 85 | fixture.detectChanges(); 86 | component.services.resource.resourceModified.emit(); 87 | }); 88 | 89 | it('should create', () => { 90 | expect(component).toBeTruthy(); 91 | }); 92 | 93 | it('should provide links', () => { 94 | component.onTraverse({ contextPath: '/', context: {} }); 95 | expect(component.links.length).toBe(2); 96 | }); 97 | 98 | it('should have active class on last link', () => { 99 | let activeLink: HTMLElement; 100 | component.onTraverse({ contextPath: '/a-folder/test', path: '/a-folder/test', context: {} }); 101 | fixture.detectChanges(); 102 | activeLink = fixture.debugElement.query(By.css('.active')).nativeElement; 103 | expect(activeLink.innerHTML).toContain('test'); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/breadcrumbs.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Services } from '../services'; 3 | import { TraversingComponent } from '../traversing'; 4 | import { NavLink } from '../interfaces'; 5 | import { Target } from 'angular-traversal'; 6 | 7 | 8 | @Component({ 9 | selector: 'plone-breadcrumbs', 10 | template: ` 11 | ` 18 | }) 19 | export class Breadcrumbs extends TraversingComponent { 20 | 21 | links: NavLink[] = []; 22 | 23 | constructor(public services: Services) { 24 | super(services); 25 | } 26 | 27 | onTraverse(target: Target) { 28 | const components = target.context['@components']; // breadcrumbs we got with expansion; 29 | if (components && components.breadcrumbs.items) { 30 | this.links = components.breadcrumbs.items; 31 | } else { 32 | if (target.contextPath) { 33 | this.services.resource.breadcrumbs(target.contextPath) 34 | .subscribe((links: NavLink[]) => { 35 | this.links = links; 36 | }); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/comments.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { By } from '@angular/platform-browser'; 3 | import { DatePipe } from '@angular/common'; 4 | import { CommentItem } from '../interfaces'; 5 | 6 | import { Comment } from './comments'; 7 | 8 | describe('Comment component', () => { 9 | let comp: Comment; 10 | let fixture: ComponentFixture; 11 | let commentContent, commentAuthor, commentDate: HTMLElement; 12 | 13 | beforeEach(() => { 14 | TestBed.configureTestingModule({ 15 | declarations: [Comment], 16 | }); 17 | 18 | fixture = TestBed.createComponent(Comment); 19 | 20 | comp = fixture.componentInstance; 21 | 22 | commentContent = fixture.debugElement.query(By.css('.comment-body')).nativeElement; 23 | commentAuthor = fixture.debugElement.query(By.css('.comment-author')).nativeElement; 24 | commentDate = fixture.debugElement.query(By.css('.comment-date')).nativeElement; 25 | }); 26 | 27 | it('should display author, date and format content', () => { 28 | let comment = { 29 | comment_id: '13233313', 30 | creation_date: new Date('2000/01/01'), 31 | author_name: 'Dynausor', 32 | text: { 'mime-type': 'text/plain', 'data': 'Roaaaar\nRoooar\nRoooar' } 33 | }; 34 | 35 | comp.comment = comment; 36 | 37 | fixture.detectChanges(); 38 | 39 | expect(commentContent.innerHTML).toEqual(comment.text.data.replace(/\n/g, '
')); 40 | expect(commentAuthor.innerHTML).toEqual(comment.author_name); 41 | expect(commentDate.innerHTML).toEqual(new DatePipe('en').transform(comment.creation_date)); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/comments.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Input, 4 | Output, 5 | EventEmitter 6 | } from '@angular/core'; 7 | import { NgForm } from '@angular/forms'; 8 | 9 | import { Services } from '../services'; 10 | import { TraversingComponent } from '../traversing'; 11 | import { Target } from 'angular-traversal'; 12 | import { CommentItem, TextValue } from '../interfaces'; 13 | 14 | @Component({ 15 | selector: 'plone-comment', 16 | template: `

17 | {{ comment.author_name }} 18 | {{ comment.creation_date | date }} 19 |

20 |
` 21 | }) 22 | export class Comment { 23 | 24 | @Input() comment: CommentItem; 25 | 26 | formatText(text: TextValue) { 27 | if (!text) { 28 | return ''; 29 | } 30 | if (text['mime-type'] === 'text/plain') { 31 | return text.data.replace(/\n/g, '
'); 32 | } else { 33 | return text.data; 34 | } 35 | } 36 | } 37 | 38 | @Component({ 39 | selector: 'plone-comment-add', 40 | template: ` 41 |
42 |
{{ error }}
43 |
44 | 45 |
46 |
47 | 48 |
49 | 50 |
` 51 | }) 52 | export class CommentAdd { 53 | 54 | @Input() path: string; 55 | @Output() onCreate: EventEmitter = new EventEmitter(); 56 | error: string; 57 | 58 | constructor( 59 | private services: Services, 60 | ) { } 61 | 62 | add(form: NgForm) { 63 | this.services.comments.add(this.path, form.value).subscribe(res => { 64 | this.onCreate.next(true); 65 | form.resetForm(); 66 | }, (err: Error) => { 67 | this.error = err.message; 68 | }); 69 | } 70 | } 71 | 72 | @Component({ 73 | selector: 'plone-comments', 74 | template: ` 75 |
76 | 80 |
81 | 82 |
83 |
` 84 | }) 85 | export class Comments extends TraversingComponent { 86 | 87 | public comments: CommentItem[] = []; 88 | public contextPath: string; 89 | public allowDiscussion: boolean; 90 | 91 | constructor(public services: Services) { 92 | super(services); 93 | } 94 | 95 | onTraverse(target: Target) { 96 | if (target.contextPath) { 97 | this.contextPath = target.contextPath; 98 | this.allowDiscussion = target.context.allow_discussion || false; 99 | this.loadComments(); 100 | } 101 | } 102 | 103 | loadComments() { 104 | this.services.comments.get(this.contextPath).subscribe(res => { 105 | this.comments = res.items; 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/global.navigation.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { 3 | HttpClientTestingModule 4 | } from '@angular/common/http/testing'; 5 | import { Injectable, EventEmitter } from '@angular/core'; 6 | import { APP_BASE_HREF } from '@angular/common'; 7 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer, Target } from 'angular-traversal'; 8 | import { 9 | TypeMarker, 10 | PloneViews, 11 | RESTAPIResolver, 12 | FullPathNormalizer, 13 | } from '../traversal'; 14 | 15 | import { ConfigurationService } from '../services/configuration.service'; 16 | import { APIService } from '../services/api.service'; 17 | import { CommentsService } from '../services/comments.service'; 18 | import { NavigationService } from '../services/navigation.service'; 19 | import { AuthenticationService } from '../services/authentication.service'; 20 | import { ResourceService } from '../services/resource.service'; 21 | import { Services } from '../services'; 22 | import { GlobalNavigation } from './global.navigation'; 23 | import { CacheService } from '../services/cache.service'; 24 | import { LoadingService } from '../services/loading.service'; 25 | import { of } from 'rxjs'; 26 | 27 | @Injectable() 28 | class MockResourceService { 29 | 30 | resourceModified = new EventEmitter(); 31 | navigation() { 32 | return of( 33 | [ 34 | { 35 | "title": "A folder", 36 | "url": "http://fake/Plone/a-folder", 37 | "active": "false", 38 | "path": "/a-folder" 39 | }, 40 | { 41 | "title": "B folder", 42 | "url": "http://fake/Plone/b-folder", 43 | "active": "false", 44 | "path": "/b-folder" 45 | }, 46 | { 47 | "title": "test", 48 | "url": "http://fake/Plone/a-folder/test", 49 | "active": "false", 50 | "path": "/a-folder/test" 51 | } 52 | ]); 53 | } 54 | } 55 | 56 | describe('GlobalNavigation', () => { 57 | let component: GlobalNavigation; 58 | let fixture: ComponentFixture; 59 | 60 | beforeEach(async(() => { 61 | TestBed.configureTestingModule({ 62 | declarations: [GlobalNavigation], 63 | imports: [HttpClientTestingModule, TraversalModule], 64 | providers: [ 65 | APIService, 66 | AuthenticationService, 67 | ConfigurationService, 68 | { 69 | provide: 'CONFIGURATION', useValue: { 70 | BACKEND_URL: 'http://fake/Plone', 71 | } 72 | }, 73 | CacheService, 74 | CommentsService, 75 | LoadingService, 76 | NavigationService, 77 | TypeMarker, 78 | RESTAPIResolver, 79 | PloneViews, 80 | Services, 81 | Traverser, 82 | { provide: Resolver, useClass: RESTAPIResolver }, 83 | { provide: Marker, useClass: TypeMarker }, 84 | { provide: APP_BASE_HREF, useValue: '/' }, 85 | { provide: Normalizer, useClass: FullPathNormalizer }, 86 | { provide: ResourceService, useClass: MockResourceService }, 87 | ], 88 | }) 89 | .compileComponents(); 90 | })); 91 | 92 | beforeEach(() => { 93 | fixture = TestBed.createComponent(GlobalNavigation); 94 | component = fixture.componentInstance; 95 | fixture.detectChanges(); 96 | component.services.resource.resourceModified.emit(); 97 | }); 98 | 99 | it('should create', () => { 100 | expect(component).toBeTruthy(); 101 | }); 102 | 103 | it('should provide links', () => { 104 | component.onTraverse({ contextPath: '/', context: {}}); 105 | expect(component.links.length).toBe(3); 106 | }); 107 | 108 | it('should set the active link', () => { 109 | component.onTraverse({ contextPath: '/b-folder', path: '/b-folder', context: {}}); 110 | expect(component.links[0].active).toBeFalsy(); 111 | expect(component.links[1].active).toBeTruthy(); 112 | }); 113 | 114 | it('should set the active top level link when navigating to contained items', () => { 115 | component.onTraverse({ contextPath: '/a-folder/test', path: '/a-folder/test', context: {}}); 116 | expect(component.links[0].active).toBeTruthy(); 117 | expect(component.links[1].active).toBeFalsy(); 118 | }); 119 | 120 | }); 121 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/global.navigation.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { Services } from '../services'; 3 | import { TraversingComponent } from '../traversing'; 4 | import { NavLink } from '../interfaces'; 5 | import { Target } from 'angular-traversal'; 6 | import { Subscription } from 'rxjs'; 7 | import { mergeMap } from 'rxjs/operators'; 8 | 9 | 10 | @Component({ 11 | selector: 'plone-global-navigation', 12 | template: ` 13 | ` 18 | }) 19 | export class GlobalNavigation extends TraversingComponent implements OnInit, OnDestroy { 20 | 21 | links: NavLink[] = []; 22 | refreshNavigation: Subscription; 23 | contextPath: string; 24 | 25 | constructor(public services: Services) { 26 | super(services); 27 | } 28 | 29 | ngOnInit() { 30 | super.ngOnInit(); 31 | const component = this; 32 | 33 | component.refreshNavigation = component.services.navigation.refreshNavigation.pipe( 34 | mergeMap(() => component.services.resource.navigation()) 35 | ).subscribe((links: NavLink[]) => { 36 | this.setLinks(links); 37 | }); 38 | } 39 | 40 | protected setLinks(links: NavLink[]) { 41 | this.links = links; 42 | this.setActiveLinks(this.contextPath); 43 | } 44 | 45 | onTraverse(target: Target) { 46 | // contextPath = '' for the root of the site - always set the contextPath 47 | this.contextPath = target.contextPath; 48 | this.setActiveLinks(this.contextPath); 49 | } 50 | 51 | ngOnDestroy() { 52 | if (this.refreshNavigation.unsubscribe) { 53 | this.refreshNavigation.unsubscribe(); 54 | } 55 | } 56 | 57 | protected setActiveLinks(contextPath: string) { 58 | this.links.map((link: NavLink) => { 59 | if (!contextPath || contextPath === '/') { 60 | link.active = (!link.path || link.path === '/'); 61 | } else { 62 | const targetList: string[] = contextPath.split('/'); 63 | let linkList: string[] = link.path.split('/'); 64 | let isSubpath = true; // you could just use link.active 65 | for (const {item, index} of linkList.map((item, index) => ({ item, index }))) { 66 | if (item !== targetList[index]) { 67 | isSubpath = false; 68 | } 69 | } 70 | link.active = isSubpath; 71 | } 72 | }); 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/navigation.level.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import {NavTree} from '../interfaces'; 3 | 4 | @Component({ 5 | selector: 'plone-navigation-level', 6 | template: ` 7 | ` 15 | }) 16 | export class NavigationLevel { 17 | @Input() links: NavTree[]; 18 | } 19 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/navigation.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnDestroy } from '@angular/core'; 2 | import { Services } from '../services'; 3 | import { TraversingComponent } from '../traversing'; 4 | import { NavTree } from '../interfaces'; 5 | import { Target } from 'angular-traversal'; 6 | import { Subscription } from 'rxjs'; 7 | import { mergeMap } from 'rxjs/operators'; 8 | 9 | @Component({ 10 | selector: 'plone-navigation', 11 | template: ` 12 | ` 14 | }) 15 | export class Navigation extends TraversingComponent implements OnDestroy { 16 | 17 | @Input() root = '/'; 18 | @Input() depth = -1; 19 | links: NavTree[]; 20 | refreshNavigation: Subscription; 21 | 22 | constructor(public services: Services) { 23 | super(services); 24 | } 25 | 26 | onTraverse(target: Target) { 27 | const component = this; 28 | const navigation = component.services.navigation; 29 | component.removeSubscriptions(); 30 | this.refreshNavigation = navigation.refreshNavigation.pipe( 31 | mergeMap(() => navigation.getNavigationFor(target.context['@id'], component.root, component.depth)) 32 | ).subscribe((tree: NavTree) => { 33 | component.links = tree.children; 34 | }); 35 | } 36 | 37 | ngOnDestroy() { 38 | this.removeSubscriptions(); 39 | } 40 | 41 | private removeSubscriptions() { 42 | if (this.refreshNavigation) { 43 | this.refreshNavigation.unsubscribe(); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/components/workflow.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, EventEmitter, Input, Output 3 | } from '@angular/core'; 4 | import { Services } from '../services'; 5 | import { TraversingComponent } from '../traversing'; 6 | import { Target } from 'angular-traversal'; 7 | import { Error, WorkflowHistoryItem, WorkflowInformation, WorkflowTransitionItem } from '../interfaces'; 8 | 9 | @Component({ 10 | selector: 'plone-workflow', 11 | template: ` 12 |
13 |
14 |
    15 |
  • 16 | {{ item.title }} 17 | by {{ item.actor }} – 18 | {{ displayTime(item.time) }} 19 | – {{ item.comments }} 20 |
  • 21 |
22 |
23 |
24 |
25 | 29 |
30 | 36 |
37 |
` 38 | }) 39 | export class Workflow extends TraversingComponent { 40 | 41 | @Input() showHistory = true; 42 | @Input() haveCommentInput = true; 43 | 44 | contextPath: string; 45 | commentText = ''; 46 | public workflowInformation: WorkflowInformation | null; 47 | 48 | @Output() workflowStateChanged: EventEmitter = new EventEmitter(); 49 | 50 | constructor(public services: Services) { 51 | super(services); 52 | } 53 | 54 | onTraverse(target: Target) { 55 | if (target.contextPath) { 56 | this.contextPath = target.contextPath; 57 | this.loadWorkflowInformation(); 58 | } 59 | } 60 | 61 | protected loadWorkflowInformation() { 62 | const component = this; 63 | this.services.resource.workflow(component.contextPath) 64 | .subscribe((workflowInformation: WorkflowInformation) => { 65 | component.workflowInformation = workflowInformation; 66 | }); 67 | } 68 | 69 | processTransition(event: Event, item: WorkflowTransitionItem) { 70 | event.preventDefault(); 71 | 72 | const transitionId = item['@id'].split('/').pop(); 73 | this.services.resource.transition(this.contextPath, transitionId, { comment: this.commentText || '' }) 74 | .subscribe((historyItem: WorkflowHistoryItem) => { 75 | this.commentText = ''; 76 | this.workflowStateChanged.emit(historyItem); 77 | this.loadWorkflowInformation(); 78 | }, (error: Error) => { 79 | if (error.type === 'WorkflowException' || error.response && error.response.status === 404) { 80 | this.workflowInformation = null; 81 | } else { 82 | console.error(error); 83 | } 84 | }); 85 | } 86 | 87 | currentState(): string | null { 88 | if (this.workflowInformation && this.workflowInformation.history.length > 0) { 89 | return this.workflowInformation.history[this.workflowInformation.history.length - 1].title; 90 | } else { 91 | return ''; 92 | } 93 | } 94 | 95 | displayTime(datestr: string) { 96 | const date = new Date(datestr); 97 | return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/directives/download.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { DownloadDirective } from './download.directive'; 2 | import { APIService } from '../services/api.service'; 3 | 4 | class FakeApi { 5 | constructor() { 6 | 7 | } 8 | } 9 | 10 | describe('DownloadDirective', () => { 11 | it('should create an instance', () => { 12 | const fakeApi = new FakeApi(); 13 | const directive = new DownloadDirective(fakeApi); 14 | expect(directive).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/directives/download.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core'; 2 | import { APIService } from '../services/api.service'; 3 | import { DownloadFailedEvent, DownloadStartedEvent, DownloadSucceededEvent, NamedFile, Error } from '../interfaces'; 4 | 5 | 6 | @Directive({ 7 | selector: '[download]' 8 | }) 9 | export class DownloadDirective { 10 | 11 | @Input() download: NamedFile; 12 | @Output() onBeforeDownloadStarted: EventEmitter = new EventEmitter(); 13 | @Output() onDownloadSuccess: EventEmitter = new EventEmitter(); 14 | @Output() onDownloadFailed: EventEmitter = new EventEmitter(); 15 | 16 | constructor(private api: APIService) { 17 | } 18 | 19 | @HostListener('click', ['$event']) 20 | onClick(event: Event) { 21 | if (!!event) { 22 | event.preventDefault(); 23 | } 24 | const namedFile: NamedFile = this.download; 25 | this.onBeforeDownloadStarted.emit({ 26 | namedFile: namedFile, 27 | originalEvent: event 28 | }); 29 | this.api.download(namedFile.download) 30 | .subscribe((blob: Blob | {}) => { 31 | if (blob instanceof Blob) { 32 | DownloadDirective.saveDownloaded(namedFile, blob); 33 | this.onDownloadSuccess.emit({ 34 | namedFile: namedFile, 35 | blob: blob 36 | }); 37 | } 38 | }, (error: Error) => { 39 | this.onDownloadFailed.emit({ 40 | error: error, 41 | namedFile: namedFile 42 | }); 43 | }); 44 | } 45 | 46 | private static saveDownloaded(namedFile: NamedFile, blob: Blob) { 47 | const a = window.document.createElement('a'); 48 | a.href = window.URL.createObjectURL( 49 | new Blob([blob], { type: namedFile['mime-type'] }) 50 | ); 51 | a.download = namedFile.filename; 52 | document.body.appendChild(a); 53 | a.click(); 54 | document.body.removeChild(a); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interfaces'; 2 | export { APIService } from './services/api.service'; 3 | export { AuthenticationService } from './services/authentication.service'; 4 | export { CacheService } from './services/cache.service'; 5 | export { ConfigurationService } from './services/configuration.service'; 6 | export { CommentsService } from './services/comments.service'; 7 | export { LoadingService } from './services/loading.service'; 8 | export { ResourceService } from './services/resource.service'; 9 | export { Services } from './services/services'; 10 | export { RESTAPIModule } from './module'; 11 | export { PloneViews, InterfaceMarker, TypeMarker } from './traversal'; 12 | export { TraversingComponent } from './traversing'; 13 | export { AddView } from './views/add'; 14 | export { EditView } from './views/edit'; 15 | export { LoginView } from './views/login'; 16 | export { SearchView } from './views/search'; 17 | export { SitemapView } from './views/sitemap'; 18 | export { PasswordResetView } from './views/password-reset'; 19 | export { RequestPasswordResetView } from './views/request-password-reset'; 20 | export { ViewView } from './views/view'; 21 | export { Breadcrumbs } from './components/breadcrumbs'; 22 | export { Comments, Comment, CommentAdd } from './components/comments'; 23 | export { GlobalNavigation } from './components/global.navigation'; 24 | export { Navigation } from './components/navigation'; 25 | export { NavigationLevel } from './components/navigation.level'; 26 | export { Workflow } from './components/workflow'; 27 | export { Term, Vocabulary } from './vocabularies'; 28 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/interfaces.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * STATUS 3 | * 4 | */ 5 | import { HttpErrorResponse } from '@angular/common/http'; 6 | 7 | export interface LoadingStatus { 8 | loading?: boolean; 9 | error?: Error; 10 | } 11 | 12 | export interface Error { 13 | type: string; // mostly, Python exception class 14 | message: string; 15 | traceback?: string[]; // Plone traceback 16 | 17 | errors?: { // mostly, dexterity fields validation errors 18 | field?: string; 19 | message?: string; 20 | error?: string; 21 | [x: string]: any; 22 | }[]; 23 | 24 | response?: HttpErrorResponse; 25 | 26 | [x: string]: any; 27 | } 28 | 29 | 30 | /* 31 | * NAVIGATION 32 | * 33 | */ 34 | 35 | 36 | /* 37 | * Navigation element of breadcrumbs and global nav 38 | */ 39 | 40 | export interface NavLink { 41 | '@id': string; 42 | title: string; 43 | url: string; 44 | path: string; 45 | active: boolean; 46 | properties?: any; 47 | } 48 | 49 | /* 50 | * Navigation tree 51 | * 52 | */ 53 | export interface NavTree { 54 | children: NavTree[]; 55 | inPath?: boolean; 56 | active?: boolean; 57 | properties?: any; 58 | } 59 | 60 | 61 | /* 62 | * COMMENTS 63 | * 64 | */ 65 | 66 | export interface TextValue { 67 | data: string; 68 | 'mime-type': string; 69 | } 70 | 71 | export interface CommentItem { 72 | '@id': string; 73 | author_name: string; 74 | author_username: string; 75 | comment_id: string; 76 | creation_date: Date; 77 | in_reply_to?: string | null; 78 | modification_date: Date; 79 | text: TextValue; 80 | user_notification: boolean | null; 81 | } 82 | 83 | 84 | /* 85 | * SEARCH 86 | * 87 | */ 88 | 89 | export interface SearchOptions { 90 | sort_on?: string; 91 | sort_order?: string; 92 | metadata_fields?: string[]; 93 | start?: number; 94 | size?: number; 95 | fullobjects?: boolean; 96 | } 97 | 98 | export interface Batching { 99 | '@id': string; 100 | first: string; 101 | last: string; 102 | next: string; 103 | prev: string; 104 | } 105 | 106 | export interface SearchResults { 107 | '@id': string; 108 | items_total: number; 109 | items: any[]; 110 | batching: Batching; 111 | } 112 | 113 | /* 114 | * AUTHENTICATION 115 | * 116 | */ 117 | 118 | /* Authentication status */ 119 | export interface AuthenticatedStatus { 120 | state: boolean; 121 | pending: boolean; 122 | username: string | null; 123 | error?: string; 124 | } 125 | 126 | export interface LoginInfo { 127 | login: string; 128 | password: string; 129 | } 130 | 131 | export interface PasswordResetInfo { 132 | oldPassword?: string; 133 | newPassword: string; 134 | login: string; 135 | token?: string; 136 | } 137 | 138 | 139 | /* FILE DOWNLOAD */ 140 | 141 | export interface NamedFile { 142 | download: string; // download path 143 | filename: string; 144 | 'mime-type': string; 145 | } 146 | 147 | export interface DownloadStartedEvent { 148 | namedFile: NamedFile; 149 | originalEvent: Event; 150 | } 151 | 152 | export interface DownloadSucceededEvent { 153 | blob: Blob; 154 | namedFile: NamedFile; 155 | } 156 | 157 | export interface DownloadFailedEvent { 158 | error: Error; 159 | namedFile: NamedFile; 160 | } 161 | 162 | /* 163 | * FILE UPLOAD 164 | */ 165 | 166 | export interface NamedFileUpload { 167 | data: any; 168 | encoding: string; 169 | filename: string; 170 | 'content-type': string; 171 | } 172 | 173 | /* 174 | * WORKFLOW 175 | * 176 | */ 177 | 178 | export interface WorkflowHistoryItem { 179 | action: T | null; 180 | actor: string; 181 | comments: string; 182 | review_state: S; 183 | time: string; 184 | title: string; 185 | } 186 | 187 | export interface WorkflowTransitionItem { 188 | '@id': string; 189 | title: string; 190 | } 191 | 192 | export interface WorkflowInformation { 193 | '@id': string; 194 | history: WorkflowHistoryItem[]; 195 | transitions: WorkflowTransitionItem[]; 196 | } 197 | 198 | export interface WorkflowTransitionOptions { 199 | comment?: string; 200 | 201 | [x: string]: any; 202 | } 203 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; 3 | 4 | import { NgModule } from '@angular/core'; 5 | import { FormsModule } from '@angular/forms'; 6 | import { Marker, Normalizer, Resolver, TraversalModule, } from 'angular-traversal'; 7 | 8 | import { Breadcrumbs } from './components/breadcrumbs'; 9 | import { Comment, CommentAdd, Comments } from './components/comments'; 10 | import { GlobalNavigation } from './components/global.navigation'; 11 | import { Navigation } from './components/navigation'; 12 | import { NavigationLevel } from './components/navigation.level'; 13 | import { Workflow } from './components/workflow'; 14 | import { DownloadDirective } from './directives/download.directive'; 15 | 16 | import { APIService } from './services/api.service'; 17 | import { AuthenticationService } from './services/authentication.service'; 18 | import { CacheService } from './services/cache.service'; 19 | import { CommentsService } from './services/comments.service'; 20 | import { ConfigurationService } from './services/configuration.service'; 21 | import { LoadingInterceptor, LoadingService } from './services/loading.service'; 22 | import { NavigationService } from './services/navigation.service'; 23 | import { ResourceService } from './services/resource.service'; 24 | import { Services } from './services/services'; 25 | import { 26 | TypeMarker, 27 | PloneViews, 28 | RESTAPIResolver, 29 | FullPathNormalizer, 30 | } from './traversal'; 31 | 32 | import { AddView } from './views/add'; 33 | import { EditView } from './views/edit'; 34 | import { LoginView } from './views/login'; 35 | import { PasswordResetView } from './views/password-reset'; 36 | import { RequestPasswordResetView } from './views/request-password-reset'; 37 | import { SearchView } from './views/search'; 38 | import { SitemapView } from './views/sitemap'; 39 | import { ViewView } from './views/view'; 40 | 41 | @NgModule({ 42 | declarations: [ 43 | AddView, 44 | EditView, 45 | LoginView, 46 | RequestPasswordResetView, 47 | PasswordResetView, 48 | SearchView, 49 | SitemapView, 50 | ViewView, 51 | DownloadDirective, 52 | Breadcrumbs, 53 | Comments, 54 | Comment, 55 | CommentAdd, 56 | GlobalNavigation, 57 | Navigation, 58 | NavigationLevel, 59 | Workflow, 60 | ], 61 | entryComponents: [ 62 | AddView, 63 | EditView, 64 | LoginView, 65 | RequestPasswordResetView, 66 | PasswordResetView, 67 | SearchView, 68 | SitemapView, 69 | ViewView, 70 | ], 71 | imports: [ 72 | FormsModule, 73 | HttpClientModule, 74 | CommonModule, 75 | TraversalModule, 76 | ], 77 | providers: [ 78 | APIService, 79 | AuthenticationService, 80 | CacheService, 81 | CommentsService, 82 | ConfigurationService, 83 | LoadingService, 84 | NavigationService, 85 | PloneViews, 86 | ResourceService, 87 | Services, 88 | { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true }, 89 | { provide: Resolver, useClass: RESTAPIResolver }, 90 | { provide: Marker, useClass: TypeMarker }, 91 | { provide: Normalizer, useClass: FullPathNormalizer }, 92 | ], 93 | exports: [ 94 | EditView, 95 | LoginView, 96 | RequestPasswordResetView, 97 | PasswordResetView, 98 | SearchView, 99 | ViewView, 100 | DownloadDirective, 101 | Breadcrumbs, 102 | Comments, 103 | Comment, 104 | CommentAdd, 105 | GlobalNavigation, 106 | Navigation, 107 | NavigationLevel, 108 | Workflow, 109 | ] 110 | }) 111 | export class RESTAPIModule {} 112 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/api.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, inject, fakeAsync, tick } from '@angular/core/testing'; 4 | import { 5 | HttpTestingController, 6 | HttpClientTestingModule 7 | } from '@angular/common/http/testing'; 8 | 9 | import { HttpClient } from '@angular/common/http'; 10 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 11 | import { LoadingService, LoadingInterceptor } from './loading.service'; 12 | import { APIService } from './api.service'; 13 | import { ConfigurationService } from './configuration.service'; 14 | import { AuthenticationService } from './authentication.service'; 15 | 16 | describe('APIService', () => { 17 | 18 | let service: APIService; 19 | 20 | beforeEach(() => { 21 | TestBed.configureTestingModule({ 22 | imports: [HttpClientTestingModule], 23 | providers: [ 24 | APIService, 25 | AuthenticationService, 26 | ConfigurationService, 27 | LoadingService, 28 | { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true }, 29 | { 30 | provide: 'CONFIGURATION', useValue: { 31 | BACKEND_URL: 'http://fake/Plone', 32 | }} 33 | ] 34 | }); 35 | 36 | service = TestBed.get(APIService); 37 | 38 | }); 39 | 40 | 41 | it('should make a get request to the configured backend url', 42 | inject([HttpClient, HttpTestingController], 43 | (http: HttpClient, httpMock: HttpTestingController) => { 44 | 45 | // fake response 46 | const response = { 47 | 'dummykey': 'dummyvalue', 48 | }; 49 | 50 | service 51 | .get('/data') 52 | .subscribe(data => expect(data['dummykey']).toEqual('dummyvalue')); 53 | 54 | const req = httpMock.expectOne('http://fake/Plone/data'); 55 | req.flush(response); 56 | httpMock.verify(); 57 | 58 | })); 59 | 60 | it('should reflect the loading status of the loading service http interceptor in its own status', 61 | fakeAsync(inject([HttpClient, HttpTestingController], 62 | (http: HttpClient, httpMock: HttpTestingController) => { 63 | 64 | // fake response 65 | const response = { 66 | 'dummykey': 'dummyvalue', 67 | }; 68 | 69 | // initially the loading status is false 70 | expect(service.status.getValue()).toEqual({ loading: false }); 71 | 72 | service 73 | .get('/data') 74 | .subscribe(data => expect(data['dummykey']).toEqual('dummyvalue')); 75 | 76 | tick(); // wait for async to complete 77 | // At this point, the request is pending, and no response has been 78 | // received. 79 | expect(service.status.getValue()).toEqual({ loading: true }); 80 | 81 | const req = httpMock.expectOne('http://fake/Plone/data'); 82 | 83 | // Next, fulfill the request by transmitting a response. 84 | req.flush(response); 85 | httpMock.verify(); 86 | 87 | expect(service.status.getValue()).toEqual({ loading: false }); 88 | 89 | }))); 90 | 91 | }); 92 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http'; 3 | import { BehaviorSubject, Observable, timer, throwError } from 'rxjs'; 4 | import { catchError, map, retryWhen, tap, timeout, delayWhen } from 'rxjs/operators'; 5 | 6 | import { AuthenticationService } from './authentication.service'; 7 | import { ConfigurationService } from './configuration.service'; 8 | import { LoadingStatus } from '../interfaces'; 9 | import { getError } from './authentication.service'; 10 | import { LoadingService } from './loading.service'; 11 | 12 | @Injectable() 13 | export class APIService { 14 | 15 | public status: BehaviorSubject = new BehaviorSubject( 16 | { loading: false } 17 | ); 18 | public backendAvailable: BehaviorSubject = new BehaviorSubject(true); 19 | 20 | constructor(protected authentication: AuthenticationService, 21 | protected config: ConfigurationService, 22 | protected http: HttpClient, 23 | loading: LoadingService) { 24 | loading.status.subscribe((isLoading: boolean) => { 25 | this.status.next({ loading: isLoading }); 26 | }); 27 | } 28 | 29 | get(path: string): Observable { 30 | const url = this.getFullPath(path); 31 | const headers = this.authentication.getHeaders(); 32 | return this.wrapRequest(this.http.get(url, { headers: headers })); 33 | } 34 | 35 | post(path: string, data: Object): Observable { 36 | const url = this.getFullPath(path); 37 | const headers = this.authentication.getHeaders(); 38 | return this.wrapRequest(this.http.post(url, data, { headers: headers })); 39 | } 40 | 41 | put(path: string, data: Object): Observable { 42 | const url = this.getFullPath(path); 43 | const headers = this.authentication.getHeaders(); 44 | return this.wrapRequest(this.http.put(url, data, { headers: headers })); 45 | } 46 | 47 | patch(path: string, data: Object): Observable { 48 | const url = this.getFullPath(path); 49 | let headers = this.authentication.getHeaders(); 50 | if (this.config.get('PATCH_RETURNS_REPRESENTATION', true)) { 51 | headers = headers.set('Prefer', 'return=representation'); 52 | } 53 | return this.wrapRequest(this.http.patch(url, data, { headers: headers })); 54 | } 55 | 56 | delete(path: string): Observable { 57 | const url = this.getFullPath(path); 58 | const headers = this.authentication.getHeaders(); 59 | return this.wrapRequest(this.http.delete(url, { headers: headers })); 60 | } 61 | 62 | download(path: string): Observable { 63 | const url = this.getFullPath(path); 64 | let headers: HttpHeaders = this.authentication.getHeaders(); 65 | headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); 66 | return this.wrapRequest(this.http.get(url, { 67 | responseType: 'blob', 68 | headers: headers 69 | }).pipe( 70 | map((blob: Blob) => { 71 | return blob; 72 | }) 73 | ) 74 | ); 75 | } 76 | 77 | getFullPath(path: string): string { 78 | const base = this.config.get('BACKEND_URL'); 79 | // if path is already prefixed by base, no need to prefix twice 80 | // if path is already a full url no need to prefix either 81 | if (path.startsWith(base) || path.startsWith('http:') || path.startsWith('https:')) { 82 | return path; 83 | } else { 84 | return base + path; 85 | } 86 | } 87 | 88 | getPath(path: string): string { 89 | const base: string = this.config.get('BACKEND_URL'); 90 | // if path is prefixed by base, remove it 91 | if (path.startsWith(base)) { 92 | return path.substring(base.length); 93 | } else { 94 | return path; 95 | } 96 | } 97 | 98 | private wrapRequest(request: Observable): Observable { 99 | const clientTimeout = this.config.get('CLIENT_TIMEOUT', 15000); 100 | let attempts = 0; 101 | return request.pipe( 102 | timeout(clientTimeout), 103 | retryWhen((errors: Observable) => { 104 | /* retry when backend unavailable errors */ 105 | return errors.pipe(delayWhen((response: Response) => { 106 | if ([0, 502, 503, 504].indexOf(response.status) >= 0) { 107 | if (attempts < this.config.get('RETRY_REQUEST_ATTEMPTS', 3)) { 108 | attempts += 1; 109 | return timer(this.config.get('RETRY_REQUEST_DELAY', 2000)); 110 | } 111 | this.setBackendAvailability(false); 112 | } 113 | return throwError(response); 114 | })); 115 | }), 116 | tap(() => this.setBackendAvailability(true)), 117 | catchError((errorResponse: HttpErrorResponse) => { 118 | return throwError(getError(errorResponse)); 119 | }) 120 | ); 121 | } 122 | 123 | /* Emits only if it has changed */ 124 | protected setBackendAvailability(availability: boolean): void { 125 | if (this.backendAvailable.getValue() !== availability) { 126 | this.backendAvailable.next(availability); 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { 3 | HttpClient, 4 | HttpErrorResponse, 5 | HttpHeaders, 6 | } from '@angular/common/http'; 7 | import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; 8 | import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; 9 | import { AuthenticatedStatus, Error, PasswordResetInfo } from '../interfaces'; 10 | import { ConfigurationService } from './configuration.service'; 11 | import { tap, catchError } from 'rxjs/operators'; 12 | 13 | interface LoginToken { 14 | token: string; 15 | } 16 | 17 | /* User information */ 18 | export interface UserInfoTokenParts { 19 | username?: string; 20 | sub?: string; 21 | exp?: number; 22 | fullname?: string; 23 | } 24 | 25 | @Injectable() 26 | export class AuthenticationService { 27 | isAuthenticated: BehaviorSubject = new BehaviorSubject( 28 | { state: false, pending: false, username: null }, 29 | ); 30 | basicCredentials: string[]|null; 31 | 32 | constructor( 33 | protected config: ConfigurationService, 34 | protected http: HttpClient, 35 | @Inject(PLATFORM_ID) protected platformId: Object, 36 | ) { 37 | if (isPlatformBrowser(this.platformId)) { 38 | let token = localStorage.getItem('auth'); 39 | const lastLogin = localStorage.getItem('auth_time'); 40 | // token expires after 12 hours 41 | const expire = config.get( 42 | 'AUTH_TOKEN_EXPIRES', 43 | 12 * 60 * 60 * 1000, 44 | ); 45 | if (!lastLogin || Date.now() - Date.parse(lastLogin) > expire) { 46 | localStorage.removeItem('auth'); 47 | token = null; 48 | } 49 | if (token) { 50 | this.isAuthenticated.next({ 51 | state: true, 52 | pending: false, 53 | username: this.getUsername(), 54 | }); 55 | } 56 | } 57 | } 58 | 59 | getUsername(): string | null { 60 | const userTokenInfo = this.getUserTokenInfo(); 61 | if (userTokenInfo === null) { 62 | return null; 63 | } else { 64 | return userTokenInfo.username || userTokenInfo.sub || null; 65 | } 66 | } 67 | 68 | protected getUserTokenInfo(): UserInfoTokenParts | null { 69 | if (isPlatformBrowser(this.platformId)) { 70 | const token = localStorage.getItem('auth'); 71 | if (token) { 72 | const tokenParts = token.split('.'); 73 | return JSON.parse(atob(tokenParts[1])); 74 | } else { 75 | return null; 76 | } 77 | } else { 78 | return null; 79 | } 80 | } 81 | 82 | setBasicCredentials(login: string, password: string, temporary = false) { 83 | this.basicCredentials = [login, password]; 84 | this.isAuthenticated.next({ 85 | state: !temporary, 86 | pending: temporary, 87 | username: login, 88 | }); 89 | } 90 | 91 | cleanBasicCredentials() { 92 | this.basicCredentials = null; 93 | this.isAuthenticated.next({ 94 | state: false, 95 | pending: false, 96 | username: null, 97 | }); 98 | } 99 | 100 | login(login: string, password: string, path?: string): Observable { 101 | if (isPlatformBrowser(this.platformId)) { 102 | const headers = this.getHeaders(); 103 | const body = JSON.stringify({ 104 | login: login, // on plone.restapi login endpoint, username key is login 105 | password: password, 106 | }); 107 | return this.http 108 | .post(this.config.get('BACKEND_URL') + (path || '') + '/@login', body, { 109 | headers: headers, 110 | }).pipe( 111 | tap((data: LoginToken) => { 112 | if (data.token) { 113 | localStorage.setItem('auth', data['token']); 114 | localStorage.setItem( 115 | 'auth_time', 116 | new Date().toISOString(), 117 | ); 118 | this.isAuthenticated.next({ 119 | state: true, 120 | pending: false, 121 | username: this.getUsername(), 122 | }); 123 | } else { 124 | localStorage.removeItem('auth'); 125 | localStorage.removeItem('auth_time'); 126 | this.isAuthenticated.next({ 127 | state: false, 128 | pending: false, 129 | username: null, 130 | }); 131 | } 132 | }), 133 | catchError((errorResponse: HttpErrorResponse) => { 134 | const error = getError(errorResponse); 135 | if (errorResponse.status === 404) { 136 | // @login endpoint does not exist on this backend 137 | // we keep with basic auth 138 | this.setBasicCredentials(login, password, false); 139 | } else { 140 | localStorage.removeItem('auth'); 141 | localStorage.removeItem('auth_time'); 142 | this.isAuthenticated.next({ 143 | state: false, 144 | pending: false, 145 | username: null, 146 | error: error.message, 147 | }); 148 | } 149 | return throwError(error); 150 | }) 151 | ); 152 | } else { 153 | return of({}); 154 | } 155 | } 156 | 157 | logout() { 158 | this.cleanBasicCredentials(); 159 | if (isPlatformBrowser(this.platformId)) { 160 | localStorage.removeItem('auth'); 161 | localStorage.removeItem('auth_time'); 162 | this.isAuthenticated.next({ state: false, pending: false, username: null }); 163 | } 164 | } 165 | 166 | requestPasswordReset(login: string): Observable { 167 | const headers = this.getHeaders(); 168 | const url = 169 | this.config.get('BACKEND_URL') + `/@users/${login}/reset-password`; 170 | return this.http 171 | .post(url, {}, { headers: headers }) 172 | .pipe( 173 | catchError(this.error.bind(this)) 174 | ); 175 | } 176 | 177 | passwordReset(resetInfo: PasswordResetInfo): Observable { 178 | const headers = this.getHeaders(); 179 | const data: { [key: string]: string } = { 180 | new_password: resetInfo.newPassword, 181 | }; 182 | if (resetInfo.oldPassword) { 183 | data['old_password'] = resetInfo.oldPassword; 184 | } 185 | if (resetInfo.token) { 186 | data['reset_token'] = resetInfo.token; 187 | } 188 | const url = 189 | this.config.get('BACKEND_URL') + 190 | `/@users/${resetInfo.login}/reset-password`; 191 | return this.http 192 | .post(url, data, { headers: headers }) 193 | .pipe( 194 | catchError(this.error.bind(this)) 195 | ); 196 | } 197 | 198 | getHeaders(): HttpHeaders { 199 | let headers = new HttpHeaders(); 200 | headers = headers.set('Accept', 'application/json'); 201 | headers = headers.set('Content-Type', 'application/json'); 202 | if (isPlatformBrowser(this.platformId)) { 203 | const auth = localStorage.getItem('auth'); 204 | if (auth) { 205 | headers = headers.set('Authorization', 'Bearer ' + auth); 206 | } else if (this.basicCredentials) { 207 | headers = headers.set('Authorization', 'Basic ' + btoa(this.basicCredentials.join(':'))); 208 | } 209 | } 210 | return headers; 211 | } 212 | 213 | setAuthenticated(isAuthenticated: boolean) { 214 | this.isAuthenticated.next({state: isAuthenticated, pending: false, username: this.getUsername()}); 215 | } 216 | 217 | protected error(errorResponse: HttpErrorResponse): Observable { 218 | const error: Error = getError(errorResponse); 219 | return throwError(error); 220 | } 221 | } 222 | 223 | export function getError(errorResponse: HttpErrorResponse): Error { 224 | let error: Error; 225 | if (errorResponse.error) { 226 | let errorResponseError: any = errorResponse.error; 227 | try { 228 | // string plone error 229 | errorResponseError = JSON.parse(errorResponseError); 230 | if (errorResponseError.error && errorResponseError.error.message) { 231 | // two levels of error properties 232 | error = Object.assign({}, errorResponseError.error); 233 | } else { 234 | error = errorResponseError; 235 | } 236 | } catch (SyntaxError) { 237 | if (errorResponseError.message && errorResponseError.type) { 238 | // object plone error 239 | error = errorResponseError; 240 | } else if ( 241 | typeof errorResponseError.error === 'object' && 242 | errorResponseError.error.type 243 | ) { 244 | // object plone error with two levels of error properties 245 | error = Object.assign({}, errorResponseError.error); 246 | } else { 247 | // not a plone error 248 | error = { 249 | type: errorResponse.statusText, 250 | message: errorResponse.message, 251 | traceback: [], 252 | }; 253 | } 254 | } 255 | } else { 256 | error = { 257 | type: errorResponse.statusText, 258 | message: errorResponse.message, 259 | traceback: [], 260 | }; 261 | } 262 | // check if message is a jsonified list 263 | try { 264 | const parsedMessage = JSON.parse(error.message); 265 | if (Array.isArray(parsedMessage)) { // a list of errors - dexterity validation error for instance 266 | error.errors = parsedMessage; 267 | error.message = errorResponse.message; 268 | } 269 | } catch (SyntaxError) { 270 | // 271 | } 272 | error.response = errorResponse; 273 | return error; 274 | } 275 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/cache.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed } from '@angular/core/testing'; 4 | 5 | import { 6 | HttpTestingController, 7 | HttpClientTestingModule 8 | } from '@angular/common/http/testing'; 9 | 10 | import { APIService } from './api.service'; 11 | import { ConfigurationService } from './configuration.service'; 12 | import { AuthenticationService } from './authentication.service'; 13 | import { CacheService } from './cache.service'; 14 | import { Observable, forkJoin, timer } from 'rxjs'; 15 | import { LoadingService } from './loading.service'; 16 | 17 | const front_page_response = { 18 | "@id": "http://fake/Plone/", 19 | "@type": "Plone Site", 20 | "id": "Plone", 21 | "items": [ 22 | { 23 | "@id": "http://fake/Plone/front-page", 24 | "@type": "Document", 25 | "description": "Congratulations! You have successfully installed Plone.", 26 | "title": "Welcome to Plone" 27 | } 28 | ], 29 | "items_total": 1, 30 | "parent": {} 31 | }; 32 | 33 | const events_page_response = { 34 | "@id": "http://fake/Plone/events", 35 | "@type": "Folder", 36 | "id": "Plone", 37 | "items": [ 38 | { 39 | "@id": "http://fake/Plone/events/event1", 40 | "@type": "Document", 41 | "description": "", 42 | "title": "Event 1" 43 | } 44 | ], 45 | "items_total": 1, 46 | "parent": {} 47 | }; 48 | 49 | describe('CacheService', () => { 50 | beforeEach(() => { 51 | TestBed.configureTestingModule({ 52 | imports: [HttpClientTestingModule], 53 | providers: [ 54 | APIService, 55 | AuthenticationService, 56 | ConfigurationService, 57 | LoadingService, 58 | { 59 | provide: 'CONFIGURATION', useValue: { 60 | BACKEND_URL: 'http://fake/Plone', 61 | CACHE_REFRESH_DELAY: 1000, 62 | } 63 | }, 64 | CacheService, 65 | ] 66 | }); 67 | }); 68 | 69 | afterEach(() => { 70 | TestBed.get(CacheService).revoke.emit(); 71 | }); 72 | 73 | it('should cache and then get cached content', () => { 74 | const cache = TestBed.get(CacheService); 75 | const http = TestBed.get(HttpTestingController); 76 | let response = front_page_response; 77 | 78 | expect(cache.cache['http://fake/Plone/']).toBeUndefined(); 79 | 80 | cache.get('http://fake/Plone/').subscribe(() => {}); 81 | http.expectOne('http://fake/Plone/').flush(response); 82 | expect(cache.cache['http://fake/Plone/']).toBeDefined(); 83 | expect(cache.hits['http://fake/Plone/']).toBe(1); 84 | 85 | // we actually get content but request has not been sent again 86 | cache.get('http://fake/Plone/').subscribe((content) => { 87 | expect(content).toBe(front_page_response); 88 | }); 89 | http.expectNone('http://fake/Plone/'); 90 | expect(cache.hits['http://fake/Plone/']).toBe(2); 91 | }); 92 | 93 | it('should clear cache at revoke', () => { 94 | const cache = TestBed.get(CacheService); 95 | const http = TestBed.get(HttpTestingController); 96 | let response = front_page_response; 97 | cache.get('http://fake/Plone/').subscribe(() => {}); 98 | http.expectOne('http://fake/Plone/').flush(response); 99 | cache.revoke.emit(); 100 | cache.get('http://fake/Plone/').subscribe(() => {}); 101 | http.expectOne('http://fake/Plone/').flush(response); 102 | expect(cache.hits['http://fake/Plone/']).toBe(1); 103 | expect(cache.cache['http://fake/Plone/']).toBeDefined(); 104 | }); 105 | 106 | it('should clear cache by key when revoke for key', () => { 107 | const cache = TestBed.get(CacheService); 108 | const http = TestBed.get(HttpTestingController); 109 | const response1 = front_page_response; 110 | const response2 = events_page_response; 111 | forkJoin( 112 | cache.get('http://fake/Plone/'), 113 | cache.get('http://fake/Plone/events') 114 | ).subscribe(() => {}); 115 | 116 | http.expectOne('http://fake/Plone/').flush(response1); 117 | http.expectOne('http://fake/Plone/events').flush(response2); 118 | expect(cache.hits['http://fake/Plone/']).toBe(1); 119 | expect(cache.hits['http://fake/Plone/events']).toBe(1); 120 | 121 | cache.revoke.emit('http://fake/Plone/events'); 122 | expect(cache.hits['http://fake/Plone/']).toBe(1); 123 | expect(cache.cache['http://fake/Plone/']).toBeDefined(); 124 | expect(cache.hits['http://fake/Plone/events']).toBeUndefined(); 125 | expect(cache.cache['http://fake/Plone/events']).toBeUndefined(); 126 | }); 127 | 128 | it('should revoke the cache when user log in', () => { 129 | const cache = TestBed.get(CacheService); 130 | const http = TestBed.get(HttpTestingController); 131 | const auth = TestBed.get(AuthenticationService); 132 | let response = front_page_response; 133 | cache.get('http://fake/Plone/').subscribe(() => {}); 134 | http.expectOne('http://fake/Plone/').flush(response); 135 | auth.isAuthenticated.next({ state: true }); 136 | expect(cache.cache['http://fake/Plone/']).toBeUndefined(); 137 | }); 138 | }); 139 | 140 | describe('CacheService', () => { 141 | beforeEach(() => { 142 | TestBed.configureTestingModule({ 143 | imports: [HttpClientTestingModule], 144 | providers: [ 145 | APIService, 146 | AuthenticationService, 147 | ConfigurationService, 148 | LoadingService, 149 | { 150 | provide: 'CONFIGURATION', useValue: { 151 | BACKEND_URL: 'http://fake/Plone', 152 | CACHE_REFRESH_DELAY: 5 153 | } 154 | }, 155 | CacheService, 156 | ] 157 | }); 158 | }); 159 | 160 | // because of timer we do not revoke cache in afterEach, which is executed in same event loop 161 | 162 | it('should not use cache if delay is passed', () => { 163 | const cache = TestBed.get(CacheService); 164 | const http = TestBed.get(HttpTestingController); 165 | let response = front_page_response; 166 | expect(cache.refreshDelay).toBe(5); 167 | expect(cache.cache['http://fake/Plone/']).toBeUndefined(); 168 | 169 | cache.get('http://fake/Plone/').subscribe(() => {}); 170 | http.expectOne('http://fake/Plone/').flush(response); 171 | 172 | cache.get('http://fake/Plone/').subscribe(() => {}); 173 | http.expectNone('http://fake/Plone/'); 174 | 175 | timer(5).subscribe(() => { 176 | cache.get('http://fake/Plone/').subscribe(() => {}); 177 | http.expectOne('http://fake/Plone/').flush(response); 178 | expect(cache.hits['http://fake/Plone/']).toBe(1); 179 | cache.revoke.emit(); 180 | }); 181 | }); 182 | }); 183 | 184 | describe('CacheService', () => { 185 | beforeEach(() => { 186 | TestBed.configureTestingModule({ 187 | imports: [HttpClientTestingModule], 188 | providers: [ 189 | APIService, 190 | AuthenticationService, 191 | ConfigurationService, 192 | LoadingService, 193 | { 194 | provide: 'CONFIGURATION', useValue: { 195 | BACKEND_URL: 'http://fake/Plone', 196 | CACHE_MAX_SIZE: 2, 197 | } 198 | }, 199 | CacheService, 200 | ] 201 | }); 202 | }); 203 | 204 | afterEach(() => { 205 | TestBed.get(CacheService).revoke.emit(); 206 | }); 207 | 208 | it('should refreshed store when cache max size is reached', () => { 209 | const cache = TestBed.get(CacheService); 210 | const http = TestBed.get(HttpTestingController); 211 | let response = front_page_response; 212 | expect(cache.maxSize).toBe(2); 213 | 214 | cache.get('http://fake/Plone/').subscribe(() => {}); 215 | http.expectOne('http://fake/Plone/').flush(response); 216 | expect(cache.cache['http://fake/Plone/']).toBeDefined(); 217 | 218 | cache.get('http://fake/Plone/1').subscribe(() => {}); 219 | http.expectOne('http://fake/Plone/1').flush(response); 220 | 221 | cache.get('http://fake/Plone/2').subscribe(() => {}); 222 | http.expectOne('http://fake/Plone/2').flush(response); 223 | expect(cache.cache['http://fake/Plone/2']).toBeDefined(); 224 | expect(cache.hits['http://fake/Plone/2']).toBe(1); 225 | expect(cache.cache['http://fake/Plone/']).toBeUndefined(); 226 | expect(cache.hits['http://fake/Plone/']).toBeUndefined(); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { APIService } from './api.service'; 4 | import { AuthenticationService } from './authentication.service'; 5 | 6 | import { ConfigurationService } from './configuration.service'; 7 | import { map, publishReplay, refCount, take } from 'rxjs/operators'; 8 | 9 | 10 | @Injectable() 11 | export class CacheService { 12 | 13 | private cache: { [key: string]: Observable } = {}; 14 | private refreshDelay: number; 15 | private maxSize: number; 16 | public revoke: EventEmitter = new EventEmitter(); 17 | public hits: { [key: string]: number } = {}; 18 | 19 | constructor( 20 | protected auth: AuthenticationService, 21 | protected api: APIService, 22 | protected config: ConfigurationService 23 | ) { 24 | const service = this; 25 | this.auth.isAuthenticated.subscribe(() => { 26 | this.revoke.emit(); 27 | }); 28 | service.refreshDelay = service.config.get('CACHE_REFRESH_DELAY', 10000); 29 | service.maxSize = service.config.get('CACHE_MAX_SIZE', 1000); 30 | service.revoke.subscribe((revoked: string | null) => { 31 | if (!revoked) { 32 | service.cache = {}; 33 | service.hits = {}; 34 | } else if (typeof revoked === 'string') { 35 | delete service.cache[revoked]; 36 | delete service.hits[revoked]; 37 | } 38 | }); 39 | } 40 | 41 | /* 42 | * gets an observable 43 | * that broadcasts a ReplaySubject 44 | * which emits the response of a get request 45 | * during service.refreshDelay ms without sending a new http request 46 | */ 47 | public get(url: string): Observable { 48 | const service = this; 49 | if (!service.cache.hasOwnProperty(url)) { 50 | if (Object.keys(service.cache).length >= service.maxSize) { 51 | // TODO: do not revoke everything 52 | this.revoke.emit(); 53 | } 54 | service.cache[url] = service.api.get(url).pipe( 55 | // set hits to 0 each time request is actually sent 56 | map((observable: Observable) => { 57 | service.hits[url] = 0; 58 | return observable; 59 | }), 60 | // create a ReplaySubject that stores and emit last response during delay 61 | publishReplay(1, service.refreshDelay), 62 | // broadcast ReplaySubject 63 | refCount(), 64 | // complete each observer after response has been emitted 65 | take(1), 66 | // increment hits each time request is subscribed 67 | map((observable: Observable) => { 68 | const hits = this.hits[url]; 69 | service.hits[url] = hits ? hits + 1 : 1; 70 | return observable; 71 | }) 72 | ); 73 | } 74 | return service.cache[url]; 75 | } 76 | 77 | /* 78 | Make the observable revoke the cache when it emits 79 | */ 80 | public revoking(observable: Observable, revoked?: string | null): Observable { 81 | const service = this; 82 | return observable.pipe( 83 | map((val: T): T => { 84 | service.revoke.emit(revoked); 85 | return val; 86 | }) 87 | ); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/comments.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed } from '@angular/core/testing'; 4 | import { 5 | HttpTestingController, 6 | HttpClientTestingModule 7 | } from '@angular/common/http/testing'; 8 | 9 | import { APIService } from './api.service'; 10 | import { AuthenticationService } from './authentication.service'; 11 | import { ConfigurationService } from './configuration.service'; 12 | import { CommentsService } from './comments.service'; 13 | import { CacheService } from './cache.service'; 14 | import { LoadingService } from './loading.service'; 15 | 16 | describe('CommentsService', () => { 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | imports: [ 20 | HttpClientTestingModule, 21 | ], 22 | providers: [ 23 | APIService, 24 | AuthenticationService, 25 | CacheService, 26 | CommentsService, 27 | ConfigurationService, 28 | LoadingService, 29 | { 30 | provide: 'CONFIGURATION', useValue: { 31 | BACKEND_URL: 'http://fake/Plone', 32 | } 33 | }, 34 | ] 35 | }); 36 | }); 37 | 38 | it('should return the comments', () => { 39 | const service = TestBed.get(CommentsService); 40 | const http = TestBed.get(HttpTestingController); 41 | let length = 0; 42 | let author_name = ''; 43 | 44 | // fake response 45 | const response = { 46 | "@id": "http://fake/Plone/a-folder/test/@comments", 47 | "items": [ 48 | { 49 | "@id": "http://fake/Plone/a-folder/test/@comments/1496662661977916", 50 | "@parent": null, 51 | "@type": "Discussion Item", 52 | "author_name": "Bridgekeeper", 53 | "author_username": "bkeeper", 54 | "comment_id": "1496662661977916", 55 | "creation_date": "2017-06-05T11:37:41", 56 | "in_reply_to": null, 57 | "modification_date": "2017-06-05T11:37:41", 58 | "text": { 59 | "data": "What... is your favourite colour?", 60 | "mime-type": "text/plain" 61 | }, 62 | "user_notification": null 63 | }, 64 | { 65 | "@id": "http://fake/Plone/a-folder/test/@comments/1496665801430054", 66 | "@parent": null, 67 | "@type": "Discussion Item", 68 | "author_name": "Galahad of Camelot", 69 | "author_username": "galahad", 70 | "comment_id": "1496665801430054", 71 | "creation_date": "2017-06-05T12:30:01", 72 | "in_reply_to": null, 73 | "modification_date": "2017-06-05T12:30:01", 74 | "text": { 75 | "data": "Blue. No, yel...\nauuuuuuuugh.", 76 | "mime-type": "text/plain" 77 | }, 78 | "user_notification": null 79 | } 80 | ], 81 | "items_total": 2 82 | }; 83 | 84 | service.get('/a-folder/test').subscribe(res => { 85 | let comments = res.items; 86 | length = comments.length; 87 | author_name = comments[1].author_name; 88 | }); 89 | 90 | http.expectOne('http://fake/Plone/a-folder/test/@comments').flush(response); 91 | 92 | expect(length).toBe(2); 93 | expect(author_name).toBe('Galahad of Camelot'); 94 | }); 95 | 96 | }); 97 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/comments.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { APIService } from './api.service'; 5 | import { CacheService } from './cache.service'; 6 | 7 | @Injectable() 8 | export class CommentsService { 9 | 10 | constructor(protected api: APIService, 11 | protected cache: CacheService) { 12 | } 13 | 14 | add(path: string, data: Object): Observable { 15 | const url = path + '/@comments'; 16 | return this.cache.revoking( 17 | this.api.post(url, data), url 18 | ); 19 | } 20 | 21 | delete(path: string): Observable { 22 | return this.cache.revoking( 23 | this.api.delete(path), path 24 | ); 25 | } 26 | 27 | get(path: string): Observable { 28 | return this.cache.get(path + '/@comments'); 29 | } 30 | 31 | update(path: string, data: Object): Observable { 32 | return this.cache.revoking( 33 | this.api.patch(path, data), path 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/configuration.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, inject } from '@angular/core/testing'; 4 | 5 | import { ConfigurationService } from './configuration.service'; 6 | 7 | describe('ConfigurationService', () => { 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({ 10 | providers: [ 11 | ConfigurationService, 12 | { 13 | provide: 'CONFIGURATION', useValue: { 14 | BACKEND_URL: 'http://fake/Plone', 15 | } 16 | }, 17 | ] 18 | }); 19 | }); 20 | 21 | it('should return stored values', inject([ConfigurationService], (service: ConfigurationService) => { 22 | expect(service.get('BACKEND_URL')).toBe('http://fake/Plone'); 23 | expect(service.get('YO', 'LO')).toBe('LO'); 24 | })); 25 | }); 26 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/configuration.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | 3 | @Injectable() 4 | export class ConfigurationService { 5 | 6 | constructor( 7 | @Inject('CONFIGURATION') protected config: any, 8 | ) {} 9 | 10 | get(key: string, defaultValue?: any): any { 11 | if (defaultValue !== undefined && !(this.config.hasOwnProperty(key))) { 12 | return defaultValue; 13 | } else { 14 | return this.config[key]; 15 | } 16 | } 17 | 18 | urlToPath(url: string): string { 19 | const base: string = this.get('BACKEND_URL'); 20 | return url.split(base)[1] || '/'; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/index.ts: -------------------------------------------------------------------------------- 1 | export { Services } from './services'; 2 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/loading.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed, inject, async, fakeAsync, tick } from '@angular/core/testing'; 4 | import { 5 | HttpTestingController, 6 | HttpClientTestingModule 7 | } from '@angular/common/http/testing'; 8 | 9 | import { HttpClient } from '@angular/common/http'; 10 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 11 | import { LoadingService, LoadingInterceptor } from './loading.service'; 12 | 13 | describe('LoadingService', () => { 14 | 15 | let service: LoadingService; 16 | 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | imports: [HttpClientTestingModule], 20 | providers: [ 21 | LoadingService, 22 | { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, multi: true } 23 | ] 24 | }); 25 | 26 | service = TestBed.get(LoadingService); 27 | 28 | }); 29 | 30 | it('should be in loading state during loading', () => { 31 | service.begin('test-1'); 32 | expect(service.status.getValue()).toBe(true); 33 | }); 34 | 35 | it('should be in loading state loading stopped', () => { 36 | service.begin('test-1'); 37 | service.finish('test-1'); 38 | expect(service.status.getValue()).toBe(false); 39 | }); 40 | 41 | it('should be robust on multiple loading starts', () => { 42 | service.begin('test-1'); 43 | service.begin('test-1'); 44 | service.finish('test-1'); 45 | expect(service.status.getValue()).toBe(false); 46 | }); 47 | 48 | it('should be robust on multiple loading finished', () => { 49 | service.begin('test-1'); 50 | service.finish('test-1'); 51 | service.finish('test-1'); 52 | expect(service.status.getValue()).toBe(false); 53 | }); 54 | 55 | it('should handle loading of several apps', () => { 56 | service.begin('test-1'); 57 | service.begin('test-2'); 58 | service.finish('test-1'); 59 | service.finish('test-1'); 60 | expect(service.status.getValue()).toBe(true); 61 | }); 62 | 63 | it('should finish all apps', () => { 64 | service.begin('test-1'); 65 | service.begin('test-2'); 66 | service.finish(); 67 | expect(service.status.getValue()).toBe(false); 68 | }); 69 | 70 | it('should know if an app is loading', () => { 71 | service.begin('test-1'); 72 | service.begin('test-2'); 73 | service.finish('test-1'); 74 | service.isLoading('test-1').subscribe((isLoading) => { 75 | expect(isLoading).toBe(false); 76 | }); 77 | service.isLoading('test-2').subscribe((isLoading) => { 78 | expect(isLoading).toBe(true); 79 | }); 80 | }); 81 | 82 | it('should handle the setting of loading status (begin and finish) in the http interceptor', 83 | fakeAsync(inject([HttpClient, HttpTestingController], 84 | (http: HttpClient, httpMock: HttpTestingController) => { 85 | 86 | // fake response 87 | const response = { 88 | 'dummykey': 'dummyvalue', 89 | }; 90 | 91 | // General loading status is initially false 92 | expect(service.status.getValue()).toBe(false); 93 | // There is no specific loading status for our method 94 | let subscriber0 = service.isLoading('GET-/data').subscribe((isLoading) => { 95 | expect(isLoading).toBe(false); 96 | }); 97 | tick(); // wait for async to complete before unsubscribing 98 | subscriber0.unsubscribe(); 99 | 100 | http 101 | .get('/data') 102 | .subscribe(data => expect(data['dummykey']).toEqual('dummyvalue')); 103 | 104 | // At this point, the request is pending, and no response has been 105 | // received. 106 | expect(service.status.getValue()).toBe(true); 107 | let subscriber1 = service.isLoading('GET-/data').subscribe((isLoading) => { 108 | expect(isLoading).toBe(true); 109 | }); 110 | tick(); // wait for async to complete before unsubscribing 111 | subscriber1.unsubscribe(); 112 | 113 | const req = httpMock.expectOne('/data'); 114 | expect(req.request.method).toEqual('GET'); 115 | 116 | // Next, fulfill the request by transmitting a response. 117 | req.flush(response); 118 | httpMock.verify(); 119 | 120 | expect(service.status.getValue()).toBe(false); 121 | let subscriber2 = service.isLoading('GET-/data').subscribe((isLoading) => { 122 | expect(isLoading).toBe(false); 123 | }); 124 | tick(); // wait for async to complete before unsubscribing 125 | subscriber2.unsubscribe(); 126 | 127 | }))); 128 | 129 | }); 130 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/loading.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { BehaviorSubject, Observable, throwError } from 'rxjs'; 4 | import { map, tap, catchError } from 'rxjs/operators'; 5 | 6 | 7 | @Injectable() 8 | export class LoadingService { 9 | 10 | status = new BehaviorSubject(false); // loading state 11 | 12 | private loadingIds = new BehaviorSubject([]); // list of ids 13 | 14 | constructor() { 15 | const service = this; 16 | this.loadingIds.pipe(map((ids: string[]) => ids.length > 0)).subscribe((isLoading: boolean) => { 17 | if (isLoading !== service.status.getValue()) { 18 | service.status.next(isLoading); 19 | } 20 | }); 21 | } 22 | 23 | begin(id: string): void { 24 | const ids = this.loadingIds.getValue(); 25 | ids.push(id); 26 | this.loadingIds.next(ids); 27 | } 28 | 29 | finish(id: string): void; 30 | finish(): void; 31 | 32 | finish(id?: string): void { 33 | if (id === undefined) { 34 | this.loadingIds.next([]); 35 | } else { 36 | const ids = this.loadingIds.getValue() 37 | .filter((loadingId: string) => loadingId !== id); 38 | this.loadingIds.next(ids); 39 | } 40 | } 41 | 42 | isLoading(id: string): Observable { 43 | return this.loadingIds.pipe(map((loadingIds: string[]) => { 44 | return loadingIds.indexOf(id) >= 0; 45 | })); 46 | } 47 | 48 | } 49 | 50 | @Injectable() 51 | export class LoadingInterceptor implements HttpInterceptor { 52 | constructor(protected loading: LoadingService) {} 53 | 54 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 55 | const loadingId = `${req.method}-${req.urlWithParams}`; 56 | this.loading.begin(loadingId); 57 | return next 58 | .handle(req) 59 | .pipe( 60 | tap((event: HttpEvent) => { 61 | if (event instanceof HttpResponse) { 62 | this.loading.finish(loadingId); 63 | } 64 | }), 65 | catchError((error: any) => { 66 | this.loading.finish(loadingId); 67 | return throwError(error); 68 | }) 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/navigation.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed } from '@angular/core/testing'; 4 | import { 5 | HttpTestingController, 6 | HttpClientTestingModule 7 | } from '@angular/common/http/testing'; 8 | 9 | import { ConfigurationService } from './configuration.service'; 10 | import { APIService } from './api.service'; 11 | import { AuthenticationService } from './authentication.service'; 12 | import { ResourceService } from './resource.service'; 13 | import { NavigationService } from './navigation.service'; 14 | import { CacheService } from './cache.service'; 15 | import { LoadingService } from './loading.service'; 16 | 17 | describe('NavigationService', () => { 18 | beforeEach(() => { 19 | TestBed.configureTestingModule({ 20 | imports: [HttpClientTestingModule], 21 | providers: [ 22 | ResourceService, 23 | APIService, 24 | AuthenticationService, 25 | ConfigurationService, 26 | CacheService, 27 | LoadingService, 28 | NavigationService, 29 | { 30 | provide: 'CONFIGURATION', useValue: { 31 | BACKEND_URL: 'http://fake/Plone', 32 | } 33 | }, 34 | ] 35 | }); 36 | }); 37 | 38 | it('should return navigation tree', () => { 39 | const service = TestBed.get(NavigationService); 40 | const http = TestBed.get(HttpTestingController); 41 | let length = 0; 42 | let length0 = 0; 43 | let title = ''; 44 | 45 | const response = { 46 | '@id': "http://fake/Plone/@search?is_default_page=0&path.depth=2&metadata_fields:list=exclude_from_nav&metadata_fields:list=getObjPositionInParent&b_size=1000", 47 | "items": [ 48 | { 49 | '@id': "http://fake/Plone/a-folder/test", 50 | "@type": "Document", 51 | "description": "", 52 | "exclude_from_nav": false, 53 | "getObjPositionInParent": 0, 54 | "review_state": "published", 55 | "title": "test" 56 | }, 57 | { 58 | '@id': "http://fake/Plone/a-folder/test-2", 59 | "@type": "Document", 60 | "description": "", 61 | "exclude_from_nav": false, 62 | "getObjPositionInParent": 1, 63 | "review_state": "published", 64 | "title": "test 3" 65 | }, 66 | { 67 | '@id': "http://fake/Plone/a-folder/test4", 68 | "@type": "Document", 69 | "description": "fdfd", 70 | "exclude_from_nav": false, 71 | "getObjPositionInParent": 2, 72 | "review_state": "published", 73 | "title": "test4" 74 | }, 75 | { 76 | '@id': "http://fake/Plone/a-folder", 77 | "@type": "Folder", 78 | "description": "", 79 | "exclude_from_nav": false, 80 | "getObjPositionInParent": 50, 81 | "review_state": "published", 82 | "title": "A folder" 83 | } 84 | ], 85 | "items_total": 4 86 | }; 87 | service.getNavigationFor('/a-folder/test', -1, 2).subscribe(tree => { 88 | length = tree.children.length; 89 | length0 = tree.children[0].children.length; 90 | title = tree.children[0].children[0].properties['title']; 91 | }); 92 | 93 | http.expectOne('http://fake/Plone/@search?is_default_page=0&path.depth=2&metadata_fields:list=exclude_from_nav&metadata_fields:list=getObjPositionInParent&b_size=1000').flush(response); 94 | 95 | expect(length).toBe(1); 96 | expect(length0).toBe(3); 97 | expect(title).toBe('test'); 98 | }); 99 | 100 | }); 101 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/navigation.service.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter, Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { ConfigurationService } from './configuration.service'; 4 | import { NavTree } from '../interfaces'; 5 | 6 | import { ResourceService } from './resource.service'; 7 | import { AuthenticationService } from './authentication.service'; 8 | import {merge, filter, map} from 'rxjs/operators'; 9 | 10 | interface UnorderedContentTree { 11 | children: { [key: string]: UnorderedContentTree }; 12 | properties?: any; 13 | } 14 | 15 | @Injectable() 16 | export class NavigationService { 17 | 18 | public refreshNavigation: EventEmitter = new EventEmitter(); 19 | 20 | constructor(auth: AuthenticationService, 21 | protected resource: ResourceService, 22 | protected config: ConfigurationService) { 23 | resource.resourceModified.pipe( 24 | merge(auth.isAuthenticated) 25 | ).subscribe(() => { 26 | this.refreshNavigation.emit(); 27 | }); 28 | } 29 | 30 | getNavigationFor(currentPath: string, root: string | number, depth: number): Observable { 31 | const rootPath = this.getRoot(currentPath, root); 32 | return this.resource.find( 33 | { 34 | is_default_page: false, 35 | path: { depth: depth } 36 | }, 37 | rootPath, 38 | { 39 | metadata_fields: ['exclude_from_nav', 'getObjPositionInParent'], 40 | size: 1000 41 | } 42 | ).pipe( 43 | map((res: any) => { 44 | const tree: UnorderedContentTree = { children: {} }; 45 | res.items 46 | .sort((item: any) => item.getObjPositionInParent) 47 | .map((item: any) => { 48 | const localpath: string = this.config.urlToPath(item['@id']); 49 | const path: string[] = localpath.slice( 50 | localpath.indexOf(rootPath) + rootPath.length).split('/'); 51 | if (path[0] === '') { 52 | path.shift(); 53 | if (!path.length) { 54 | return; 55 | } 56 | } 57 | const id = path.pop() || ''; 58 | let current: UnorderedContentTree = tree; 59 | path.map((folder: string) => { 60 | if (!current.children[folder]) { 61 | current.children[folder] = { children: {} }; 62 | } 63 | current = current.children[folder]; 64 | if (!current.children) { 65 | current.children = {}; 66 | } 67 | }); 68 | if (!current.children[id]) { 69 | current.children[id] = { 70 | children: {}, 71 | properties: null 72 | }; 73 | } 74 | current.children[id].properties = item; 75 | }); 76 | const orderedTree = { children: this.getOrderedChildren(tree.children), properties: tree.properties }; 77 | if (currentPath) { 78 | return this.markActive(currentPath, orderedTree); 79 | } else { 80 | return orderedTree; 81 | } 82 | }), 83 | filter((item: NavTree) => { 84 | return !item.properties || !item.properties.exclude_from_nav; 85 | }) 86 | ); 87 | } 88 | 89 | private getOrderedChildren(children: { [key: string]: UnorderedContentTree }): NavTree[] { 90 | return Object.keys(children).map(key => { 91 | const child: UnorderedContentTree = children[key]; 92 | const orderedChild: NavTree[] = child.children ? this.getOrderedChildren(child.children) : []; 93 | return Object.assign({}, child, { children: orderedChild }); 94 | }).filter(item => item.properties).sort((a, b) => { 95 | return a.properties.getObjPositionInParent - b.properties.getObjPositionInParent; 96 | }); 97 | } 98 | 99 | private markActive(currentPath: string, tree: NavTree): NavTree { 100 | if (tree.children) { 101 | tree.children = tree.children.map(item => { 102 | item.active = (item.properties['@id'] === currentPath); 103 | item.inPath = (currentPath.startsWith(item.properties['@id'])); 104 | return this.markActive(currentPath, item); 105 | }); 106 | } 107 | return tree; 108 | } 109 | 110 | private getRoot(currentPath: string, root: string | number): string { 111 | currentPath = this.config.urlToPath(currentPath || '/'); 112 | let rootPath = (typeof root === 'string') ? root : '/'; 113 | if (typeof root === 'number') { 114 | if (root <= 0) { 115 | let path = currentPath.split('/'); 116 | if (root < path.length) { 117 | rootPath = path.slice(0, path.length + root).join('/'); 118 | } 119 | } 120 | } 121 | return rootPath; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/services/services.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Meta, Title } from '@angular/platform-browser'; 3 | import { Traverser } from 'angular-traversal'; 4 | 5 | import { APIService } from './api.service'; 6 | import { AuthenticationService } from './authentication.service'; 7 | import { CommentsService } from './comments.service'; 8 | import { ConfigurationService } from './configuration.service'; 9 | import { NavigationService } from './navigation.service'; 10 | import { ResourceService } from './resource.service'; 11 | import { CacheService } from './cache.service'; 12 | 13 | @Injectable() 14 | export class Services { 15 | 16 | constructor( 17 | public api: APIService, 18 | public authentication: AuthenticationService, 19 | public cache: CacheService, 20 | public comments: CommentsService, 21 | public configuration: ConfigurationService, 22 | public navigation: NavigationService, 23 | public resource: ResourceService, 24 | public traverser: Traverser, 25 | public meta: Meta, 26 | public title: Title, 27 | ) { } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/traversal.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-unused-variable */ 2 | 3 | import { TestBed } from '@angular/core/testing'; 4 | import { 5 | HttpTestingController, 6 | HttpClientTestingModule 7 | } from '@angular/common/http/testing'; 8 | 9 | import { APP_BASE_HREF } from '@angular/common'; 10 | 11 | import { ConfigurationService } from './services/configuration.service'; 12 | import { APIService } from './services/api.service'; 13 | import { AuthenticationService } from './services/authentication.service'; 14 | import { ResourceService } from './services/resource.service'; 15 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer } from 'angular-traversal'; 16 | import { InterfaceMarker, RESTAPIResolver, PloneViews, FullPathNormalizer } from './traversal'; 17 | import { ViewView } from '.'; 18 | import { CacheService } from './services/cache.service'; 19 | import { LoadingService } from './services/loading.service'; 20 | 21 | describe('Traversal', () => { 22 | beforeEach(() => { 23 | TestBed.configureTestingModule({ 24 | imports: [HttpClientTestingModule, TraversalModule], 25 | providers: [ 26 | APIService, 27 | AuthenticationService, 28 | CacheService, 29 | ConfigurationService, 30 | { 31 | provide: 'CONFIGURATION', useValue: { 32 | BACKEND_URL: 'http://fake/Plone', 33 | } 34 | }, 35 | LoadingService, 36 | ResourceService, 37 | InterfaceMarker, 38 | RESTAPIResolver, 39 | PloneViews, 40 | Traverser, 41 | { provide: Resolver, useClass: RESTAPIResolver }, 42 | { provide: Marker, useClass: InterfaceMarker }, 43 | { provide: APP_BASE_HREF, useValue: '/' }, 44 | { provide: Normalizer, useClass: FullPathNormalizer }, 45 | ] 46 | }); 47 | }); 48 | 49 | it('should mark context according to interfaces', () => { 50 | const service = TestBed.get(InterfaceMarker); 51 | const http = TestBed.get(HttpTestingController); 52 | let context = { 53 | '@id': 'http://fake/Plone/page', 54 | 'interfaces': ['ISomething', 'IWhatever'] 55 | }; 56 | expect(service.mark(context)).toEqual(['ISomething', 'IWhatever']); 57 | }); 58 | 59 | it('should register ViewView as default view', () => { 60 | const service = TestBed.get(PloneViews); 61 | const traverser = TestBed.get(Traverser); 62 | service.initialize(); 63 | expect(traverser.views['view']['*']).toBe(ViewView); 64 | }); 65 | 66 | it('should call backend to resolve path', () => { 67 | const service = TestBed.get(RESTAPIResolver); 68 | const http = TestBed.get(HttpTestingController); 69 | let id = ''; 70 | const response = { 71 | "@id": "http://fake/Plone/", 72 | "@type": "Plone Site", 73 | "id": "Plone", 74 | "items": [ 75 | { 76 | "@id": "http://fake/Plone/front-page", 77 | "@type": "Document", 78 | "description": "Congratulations! You have successfully installed Plone.", 79 | "title": "Welcome to Plone" 80 | }, 81 | { 82 | "@id": "http://fake/Plone/news", 83 | "@type": "Folder", 84 | "description": "Site News", 85 | "title": "News" 86 | }, 87 | { 88 | "@id": "http://fake/Plone/events", 89 | "@type": "Folder", 90 | "description": "Site Events", 91 | "title": "Events" 92 | }, 93 | { 94 | "@id": "http://fake/Plone/Members", 95 | "@type": "Folder", 96 | "description": "Site Users", 97 | "title": "Users" 98 | } 99 | ], 100 | "items_total": 5, 101 | "parent": {} 102 | }; 103 | 104 | service.resolve('/').subscribe(content => { 105 | id = content['@id']; 106 | }); 107 | 108 | http.expectOne('http://fake/Plone/').flush(response); 109 | 110 | expect(id).toBe('http://fake/Plone/'); 111 | }); 112 | 113 | }); 114 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/traversal.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Marker, Normalizer, Resolver, Traverser } from 'angular-traversal'; 3 | import { Observable } from 'rxjs'; 4 | import { APIService } from './services/api.service'; 5 | import { ConfigurationService } from './services/configuration.service'; 6 | 7 | import { ResourceService } from './services/resource.service'; 8 | import { AddView } from './views/add'; 9 | import { EditView } from './views/edit'; 10 | import { LoginView } from './views/login'; 11 | import { PasswordResetView } from './views/password-reset'; 12 | import { RequestPasswordResetView } from './views/request-password-reset'; 13 | import { SearchView } from './views/search'; 14 | import { SitemapView } from './views/sitemap'; 15 | import { ViewView } from './views/view'; 16 | import { Error } from './interfaces'; 17 | import { catchError } from 'rxjs/operators'; 18 | 19 | @Injectable() 20 | export class InterfaceMarker extends Marker { 21 | mark(context: any): string[] { 22 | return context.interfaces; 23 | } 24 | } 25 | 26 | @Injectable() 27 | export class TypeMarker extends Marker { 28 | mark(context: any): string { 29 | return context['@type']; 30 | } 31 | } 32 | 33 | @Injectable() 34 | export class RESTAPIResolver extends Resolver { 35 | constructor(private api: APIService, private resource: ResourceService) { 36 | super(); 37 | } 38 | 39 | resolve(path: string, view: string, queryString: string): Observable { 40 | if (view === 'search') { 41 | path = !path.endsWith('/') ? path + '/' : path; 42 | return this.api.get(path + '@search?' + queryString); 43 | } else { 44 | return this.resource.get(path).pipe(catchError((err: Error) => { 45 | if (!!err.response && err.response.status === 401) { 46 | this.resource.traversingUnauthorized.emit(path); 47 | } 48 | throw err; 49 | })); 50 | } 51 | } 52 | } 53 | 54 | @Injectable() 55 | export class PloneViews { 56 | constructor(private traverser: Traverser) {} 57 | 58 | initialize() { 59 | this.traverser.addView('add', '*', AddView); 60 | this.traverser.addView('edit', '*', EditView); 61 | this.traverser.addView('login', '*', LoginView); 62 | this.traverser.addView( 63 | 'request-password-reset', 64 | '*', 65 | RequestPasswordResetView, 66 | ); 67 | this.traverser.addView('password-reset', '*', PasswordResetView); 68 | this.traverser.addView('search', '*', SearchView); 69 | this.traverser.addView('sitemap', '*', SitemapView); 70 | this.traverser.addView('view', '*', ViewView); 71 | } 72 | } 73 | 74 | @Injectable() 75 | export class FullPathNormalizer extends Normalizer { 76 | constructor(private config: ConfigurationService) { 77 | super(); 78 | } 79 | 80 | normalize(path: string): string { 81 | if (path) { 82 | const base = this.config.get('BACKEND_URL'); 83 | if (base.startsWith('/') && path.startsWith('http')) { 84 | path = '/' + path.split('/').slice(3).join('/'); 85 | } 86 | if (path.startsWith(base)) { 87 | path = path.substring(base.length); 88 | } 89 | } 90 | return path; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/traversing.ts: -------------------------------------------------------------------------------- 1 | import { OnInit, OnDestroy, Directive } from '@angular/core'; 2 | import { Target } from 'angular-traversal'; 3 | import { Services } from './services'; 4 | import { Subject } from 'rxjs'; 5 | import { takeUntil } from 'rxjs/operators'; 6 | 7 | @Directive() 8 | export abstract class TraversingComponent implements OnInit, OnDestroy { 9 | 10 | context: any; 11 | ngUnsubscribe: Subject = new Subject(); 12 | 13 | constructor( 14 | public services: Services, 15 | ) { } 16 | 17 | ngOnInit() { 18 | this.services.traverser.target.pipe( 19 | takeUntil(this.ngUnsubscribe) 20 | ).subscribe((target: Target) => { 21 | this.context = target.context; 22 | window.scrollTo(0, 0); 23 | this.onTraverse(target); 24 | }); 25 | } 26 | 27 | onTraverse(target: Target) {} 28 | 29 | ngOnDestroy() { 30 | this.ngUnsubscribe.next(); 31 | this.ngUnsubscribe.complete(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/add.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, Inject, PLATFORM_ID } from '@angular/core'; 2 | import { isPlatformBrowser } from '@angular/common'; 3 | 4 | import { TraversingComponent } from '../traversing'; 5 | import { Services } from '../services'; 6 | import { HttpParams } from '@angular/common/http'; 7 | 8 | @Component({ 9 | selector: 'plone-add', 10 | template: `Add a new: 15 |
16 |

17 |

18 | 19 |
` 20 | }) 21 | export class AddView extends TraversingComponent implements OnInit { 22 | type: string | null; 23 | // TODO: addable types should be provided by the backend 24 | types: string[] = [ 25 | 'Document', 26 | 'Folder', 27 | 'News Item', 28 | 'Event', 29 | 'File', 30 | 'Image', 31 | ]; 32 | 33 | constructor( 34 | public services: Services, 35 | @Inject(PLATFORM_ID) private platformId: Object, 36 | ) { 37 | super(services); 38 | } 39 | 40 | ngOnInit() { 41 | super.ngOnInit(); 42 | if (isPlatformBrowser(this.platformId)) { 43 | const httpParams = new HttpParams({fromString: window.location.href.split('?')[1] || ''}); 44 | this.type = httpParams.get('type'); 45 | } 46 | } 47 | 48 | onSave(model: any) { 49 | model['@type'] = this.type; 50 | this.services.resource.create(this.context['@id'], model).subscribe((res: any) => { 51 | this.services.traverser.traverse(res['@id']); 52 | }); 53 | } 54 | 55 | onCancel() { 56 | this.services.traverser.traverse(this.context['@id']); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/edit.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; 2 | import { 3 | HttpTestingController, 4 | HttpClientTestingModule 5 | } from '@angular/common/http/testing'; 6 | 7 | import { APP_BASE_HREF } from '@angular/common'; 8 | import { FormsModule } from '@angular/forms'; 9 | 10 | import { ConfigurationService } from '../services/configuration.service'; 11 | import { APIService } from '../services/api.service'; 12 | import { AuthenticationService } from '../services/authentication.service'; 13 | import { ResourceService } from '../services/resource.service'; 14 | import { CommentsService } from '../services/comments.service'; 15 | import { NavigationService } from '../services/navigation.service'; 16 | import { Services } from '../services'; 17 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer } from 'angular-traversal'; 18 | import { TypeMarker, RESTAPIResolver, PloneViews, FullPathNormalizer } from '../traversal'; 19 | import { EditView } from './edit'; 20 | import { CacheService } from '../services/cache.service'; 21 | import { LoadingService } from '../services/loading.service'; 22 | import { filter } from 'rxjs/operators'; 23 | 24 | describe('EditView', () => { 25 | let component: EditView; 26 | let fixture: ComponentFixture; 27 | 28 | beforeEach(async(() => { 29 | TestBed.configureTestingModule({ 30 | declarations: [EditView], 31 | imports: [HttpClientTestingModule, TraversalModule, FormsModule], 32 | providers: [ 33 | APIService, 34 | AuthenticationService, 35 | CacheService, 36 | ConfigurationService, 37 | { 38 | provide: 'CONFIGURATION', useValue: { 39 | BACKEND_URL: 'http://fake/Plone', 40 | } 41 | }, 42 | CommentsService, 43 | LoadingService, 44 | NavigationService, 45 | ResourceService, 46 | TypeMarker, 47 | RESTAPIResolver, 48 | Services, 49 | PloneViews, 50 | Traverser, 51 | { provide: Resolver, useClass: RESTAPIResolver }, 52 | { provide: Marker, useClass: TypeMarker }, 53 | { provide: APP_BASE_HREF, useValue: '/' }, 54 | { provide: Normalizer, useClass: FullPathNormalizer }, 55 | ] 56 | }) 57 | .compileComponents(); 58 | })); 59 | 60 | beforeEach(() => { 61 | fixture = TestBed.createComponent(EditView); 62 | component = fixture.componentInstance; 63 | fixture.detectChanges(); 64 | component.services.traverser.addView('edit', '*', EditView); 65 | }); 66 | 67 | it('should create', () => { 68 | expect(component).toBeTruthy(); 69 | }); 70 | 71 | it('should get search results according to querystring params', () => { 72 | const http = TestBed.get(HttpTestingController); 73 | const response_document = { 74 | "@id": "http://fake/Plone/somepage", 75 | "@type": "Document", 76 | "id": "somepage", 77 | "text": { 78 | "content-type": "text/plain", 79 | "data": "If you're seeing this instead of the web site you were expecting, the owner of this web site has just installed Plone. Do not contact the Plone Team or the Plone mailing lists about this.", 80 | "encoding": "utf-8" 81 | }, 82 | "title": "Welcome to Plone" 83 | }; 84 | 85 | component.services.traverser.traverse('/somepage/@@edit'); 86 | const req_document = http.expectOne('http://fake/Plone/somepage'); 87 | req_document.flush(response_document); 88 | component.services.traverser.target.pipe(filter(target => { 89 | return Object.keys(target.context).length > 0; 90 | })) 91 | .subscribe(target => { 92 | expect(component.model.title).toBe('Welcome to Plone'); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/edit.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Target } from 'angular-traversal'; 3 | 4 | import { TraversingComponent } from '../traversing'; 5 | import { Services } from '../services'; 6 | 7 | @Component({ 8 | selector: 'plone-edit', 9 | template: `
10 |

11 |

12 | 13 |
` 14 | }) 15 | export class EditView extends TraversingComponent { 16 | 17 | model: any = {}; 18 | path: string; 19 | 20 | constructor(services: Services) { 21 | super(services); 22 | } 23 | 24 | onTraverse(target: Target) { 25 | this.path = target.contextPath; 26 | this.model = target.context; 27 | } 28 | 29 | onSave(data: any) { 30 | this.services.resource.update(this.path, data).subscribe(() => { 31 | this.services.traverser.traverse(this.path); 32 | }); 33 | } 34 | 35 | onCancel() { 36 | this.services.traverser.traverse(this.path); 37 | } 38 | 39 | loginOn401(err: Response) { 40 | if (err.status === 401) { 41 | this.services.authentication.logout(); 42 | this.services.traverser.traverse( 43 | this.services.traverser.target.getValue().contextPath + '/@@login'); 44 | } else { 45 | this.onError(err); 46 | } 47 | } 48 | 49 | onError(err: Response) { 50 | console.error(err); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; 2 | import { 3 | HttpTestingController, 4 | HttpClientTestingModule 5 | } from '@angular/common/http/testing'; 6 | import { FormsModule } from '@angular/forms'; 7 | 8 | import { APP_BASE_HREF } from '@angular/common'; 9 | 10 | import { ConfigurationService } from '../services/configuration.service'; 11 | import { APIService } from '../services/api.service'; 12 | import { AuthenticationService } from '../services/authentication.service'; 13 | import { CacheService } from '../services/cache.service'; 14 | import { CommentsService } from '../services/comments.service'; 15 | import { LoadingService } from '../services/loading.service'; 16 | import { NavigationService } from '../services/navigation.service'; 17 | import { ResourceService } from '../services/resource.service'; 18 | import { Services } from '../services'; 19 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer } from 'angular-traversal'; 20 | import { TypeMarker, RESTAPIResolver, PloneViews, FullPathNormalizer } from '../traversal'; 21 | import { LoginView } from './login'; 22 | import { ViewView } from './view'; 23 | import { AuthenticatedStatus } from '../interfaces'; 24 | 25 | 26 | describe('LoginView', () => { 27 | let component: LoginView; 28 | let fixture: ComponentFixture; 29 | 30 | beforeEach(async(() => { 31 | TestBed.configureTestingModule({ 32 | declarations: [LoginView, ViewView], 33 | imports: [HttpClientTestingModule, TraversalModule, FormsModule], 34 | providers: [ 35 | APIService, 36 | AuthenticationService, 37 | CacheService, 38 | ConfigurationService, 39 | { 40 | provide: 'CONFIGURATION', useValue: { 41 | BACKEND_URL: 'http://fake/Plone', 42 | } 43 | }, 44 | CommentsService, 45 | LoadingService, 46 | NavigationService, 47 | ResourceService, 48 | TypeMarker, 49 | RESTAPIResolver, 50 | Services, 51 | PloneViews, 52 | Traverser, 53 | { provide: Resolver, useClass: RESTAPIResolver }, 54 | { provide: Marker, useClass: TypeMarker }, 55 | { provide: APP_BASE_HREF, useValue: '/' }, 56 | { provide: Normalizer, useClass: FullPathNormalizer }, 57 | ] 58 | }) 59 | .compileComponents(); 60 | })); 61 | 62 | beforeEach(() => { 63 | fixture = TestBed.createComponent(LoginView); 64 | component = fixture.componentInstance; 65 | fixture.detectChanges(); 66 | component.services.traverser.addView('view', '*', ViewView); 67 | component.services.traverser.addView('login', '*', LoginView); 68 | }); 69 | 70 | it('should create', () => { 71 | expect(component).toBeTruthy(); 72 | }); 73 | 74 | it('should authenticate on submit', () => { 75 | const http = TestBed.get(HttpTestingController); 76 | let authenticatedStatus: AuthenticatedStatus = { state: false, username: null, pending: false }; 77 | const response_home = { 78 | '@id': 'Plone' 79 | }; 80 | const response_login = { 81 | token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZnVsbG5hbWUiOiJGb28gYmFyIiwiZXhwaXJlcy' + 82 | 'I6MTQ2NjE0MDA2Ni42MzQ5ODYsInR5cGUiOiJKV1QiLCJhbGdvcml0aG0iOiJIUzI1NiJ9.D9EL5A9xD1z3E_HPecXA-Ee7kKlljYvpDtan69KHwZ8' 83 | }; 84 | component.services.traverser.traverse('/@@login'); 85 | component.onSubmit({ login: 'admin', password: 'secret' }); 86 | component.services.authentication.isAuthenticated.subscribe(logged => { 87 | authenticatedStatus = logged; 88 | }); 89 | 90 | http.expectOne('http://fake/Plone').flush(response_home); 91 | http.expectOne('http://fake/Plone/@login').flush(response_login); 92 | 93 | expect(authenticatedStatus.username).toBe('admin'); 94 | expect(authenticatedStatus.state).toBe(true); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/login.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Services } from '../services'; 3 | import { AuthenticatedStatus, LoginInfo } from '../interfaces'; 4 | 5 | 6 | @Component({ 7 | selector: 'plone-login', 8 | template: `

Login

9 |
10 |

{{ error }}

11 |

12 |

13 | 14 |
` 15 | }) 16 | export class LoginView implements OnInit { 17 | error: string = ''; 18 | isLogged: boolean; 19 | 20 | constructor(public services: Services,) { 21 | } 22 | 23 | ngOnInit() { 24 | this.services.authentication.isAuthenticated 25 | .subscribe((auth: AuthenticatedStatus) => { 26 | this.isLogged = auth.state; 27 | }); 28 | } 29 | 30 | onSubmit(data: LoginInfo) { 31 | this.services.authentication.login(data.login, data.password) 32 | .subscribe(() => { 33 | this.services.traverser.traverse( 34 | this.services.traverser.target.getValue().contextPath); 35 | this.error = ''; 36 | }, (error: Error) => { 37 | this.error = error.message; 38 | }); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/password-reset.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from '@angular/core'; 2 | import { Subscription } from 'rxjs'; 3 | import { PasswordResetInfo, Error } from '../interfaces'; 4 | import { Services } from '../services'; 5 | import { HttpParams } from '@angular/common/http'; 6 | 7 | @Component({ 8 | selector: 'plone-password-reset', 9 | template: `

Set new password

10 |
11 |

{{ error }}

12 |

13 |

14 |

15 |

16 | 17 |
` 18 | }) 19 | export class PasswordResetView implements OnInit, OnDestroy { 20 | token: string | null; 21 | login: string | null; 22 | error: string = ''; 23 | 24 | constructor(public services: Services) { 25 | } 26 | 27 | private isAuthenticatedSub: Subscription | null; 28 | 29 | ngOnInit() { 30 | const authentication = this.services.authentication; 31 | authentication.isAuthenticated 32 | .subscribe(() => { 33 | const authenticatedStatus = authentication.isAuthenticated.getValue(); 34 | if (!authenticatedStatus.state) { 35 | this.login = null; 36 | } else { 37 | this.login = authenticatedStatus.username; 38 | } 39 | }); 40 | // TODO: get queryString on traverse 41 | const httpParams = new HttpParams({fromString: window.location.href.split('?')[1] || ''}); 42 | this.token = httpParams.get('token') || null; 43 | } 44 | 45 | onSubmit(formInfo: any) { 46 | if (formInfo.newPasswordRepeat !== formInfo.newPassword) { 47 | this.error = 'Passwords does not match'; 48 | return; 49 | } 50 | this.services.authentication.passwordReset({ 51 | login: this.login || formInfo.login, 52 | token: this.token, 53 | newPassword: formInfo.newPassword, 54 | oldPassword: formInfo.oldPassword 55 | } 56 | ).subscribe(() => { 57 | this.error = ''; 58 | this.services.traverser.traverse('/@@login'); 59 | }, (error: Error) => { 60 | if (error.response && error.response.status === 404) { 61 | this.error = 'This user does not exist'; 62 | } else if (error.response && error.response.status < 500) { 63 | this.error = error.message; 64 | } else { 65 | this.error = 'Password reset failed.'; 66 | console.error(error); 67 | } 68 | }); 69 | } 70 | 71 | ngOnDestroy() { 72 | if (this.isAuthenticatedSub) { 73 | this.isAuthenticatedSub.unsubscribe(); 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/request-password-reset.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Services } from '../services'; 3 | import { Error } from '../interfaces'; 4 | 5 | @Component({ 6 | selector: 'plone-request-password-reset', 7 | template: `

Request password reset

8 |
9 |

{{ error }}

10 |

11 | 12 |
` 13 | }) 14 | export class RequestPasswordResetView implements OnInit { 15 | error: string = ''; 16 | 17 | constructor(public services: Services) { 18 | } 19 | 20 | ngOnInit() { 21 | } 22 | 23 | onSubmit(formInfo: any) { 24 | this.services.authentication.requestPasswordReset(formInfo.login) 25 | .subscribe(() => { 26 | this.error = ''; 27 | this.services.traverser.traverse('/@@login'); 28 | }, (error: Error) => { 29 | if (error.response && error.response.status === 404) { 30 | this.error = 'This user does not exist.'; 31 | } else if (error.response && error.response.status < 500) { 32 | this.error = error.message; 33 | } else { 34 | this.error = 'Password reset failed.'; 35 | console.error(error); 36 | } 37 | }); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/search.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { 3 | HttpTestingController, 4 | HttpClientTestingModule 5 | } from '@angular/common/http/testing'; 6 | 7 | import { APP_BASE_HREF } from '@angular/common'; 8 | 9 | import { ConfigurationService } from '../services/configuration.service'; 10 | import { APIService } from '../services/api.service'; 11 | import { AuthenticationService } from '../services/authentication.service'; 12 | import { CacheService } from '../services/cache.service'; 13 | import { CommentsService } from '../services/comments.service'; 14 | import { LoadingService } from '../services/loading.service'; 15 | import { NavigationService } from '../services/navigation.service'; 16 | import { ResourceService } from '../services/resource.service'; 17 | import { Services } from '../services'; 18 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer } from 'angular-traversal'; 19 | import { TypeMarker, RESTAPIResolver, PloneViews, FullPathNormalizer } from '../traversal'; 20 | import { SearchView } from './search'; 21 | 22 | describe('SearchView', () => { 23 | let component: SearchView; 24 | let fixture: ComponentFixture; 25 | 26 | beforeEach(() => { 27 | TestBed.configureTestingModule({ 28 | declarations: [SearchView], 29 | imports: [HttpClientTestingModule, TraversalModule], 30 | providers: [ 31 | APIService, 32 | AuthenticationService, 33 | CacheService, 34 | ConfigurationService, 35 | { 36 | provide: 'CONFIGURATION', useValue: { 37 | BACKEND_URL: 'http://fake/Plone', 38 | } 39 | }, 40 | CommentsService, 41 | LoadingService, 42 | NavigationService, 43 | ResourceService, 44 | TypeMarker, 45 | RESTAPIResolver, 46 | Services, 47 | PloneViews, 48 | Traverser, 49 | { provide: Resolver, useClass: RESTAPIResolver }, 50 | { provide: Marker, useClass: TypeMarker }, 51 | { provide: APP_BASE_HREF, useValue: '/' }, 52 | { provide: Normalizer, useClass: FullPathNormalizer }, 53 | ] 54 | }) 55 | .compileComponents(); 56 | }); 57 | 58 | beforeEach(() => { 59 | fixture = TestBed.createComponent(SearchView); 60 | component = fixture.componentInstance; 61 | fixture.detectChanges(); 62 | component.services.traverser.addView('search', '*', SearchView); 63 | }); 64 | 65 | it('should create', () => { 66 | expect(component).toBeTruthy(); 67 | }); 68 | 69 | it('should get search results according to querystring params', () => { 70 | const http = TestBed.get(HttpTestingController); 71 | let length = 0; 72 | const response = { 73 | "@id": "http://fake/Plone/@search?SearchableText=test", 74 | "items": [ 75 | { 76 | "@id": "http://fake/Plone/a-folder/test", 77 | "@type": "Document", 78 | "description": "", 79 | "review_state": "published", 80 | "title": "test" 81 | }, 82 | { 83 | "@id": "http://fake/Plone/a-folder/test-2", 84 | "@type": "Document", 85 | "description": "", 86 | "review_state": "published", 87 | "title": "test 3" 88 | } 89 | ], 90 | "items_total": 2 91 | }; 92 | component.services.traverser.traverse('/@@search?SearchableText=test'); 93 | component.services.traverser.target.subscribe(() => { 94 | if (Object.keys(component.context).length > 0) { 95 | length = component.context.items.length; 96 | } 97 | }); 98 | 99 | http.expectOne('http://fake/Plone/@search?SearchableText=test').flush(response); 100 | expect(length).toBe(2); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/search.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { Target } from 'angular-traversal'; 3 | import { TraversingComponent } from '../traversing'; 4 | import { Services } from '../services'; 5 | 6 | @Component({ 7 | selector: 'plone-search', 8 | template: `

Search

9 |
Total results: {{ total }}
10 |
    11 |
  1. 12 | {{ item.title }} 13 | {{ item.title }} 14 |
  2. 15 |
`, 16 | }) 17 | export class SearchView extends TraversingComponent { 18 | 19 | results: any[] = []; 20 | total: number = 0; 21 | 22 | constructor( 23 | public services: Services, 24 | ) { 25 | super(services); 26 | } 27 | 28 | onTraverse(target: Target) { 29 | this.results = target.context.items; 30 | this.total = target.context.items_total; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'plone-sitemap', 5 | template: `

Sitemap

6 | ` 7 | }) 8 | export class SitemapView {} 9 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/view.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { 3 | HttpTestingController, 4 | HttpClientTestingModule 5 | } from '@angular/common/http/testing'; 6 | import { APP_BASE_HREF } from '@angular/common'; 7 | 8 | import { ConfigurationService } from '../services/configuration.service'; 9 | import { APIService } from '../services/api.service'; 10 | import { AuthenticationService } from '../services/authentication.service'; 11 | import { ResourceService } from '../services/resource.service'; 12 | import { CommentsService } from '../services/comments.service'; 13 | import { NavigationService } from '../services/navigation.service'; 14 | import { Services } from '../services'; 15 | import { Traverser, TraversalModule, Resolver, Marker, Normalizer } from 'angular-traversal'; 16 | import { TypeMarker, RESTAPIResolver, PloneViews, FullPathNormalizer } from '../traversal'; 17 | import { ViewView } from './view'; 18 | import { CacheService } from '../services/cache.service'; 19 | import { LoadingService } from '../services/loading.service'; 20 | 21 | 22 | describe('ViewView', () => { 23 | let component: ViewView; 24 | let fixture: ComponentFixture; 25 | 26 | beforeEach(() => { 27 | TestBed.configureTestingModule({ 28 | declarations: [ ViewView ], 29 | imports: [HttpClientTestingModule, TraversalModule], 30 | providers: [ 31 | APIService, 32 | AuthenticationService, 33 | ConfigurationService, 34 | { 35 | provide: 'CONFIGURATION', useValue: { 36 | BACKEND_URL: 'http://fake/Plone', 37 | } 38 | }, 39 | CacheService, 40 | CommentsService, 41 | NavigationService, 42 | LoadingService, 43 | ResourceService, 44 | TypeMarker, 45 | RESTAPIResolver, 46 | Services, 47 | PloneViews, 48 | Traverser, 49 | { provide: Resolver, useClass: RESTAPIResolver }, 50 | { provide: Marker, useClass: TypeMarker }, 51 | { provide: APP_BASE_HREF, useValue: '/' }, 52 | { provide: Normalizer, useClass: FullPathNormalizer }, 53 | ] 54 | }) 55 | .compileComponents(); 56 | }); 57 | 58 | beforeEach(() => { 59 | fixture = TestBed.createComponent(ViewView); 60 | component = fixture.componentInstance; 61 | fixture.detectChanges(); 62 | component.services.traverser.addView('view', '*', ViewView); 63 | }); 64 | 65 | it('should create', () => { 66 | expect(component).toBeTruthy(); 67 | }); 68 | 69 | it('should get current context according to path', () => { 70 | const http = TestBed.get(HttpTestingController); 71 | let id = ''; 72 | const response = { 73 | "@id": "http://fake/Plone/", 74 | "@type": "Plone Site", 75 | "id": "Plone", 76 | "items": [ 77 | { 78 | "@id": "http://fake/Plone/front-page", 79 | "@type": "Document", 80 | "description": "Congratulations! You have successfully installed Plone.", 81 | "title": "Welcome to Plone" 82 | }, 83 | { 84 | "@id": "http://fake/Plone/news", 85 | "@type": "Folder", 86 | "description": "Site News", 87 | "title": "News" 88 | }, 89 | { 90 | "@id": "http://fake/Plone/events", 91 | "@type": "Folder", 92 | "description": "Site Events", 93 | "title": "Events" 94 | }, 95 | { 96 | "@id": "http://fake/Plone/Members", 97 | "@type": "Folder", 98 | "description": "Site Users", 99 | "title": "Users" 100 | } 101 | ], 102 | "items_total": 5, 103 | "parent": {} 104 | }; 105 | 106 | component.services.traverser.traverse('/'); 107 | component.services.traverser.target.subscribe(() => { 108 | id = component.context.id; 109 | }); 110 | 111 | http.expectOne('http://fake/Plone/').flush(response); 112 | 113 | expect(id).toBe('Plone'); 114 | }); 115 | 116 | it('should get current context text content', () => { 117 | const http = TestBed.get(HttpTestingController); 118 | let id = ''; 119 | let text = ''; 120 | const response = { 121 | "@id": "http://fake/Plone/somepage", 122 | "@type": "Document", 123 | "id": "somepage", 124 | "text": { 125 | "content-type": "text/plain", 126 | "data": "If you're seeing this instead of the web site you were expecting, the owner of this web site has just installed Plone. Do not contact the Plone Team or the Plone mailing lists about this.", 127 | "encoding": "utf-8" 128 | }, 129 | "title": "Welcome to Plone" 130 | }; 131 | component.services.traverser.traverse('/somepage'); 132 | component.services.traverser.target.subscribe(() => { 133 | id = component.context.id; 134 | text = component.text; 135 | }); 136 | 137 | http.expectOne('http://fake/Plone/somepage').flush(response); 138 | 139 | expect(id).toBe('somepage'); 140 | expect(text).toBe("If you're seeing this instead of the web site you were expecting, the owner of this web site has just installed Plone. Do not contact the Plone Team or the Plone mailing lists about this."); 141 | }); 142 | 143 | }); 144 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/views/view.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { Services } from '../services'; 4 | import { TraversingComponent } from '../traversing'; 5 | import { Target } from 'angular-traversal'; 6 | 7 | @Component({ 8 | selector: 'plone-view', 9 | template: `

{{ context.title }}

10 |
11 | ` 16 | }) 17 | export class ViewView extends TraversingComponent { 18 | 19 | text: string; 20 | 21 | constructor( 22 | public services: Services, 23 | ) { 24 | super(services); 25 | } 26 | 27 | onTraverse(target: Target) { 28 | this.services.title.setTitle(target.context.title); 29 | this.services.meta.updateTag({ 30 | name: 'description', 31 | content: target.context.description 32 | }); 33 | if (target.context.text) { 34 | this.text = target.context.text.data; 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/lib/vocabularies.ts: -------------------------------------------------------------------------------- 1 | export interface Term { 2 | '@id': string; 3 | token: T; 4 | title: string; 5 | } 6 | 7 | export class Vocabulary implements Iterable> { 8 | protected _terms: Term[]; 9 | protected _byToken: any = {}; // can't declare indexer with generics 10 | 11 | constructor(terms: Term[]) { 12 | this._terms = terms; 13 | for (const term of terms) { 14 | this._byToken[term.token] = term; 15 | } 16 | } 17 | 18 | public terms() { 19 | return this._terms; 20 | } 21 | 22 | public byToken(token: T): Term { 23 | const term = this._byToken[token]; 24 | if (term === undefined) { 25 | return { 26 | '@id': '', 27 | token: token, 28 | title: (typeof token === 'number' ? token.toString() : token) 29 | }; 30 | } else { 31 | return term; 32 | } 33 | } 34 | 35 | [Symbol.iterator]() { 36 | let counter = 0; 37 | const terms = this._terms; 38 | 39 | return >>{ 40 | 41 | next(): IteratorResult> { 42 | if (counter < terms.length) { 43 | return { done: false, value: terms[counter++] }; 44 | } else { 45 | return { done: true, value: undefined } as any as IteratorResult>; 46 | } 47 | } 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of plone-restapi-angular 3 | */ 4 | export * from './lib'; 5 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "target": "es2015", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "types": [], 14 | "lib": [ 15 | "dom", 16 | "es2018" 17 | ] 18 | }, 19 | "angularCompilerOptions": { 20 | "annotateForClosureCompiler": true, 21 | "skipTemplateCodegen": true, 22 | "strictMetadataEmit": true, 23 | "fullTemplateTypeCheck": true, 24 | "strictInjectionParameters": true, 25 | "enableResourceInlining": true 26 | }, 27 | "exclude": [ 28 | "src/test.ts", 29 | "**/*.spec.ts" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jest" 7 | ] 8 | }, 9 | "files": [ 10 | "src/test.ts" 11 | ], 12 | "include": [ 13 | "**/*.spec.ts", 14 | "**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /projects/plone-restapi-angular/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "lib", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "lib", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # 5 | # For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed 6 | 7 | > 0.5% 8 | last 2 versions 9 | Firefox ESR 10 | not dead 11 | not IE 9-11 -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-wrap: wrap; 4 | } 5 | 6 | .container > * { 7 | margin: 24px; 8 | } 9 | 10 | header { 11 | width: 100%; 12 | } 13 | 14 | nav { 15 | width: auto; 16 | } 17 | 18 | main { 19 | width: auto; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Status: 4 | {{ loading }} {{ error }} 5 |

6 |

7 | Ooops ! Looks like Plone backend is not available, check your connectivity or retry within a moment.

8 |
9 | 28 |
29 |

Breadcrumbs

30 | 31 | 32 |
33 |
34 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { 3 | HttpClientTestingModule 4 | } from '@angular/common/http/testing'; 5 | import { Component } from '@angular/core'; 6 | import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; 7 | import { APP_BASE_HREF } from '@angular/common'; 8 | import { FormsModule } from '@angular/forms'; 9 | import { RESTAPIModule } from '../../projects/plone-restapi-angular/src/public-api'; 10 | import { TraversalModule } from 'angular-traversal'; 11 | 12 | import { AppComponent } from './app.component'; 13 | import { Search } from './components/search'; 14 | import { CustomSfEditView, CustomViewView } from './custom'; 15 | import { CustomGlobalNavigation } from './custom'; 16 | import { SchemaFormModule } from '../develop/ngx-schema-form/projects/schema-form/src/public_api'; 17 | 18 | @Component({ 19 | selector: 'custom-breadcrumbs', 20 | template: '' 21 | }) 22 | export class FakeCustomBreadcrumbs { 23 | } 24 | 25 | describe('AppComponent', () => { 26 | beforeEach(() => { 27 | TestBed.configureTestingModule({ 28 | declarations: [ 29 | AppComponent, 30 | CustomViewView, 31 | CustomSfEditView, 32 | FakeCustomBreadcrumbs, 33 | CustomGlobalNavigation, 34 | Search, 35 | ], 36 | imports: [ 37 | HttpClientTestingModule, 38 | TraversalModule, 39 | RESTAPIModule, 40 | SchemaFormModule.forRoot(), 41 | FormsModule, 42 | ], 43 | providers: [ 44 | { provide: APP_BASE_HREF, useValue: '/' }, 45 | { 46 | provide: 'CONFIGURATION', useValue: { 47 | BACKEND_URL: 'http://fake/Plone', 48 | } 49 | }, 50 | ], 51 | }); 52 | 53 | TestBed.overrideModule(BrowserDynamicTestingModule, { 54 | set: { 55 | entryComponents: [CustomViewView], 56 | }, 57 | }); 58 | }); 59 | 60 | it('should create the app', () => { 61 | const fixture = TestBed.createComponent(AppComponent); 62 | fixture.detectChanges(); 63 | 64 | const app = fixture.debugElement.componentInstance; 65 | expect(app).toBeTruthy(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { CustomSfEditView, CustomViewView } from './custom'; 4 | import { 5 | AuthenticatedStatus, 6 | LoadingStatus, 7 | PloneViews, 8 | SearchView, 9 | Services, 10 | Vocabulary 11 | } from '../../projects/plone-restapi-angular/src/public-api'; 12 | import { BehaviorSubject } from 'rxjs'; 13 | 14 | @Component({ 15 | selector: 'app-root', 16 | templateUrl: './app.component.html', 17 | styleUrls: ['./app.component.css'] 18 | }) 19 | export class AppComponent implements OnInit { 20 | 21 | loading = 'OK'; 22 | error = ''; 23 | logged = false; 24 | public backendAvailable: BehaviorSubject; 25 | 26 | constructor( 27 | private views: PloneViews, 28 | private services: Services, 29 | ) { 30 | this.views.initialize(); 31 | this.services.traverser.addView('view', '*', CustomViewView); 32 | this.services.resource.defaultExpand.breadcrumbs = true; 33 | this.services.resource.defaultExpand.navigation = true; 34 | this.backendAvailable = this.services.api.backendAvailable; 35 | } 36 | 37 | ngOnInit() { 38 | this.services.authentication.isAuthenticated 39 | .subscribe((auth: AuthenticatedStatus) => { 40 | this.logged = auth.state; 41 | }); 42 | 43 | this.services.api.status 44 | .subscribe((status: LoadingStatus) => { 45 | this.loading = status.loading ? 'Loading...' : 'OK'; 46 | this.error = status.error ? status.error.message : ''; 47 | }); 48 | } 49 | 50 | logout(event: Event) { 51 | event.preventDefault(); 52 | this.services.authentication.logout(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | 5 | import { RESTAPIModule } from '../../projects/plone-restapi-angular/src/public-api'; 6 | import { TraversalModule } from 'angular-traversal'; 7 | import { DefaultWidgetRegistry, SchemaFormModule, WidgetRegistry } from 'ngx-schema-form'; 8 | 9 | import { environment } from '../environments/environment'; 10 | import { AppComponent } from './app.component'; 11 | import { Search } from './components/search'; 12 | import { CustomBreadcrumbs, CustomGlobalNavigation, CustomSfEditView, CustomViewView } from './custom'; 13 | 14 | @NgModule({ 15 | declarations: [ 16 | AppComponent, 17 | CustomViewView, 18 | CustomSfEditView, 19 | CustomBreadcrumbs, 20 | CustomGlobalNavigation, 21 | Search, 22 | ], 23 | entryComponents: [ 24 | CustomViewView, 25 | ], 26 | imports: [ 27 | BrowserModule, 28 | FormsModule, 29 | TraversalModule, 30 | SchemaFormModule.forRoot(), 31 | RESTAPIModule, 32 | ], 33 | providers: [ 34 | { 35 | provide: 'CONFIGURATION', useValue: { 36 | BACKEND_URL: environment.backendUrl, 37 | } 38 | }, 39 | { provide: WidgetRegistry, useClass: DefaultWidgetRegistry } 40 | ], 41 | bootstrap: [AppComponent] 42 | }) 43 | export class AppModule { } 44 | -------------------------------------------------------------------------------- /src/app/components/search.html: -------------------------------------------------------------------------------- 1 |
2 | 8 | 9 | 10 |
11 | -------------------------------------------------------------------------------- /src/app/components/search.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Services, Vocabulary } from '../../../projects/plone-restapi-angular/src/public-api'; 3 | 4 | @Component({ 5 | selector: 'app-search', 6 | templateUrl: './search.html', 7 | }) 8 | export class Search implements OnInit { 9 | 10 | typesVocabulary: Vocabulary; 11 | 12 | constructor(protected services: Services) { 13 | } 14 | 15 | ngOnInit() { 16 | 17 | this.services.resource.vocabulary('plone.app.vocabularies.ReallyUserFriendlyTypes') 18 | .subscribe((typesVocabulary: Vocabulary) => { 19 | this.typesVocabulary = typesVocabulary; 20 | }); 21 | } 22 | 23 | search(form) { 24 | let searchString = `/@@search?SearchableText=${form.value.text}`; 25 | if (form.value.type) { 26 | searchString += `&portal_type=${form.value.type}`; 27 | } 28 | this.services.traverser.traverse(searchString); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/app/custom/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/custom/index.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { Breadcrumbs, EditView, GlobalNavigation, Services, ViewView } from '../../../projects/plone-restapi-angular/src/public-api'; 4 | import { Target } from 'angular-traversal'; 5 | 6 | @Component({ 7 | selector: 'custom-breadcrumbs', 8 | templateUrl: './breadcrumbs.html' 9 | }) 10 | export class CustomBreadcrumbs extends Breadcrumbs { 11 | } 12 | 13 | @Component({ 14 | selector: 'custom-navigation', 15 | templateUrl: './navigation.html' 16 | }) 17 | export class CustomGlobalNavigation extends GlobalNavigation { 18 | } 19 | 20 | @Component({ 21 | selector: 'custom-view', 22 | templateUrl: './view.html' 23 | }) 24 | export class CustomViewView extends ViewView { 25 | mode: 'view' | 'edit' | 'advanced-edit' = 'view'; 26 | downloaded = false; 27 | 28 | changeMode(mode: 'view' | 'edit' | 'advanced-edit') { 29 | this.mode = mode; 30 | } 31 | 32 | } 33 | 34 | @Component({ 35 | selector: 'custom-sf-edit', 36 | template: `` 37 | }) 38 | export class CustomSfEditView extends EditView implements OnInit { 39 | 40 | schema: any; 41 | actions: any = {}; 42 | 43 | constructor(services: Services) { 44 | super(services); 45 | this.schema = { 46 | 'properties': {}, 47 | 'buttons': [ 48 | { id: 'save', label: 'Save' }, 49 | { id: 'cancel', label: 'Cancel' } 50 | ] 51 | }; 52 | } 53 | 54 | onTraverse(target: Target) { 55 | super.onTraverse(target); 56 | const model = target.context; 57 | this.actions = { 58 | save: this.onSave.bind(this), 59 | cancel: this.onCancel.bind(this) 60 | }; 61 | this.services.resource.type(target.context['@type']).subscribe(schema => { 62 | schema.buttons = [ 63 | { id: 'save', label: 'Save' }, 64 | { id: 'cancel', label: 'Cancel' } 65 | ]; 66 | // FIX THE SCHEMA AND THE MODEL 67 | for (const property in schema.properties) { 68 | if (!schema.properties.hasOwnProperty(property)) { 69 | continue; 70 | } 71 | if (property === 'allow_discussion') { 72 | schema.properties[property].type = 'boolean'; 73 | } 74 | if (property === 'effective' || property === 'expires') { 75 | schema.properties[property].widget = 'date'; 76 | } 77 | } 78 | 79 | this.schema = schema; 80 | this.model = model; 81 | }); 82 | } 83 | 84 | onSave(schemaForm: any) { 85 | const model = schemaForm.value; 86 | Object.keys(model).forEach((key: string) => { 87 | if (model[key] === '' && this.schema.properties[key].widget.id === 'date') { 88 | model[key] = null; 89 | } 90 | }); 91 | this.services.resource.update(this.path, model).subscribe(() => { 92 | this.services.traverser.traverse(this.path); 93 | }); 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/app/custom/navigation.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/custom/view.html: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 |

{{ context.title }}

7 | 8 | {{ context.description }} 9 |
10 | 11 |
12 |

Download file

13 | 14 | {{ context.file.filename }} 15 | 16 | Download done 17 |
18 |
19 |

Subjects

20 |
    21 |
  • 22 | {{subject}} 23 |
  • 24 |
25 |
26 | 27 |

Workflow

28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.restapi-angular/b65c901a8f2efa673345cddc0e005edc0333acb8/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | backendUrl: 'http://localhost:4200/Plone', 3 | production: true 4 | }; 5 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | backendUrl: 'http://localhost:4200/Plone', 7 | production: false 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plone/plone.restapi-angular/b65c901a8f2efa673345cddc0e005edc0333acb8/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RestapiAngular 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/jestGlobalMocks.ts: -------------------------------------------------------------------------------- 1 | const mockStorage = () => { 2 | let storage = {}; 3 | return { 4 | getItem: key => key in storage ? storage[key] : null, 5 | setItem: (key, value) => storage[key] = value || '', 6 | removeItem: key => delete storage[key], 7 | clear: () => storage = {}, 8 | }; 9 | }; 10 | 11 | const mockScroll = () => { 12 | return () => {}; 13 | } 14 | 15 | Object.defineProperty(window, 'localStorage', { value: mockStorage() }); 16 | Object.defineProperty(window, 'sessionStorage', { value: mockStorage() }); 17 | Object.defineProperty(window, 'getComputedStyle', { 18 | value: () => ['-webkit-appearance'] 19 | }); 20 | Object.defineProperty(window, 'scrollTo', {value: mockScroll()}); 21 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/setupJest.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular'; 2 | import './jestGlobalMocks'; 3 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "exclude": [ 8 | "test.ts", 9 | "**/*.spec.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": [ 6 | "jest" 7 | ] 8 | }, 9 | "files": [ 10 | "polyfills.ts" 11 | ], 12 | "include": [ 13 | "**/*.spec.ts", 14 | "**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "src", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "es2015", 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "importHelpers": true, 12 | "target": "es5", 13 | "typeRoots": [ 14 | "node_modules/@types" 15 | ], 16 | "lib": [ 17 | "es2018", 18 | "dom" 19 | ], 20 | "paths": { 21 | "ngx-schema-form": [ 22 | "develop/ngx-schema-form/projects/schema-form/src/lib" 23 | ] 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "array-type": false, 8 | "arrow-parens": false, 9 | "deprecation": { 10 | "severity": "warn" 11 | }, 12 | "import-blacklist": [ 13 | true, 14 | "rxjs/Rx" 15 | ], 16 | "interface-name": false, 17 | "max-classes-per-file": false, 18 | "max-line-length": [ 19 | true, 20 | 140 21 | ], 22 | "member-access": false, 23 | "member-ordering": [ 24 | true, 25 | { 26 | "order": [ 27 | "static-field", 28 | "instance-field", 29 | "static-method", 30 | "instance-method" 31 | ] 32 | } 33 | ], 34 | "no-consecutive-blank-lines": false, 35 | "no-console": [ 36 | true, 37 | "debug", 38 | "info", 39 | "time", 40 | "timeEnd", 41 | "trace" 42 | ], 43 | "no-empty": false, 44 | "no-inferrable-types": [ 45 | true, 46 | "ignore-params" 47 | ], 48 | "no-non-null-assertion": true, 49 | "no-redundant-jsdoc": true, 50 | "no-switch-case-fall-through": true, 51 | "no-use-before-declare": true, 52 | "no-var-requires": false, 53 | "object-literal-key-quotes": [ 54 | true, 55 | "as-needed" 56 | ], 57 | "object-literal-sort-keys": false, 58 | "ordered-imports": false, 59 | "quotemark": [ 60 | true, 61 | "single" 62 | ], 63 | "trailing-comma": false, 64 | "no-output-on-prefix": true, 65 | "use-input-property-decorator": true, 66 | "use-output-property-decorator": true, 67 | "use-host-property-decorator": true, 68 | "no-input-rename": true, 69 | "no-output-rename": true, 70 | "use-life-cycle-interface": true, 71 | "use-pipe-transform-interface": true, 72 | "component-class-suffix": true, 73 | "directive-class-suffix": true 74 | } 75 | } 76 | --------------------------------------------------------------------------------