diff --git a/.cspell.json b/.cspell.json index d772c92..68b8238 100644 --- a/.cspell.json +++ b/.cspell.json @@ -37,7 +37,9 @@ "Multichoice", "Gridster", "gridster", - "datetime" + "datetime", + "PUID", + "PGID" ], "flagWords": [], "ignorePaths": [ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/pr-build.yaml b/.github/workflows/pr-build.yaml index 352b5b8..34a1a23 100644 --- a/.github/workflows/pr-build.yaml +++ b/.github/workflows/pr-build.yaml @@ -29,6 +29,12 @@ jobs: - name: Build run: npm run build + - name: Build container image + run: docker build -t application-builder:smoke . + + - name: Smoke-test process file storage + run: scripts/container-smoke-test.sh application-builder:smoke + # - name: Edit Path # run: | # sed -i 's/SF:.*.src/SF:src/g' coverage/lcov.info diff --git a/.github/workflows/release-build.yaml b/.github/workflows/release-build.yaml index d0d2e44..a922a68 100644 --- a/.github/workflows/release-build.yaml +++ b/.github/workflows/release-build.yaml @@ -20,28 +20,37 @@ jobs: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_AUTH_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Read package.json uses: zvonimirsun/read-package-version-actions@v2 id: getVersion - name: Push Version ${{ steps.getVersion.outputs.version }} - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: push: true + platforms: linux/amd64,linux/arm64 tags: netgrif/application-builder:${{ steps.getVersion.outputs.version }} - name: Push Latest if: ${{ !contains(steps.getVersion.outputs.version, '-') }} - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: push: true + platforms: linux/amd64,linux/arm64 tags: netgrif/application-builder:latest - name: Push Next if: ${{ contains(steps.getVersion.outputs.version, '-') }} - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: push: true + platforms: linux/amd64,linux/arm64 tags: netgrif/application-builder:next publish-assets: diff --git a/Dockerfile b/Dockerfile index 0663690..f0d4087 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,27 @@ -FROM node:18 as build +FROM node:18.20.8 AS build MAINTAINER NETGRIF WORKDIR /app COPY . . RUN npm install --legacy-peer-deps RUN npm run build -FROM nginx:alpine +FROM nginx/nginx-unprivileged:1.31.8-alpine-slim + MAINTAINER NETGRIF + +USER root + +RUN apk add --no-cache su-exec gettext-envsubst libcap-utils curl \ + && setcap cap_net_bind_service=+ep /usr/sbin/nginx \ + && getcap /usr/sbin/nginx | grep -q cap_net_bind_service \ + && nginx -V 2>&1 | grep -q -- '--with-http_dav_module' + COPY default.nginx.conf /etc/nginx/conf.d/default.conf +COPY process-files.nginx.conf /etc/nginx/process-files-enabled.conf COPY --from=build /app/dist/application-builder/ /usr/share/nginx/html/ +COPY entrypoint.sh /usr/local/bin/application-builder-entrypoint + +RUN chmod 755 /usr/local/bin/application-builder-entrypoint + EXPOSE 80 -ENTRYPOINT ["/bin/sh","-c","envsubst < /usr/share/nginx/html/env.template.js > /usr/share/nginx/html/env.js && exec nginx -g 'daemon off;'"] +ENTRYPOINT ["/usr/local/bin/application-builder-entrypoint"] diff --git a/README.md b/README.md index 214578c..7a44c59 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Petriflow processes can be run/interpreted in Netgrif Application Engine. Live build: [https://builder.netgrif.cloud](https://builder.netgrif.cloud) Application Builder consists of several modules that helps with development of business processes, application's dataset, permission schema, and many more. -Builder can be deployed as is directly from the release artifact or as a docker container. It doesn't save your work on any server. It works only in a browser. +Builder can be deployed as is directly from the release artifact or as a Docker container. By default, work stays in the browser; the container's optional process-file storage writes only to an explicitly mounted directory and is disabled unless enabled at startup. * Petriflow low-code language: [http://petriflow.com](https://petriflow.com) * Netgrif Application Engine: [NAE repository](https://github.com/netgrif/application-engine), [NAE documentation](https://engine.netgrif.com) @@ -38,6 +38,51 @@ npm i --legacy-peer-deps npm run start ``` +### Optional process-file storage + +The container can store process files on a mounted directory. Each Petriflow +application has its own subdirectory, for example +`processes/demo-app/demo-app.xml`. +The reserved `manifest.xml` file is not treated as a process model. Process-file +storage is disabled by default; enable it only on a trusted/private instance. +The **Save process file** toolbar action appears only when +`PROCESS_FILES_ENABLED=true`. + +```shell +mkdir -p processes/demo-app +docker build -t application-builder:local . +docker run --rm -p 18080:80 \ + -e PROCESS_FILES_ENABLED=true \ + -v "$PWD/processes:/data/processes:z" \ + application-builder:local +``` + +Open `http://localhost:18080/?process=demo-app/demo-app.xml` to load a named +model. For a new model, Save uses its model ID for both the app directory and +the XML filename (`/.xml`). For a loaded model, Save keeps +the path it was opened from. +For local Angular development, start the storage container as above and run +`npm run start`; the dev-server proxy forwards `/processes` to port `18080`. + +The mounted directory must contain a writable subdirectory for each app. The +entrypoint uses the mounted directory's numeric owner by default. You can set +`PUID` and `PGID` to select another non-root owner; the selected user must be +able to write to the mount, and `PUID=0` is refused. +When an ETag check finds the file has changed, the UI offers reload, overwrite, +or cancel. This detects changes made before the save check, but Nginx DAV does +not make the subsequent PUT conditional; simultaneous writers can still race. + +To verify the SPA flow manually, create +`processes/demo-app/demo-app.xml`, open +`http://localhost:18080/?process=demo-app/demo-app.xml`, change a model property, +press **Save process file**, and confirm the mounted XML changed. The image +smoke test checks the HTTP listing, PUT/GET behavior, manifest exclusion, and +host ownership: + +```shell +scripts/container-smoke-test.sh application-builder:local +``` + ## Other projects ### Application Engine diff --git a/angular.json b/angular.json index 93a5291..6e78b32 100644 --- a/angular.json +++ b/angular.json @@ -96,7 +96,9 @@ }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", - "options": {}, + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "application-builder:build:production" diff --git a/default.nginx.conf b/default.nginx.conf index 4835775..ad4693b 100644 --- a/default.nginx.conf +++ b/default.nginx.conf @@ -10,9 +10,16 @@ server { listen 80; root /usr/share/nginx/html; index index.html index.htm; + location = /processes { + return 308 /processes/; + } + location /processes/ { + return 404; + } + include /etc/nginx/process-files.conf; location / { try_files $uri $uri/ /index.html; - } + } expires $expires; gzip on; } diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5bb0272 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +envsubst < /usr/share/nginx/html/env.template.js > /usr/share/nginx/html/env.js + +if [ "${PROCESS_FILES_ENABLED:-false}" = "true" ]; then + if [ ! -d /data/processes ]; then + echo "PROCESS_FILES_ENABLED=true requires /data/processes to be mounted." >&2 + exit 1 + fi + cp /etc/nginx/process-files-enabled.conf /etc/nginx/process-files.conf + if [ -n "${PUID:-}" ]; then + process_uid=$PUID + process_gid=${PGID:-$PUID} + else + process_owner=$(stat -c '%u:%g' /data/processes) + process_uid=${process_owner%:*} + process_gid=${process_owner#*:} + fi + case "$process_uid$process_gid" in + ''|*[!0-9]*) echo "PUID and PGID must be numeric." >&2; exit 1 ;; + esac + if [ "$process_uid" = "0" ]; then + host_root_uid=$(awk '$1 == 0 {print $2; exit}' /proc/self/uid_map) + if [ -z "$host_root_uid" ] || [ "$host_root_uid" = "0" ]; then + if [ -n "${PUID:-}" ]; then + echo "Refusing to run process storage as host root (PUID=0)." >&2 + exit 1 + fi + echo "WARNING: /data/processes looks root-owned; running as 101:101. Set PUID and PGID to choose another user." >&2 + process_uid=101 + process_gid=101 + fi + fi + if ! su-exec "$process_uid:$process_gid" test -w /data/processes; then + echo "User $process_uid:$process_gid cannot write to /data/processes. Set PUID and PGID to the owner of the mounted folder." >&2 + exit 1 + fi +else + : > /etc/nginx/process-files.conf + process_uid=101 + process_gid=101 +fi + +chmod 644 /etc/nginx/process-files.conf +nginx -t +nginx_globals='daemon off;' +if [ "$process_uid" = "0" ]; then + nginx_globals='daemon off; user root;' +fi +exec su-exec "$process_uid:$process_gid" nginx -g "$nginx_globals" diff --git a/k8s/application-builder-prod.k8s.yaml b/k8s/application-builder-prod.k8s.yaml deleted file mode 100644 index 037d346..0000000 --- a/k8s/application-builder-prod.k8s.yaml +++ /dev/null @@ -1,89 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - namespace: builder - name: netgrif-application-builder-prod-dpl - labels: - company: netgrif - project: nab - app: builder - layer: deployment - env: prod -spec: - replicas: 1 - selector: - matchLabels: - company: netgrif - project: nab - app: builder - layer: frontend - env: prod - template: - metadata: - namespace: builder - labels: - company: netgrif - project: nab - app: builder - layer: frontend - env: prod - spec: - containers: - - name: netgrif-application-builder-prod-pod - image: netgrif/application-builder:latest - imagePullPolicy: Always - ports: - - containerPort: 80 ---- -apiVersion: v1 -kind: Service -metadata: - namespace: builder - name: netgrif-application-builder-prod-svc - labels: - company: netgrif - project: nab - app: builder - layer: service - env: prod -spec: - externalIPs: [ ] - ports: - - port: 80 - targetPort: 80 - selector: - company: netgrif - project: nab - app: builder - layer: frontend - env: prod ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - namespace: builder - name: netgrif-application-builder-prod-ingress - labels: - company: netgrif - project: nab - app: builder - layer: ingress - env: prod - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod -spec: - tls: - - hosts: - - builder.netgrif.cloud - secretName: netgrif-application-builder-prod-secret-tls - rules: - - host: builder.netgrif.cloud - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: netgrif-application-builder-prod-svc - port: - number: 80 diff --git a/k8s/application-builder-uat.k8s.yaml b/k8s/application-builder-uat.k8s.yaml deleted file mode 100644 index 65c79d3..0000000 --- a/k8s/application-builder-uat.k8s.yaml +++ /dev/null @@ -1,89 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - namespace: builder - name: netgrif-application-builder-uat-dpl - labels: - company: netgrif - project: nab - app: builder - layer: deployment - env: uat -spec: - replicas: 1 - selector: - matchLabels: - company: netgrif - project: nab - app: builder - layer: frontend - env: uat - template: - metadata: - namespace: builder - labels: - company: netgrif - project: nab - app: builder - layer: frontend - env: uat - spec: - containers: - - name: netgrif-application-builder-uat-pod - image: netgrif/application-builder:next - imagePullPolicy: Always - ports: - - containerPort: 80 ---- -apiVersion: v1 -kind: Service -metadata: - namespace: builder - name: netgrif-application-builder-uat-svc - labels: - company: netgrif - project: nab - app: builder - layer: service - env: uat -spec: - externalIPs: [ ] - ports: - - port: 80 - targetPort: 80 - selector: - company: netgrif - project: nab - app: builder - layer: frontend - env: uat ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - namespace: builder - name: netgrif-application-builder-uat-ingress - labels: - company: netgrif - project: nab - app: builder - layer: ingress - env: uat - annotations: - cert-manager.io/cluster-issuer: letsencrypt-prod -spec: - tls: - - hosts: - - next.builder.netgrif.cloud - secretName: netgrif-application-builder-uat-secret-tls - rules: - - host: next.builder.netgrif.cloud - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: netgrif-application-builder-uat-svc - port: - number: 80 diff --git a/package.json b/package.json index 9d47dcd..14d2185 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netgrif/application-builder", - "version": "2.1.1-rc.2", + "version": "2.1.1-rc.3", "description": "Netgrif Application Builder for building, configuring and modeling applications for Application Engine.", "homepage": "https://builder.netgrif.com", "license": "SEE LICENSE IN LICENSE FILE", diff --git a/process-files.nginx.conf b/process-files.nginx.conf new file mode 100644 index 0000000..b17593e --- /dev/null +++ b/process-files.nginx.conf @@ -0,0 +1,30 @@ +location = /processes/ { + alias /data/processes/; + autoindex on; + autoindex_format json; + autoindex_exact_size on; + add_header Cache-Control "no-store" always; + limit_except GET { deny all; } +} + +location ~* ^/processes/[^/]+/manifest\.xml$ { + return 404; +} + +location ~ ^/processes/(?[A-Za-z0-9][A-Za-z0-9._-]*)/$ { + alias /data/processes/$listing_app/; + autoindex on; + autoindex_format json; + autoindex_exact_size on; + add_header Cache-Control "no-store" always; + limit_except GET { deny all; } +} + +location ~ ^/processes/(?[A-Za-z0-9][A-Za-z0-9._-]*)/(?[A-Za-z0-9][A-Za-z0-9._-]*\.xml)$ { + alias /data/processes/$file_app/$process_file; + dav_methods PUT; + dav_access user:rw group:rw all:r; + client_max_body_size 0; + add_header Cache-Control "no-store" always; + limit_except GET PUT { deny all; } +} diff --git a/proxy.conf.json b/proxy.conf.json new file mode 100644 index 0000000..2f09de2 --- /dev/null +++ b/proxy.conf.json @@ -0,0 +1,14 @@ +{ + "/env.js": { + "target": "http://localhost:18080", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + }, + "/processes": { + "target": "http://localhost:18080", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + } +} diff --git a/scripts/container-smoke-test.sh b/scripts/container-smoke-test.sh new file mode 100644 index 0000000..b168edd --- /dev/null +++ b/scripts/container-smoke-test.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu + +image=${1:-application-builder:smoke} +port=${PROCESS_FILE_TEST_PORT:-18080} +temporary_directory=$(mktemp -d) +process_directory="$temporary_directory/processes" +app_directory="$process_directory/demo-app" +container_id= + +cleanup() { + status=$? + trap - EXIT INT TERM + if [ -n "$container_id" ]; then + if [ "$status" -ne 0 ]; then + docker logs "$container_id" 2>/dev/null || true + fi + docker rm -f "$container_id" >/dev/null 2>&1 || true + fi + rm -rf "$temporary_directory" + exit "$status" +} +trap cleanup EXIT INT TERM + +mkdir -p "$app_directory" +printf '%s' '' > "$app_directory/manifest.xml" +container_id=$(docker run -d --rm \ + -p "127.0.0.1:${port}:80" \ + -e PROCESS_FILES_ENABLED=true \ + -v "$process_directory:/data/processes" \ + "$image") + +base_url="http://127.0.0.1:${port}" +ready=false +for _ in $(seq 1 40); do + if curl -fsS "$base_url/" >/dev/null; then + ready=true + break + fi + sleep 1 +done +if [ "$ready" != true ]; then + echo 'Container did not become ready.' >&2 + exit 1 +fi + +curl -fsS "$base_url/processes/" | python3 -c \ + 'import json,sys; assert any(x["name"] == "demo-app" and x["type"] == "directory" for x in json.load(sys.stdin))' + +process_url="$base_url/processes/demo-app/flow.xml" +curl -fsS -X PUT -H 'Content-Type: application/xml' --data-binary '' "$process_url" >/dev/null +test "$(curl -fsS "$process_url")" = '' +test "$(stat -c '%u:%g' "$app_directory/flow.xml")" = "$(id -u):$(id -g)" +curl -fsS "$base_url/processes/demo-app/" | python3 -c \ + 'import json,sys; assert any(x["name"] == "flow.xml" and x["type"] == "file" for x in json.load(sys.stdin))' + +manifest_status=$(curl -sS -o /dev/null -w '%{http_code}' "$base_url/processes/demo-app/manifest.xml") +test "$manifest_status" = 404 + +echo 'Container process-file smoke test passed.' diff --git a/src/app/app.component.ts b/src/app/app.component.ts index fa6d28d..2aa6861 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,6 +12,7 @@ import {ModelImportService} from './modeler/model-import-service'; import {ModelerConfig} from './modeler/modeler-config'; import {MortgageService} from './modeler/mortgage.service'; import {ModelService} from './modeler/services/model/model.service'; +import {ProcessFileService} from './modeler/services/process-file/process-file.service'; import {TutorialService} from './tutorial/tutorial-service'; @Component({ @@ -36,6 +37,7 @@ export class AppComponent implements AfterViewInit { private _mortgageService: MortgageService, private tutorialService: TutorialService, private modelService: ModelService, + private processFileService: ProcessFileService, private importService: ModelImportService, ) { this.config = config.get(); @@ -43,6 +45,10 @@ export class AppComponent implements AfterViewInit { ngAfterViewInit(): void { // TODO: NAB-326 https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API + const queryParams = this.router.routerState.snapshot.root.queryParamMap; + if (queryParams.has('process') || queryParams.has('modelUrl')) { + return; + } const oldModel = localStorage.getItem(ModelerConfig.LOCALSTORAGE.DRAFT_MODEL.KEY); if (!oldModel) { return; @@ -68,6 +74,7 @@ export class AppComponent implements AfterViewInit { dialogRef.afterClosed().subscribe(result => { if (result === true) { + this.processFileService.clearCurrentProcess(); this._mortgageService.loadModel(); this.router.navigate(['/modeler']); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3028f60..2b93b11 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -28,6 +28,7 @@ import {DialogMarkingChangeComponent} from './dialogs/dialog-marking-change/dial import {DialogModelEditComponent} from './dialogs/dialog-model-edit/dialog-model-edit.component'; import {DialogPlaceEditComponent} from './dialogs/dialog-place-edit/dialog-place-edit.component'; import {DialogPlaceRefDeleteComponent} from './dialogs/dialog-place-ref-delete/dialog-place-ref-delete.component'; +import {DialogProcessConflictComponent} from './dialogs/dialog-process-conflict/dialog-process-conflict.component'; import {DialogRefactorComponent} from './dialogs/dialog-refactor/dialog-refactor.component'; import {DialogTransitionEditComponent} from './dialogs/dialog-transition-edit/dialog-transition-edit.component'; import {FormBuilderComponent} from './form-builder/form-builder.component'; @@ -78,7 +79,8 @@ const appRoutes: Routes = [ DialogModelEditComponent, MaterialIconPickerComponent, DialogLocalStorageModelComponent, - DialogMarkingChangeComponent + DialogMarkingChangeComponent, + DialogProcessConflictComponent ], imports: [ BrowserModule, diff --git a/src/app/dialogs/dialog-model-edit/dialog-model-edit.component.ts b/src/app/dialogs/dialog-model-edit/dialog-model-edit.component.ts index 05d7893..752452f 100644 --- a/src/app/dialogs/dialog-model-edit/dialog-model-edit.component.ts +++ b/src/app/dialogs/dialog-model-edit/dialog-model-edit.component.ts @@ -57,7 +57,7 @@ export class DialogModelEditComponent { openActions() { this._actionMode.activate(this._processTool); - this.router.navigate(['modeler/actions']); + this.router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } private validVersion(): ValidatorFn { diff --git a/src/app/dialogs/dialog-process-conflict/dialog-process-conflict.component.html b/src/app/dialogs/dialog-process-conflict/dialog-process-conflict.component.html new file mode 100644 index 0000000..f8f23b7 --- /dev/null +++ b/src/app/dialogs/dialog-process-conflict/dialog-process-conflict.component.html @@ -0,0 +1,12 @@ +

Process file conflict

+ +

{{ data.process }} changed on disk since it was opened.

+

{{ data.process }} was removed from disk since it was opened.

+

{{ data.process }} already exists.

+

Choose how to continue.

+
+ + + + + diff --git a/src/app/dialogs/dialog-process-conflict/dialog-process-conflict.component.ts b/src/app/dialogs/dialog-process-conflict/dialog-process-conflict.component.ts new file mode 100644 index 0000000..20e9491 --- /dev/null +++ b/src/app/dialogs/dialog-process-conflict/dialog-process-conflict.component.ts @@ -0,0 +1,18 @@ +import {Component, Inject} from '@angular/core'; +import {MAT_DIALOG_DATA} from '@angular/material/dialog'; + +export interface ProcessConflictDialogData { + process: string; + reason: 'changed' | 'missing' | 'exists'; +} + +@Component({ + selector: 'nab-dialog-process-conflict', + templateUrl: './dialog-process-conflict.component.html', +}) +export class DialogProcessConflictComponent { + + constructor(@Inject(MAT_DIALOG_DATA) public data: ProcessConflictDialogData) { + } + +} diff --git a/src/app/dialogs/dialog-transition-edit/dialog-transition-edit.component.ts b/src/app/dialogs/dialog-transition-edit/dialog-transition-edit.component.ts index 536cbae..fe92283 100644 --- a/src/app/dialogs/dialog-transition-edit/dialog-transition-edit.component.ts +++ b/src/app/dialogs/dialog-transition-edit/dialog-transition-edit.component.ts @@ -51,14 +51,14 @@ export class DialogTransitionEditComponent implements OnInit { openFormBuilder() { // TODO: NAB-326 refactor SelectedTransitionService this.transitionService.id = this.transition.id; - this.router.navigate(['/form']); + this.router.navigate(['/form'], {queryParamsHandling: 'preserve'}); } openActions() { this._actionMode.activate(this._actionMode.transitionActionsTool); this._actionsMasterDetail.select(this.transition.transition); this.transitionService.id = this.transition.id; - this.router.navigate(['modeler/actions']); + this.router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } private validUnique(): ValidatorFn { diff --git a/src/app/form-builder/edit-panel/edit-panel.component.ts b/src/app/form-builder/edit-panel/edit-panel.component.ts index c76dfd0..8dae6d2 100644 --- a/src/app/form-builder/edit-panel/edit-panel.component.ts +++ b/src/app/form-builder/edit-panel/edit-panel.component.ts @@ -523,13 +523,13 @@ export class EditPanelComponent implements OnInit, AfterViewInit { openActions() { this._actionMode.activate(this._actionMode.dataActionsTool); this._actionsMasterDetail.select(this.gridsterService.selectedDataField.dataVariable); - this._router.navigate(['modeler/actions']); + this._router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } openTransitionActions() { this._actionMode.activate(this._actionMode.transitionActionsTool); this._actionsMasterDetail.select(this.modelService.model.getTransition(this.transitionService.id)); - this._router.navigate(['modeler/actions']); + this._router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } numberOfActions(): number { diff --git a/src/app/form-builder/field-list/field-list.component.ts b/src/app/form-builder/field-list/field-list.component.ts index 6010ab7..2bb54e5 100644 --- a/src/app/form-builder/field-list/field-list.component.ts +++ b/src/app/form-builder/field-list/field-list.component.ts @@ -127,7 +127,7 @@ export class FieldListComponent implements OnInit, AfterViewInit { backModeler() { this.transitionService.id = undefined; - this.router.navigate(['/modeler']); + this.router.navigate(['/modeler'], {queryParamsHandling: 'preserve'}); this.gridsterService.placedDataFields = []; this.gridsterService.options.api.optionsChanged(); } diff --git a/src/app/form-builder/info-label/info-label.component.ts b/src/app/form-builder/info-label/info-label.component.ts index 7d7f6bb..71f5808 100644 --- a/src/app/form-builder/info-label/info-label.component.ts +++ b/src/app/form-builder/info-label/info-label.component.ts @@ -27,6 +27,6 @@ export class InfoLabelComponent { } redirect() { - this.router.navigate(['']); + this.router.navigate([''], {queryParamsHandling: 'preserve'}); } } diff --git a/src/app/modeler/control-panel/control-panel.service.ts b/src/app/modeler/control-panel/control-panel.service.ts index f4a9af3..106ea13 100644 --- a/src/app/modeler/control-panel/control-panel.service.ts +++ b/src/app/modeler/control-panel/control-panel.service.ts @@ -10,6 +10,7 @@ import {RoleModeService} from '../role-mode/role-mode.service'; import {SimulationModeService} from '../simulation-mode/simulation-mode.service'; import {ExportTool} from './modes/export-tool'; import {ImportTool} from './modes/import-tool'; +import {ProcessSaveTool} from './modes/process-save-tool'; import {Mode} from './modes/mode'; import {ModeService} from './modes/mode-component/mode.service'; import {ModeRegistry} from './modes/mode-registry'; @@ -18,6 +19,7 @@ import {SvgExportTool} from './modes/svg-export-tool'; import {UndoTool} from './modes/undo-tool'; import {GlobalToolRegistry} from './tools/global-tool-registry'; import {Tool} from './tools/tool'; +import {ProcessFileService} from '../services/process-file/process-file.service'; @Injectable({ providedIn: 'root' @@ -42,6 +44,8 @@ export class ControlPanelService { private _exportSvgTool: SvgExportTool, private _redoTool: RedoTool, private _undoTool: UndoTool, + private _processSaveTool: ProcessSaveTool, + private _processFileService: ProcessFileService, private _router: Router ) { this._modeRegistry = new ModeRegistry(); @@ -59,6 +63,9 @@ export class ControlPanelService { this._globalToolRegistry.registerItem(_exportSvgTool); this._globalToolRegistry.registerItem(_undoTool); this._globalToolRegistry.registerItem(_redoTool); + if (this._processFileService.enabled) { + this._globalToolRegistry.registerItem(_processSaveTool); + } this._router.events .pipe(filter(event => event instanceof NavigationEnd)) .subscribe((event: NavigationEnd) => { diff --git a/src/app/modeler/control-panel/modes/mode-component/mode.component.html b/src/app/modeler/control-panel/modes/mode-component/mode.component.html index 257c948..b19785a 100644 --- a/src/app/modeler/control-panel/modes/mode-component/mode.component.html +++ b/src/app/modeler/control-panel/modes/mode-component/mode.component.html @@ -3,6 +3,7 @@ [ngClass]="{'button-highlight': isActive()}" matTooltip="{{mode.tooltip}}" [routerLink]="mode.routerLink" + queryParamsHandling="preserve" joyrideStep="{{mode.tutorialStep.step}}" title="{{mode.tutorialStep.title}}" text="{{mode.tutorialStep.text}}" diff --git a/src/app/modeler/control-panel/modes/process-save-tool.ts b/src/app/modeler/control-panel/modes/process-save-tool.ts new file mode 100644 index 0000000..c4c4e78 --- /dev/null +++ b/src/app/modeler/control-panel/modes/process-save-tool.ts @@ -0,0 +1,84 @@ +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import { + DialogProcessConflictComponent +} from '../../../dialogs/dialog-process-conflict/dialog-process-conflict.component'; +import {ModelService} from '../../services/model/model.service'; +import {ModelExportService} from '../../services/model/model-export.service'; +import {ModelImportService} from '../../model-import-service'; +import { + ProcessFileService, + ProcessSaveResult, + parseProcessPath +} from '../../services/process-file/process-file.service'; +import {ControlPanelButton} from '../control-panel-button'; +import {ControlPanelIcon} from '../control-panel-icon'; +import {Tool} from '../tools/tool'; + +@Injectable({ + providedIn: 'root' +}) +export class ProcessSaveTool extends Tool { + + constructor( + private processFiles: ProcessFileService, + private modelService: ModelService, + private modelExportService: ModelExportService, + private modelImportService: ModelImportService, + private dialog: MatDialog, + private snackBar: MatSnackBar, + ) { + super( + 'process-save', + new ControlPanelButton(new ControlPanelIcon('save', false, true), 'Save process file'), + ); + } + + onClick(): void { + const current = this.processFiles.currentProcess; + const value = current + ? `${current.app}/${current.filename}` + : this.processFiles.processPathForModelId(this.modelService.model?.id); + if (!value || !parseProcessPath(value)) { + this.snackBar.open('The model ID cannot be used as an app and XML filename.', 'X'); + return; + } + this.save(value, this.modelExportService.exportXml(this.modelService.model), current?.etag ?? null); + } + + private save(value: string, xml: string, expectedEtag: string | null): void { + const path = parseProcessPath(value); + if (!path) { + return; + } + this.processFiles.saveProcess(value, xml, expectedEtag).subscribe({ + next: result => { + if (result.kind === 'saved') { + this.processFiles.currentProcess = {...path, xml, etag: result.etag}; + this.snackBar.open(`Saved ${value}`, undefined, {duration: 3000}); + } else { + this.resolveConflict(value, xml, result); + } + }, + error: error => this.snackBar.open(error?.message ?? 'Unable to save process file.', 'X'), + }); + } + + private resolveConflict(value: string, xml: string, conflict: Extract): void { + this.dialog.open(DialogProcessConflictComponent, { + data: {process: value, reason: conflict.reason}, + }).afterClosed().subscribe(choice => { + if (choice === 'overwrite') { + this.save(value, xml, conflict.currentEtag); + } else if (choice === 'reload') { + this.processFiles.loadProcess(value).subscribe({ + next: loaded => this.modelImportService.importFromXml(loaded.xml, true, false), + error: error => this.snackBar.open(error?.message ?? 'Unable to reload process file.', 'X'), + }); + } + }); + } +} diff --git a/src/app/modeler/data-mode/data-detail/data-detail.component.ts b/src/app/modeler/data-mode/data-detail/data-detail.component.ts index dc4f66e..948b0a0 100644 --- a/src/app/modeler/data-mode/data-detail/data-detail.component.ts +++ b/src/app/modeler/data-mode/data-detail/data-detail.component.ts @@ -398,7 +398,7 @@ export class DataDetailComponent implements OnDestroy { openActions() { this._actionMode.activate(this._actionMode.dataActionsTool); this._actionsMasterDetail.select(this._masterService.getSelected()); - this._router.navigate(['modeler/actions']); + this._router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } numberOfActions(): number { diff --git a/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-form-menu-item.ts b/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-form-menu-item.ts index cdf2533..606582e 100644 --- a/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-form-menu-item.ts +++ b/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-form-menu-item.ts @@ -10,7 +10,7 @@ export class EditFormMenuItem extends MenuItem { 'dashboard', () => { tool.transitionService.id = transition.id; - tool.router.navigate(['/form']); + tool.router.navigate(['/form'], {queryParamsHandling: 'preserve'}); } ); } diff --git a/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-transition-actions-menu-item.ts b/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-transition-actions-menu-item.ts index 5c8420c..1341ec0 100644 --- a/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-transition-actions-menu-item.ts +++ b/src/app/modeler/edit-mode/context-menu/menu-items/transition/edit-transition-actions-menu-item.ts @@ -13,7 +13,7 @@ export class EditTransitionActionsMenuItem extends MenuItem { tool.actionMode.activate(tool.actionMode.transitionActionsTool); tool.actionsMasterDetail.select(transition.modelTransition); tool.transitionService.id = transition.id; - tool.router.navigate(['modeler/actions']); + tool.router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } ); } diff --git a/src/app/modeler/edit-mode/services/modes/select-tool.ts b/src/app/modeler/edit-mode/services/modes/select-tool.ts index e478bc4..3f4d481 100644 --- a/src/app/modeler/edit-mode/services/modes/select-tool.ts +++ b/src/app/modeler/edit-mode/services/modes/select-tool.ts @@ -239,7 +239,7 @@ export class SelectTool extends CanvasTool { super.onTransitionUp(event, transition); if (this.isDoubleClick(event) && this.isLeftButtonClick(event)) { this.transitionService.id = transition.id; - this.router.navigate(['/form']); + this.router.navigate(['/form'], {queryParamsHandling: 'preserve'}); return; } this.lastClickTimestamp = event.timeStamp; diff --git a/src/app/modeler/model-import-service.ts b/src/app/modeler/model-import-service.ts index 2c84eb5..c8cc2fc 100644 --- a/src/app/modeler/model-import-service.ts +++ b/src/app/modeler/model-import-service.ts @@ -7,6 +7,7 @@ import {DialogErrorsComponent} from '../dialogs/dialog-errors/dialog-errors.comp import {ImportSuccessfulComponent} from './control-panel/import-successful/import-successful.component'; import {HistoryService} from './services/history/history.service'; import {ModelService} from './services/model/model.service'; +import {ProcessFileService} from './services/process-file/process-file.service'; @Injectable({ providedIn: 'root' @@ -19,11 +20,15 @@ export class ModelImportService { private snackBar: MatSnackBar, private dialog: MatDialog, private router: Router, - private historyService: HistoryService + private historyService: HistoryService, + private processFileService: ProcessFileService, ) { } - public importFromXml(content: string): void { + public importFromXml(content: string, preserveCurrentProcess = false, navigateToModeler = true): void { + if (!preserveCurrentProcess) { + this.processFileService.clearCurrentProcess(); + } const petriNetResult = this.importService.parseFromXml(content); if (petriNetResult.errors.length + petriNetResult.warnings.length + petriNetResult.info.length > 0) { @@ -46,7 +51,11 @@ export class ModelImportService { if (petriNetResult.model !== undefined) { this.modelService.model = petriNetResult.model; this.historyService.save(`Model ${this.modelService.model.id} has been imported.`) + } else { + this.processFileService.clearCurrentProcess(); + } + if (navigateToModeler) { + this.router.navigate(['/modeler']); } - this.router.navigate(['/modeler']); } } diff --git a/src/app/modeler/modeler.component.ts b/src/app/modeler/modeler.component.ts index e663a20..86b2719 100644 --- a/src/app/modeler/modeler.component.ts +++ b/src/app/modeler/modeler.component.ts @@ -1,14 +1,17 @@ import {HttpClient} from '@angular/common/http'; import {Component, ViewChild} from '@angular/core'; import {MatDialog} from '@angular/material/dialog'; +import {MatSnackBar} from '@angular/material/snack-bar'; import {MatSidenav} from '@angular/material/sidenav'; import {MatTabGroup} from '@angular/material/tabs'; import {ActivatedRoute, Router} from '@angular/router'; import {ImportService, PetriNet as PetriflowPetriNet} from '@netgrif/petriflow'; import {ProjectService} from '../project-builder/project.service'; +import {ModelImportService} from './model-import-service'; import {EditModeService} from './edit-mode/edit-mode.service'; import {HistoryService} from './services/history/history.service'; import {ModelService} from './services/model/model.service'; +import {ProcessFileService} from './services/process-file/process-file.service'; @Component({ selector: 'nab-modeler', @@ -30,11 +33,33 @@ export class ModelerComponent { private httpClient: HttpClient, private _importService: ImportService, private _petriflowCanvasService: EditModeService, - private historyService: HistoryService + private historyService: HistoryService, + private modelImportService: ModelImportService, + private processFileService: ProcessFileService, + private snackBar: MatSnackBar, ) { this.projectModels = this.projectService.models; this.route.queryParams.subscribe(params => { + if (params.process && params.modelUrl) { + this.snackBar.open('Use either process or modelUrl, not both.', 'X'); + this.router.navigate(['/modeler']); + return; + } + if (params.process) { + if (this.processFileService.isCurrentProcess(params.process)) { + return; + } + this.processFileService.loadProcess(params.process).subscribe({ + next: loaded => this.modelImportService.importFromXml(loaded.xml, true, false), + error: error => { + this.snackBar.open(error?.message ?? 'Unable to open process file.', 'X'); + this.router.navigate(['/modeler']); + }, + }); + return; + } if (params.modelUrl) { + this.processFileService.clearCurrentProcess(); httpClient.get(params.modelUrl, { responseType: 'text' }).subscribe(data => { diff --git a/src/app/modeler/role-mode/role-detail/role-detail.component.ts b/src/app/modeler/role-mode/role-detail/role-detail.component.ts index 60324fc..e059568 100644 --- a/src/app/modeler/role-mode/role-detail/role-detail.component.ts +++ b/src/app/modeler/role-mode/role-detail/role-detail.component.ts @@ -91,7 +91,7 @@ export class RoleDetailComponent implements OnDestroy { openActions(): void { this._actionMode.activate(this._actionMode.roleActionsTool); this._actionsMasterDetail.select(this.item); - this._router.navigate(['modeler/actions']); + this._router.navigate(['modeler/actions'], {queryParamsHandling: 'preserve'}); } numberOfActions(): number { diff --git a/src/app/modeler/services/process-file/process-file.service.spec.ts b/src/app/modeler/services/process-file/process-file.service.spec.ts new file mode 100644 index 0000000..512a91f --- /dev/null +++ b/src/app/modeler/services/process-file/process-file.service.spec.ts @@ -0,0 +1,168 @@ +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +import {ProcessFileService, parseProcessPath} from './process-file.service'; + +describe('process file paths', () => { + it('parses an app directory and XML filename from one process value', () => { + expect(parseProcessPath('claims/onboarding.xml')).toEqual({ + app: 'claims', + filename: 'onboarding.xml', + }); + }); + + it('rejects reserved manifests and unsafe or ambiguous process paths', () => { + for (const value of [ + 'claims/manifest.xml', + '../onboarding.xml', + 'claims/../onboarding.xml', + 'claims/onboarding.xml/extra', + 'claims\\onboarding.xml', + 'claims/not-xml.json', + ]) { + expect(parseProcessPath(value)).withContext(value).toBeNull(); + } + }); + + it('derives a new file path from the model id for both app and filename', () => { + const service = new ProcessFileService(null as any); + + expect((service as any).processPathForModelId('order-app')).toBe('order-app/order-app.xml'); + expect((service as any).processPathForModelId('bad/app')).toBeNull(); + }); +}); + +describe('ProcessFileService', () => { + let service: ProcessFileService; + let http: HttpTestingController; + + beforeEach(() => { + (window as any).processFilesEnabled = 'true'; + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ProcessFileService], + }); + service = TestBed.inject(ProcessFileService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + it('loads the XML and captures its ETag without caching', () => { + let loaded; + service.loadProcess('claims/onboarding.xml').subscribe(value => loaded = value); + + const request = http.expectOne('/processes/claims/onboarding.xml'); + expect(request.request.method).toBe('GET'); + expect(request.request.headers.get('Cache-Control')).toBe('no-store'); + request.flush('', {headers: {'ETag': '"revision-1"'}}); + + expect(loaded).toEqual({ + app: 'claims', + filename: 'onboarding.xml', + xml: '', + etag: '"revision-1"', + }); + expect((service as any).currentProcess).toEqual(loaded); + }); + + it('recognizes the same active process path without reloading it', () => { + service.currentProcess = { + app: 'claims', + filename: 'onboarding.xml', + xml: '', + etag: '"revision-1"', + }; + + expect((service as any).isCurrentProcess('claims/onboarding.xml')).toBeTrue(); + expect((service as any).isCurrentProcess('claims/another.xml')).toBeFalse(); + expect((service as any).isCurrentProcess('claims/manifest.xml')).toBeFalse(); + }); + + it('lists safe app directories only', () => { + let apps: string[]; + service.listApps().subscribe(value => apps = value); + + http.expectOne('/processes/').flush([ + {name: 'claims', type: 'directory'}, + {name: 'other.app', type: 'directory'}, + {name: '../outside', type: 'directory'}, + {name: 'manifest.xml', type: 'file'}, + ]); + + expect(apps).toEqual(['claims', 'other.app']); + }); + + it('does not call the process endpoint when storage is disabled', () => { + (window as any).processFilesEnabled = 'false'; + let error: Error; + service.listApps().subscribe({error: value => error = value}); + + expect(error.message).toBe('Process file storage is disabled'); + http.expectNone('/processes/'); + }); + + it('enables the save tool only for a literal true runtime flag', () => { + (window as any).processFilesEnabled = undefined; + expect(service.enabled).toBeFalse(); + + (window as any).processFilesEnabled = 'false'; + expect(service.enabled).toBeFalse(); + + (window as any).processFilesEnabled = 'true'; + expect(service.enabled).toBeTrue(); + }); + + it('filters manifest.xml and non-XML entries from an app listing', () => { + let files: string[]; + service.listProcesses('claims').subscribe(value => files = value); + + const request = http.expectOne('/processes/claims/'); + request.flush([ + {name: 'onboarding.xml', type: 'file'}, + {name: 'manifest.xml', type: 'file'}, + {name: 'notes.txt', type: 'file'}, + {name: 'archive', type: 'directory'}, + ]); + + expect(files).toEqual(['onboarding.xml']); + }); + + it('reports an ETag conflict without issuing a PUT', () => { + let result; + service.saveProcess('claims/onboarding.xml', '', '"revision-1"').subscribe(value => result = value); + + const head = http.expectOne('/processes/claims/onboarding.xml'); + expect(head.request.method).toBe('HEAD'); + head.flush(null, {headers: {'ETag': '"revision-2"'}}); + + expect(result).toEqual({kind: 'conflict', reason: 'changed', currentEtag: '"revision-2"'}); + http.expectNone(request => request.method === 'PUT'); + }); + + it('writes only when the current ETag still matches and refreshes it after save', () => { + let result; + service.saveProcess('claims/onboarding.xml', '', '"revision-1"').subscribe(value => result = value); + + http.expectOne(request => request.method === 'HEAD').flush(null, {headers: {'ETag': '"revision-1"'}}); + const put = http.expectOne(request => request.method === 'PUT'); + expect(put.request.body).toBe(''); + put.flush(null); + http.expectOne(request => request.method === 'HEAD').flush(null, {headers: {'ETag': '"revision-2"'}}); + + expect(result).toEqual({kind: 'saved', etag: '"revision-2"'}); + }); + + it('creates a new process file only when the target does not exist', () => { + let result; + service.saveProcess('claims/new-flow.xml', '', null).subscribe(value => result = value); + + http.expectOne(request => request.method === 'HEAD').flush('missing', { + status: 404, + statusText: 'Not Found', + }); + http.expectOne(request => request.method === 'PUT').flush(null); + http.expectOne(request => request.method === 'HEAD').flush(null, {headers: {'ETag': '"revision-1"'}}); + + expect(result).toEqual({kind: 'saved', etag: '"revision-1"'}); + }); +}); diff --git a/src/app/modeler/services/process-file/process-file.service.ts b/src/app/modeler/services/process-file/process-file.service.ts new file mode 100644 index 0000000..3e937e1 --- /dev/null +++ b/src/app/modeler/services/process-file/process-file.service.ts @@ -0,0 +1,211 @@ +import {HttpClient, HttpErrorResponse, HttpResponse} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Observable, catchError, map, of, switchMap, tap, throwError} from 'rxjs'; + +export interface ProcessFilePath { + app: string; + filename: string; +} + +export interface LoadedProcessFile extends ProcessFilePath { + xml: string; + etag: string | null; +} + +export interface ProcessListingEntry { + name: string; + type: 'file' | 'directory'; +} + +export type ProcessSaveResult = + | { kind: 'saved'; etag: string | null } + | { kind: 'conflict'; reason: 'changed' | 'missing' | 'exists'; currentEtag: string | null }; + +const PROCESS_API = '/processes'; +const MANIFEST_FILENAME = 'manifest.xml'; +const APP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const PROCESS_FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.xml$/; + +export function parseProcessPath(value: string): ProcessFilePath | null { + if (!value) { + return null; + } + const segments = value.split('/'); + if (segments.length !== 2) { + return null; + } + const [app, filename] = segments; + if (!isValidAppName(app) || !isValidProcessFilename(filename)) { + return null; + } + return {app, filename}; +} + +function isValidAppName(value: string): boolean { + return APP_NAME_PATTERN.test(value); +} + +function isValidProcessFilename(value: string): boolean { + return PROCESS_FILENAME_PATTERN.test(value) && value.toLowerCase() !== MANIFEST_FILENAME; +} + +@Injectable({ + providedIn: 'root' +}) +export class ProcessFileService { + + private readonly noStoreHeaders = {'Cache-Control': 'no-store', Pragma: 'no-cache'}; + private _currentProcess: LoadedProcessFile | null = null; + + constructor(private http: HttpClient) { + } + + get enabled(): boolean { + return (window as any).processFilesEnabled === 'true'; + } + + processPathForModelId(modelId: string): string | null { + if (!modelId) { + return null; + } + const value = `${modelId}/${modelId}.xml`; + return parseProcessPath(value) ? value : null; + } + + get currentProcess(): LoadedProcessFile | null { + return this._currentProcess; + } + + set currentProcess(value: LoadedProcessFile | null) { + this._currentProcess = value; + } + + clearCurrentProcess(): void { + this._currentProcess = null; + } + + isCurrentProcess(value: string): boolean { + const path = parseProcessPath(value); + return !!path && this._currentProcess?.app === path.app && this._currentProcess.filename === path.filename; + } + + listApps(): Observable { + return this.enabledRequest(() => this.http.get(`${PROCESS_API}/`, { + headers: this.noStoreHeaders, + }).pipe( + map(entries => entries + .filter(entry => entry.type === 'directory' && isValidAppName(entry.name)) + .map(entry => entry.name)), + )); + } + + listProcesses(app: string): Observable { + if (!isValidAppName(app)) { + return throwError(() => new Error('Invalid application directory name')); + } + return this.enabledRequest(() => this.http.get(this.appUrl(app), { + headers: this.noStoreHeaders, + }).pipe( + map(entries => entries + .filter(entry => entry.type === 'file' && isValidProcessFilename(entry.name)) + .map(entry => entry.name)), + )); + } + + loadProcess(value: string): Observable { + const path = parseProcessPath(value); + if (!path) { + return throwError(() => new Error('Invalid process path')); + } + return this.enabledRequest(() => this.http.get(this.fileUrl(path), { + headers: this.noStoreHeaders, + observe: 'response', + responseType: 'text', + }).pipe( + map(response => ({ + ...path, + xml: response.body ?? '', + etag: response.headers.get('ETag'), + })), + tap(loaded => this._currentProcess = loaded), + )); + } + + saveProcess(value: string, xml: string, expectedEtag: string | null): Observable { + const path = parseProcessPath(value); + if (!path) { + return throwError(() => new Error('Invalid process path')); + } + return this.enabledRequest(() => this.http.head(this.fileUrl(path), { + headers: this.noStoreHeaders, + observe: 'response', + }).pipe( + map(response => this.checkExistingFile(response, expectedEtag)), + catchError(error => { + if (error instanceof HttpErrorResponse && error.status === 404) { + return of(expectedEtag === null + ? {kind: 'saved' as const, etag: null, shouldWrite: true} + : { + kind: 'conflict' as const, + reason: 'missing' as const, + currentEtag: null, + shouldWrite: false + }); + } + return throwError(() => error); + }), + switchMap(check => { + if (!check.shouldWrite) { + return of({ + kind: 'conflict' as const, + reason: check.reason, + currentEtag: check.currentEtag, + }); + } + return this.http.put(this.fileUrl(path), xml, { + headers: {...this.noStoreHeaders, 'Content-Type': 'application/xml'}, + observe: 'response', + responseType: 'text', + }).pipe( + switchMap(() => this.readEtag(path).pipe( + map(etag => ({kind: 'saved' as const, etag})), + catchError(() => of({kind: 'saved' as const, etag: null})), + )), + ); + }), + )); + } + + private checkExistingFile(response: HttpResponse, expectedEtag: string | null) { + const currentEtag = response.headers.get('ETag'); + if (expectedEtag === null) { + return {kind: 'conflict' as const, reason: 'exists' as const, currentEtag, shouldWrite: false}; + } + if (currentEtag !== expectedEtag) { + return {kind: 'conflict' as const, reason: 'changed' as const, currentEtag, shouldWrite: false}; + } + return {kind: 'saved' as const, etag: currentEtag, shouldWrite: true}; + } + + private readEtag(path: ProcessFilePath): Observable { + return this.http.head(this.fileUrl(path), { + headers: this.noStoreHeaders, + observe: 'response', + }).pipe(map(response => response.headers.get('ETag'))); + } + + private enabledRequest(request: () => Observable): Observable { + if (!this.enabled) { + return throwError(() => new Error('Process file storage is disabled')); + } + return request(); + } + + private appUrl(app: string): string { + return `${PROCESS_API}/${encodeURIComponent(app)}/`; + } + + private fileUrl(path: ProcessFilePath): string { + return `${PROCESS_API}/${encodeURIComponent(path.app)}/${encodeURIComponent(path.filename)}`; + } +} diff --git a/src/app/tutorial/tutorial-service.ts b/src/app/tutorial/tutorial-service.ts index 9f190e6..1365f23 100644 --- a/src/app/tutorial/tutorial-service.ts +++ b/src/app/tutorial/tutorial-service.ts @@ -46,7 +46,7 @@ export class TutorialService { this.mortgageService.loadModel(); this.mortgageLoaded = true; } - this.router.navigate(['/modeler']); + this.router.navigate(['/modeler'], {queryParamsHandling: 'preserve'}); }, () => { }, @@ -57,7 +57,7 @@ export class TutorialService { 'Process Modeler', 'In Process Modeler you can model business processes by defining tasks and their routing. As a modelling formalism for processes Petriflow language uses Petri nets that consist of state variables, tasks and their interconnections. It supports import of processes in BPMN 2.0 and its automatic translation into Petri nets.', () => { - this.router.navigate(['/modeler/simulation']); + this.router.navigate(['/modeler/simulation'], {queryParamsHandling: 'preserve'}); }, () => { this.onClose(); @@ -69,10 +69,10 @@ export class TutorialService { 'Process Simulation', 'In Process Simulation you can simulate modeled processes by executing sequences of tasks or task events.', () => { - this.router.navigate(['/modeler/data']); + this.router.navigate(['/modeler/data'], {queryParamsHandling: 'preserve'}); }, () => { - this.router.navigate(['/modeler']); + this.router.navigate(['/modeler'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -81,10 +81,10 @@ export class TutorialService { 'Data Editor', 'In Data Editor you can define data variables used in the processes. Petriflow supports all types of data variables you will need for your application, including text, numbers, date, datetime, enumerations and choices, files, images and many others. Validation and initial values of the data variables can be easily specified in Petriflow. Petriflow supports reference to a list of tasks as a data type.', () => { - this.router.navigate(['/modeler/roles']); + this.router.navigate(['/modeler/roles'], {queryParamsHandling: 'preserve'}); }, () => { - this.router.navigate(['/modeler/simulation']); + this.router.navigate(['/modeler/simulation'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -93,10 +93,10 @@ export class TutorialService { 'Role Editor', 'In Role Editor you can define roles and specify which roles can perform tasks in the process.', () => { - this.router.navigate(['/modeler/actions']); + this.router.navigate(['/modeler/actions'], {queryParamsHandling: 'preserve'}); }, () => { - this.router.navigate(['/modeler/data']); + this.router.navigate(['/modeler/data'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -105,10 +105,10 @@ export class TutorialService { 'Actions Editor', 'In Action Editor you can program reactions on events of process instances, its tasks and data fields. Actions use Groovy as a programming language. Types of events that you can catch includes construction of the process instance, assignment of a task to a user, cancellation of a task, finish of a task, and change of a data field value. In actions that react on events, you can trigger events in different process instances and in this way for example create a new instance when you finish a task, to assign a new task to a user when you finish actual task, to recalculate a value of a data field whenever you change another data field, or hide/show data fields when you change another data field. In actions you can use search functions to find specific process instances or tasks based on values of process attributes, process instance attributes, task attributes and data variables. In actions you can also call external functions as well as to send and receive data from external systems via rest or soap web services.', () => { - this.router.navigate(['/modeler/i18n']); + this.router.navigate(['/modeler/i18n'], {queryParamsHandling: 'preserve'}); }, () => { - this.router.navigate(['/modeler/roles']); + this.router.navigate(['/modeler/roles'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -117,10 +117,10 @@ export class TutorialService { 'Internationalization', 'You can add translations for different languages.', () => { - this.router.navigate(['/modeler/history']); + this.router.navigate(['/modeler/history'], {queryParamsHandling: 'preserve'}); }, () => { - this.router.navigate(['/modeler/actions']); + this.router.navigate(['/modeler/actions'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -131,7 +131,7 @@ export class TutorialService { () => { }, () => { - this.router.navigate(['/modeler/i18n']); + this.router.navigate(['/modeler/i18n'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -142,7 +142,7 @@ export class TutorialService { () => { }, () => { - this.router.navigate(['/modeler/history']); + this.router.navigate(['/modeler/history'], {queryParamsHandling: 'preserve'}); }, 'right' ); @@ -235,7 +235,7 @@ export class TutorialService { this.bug.step ]; this.onClose = () => { - this.router.navigate(['/modeler']); + this.router.navigate(['/modeler'], {queryParamsHandling: 'preserve'}); if (this.mortgageLoaded) { this.modelService.model = this.modelService.newModel(); } diff --git a/src/env.template.js b/src/env.template.js index 53d169a..59920c3 100644 --- a/src/env.template.js +++ b/src/env.template.js @@ -9,4 +9,5 @@ window["env"]["services-urls-issues"] = "${ISUUES_URL}"; window["env"]["services-log-level"] = "${LOG_LEVEL}"; + window["processFilesEnabled"] = "${PROCESS_FILES_ENABLED}"; })(this) diff --git a/src/test.ts b/src/test.ts index 2b56700..e789f1f 100644 --- a/src/test.ts +++ b/src/test.ts @@ -13,6 +13,7 @@ getTestBed().initTestEnvironment( teardown: {destroyAfterEach: false}, }, ); + // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules.