From a39bc0e26979005e71a19a3b3ca478887ad14c1d Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Mon, 18 Aug 2025 09:57:52 +0200 Subject: [PATCH 1/2] Feature: Client Cache for Document Type and Data Type Detail Models (#19904) * extend controller base * extend controller base * add package for management api * add signalr as external package * connect to server event hub * do no act on undefined * add event subject * correct alias * export token * add helper methods * cache server responses * fix import * use helpers * add detail request manager * implement for document type * implement for data type * add method for update * add support for create method * align code * Update detail-request.manager.ts * move explicit naming * move into folder * collect server code in folder * add implementation for data type request manager * implement for document type * only cache when we have connection to the server events * update * fix imports * Create cache.test.ts * use sync method to lookup data type item * use correct alias --- src/Umbraco.Web.UI.Client/package-lock.json | 135 +++++++++++++- src/Umbraco.Web.UI.Client/package.json | 2 + .../src/apps/backoffice/backoffice.element.ts | 3 +- .../src/external/signalr/index.ts | 1 + .../src/external/signalr/package.json | 11 ++ .../src/external/signalr/vite.config.ts | 18 ++ .../block/workspace/block-element-manager.ts | 18 +- .../detail/data-type-detail.repository.ts | 2 +- .../data-type-detail.server.cache.ts | 7 + .../data-type-detail.server.data-source.ts | 89 +++------- ...data-type-detail.server.request-manager.ts | 27 +++ .../detail/document-type-detail.repository.ts | 2 +- .../document-types/repository/detail/index.ts | 2 +- .../document-type-detail.server.cache.ts | 6 + ...document-type-detail.server.data-source.ts | 164 +++++++----------- ...ment-type-detail.server.request-manager.ts | 28 +++ .../document-detail.server.data-source.ts | 1 + .../management-api/detail/cache.test.ts | 89 ++++++++++ .../packages/management-api/detail/cache.ts | 67 +++++++ .../detail/detail-data.request-manager.ts | 144 +++++++++++++++ .../packages/management-api/detail/index.ts | 2 + .../src/packages/management-api/index.ts | 2 + .../src/packages/management-api/manifests.ts | 3 + .../src/packages/management-api/package.json | 8 + .../management-api/server-event/constants.ts | 1 + .../server-event/global-context/constants.ts | 1 + .../server-event/global-context/manifests.ts | 8 + .../server-event.context-token.ts | 6 + .../global-context/server-event.context.ts | 107 ++++++++++++ .../server-event/global-context/types.ts | 5 + .../management-api/server-event/manifests.ts | 3 + .../management-api/umbraco-package.ts | 9 + .../packages/management-api/vite.config.ts | 12 ++ src/Umbraco.Web.UI.Client/tsconfig.json | 4 +- 34 files changed, 799 insertions(+), 188 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/external/signalr/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/external/signalr/package.json create mode 100644 src/Umbraco.Web.UI.Client/src/external/signalr/vite.config.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.cache.ts rename src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/{ => server-data-source}/data-type-detail.server.data-source.ts (67%) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.request-manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.cache.ts rename src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/{ => server-data-source}/document-type-detail.server.data-source.ts (82%) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.request-manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/detail/detail-data.request-manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/detail/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/package.json create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/umbraco-package.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/management-api/vite.config.ts diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index dfe4fe88bd..f4696eb2c3 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -1257,6 +1257,19 @@ "react": ">=16" } }, + "node_modules/@microsoft/signalr": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz", + "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@mswjs/cookies": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@mswjs/cookies/-/cookies-0.2.2.tgz", @@ -3552,6 +3565,10 @@ "resolved": "src/packages/log-viewer", "link": true }, + "node_modules/@umbraco-backoffice/management-api": { + "resolved": "src/packages/management-api", + "link": true + }, "node_modules/@umbraco-backoffice/markdown": { "resolved": "src/packages/markdown-editor", "link": true @@ -3624,6 +3641,10 @@ "resolved": "src/packages/settings", "link": true }, + "node_modules/@umbraco-backoffice/signalr": { + "resolved": "src/external/signalr", + "link": true + }, "node_modules/@umbraco-backoffice/static-file": { "resolved": "src/packages/static-file", "link": true @@ -5030,6 +5051,18 @@ "license": "(Unlicense OR Apache-2.0)", "optional": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8011,6 +8044,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -8021,6 +8063,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8192,6 +8243,16 @@ } } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -11777,7 +11838,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" @@ -11805,21 +11865,18 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, "license": "MIT" }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, "license": "MIT", "dependencies": { "tr46": "~0.0.3", @@ -13670,6 +13727,18 @@ "dev": true, "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -13685,7 +13754,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13756,6 +13824,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-lit": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", @@ -14137,6 +14211,12 @@ "node": ">=10.13.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -14504,7 +14584,6 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", - "dev": true, "license": "MIT" }, "node_modules/set-function-length": { @@ -15409,6 +15488,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", @@ -16180,6 +16283,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -16729,7 +16842,6 @@ "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.3.0" @@ -16910,6 +17022,12 @@ "rxjs": "^7.8.2" } }, + "src/external/signalr": { + "name": "@umbraco-backoffice/signalr", + "dependencies": { + "@microsoft/signalr": "9.0.6" + } + }, "src/external/tiptap": { "name": "@umbraco-backoffice/external-tiptap", "dependencies": { @@ -17007,6 +17125,9 @@ "src/packages/log-viewer": { "name": "@umbraco-backoffice/log-viewer" }, + "src/packages/management-api": { + "name": "@umbraco-backoffice/management-api" + }, "src/packages/markdown-editor": { "name": "@umbraco-backoffice/markdown" }, diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 8031f476c6..df0a51e63f 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -60,6 +60,7 @@ "./lit-element": "./dist-cms/packages/core/lit-element/index.js", "./localization": "./dist-cms/packages/core/localization/index.js", "./log-viewer": "./dist-cms/packages/log-viewer/index.js", + "./management-api": "./dist-cms/packages/management-api/index.js", "./markdown-editor": "./dist-cms/packages/markdown-editor/index.js", "./media-type": "./dist-cms/packages/media/media-types/index.js", "./media": "./dist-cms/packages/media/media/index.js", @@ -126,6 +127,7 @@ "./external/monaco-editor": "./dist-cms/external/monaco-editor/index.js", "./external/openid": "./dist-cms/external/openid/index.js", "./external/rxjs": "./dist-cms/external/rxjs/index.js", + "./external/signalr": "./dist-cms/external/signalr/index.js", "./external/tiptap": "./dist-cms/external/tiptap/index.js", "./external/uui": "./dist-cms/external/uui/index.js" }, diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts index 2c0b5fd876..b284635d58 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/backoffice.element.ts @@ -15,6 +15,7 @@ const CORE_PACKAGES = [ import('../../packages/block/umbraco-package.js'), import('../../packages/clipboard/umbraco-package.js'), import('../../packages/code-editor/umbraco-package.js'), + import('../../packages/content/umbraco-package.js'), import('../../packages/data-type/umbraco-package.js'), import('../../packages/dictionary/umbraco-package.js'), import('../../packages/documents/umbraco-package.js'), @@ -24,6 +25,7 @@ const CORE_PACKAGES = [ import('../../packages/help/umbraco-package.js'), import('../../packages/language/umbraco-package.js'), import('../../packages/log-viewer/umbraco-package.js'), + import('../../packages/management-api/umbraco-package.js'), import('../../packages/markdown-editor/umbraco-package.js'), import('../../packages/media/umbraco-package.js'), import('../../packages/members/umbraco-package.js'), @@ -48,7 +50,6 @@ const CORE_PACKAGES = [ import('../../packages/umbraco-news/umbraco-package.js'), import('../../packages/user/umbraco-package.js'), import('../../packages/webhook/umbraco-package.js'), - import('../../packages/content/umbraco-package.js'), ]; @customElement('umb-backoffice') diff --git a/src/Umbraco.Web.UI.Client/src/external/signalr/index.ts b/src/Umbraco.Web.UI.Client/src/external/signalr/index.ts new file mode 100644 index 0000000000..324f4d0a56 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/signalr/index.ts @@ -0,0 +1 @@ +export * from '@microsoft/signalr'; diff --git a/src/Umbraco.Web.UI.Client/src/external/signalr/package.json b/src/Umbraco.Web.UI.Client/src/external/signalr/package.json new file mode 100644 index 0000000000..08e7abbc27 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/signalr/package.json @@ -0,0 +1,11 @@ +{ + "name": "@umbraco-backoffice/signalr", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "dependencies": { + "@microsoft/signalr": "9.0.6" + } +} diff --git a/src/Umbraco.Web.UI.Client/src/external/signalr/vite.config.ts b/src/Umbraco.Web.UI.Client/src/external/signalr/vite.config.ts new file mode 100644 index 0000000000..6729a413c5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/signalr/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/external/signalr'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ + dist, + base: '/umbraco/backoffice/external/signalr', + entry: { + index: './index.ts', + }, + }), +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts index dbf3edb45f..5c401f6c37 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts @@ -51,7 +51,6 @@ export class UmbBlockElementManager(); readonly structure = new UmbContentTypeStructureManager( this, @@ -91,18 +90,6 @@ export class UmbBlockElementManager { - // Make a map of the data type unique and editorAlias: - this.#dataTypeSchemaAliasMap = new Map( - dataTypes.map((dataType) => { - return [dataType.unique, dataType.propertyEditorSchemaAlias]; - }), - ); - }, - null, - ); } public isLoaded() { @@ -217,8 +204,9 @@ export class UmbBlockElementManager(); + +export { dataTypeDetailCache }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/data-type-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.data-source.ts similarity index 67% rename from src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/data-type-detail.server.data-source.ts rename to src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.data-source.ts index 9963b80846..a79c9437c5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/data-type-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.data-source.ts @@ -1,31 +1,25 @@ -import type { UmbDataTypeDetailModel, UmbDataTypePropertyValueModel } from '../../types.js'; -import { UMB_DATA_TYPE_ENTITY_TYPE } from '../../entity.js'; +import type { UmbDataTypeDetailModel, UmbDataTypePropertyValueModel } from '../../../types.js'; +import { UMB_DATA_TYPE_ENTITY_TYPE } from '../../../entity.js'; +import { UmbManagementApiDataTypeDetailDataRequestManager } from './data-type-detail.server.request-manager.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { UmbDetailDataSource } from '@umbraco-cms/backoffice/repository'; import type { CreateDataTypeRequestModel, + DataTypeResponseModel, UpdateDataTypeRequestModel, } from '@umbraco-cms/backoffice/external/backend-api'; -import { DataTypeService } from '@umbraco-cms/backoffice/external/backend-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; /** * A data source for the Data Type that fetches data from the server * @class UmbDataTypeServerDataSource * @implements {RepositoryDetailDataSource} */ -export class UmbDataTypeServerDataSource implements UmbDetailDataSource { - #host: UmbControllerHost; - - /** - * Creates an instance of UmbDataTypeServerDataSource. - * @param {UmbControllerHost} host - The controller host for this controller to be appended to - * @memberof UmbDataTypeServerDataSource - */ - constructor(host: UmbControllerHost) { - this.#host = host; - } +export class UmbDataTypeServerDataSource + extends UmbControllerBase + implements UmbDetailDataSource +{ + #detailRequestManager = new UmbManagementApiDataTypeDetailDataRequestManager(this); /** * Creates a new Data Type scaffold @@ -57,23 +51,9 @@ export class UmbDataTypeServerDataSource implements UmbDetailDataSource, - }; - - return { data: dataType }; + return { data: data ? this.#mapServerResponseModelToEntityDetailModel(data) : undefined, error }; } /** @@ -99,18 +79,9 @@ export class UmbDataTypeServerDataSource implements UmbDetailDataSource, + }; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.request-manager.ts new file mode 100644 index 0000000000..46c49519dd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/detail/server-data-source/data-type-detail.server.request-manager.ts @@ -0,0 +1,27 @@ +/* eslint-disable local-rules/no-direct-api-import */ +import { dataTypeDetailCache } from './data-type-detail.server.cache.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { + DataTypeService, + type CreateDataTypeRequestModel, + type DataTypeResponseModel, + type UpdateDataTypeRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiDetailDataRequestManager } from '@umbraco-cms/backoffice/management-api'; + +export class UmbManagementApiDataTypeDetailDataRequestManager extends UmbManagementApiDetailDataRequestManager< + DataTypeResponseModel, + UpdateDataTypeRequestModel, + CreateDataTypeRequestModel +> { + constructor(host: UmbControllerHost) { + super(host, { + create: (body: CreateDataTypeRequestModel) => DataTypeService.postDataType({ body }), + read: (id: string) => DataTypeService.getDataTypeById({ path: { id } }), + update: (id: string, body: UpdateDataTypeRequestModel) => DataTypeService.putDataTypeById({ path: { id }, body }), + delete: (id: string) => DataTypeService.deleteDataTypeById({ path: { id } }), + dataCache: dataTypeDetailCache, + serverEventSource: 'Umbraco:CMS:DataType', + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.repository.ts index faf5883411..f79ee9a5b2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.repository.ts @@ -1,6 +1,6 @@ import type { UmbDocumentTypeDetailModel } from '../../types.js'; -import { UmbDocumentTypeDetailServerDataSource } from './document-type-detail.server.data-source.js'; import { UMB_DOCUMENT_TYPE_DETAIL_STORE_CONTEXT } from './document-type-detail.store.context-token.js'; +import { UmbDocumentTypeDetailServerDataSource } from './server-data-source/document-type-detail.server.data-source.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbDetailRepositoryBase } from '@umbraco-cms/backoffice/repository'; export class UmbDocumentTypeDetailRepository extends UmbDetailRepositoryBase { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/index.ts index 68d24e6f3e..356cd69c01 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/index.ts @@ -1,2 +1,2 @@ export { UmbDocumentTypeDetailRepository } from './document-type-detail.repository.js'; -export * from './document-type-detail.server.data-source.js'; +export * from './server-data-source/document-type-detail.server.data-source.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.cache.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.cache.ts new file mode 100644 index 0000000000..208efdacbd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.cache.ts @@ -0,0 +1,6 @@ +import type { DocumentTypeResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiDetailDataCache } from '@umbraco-cms/backoffice/management-api'; + +const documentTypeDetailCache = new UmbManagementApiDetailDataCache(); + +export { documentTypeDetailCache }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.data-source.ts similarity index 82% rename from src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.server.data-source.ts rename to src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.data-source.ts index b937292ce5..9ac3abe0a1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/document-type-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.data-source.ts @@ -1,32 +1,26 @@ -import type { UmbDocumentTypeDetailModel } from '../../types.js'; -import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '../../entity.js'; +import type { UmbDocumentTypeDetailModel } from '../../../types.js'; +import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '../../../entity.js'; +import { UmbManagementApiDocumentTypeDetailDataRequestManager } from './document-type-detail.server.request-manager.js'; import { UmbId } from '@umbraco-cms/backoffice/id'; import type { UmbDetailDataSource } from '@umbraco-cms/backoffice/repository'; import type { CreateDocumentTypeRequestModel, + DocumentTypeResponseModel, UpdateDocumentTypeRequestModel, } from '@umbraco-cms/backoffice/external/backend-api'; -import { DocumentTypeService } from '@umbraco-cms/backoffice/external/backend-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { tryExecute } from '@umbraco-cms/backoffice/resources'; import type { UmbPropertyContainerTypes, UmbPropertyTypeContainerModel } from '@umbraco-cms/backoffice/content-type'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; /** * A data source for the Document Type that fetches data from the server * @class UmbDocumentTypeServerDataSource * @implements {RepositoryDetailDataSource} */ -export class UmbDocumentTypeDetailServerDataSource implements UmbDetailDataSource { - #host: UmbControllerHost; - - /** - * Creates an instance of UmbDocumentTypeServerDataSource. - * @param {UmbControllerHost} host - The controller host for this controller to be appended to - * @memberof UmbDocumentTypeServerDataSource - */ - constructor(host: UmbControllerHost) { - this.#host = host; - } +export class UmbDocumentTypeDetailServerDataSource + extends UmbControllerBase + implements UmbDetailDataSource +{ + #detailRequestManager = new UmbManagementApiDocumentTypeDetailDataRequestManager(this); /** * Creates a new Document Type scaffold @@ -74,63 +68,9 @@ export class UmbDocumentTypeDetailServerDataSource implements UmbDetailDataSourc async read(unique: string) { if (!unique) throw new Error('Unique is missing'); - const { data, error } = await tryExecute( - this.#host, - DocumentTypeService.getDocumentTypeById({ path: { id: unique } }), - ); + const { data, error } = await this.#detailRequestManager.read(unique); - if (error || !data) { - return { error }; - } - - // TODO: make data mapper to prevent errors - const DocumentType: UmbDocumentTypeDetailModel = { - entityType: UMB_DOCUMENT_TYPE_ENTITY_TYPE, - unique: data.id, - name: data.name, - alias: data.alias, - description: data.description ?? '', - icon: data.icon, - allowedAtRoot: data.allowedAsRoot, - variesByCulture: data.variesByCulture, - variesBySegment: data.variesBySegment, - isElement: data.isElement, - properties: data.properties.map((property) => { - return { - id: property.id, - unique: property.id, - container: property.container, - sortOrder: property.sortOrder, - alias: property.alias, - name: property.name, - description: property.description, - dataType: { unique: property.dataType.id }, - variesByCulture: property.variesByCulture, - variesBySegment: property.variesBySegment, - validation: property.validation, - appearance: property.appearance, - }; - }), - containers: data.containers as UmbPropertyTypeContainerModel[], - allowedContentTypes: data.allowedDocumentTypes.map((allowedDocumentType) => { - return { - contentType: { unique: allowedDocumentType.documentType.id }, - sortOrder: allowedDocumentType.sortOrder, - }; - }), - compositions: data.compositions.map((composition) => { - return { - contentType: { unique: composition.documentType.id }, - compositionType: composition.compositionType, - }; - }), - allowedTemplates: data.allowedTemplates, - defaultTemplate: data.defaultTemplate ? { id: data.defaultTemplate.id } : null, - cleanup: data.cleanup, - collection: data.collection ? { unique: data.collection?.id } : null, - }; - - return { data: DocumentType }; + return { data: data ? this.#mapServerResponseModelToEntityDetailModel(data) : undefined, error }; } /** @@ -190,18 +130,9 @@ export class UmbDocumentTypeDetailServerDataSource implements UmbDetailDataSourc collection: model.collection?.unique ? { id: model.collection?.unique } : null, }; - const { data, error } = await tryExecute( - this.#host, - DocumentTypeService.postDocumentType({ - body: body, - }), - ); + const { data, error } = await this.#detailRequestManager.create(body); - if (data) { - return this.read(data as any); - } - - return { error }; + return { data: data ? this.#mapServerResponseModelToEntityDetailModel(data) : undefined, error }; } /** @@ -266,19 +197,9 @@ export class UmbDocumentTypeDetailServerDataSource implements UmbDetailDataSourc collection: model.collection?.unique ? { id: model.collection?.unique } : null, }; - const { error } = await tryExecute( - this.#host, - DocumentTypeService.putDocumentTypeById({ - path: { id: model.unique }, - body: body, - }), - ); + const { data, error } = await this.#detailRequestManager.update(model.unique, body); - if (!error) { - return this.read(model.unique); - } - - return { error }; + return { data: data ? this.#mapServerResponseModelToEntityDetailModel(data) : undefined, error }; } /** @@ -289,12 +210,55 @@ export class UmbDocumentTypeDetailServerDataSource implements UmbDetailDataSourc */ async delete(unique: string) { if (!unique) throw new Error('Unique is missing'); + return this.#detailRequestManager.delete(unique); + } - return tryExecute( - this.#host, - DocumentTypeService.deleteDocumentTypeById({ - path: { id: unique }, + // TODO: change this to a mapper extension when the endpoints returns a $type for DocumentTypeResponseModel + #mapServerResponseModelToEntityDetailModel(data: DocumentTypeResponseModel): UmbDocumentTypeDetailModel { + return { + entityType: UMB_DOCUMENT_TYPE_ENTITY_TYPE, + unique: data.id, + name: data.name, + alias: data.alias, + description: data.description ?? '', + icon: data.icon, + allowedAtRoot: data.allowedAsRoot, + variesByCulture: data.variesByCulture, + variesBySegment: data.variesBySegment, + isElement: data.isElement, + properties: data.properties.map((property) => { + return { + id: property.id, + unique: property.id, + container: property.container, + sortOrder: property.sortOrder, + alias: property.alias, + name: property.name, + description: property.description, + dataType: { unique: property.dataType.id }, + variesByCulture: property.variesByCulture, + variesBySegment: property.variesBySegment, + validation: property.validation, + appearance: property.appearance, + }; }), - ); + containers: data.containers as UmbPropertyTypeContainerModel[], + allowedContentTypes: data.allowedDocumentTypes.map((allowedDocumentType) => { + return { + contentType: { unique: allowedDocumentType.documentType.id }, + sortOrder: allowedDocumentType.sortOrder, + }; + }), + compositions: data.compositions.map((composition) => { + return { + contentType: { unique: composition.documentType.id }, + compositionType: composition.compositionType, + }; + }), + allowedTemplates: data.allowedTemplates, + defaultTemplate: data.defaultTemplate ? { id: data.defaultTemplate.id } : null, + cleanup: data.cleanup, + collection: data.collection ? { unique: data.collection?.id } : null, + }; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.request-manager.ts new file mode 100644 index 0000000000..84e76a3b81 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/detail/server-data-source/document-type-detail.server.request-manager.ts @@ -0,0 +1,28 @@ +/* eslint-disable local-rules/no-direct-api-import */ +import { documentTypeDetailCache } from './document-type-detail.server.cache.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { + DocumentTypeService, + type CreateDocumentTypeRequestModel, + type DocumentTypeResponseModel, + type UpdateDocumentTypeRequestModel, +} from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbManagementApiDetailDataRequestManager } from '@umbraco-cms/backoffice/management-api'; + +export class UmbManagementApiDocumentTypeDetailDataRequestManager extends UmbManagementApiDetailDataRequestManager< + DocumentTypeResponseModel, + UpdateDocumentTypeRequestModel, + CreateDocumentTypeRequestModel +> { + constructor(host: UmbControllerHost) { + super(host, { + create: (body: CreateDocumentTypeRequestModel) => DocumentTypeService.postDocumentType({ body }), + read: (id: string) => DocumentTypeService.getDocumentTypeById({ path: { id } }), + update: (id: string, body: UpdateDocumentTypeRequestModel) => + DocumentTypeService.putDocumentTypeById({ path: { id }, body }), + delete: (id: string) => DocumentTypeService.deleteDocumentTypeById({ path: { id } }), + dataCache: documentTypeDetailCache, + serverEventSource: 'Umbraco:CMS:DocumentType', + }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts index 9adeb01d11..b051f6ed3f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts @@ -38,6 +38,7 @@ export class UmbDocumentServerDataSource throw new Error('Document type unique is missing'); } + // TODO: investigate if we can use the repository here instead const { data } = await new UmbDocumentTypeDetailServerDataSource(this).read(documentTypeUnique); documentTypeIcon = data?.icon ?? null; documentTypeCollection = data?.collection ?? null; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.test.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.test.ts new file mode 100644 index 0000000000..5e5a0577a9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.test.ts @@ -0,0 +1,89 @@ +import { UmbManagementApiDetailDataCache } from './cache.js'; +import { expect } from '@open-wc/testing'; + +describe('UmbManagementApiDetailDataCache', () => { + let cache: UmbManagementApiDetailDataCache<{ id: string }>; + + beforeEach(() => { + cache = new UmbManagementApiDetailDataCache(); + }); + + describe('Public API', () => { + describe('properties', () => {}); + + describe('methods', () => { + it('has a has method', () => { + expect(cache).to.have.property('has').that.is.a('function'); + }); + + it('has a set method', () => { + expect(cache).to.have.property('set').that.is.a('function'); + }); + + it('has a get method', () => { + expect(cache).to.have.property('get').that.is.a('function'); + }); + + it('has a delete method', () => { + expect(cache).to.have.property('delete').that.is.a('function'); + }); + + it('has a clear method', () => { + expect(cache).to.have.property('clear').that.is.a('function'); + }); + }); + }); + + describe('Has', () => { + it('returns true if the item exists in the cache', () => { + cache.set('item1', { id: 'item1' }); + expect(cache.has('item1')).to.be.true; + }); + + it('returns false if the item does not exist in the cache', () => { + expect(cache.has('item2')).to.be.false; + }); + }); + + describe('Set', () => { + it('adds an item to the cache', () => { + cache.set('item1', { id: 'item1' }); + expect(cache.has('item1')).to.be.true; + }); + + it('updates an existing item in the cache', () => { + cache.set('item1', { id: 'item1' }); + cache.set('item1', { id: 'item1-updated' }); + expect(cache.get('item1')).to.deep.equal({ id: 'item1-updated' }); + }); + }); + + describe('Get', () => { + it('returns an item from the cache', () => { + cache.set('item1', { id: 'item1' }); + expect(cache.get('item1')).to.deep.equal({ id: 'item1' }); + }); + + it('returns undefined if the item does not exist in the cache', () => { + expect(cache.get('item2')).to.be.undefined; + }); + }); + + describe('Delete', () => { + it('removes an item from the cache', () => { + cache.set('item1', { id: 'item1' }); + cache.delete('item1'); + expect(cache.has('item1')).to.be.false; + }); + }); + + describe('Clear', () => { + it('removes all items from the cache', () => { + cache.set('item1', { id: 'item1' }); + cache.set('item2', { id: 'item2' }); + cache.clear(); + expect(cache.has('item1')).to.be.false; + expect(cache.has('item2')).to.be.false; + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.ts new file mode 100644 index 0000000000..a8b51f3c9e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/cache.ts @@ -0,0 +1,67 @@ +// Keep internal +interface UmbCacheEntryModel { + id: string; + data: DataModelType; +} + +/** + * A runtime cache for storing entity detail data from the Management Api + * @class UmbManagementApiDetailDataCache + * @template DataModelType + */ +export class UmbManagementApiDetailDataCache { + #entries: Map> = new Map(); + + /** + * Checks if an entry exists in the cache + * @param {string} id - The ID of the entry to check + * @returns {boolean} - True if the entry exists, false otherwise + * @memberof UmbManagementApiDetailDataCache + */ + has(id: string): boolean { + return this.#entries.has(id); + } + + /** + * Adds an entry to the cache + * @param {string} id - The ID of the entry to add + * @param {DataModelType} data - The data to cache + * @memberof UmbManagementApiDetailDataCache + */ + set(id: string, data: DataModelType): void { + const cacheEntry: UmbCacheEntryModel = { + id: id, + data, + }; + + this.#entries.set(id, cacheEntry); + } + + /** + * Retrieves an entry from the cache + * @param {string} id - The ID of the entry to retrieve + * @returns {DataModelType | undefined} - The cached entry or undefined if not found + * @memberof UmbManagementApiDetailDataCache + */ + get(id: string): DataModelType | undefined { + const entry = this.#entries.get(id); + return entry ? entry.data : undefined; + } + + /** + * Deletes an entry from the cache + * @param {string} id - The ID of the entry to delete + * @memberof UmbManagementApiDetailDataCache + */ + delete(id: string): void { + this.#entries.delete(id); + } + + /** + * Clears all entries from the cache + * @memberof UmbManagementApiDetailDataCache + */ + clear(): void { + this.#entries.clear(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/detail-data.request-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/detail-data.request-manager.ts new file mode 100644 index 0000000000..db2228dd86 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/detail-data.request-manager.ts @@ -0,0 +1,144 @@ +import { UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT } from '../server-event/constants.js'; +import type { UmbManagementApiDetailDataCache } from './cache.js'; +import { + tryExecute, + type UmbApiError, + type UmbCancelError, + type UmbApiResponse, + type UmbApiWithErrorResponse, +} from '@umbraco-cms/backoffice/resources'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export interface UmbManagementApiDetailDataRequestManagerArgs< + DetailResponseModelType, + CreateRequestModelType, + UpdateRequestModelType, +> { + create: (data: CreateRequestModelType) => Promise>; + read: (id: string) => Promise>; + update: (id: string, data: UpdateRequestModelType) => Promise>; + delete: (id: string) => Promise>; + dataCache: UmbManagementApiDetailDataCache; + serverEventSource: string; +} + +export class UmbManagementApiDetailDataRequestManager< + DetailResponseModelType, + CreateRequestModelType, + UpdateRequestModelType, +> extends UmbControllerBase { + #dataCache: UmbManagementApiDetailDataCache; + #serverEventSource: string; + #serverEventContext?: typeof UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT.TYPE; + + #create; + #read; + #update; + #delete; + #isConnectedToServerEvents = false; + + constructor( + host: UmbControllerHost, + args: UmbManagementApiDetailDataRequestManagerArgs< + DetailResponseModelType, + CreateRequestModelType, + UpdateRequestModelType + >, + ) { + super(host); + + this.#create = args.create; + this.#read = args.read; + this.#update = args.update; + this.#delete = args.delete; + + this.#dataCache = args.dataCache; + this.#serverEventSource = args.serverEventSource; + + this.consumeContext(UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT, (context) => { + this.#serverEventContext = context; + this.#observeServerEvents(); + }); + } + + async create(data: CreateRequestModelType): Promise> { + const { data: createdId, error } = await tryExecute(this, this.#create(data)); + + if (!error) { + return this.read(createdId as string); + } + + return { error }; + } + + async read(id: string): Promise> { + let data: DetailResponseModelType | undefined; + let error: UmbApiError | UmbCancelError | undefined; + + // Only read from the cache when we are connected to the server events + if (this.#isConnectedToServerEvents && this.#dataCache.has(id)) { + data = this.#dataCache.get(id); + } else { + const { data: serverData, error: serverError } = await tryExecute(this, this.#read(id)); + + if (this.#isConnectedToServerEvents && serverData) { + this.#dataCache.set(id, serverData); + } + + data = serverData; + error = serverError; + } + + return { data, error }; + } + + async update(id: string, data: UpdateRequestModelType): Promise> { + const { error } = await tryExecute(this, this.#update(id, data)); + + if (!error) { + return this.read(id); + } + + return { error }; + } + + async delete(id: string): Promise { + const { error } = await this.#delete(id); + + // Only update the cache when we are connected to the server events + if (this.#isConnectedToServerEvents && !error) { + this.#dataCache.delete(id); + } + + return { error }; + } + + #observeServerEvents() { + this.observe( + this.#serverEventContext?.isConnected, + (isConnected) => { + /* We purposefully ignore the initial value of isConnected. + We only care about whether the connection is established or not (true/false) */ + if (isConnected === undefined) return; + this.#isConnectedToServerEvents = isConnected; + + // Clear the cache if we lose connection to the server events + if (this.#isConnectedToServerEvents === false) { + this.#dataCache.clear(); + } + }, + 'umbObserveServerEventsConnection', + ); + + // Invalidate cache entries when entities are updated or deleted + this.observe( + this.#serverEventContext?.byEventSourceAndTypes(this.#serverEventSource, ['Updated', 'Deleted']), + (event) => { + if (!event) return; + this.#dataCache.delete(event.key); + }, + 'umbObserveServerEvents', + ); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/index.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/index.ts new file mode 100644 index 0000000000..890386ff47 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/detail/index.ts @@ -0,0 +1,2 @@ +export * from './detail-data.request-manager.js'; +export * from './cache.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/index.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/index.ts new file mode 100644 index 0000000000..5b2e199c84 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/index.ts @@ -0,0 +1,2 @@ +export * from './detail/index.js'; +export * from './server-event/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/manifests.ts new file mode 100644 index 0000000000..26c13f92b9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as serverEventManifests } from './server-event/manifests.js'; + +export const manifests: Array = [...serverEventManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/package.json b/src/Umbraco.Web.UI.Client/src/packages/management-api/package.json new file mode 100644 index 0000000000..d8653e9835 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/package.json @@ -0,0 +1,8 @@ +{ + "name": "@umbraco-backoffice/management-api", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/constants.ts new file mode 100644 index 0000000000..b69915b2cf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/constants.ts @@ -0,0 +1 @@ +export * from './global-context/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/constants.ts new file mode 100644 index 0000000000..5822d52362 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/constants.ts @@ -0,0 +1 @@ +export * from './server-event.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/manifests.ts new file mode 100644 index 0000000000..6aaa311e9b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/manifests.ts @@ -0,0 +1,8 @@ +export const manifests: Array = [ + { + type: 'globalContext', + alias: 'Umb.GlobalContext.ManagementApi.ServerEvent', + name: 'Management Api Server Event Global Context', + api: () => import('./server-event.context.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context-token.ts new file mode 100644 index 0000000000..223eb624bb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbManagementApiServerEventContext } from './server-event.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT = new UmbContextToken( + 'UmbManagementApiServerEventContext', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts new file mode 100644 index 0000000000..34f025ebff --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/server-event.context.ts @@ -0,0 +1,107 @@ +import { UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT } from './server-event.context-token.js'; +import type { UmbManagementApiServerEventModel } from './types.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_AUTH_CONTEXT } from '@umbraco-cms/backoffice/auth'; +import { HubConnectionBuilder, type HubConnection } from '@umbraco-cms/backoffice/external/signalr'; +import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; +import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import { filter, Subject } from '@umbraco-cms/backoffice/external/rxjs'; +import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; + +export class UmbManagementApiServerEventContext extends UmbContextBase { + #connection?: HubConnection; + #authContext?: typeof UMB_AUTH_CONTEXT.TYPE; + #serverContext?: typeof UMB_SERVER_CONTEXT.TYPE; + + #events = new Subject(); + public readonly events = this.#events.asObservable(); + + #isConnected = new UmbBooleanState(undefined); + public readonly isConnected = this.#isConnected.asObservable(); + + /** + * Filters events by the given event source + * @param {string} eventSource + * @returns {Observable} - The filtered events + * @memberof UmbManagementApiServerEventContext + */ + byEventSource(eventSource: string): Observable { + return this.#events.asObservable().pipe(filter((event) => event.eventSource === eventSource)); + } + + /** + * Filters events by the given event source and event types + * @param {string} eventSource + * @param {Array} eventTypes + * @returns {Observable} - The filtered events + * @memberof UmbManagementApiServerEventContext + */ + byEventSourceAndTypes(eventSource: string, eventTypes: Array): Observable { + return this.#events + .asObservable() + .pipe(filter((event) => event.eventSource === eventSource && eventTypes.includes(event.eventType))); + } + + constructor(host: UmbControllerHost) { + super(host, UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT); + + this.consumeContext(UMB_AUTH_CONTEXT, (context) => { + this.#authContext = context; + this.#observeIsAuthorized(); + }); + + this.consumeContext(UMB_SERVER_CONTEXT, (context) => { + this.#serverContext = context; + }); + } + + #observeIsAuthorized() { + this.observe(this.#authContext?.isAuthorized, async (isAuthorized) => { + if (isAuthorized === undefined) return; + + if (isAuthorized) { + const token = await this.#authContext?.getLatestToken(); + if (token) { + this.#initHubConnection(token); + } else { + throw new Error('No auth token found'); + } + } else { + this.#isConnected.setValue(false); + this.#connection?.stop(); + this.#connection = undefined; + } + }); + } + + #initHubConnection(token: string) { + const serverURL = this.#serverContext?.getServerUrl(); + + if (!serverURL) { + throw new Error('Server URL is not defined in the server context'); + } + + // TODO: get the url from a server config? + const serverEventHubUrl = `${serverURL}/umbraco/serverEventHub`; + + this.#connection = new HubConnectionBuilder() + .withUrl(serverEventHubUrl, { + accessTokenFactory: () => token, + }) + .build(); + + this.#connection.on('notify', (payload: UmbManagementApiServerEventModel) => { + this.#events.next(payload); + }); + + this.#connection + .start() + .then(() => this.#isConnected.setValue(true)) + .catch(() => this.#isConnected.setValue(false)); + + this.#connection.onclose(() => this.#isConnected.setValue(false)); + } +} + +export { UmbManagementApiServerEventContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/types.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/types.ts new file mode 100644 index 0000000000..5a0341345d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/global-context/types.ts @@ -0,0 +1,5 @@ +export interface UmbManagementApiServerEventModel { + eventSource: string; + eventType: string; + key: string; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/manifests.ts new file mode 100644 index 0000000000..c81aafcfb4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/server-event/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as globalContextManifests } from './global-context/manifests.js'; + +export const manifests: Array = [...globalContextManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/umbraco-package.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/umbraco-package.ts new file mode 100644 index 0000000000..03e442609e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/umbraco-package.ts @@ -0,0 +1,9 @@ +export const name = 'Umbraco.ManagementApi'; +export const extensions = [ + { + name: 'Management Api Bundle', + alias: 'Umb.Bundle.ManagementApi', + type: 'bundle', + js: () => import('./manifests.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/management-api/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/management-api/vite.config.ts new file mode 100644 index 0000000000..ff9cbc2c0d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/management-api/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { rmSync } from 'fs'; +import { getDefaultConfig } from '../../vite-config-base'; + +const dist = '../../../dist-cms/packages/management-api'; + +// delete the unbundled dist folder +rmSync(dist, { recursive: true, force: true }); + +export default defineConfig({ + ...getDefaultConfig({ dist }), +}); diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 23592d2c5b..88e278fb2d 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -14,7 +14,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "moduleDetection": "force", "verbatimModuleSyntax": true, "target": "es2022", - "lib": ["es2022", "dom", "dom.iterable", "WebWorker"], + "lib": ["es2022", "dom", "dom.iterable"], "outDir": "./types", "allowSyntheticDefaultImports": true, "experimentalDecorators": true, @@ -89,6 +89,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/lit-element": ["./src/packages/core/lit-element/index.ts"], "@umbraco-cms/backoffice/localization": ["./src/packages/core/localization/index.ts"], "@umbraco-cms/backoffice/log-viewer": ["./src/packages/log-viewer/index.ts"], + "@umbraco-cms/backoffice/management-api": ["./src/packages/management-api/index.ts"], "@umbraco-cms/backoffice/markdown-editor": ["./src/packages/markdown-editor/index.ts"], "@umbraco-cms/backoffice/media-type": ["./src/packages/media/media-types/index.ts"], "@umbraco-cms/backoffice/media": ["./src/packages/media/media/index.ts"], @@ -155,6 +156,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/external/monaco-editor": ["./src/external/monaco-editor/index.ts"], "@umbraco-cms/backoffice/external/openid": ["./src/external/openid/index.ts"], "@umbraco-cms/backoffice/external/rxjs": ["./src/external/rxjs/index.ts"], + "@umbraco-cms/backoffice/external/signalr": ["./src/external/signalr/index.ts"], "@umbraco-cms/backoffice/external/tiptap": ["./src/external/tiptap/index.ts"], "@umbraco-cms/backoffice/external/uui": ["./src/external/uui/index.ts"] } From a5f9bba48170410cd6b6b920fa04131780079156 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 18 Aug 2025 10:42:36 +0100 Subject: [PATCH 2/2] Ensure appropriate create and update dates are set on updated dictionary items to allow distinguishing between created and update for server events. (#19925) --- .../Services/DictionaryItemService.cs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Services/DictionaryItemService.cs b/src/Umbraco.Core/Services/DictionaryItemService.cs index 467f4ea26a..6b99ea7e24 100644 --- a/src/Umbraco.Core/Services/DictionaryItemService.cs +++ b/src/Umbraco.Core/Services/DictionaryItemService.cs @@ -159,7 +159,25 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem /// public async Task> UpdateAsync( IDictionaryItem dictionaryItem, Guid userKey) - => await SaveAsync( + { + // Create and update dates aren't tracked for dictionary items. They exist on IDictionaryItem due to the + // inheritance from IEntity, but we don't store them. + // However we have logic in ServerEventSender that will provide SignalR events for created and update operations, + // where these dates are used to distinguish between the two (whether or not the entity has an identity cannot + // be used here, as these events fire after persistence when the identity is known for both creates and updates). + // So ensure we set something that can be distinguished here. + if (dictionaryItem.CreateDate == default) + { + dictionaryItem.CreateDate = DateTime.MinValue; + } + + if (dictionaryItem.UpdateDate == default) + { + // TODO (V17): To align with updates of system dates, this needs to change to DateTime.UtcNow. + dictionaryItem.UpdateDate = DateTime.Now; + } + + return await SaveAsync( dictionaryItem, () => { @@ -174,6 +192,7 @@ internal sealed class DictionaryItemService : RepositoryService, IDictionaryItem AuditType.Save, "Update DictionaryItem", userKey); + } /// public async Task> DeleteAsync(Guid id, Guid userKey)