From 2b5c9ebd7c2ab01fe3fff14360c284e3c50b05bf Mon Sep 17 00:00:00 2001 From: Andrew Marwood Date: Fri, 17 Mar 2023 18:36:44 +1100 Subject: [PATCH 001/261] [SDAAP-64] Fix null location crash in notifications (#1773) --- server/planning/assignments/assignments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/planning/assignments/assignments.py b/server/planning/assignments/assignments.py index 95012c881..dcc902f20 100644 --- a/server/planning/assignments/assignments.py +++ b/server/planning/assignments/assignments.py @@ -407,7 +407,7 @@ def send_assignment_notification(self, updates, original=None, force=False): event["PRIORITY"] = priority if event_item: - if len(event_item.get("location", [])) > 0: + if event_item.get("location"): location = event_item["location"][0] format_address(location) formatted_location = ( From 08c95a6c9aeca496012a5387222bd9dcd1f90382 Mon Sep 17 00:00:00 2001 From: Mark Pittaway Date: Tue, 4 Apr 2023 15:26:00 +1000 Subject: [PATCH 002/261] [SDESK-6873] fix: Backport moment-timezone upgrade --- client/planning-extension/package-lock.json | 825 +++++++++++++------- client/planning-extension/package.json | 2 +- e2e/package.json | 8 +- e2e/server/requirements.txt | 2 +- package.json | 8 +- server/requirements.txt | 2 +- 6 files changed, 566 insertions(+), 281 deletions(-) diff --git a/client/planning-extension/package-lock.json b/client/planning-extension/package-lock.json index af00aacf7..7656bf67a 100644 --- a/client/planning-extension/package-lock.json +++ b/client/planning-extension/package-lock.json @@ -18,7 +18,7 @@ "acorn-jsx": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", - "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "integrity": "sha512-AU7pnZkguthwBjKgCg6998ByQNIMjbuDQZ8bb78QAFZwPfmKia8AIzgY/gWgqCjnht8JLdXmB4YxA0KaV60ncQ==", "dev": true, "requires": { "acorn": "^3.0.4" @@ -27,7 +27,7 @@ "acorn": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", - "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "integrity": "sha512-OLUyIIZ7mF5oaAUT1w0TFqQS81q3saT46x8t7ukpPjMNk+nbs4ZHhs7ToV8EWnLYLepjETXd4XaCE4uxkMeqUw==", "dev": true } } @@ -35,7 +35,7 @@ "ajv": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", - "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "integrity": "sha512-Ajr4IcMXq/2QmMkEmSvxqfLN5zGmJ92gHXAeOXq1OekoH2rfDNsgdDoL2f7QaRCy7G/E6TpxBVdRuNraMztGHw==", "dev": true, "requires": { "co": "^4.6.0", @@ -47,7 +47,7 @@ "ajv-keywords": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz", - "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=", + "integrity": "sha512-ZFztHzVRdGLAzJmpUT9LNFLe1YiVOEylcaNpEutM26PVTCtOD919IMfD01CgbRouB42Dd9atjx1HseC15DgOZA==", "dev": true }, "ansi-escapes": { @@ -59,13 +59,13 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "dev": true }, "ansi-styles": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", "dev": true }, "argparse": { @@ -77,35 +77,64 @@ "sprintf-js": "~1.0.2" } }, + "array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + } + }, "array-includes": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", - "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", + "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.2", - "get-intrinsic": "^1.1.1", - "is-string": "^1.0.5" + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "get-intrinsic": "^1.1.3", + "is-string": "^1.0.7" } }, "array.prototype.flatmap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.4.tgz", - "integrity": "sha512-r9Z0zYoxqHz60vvQbWEdXIEtCwHF0yxaWfno9qzXeNHvfyl3BZqygmGzb84dsubyaXLH4husF+NFgMSdpZhk2Q==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", + "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", "dev": true, "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.1", - "function-bind": "^1.1.1" + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.tosorted": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", + "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.1.3" } }, + "available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true + }, "babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", - "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "integrity": "sha512-XqYMR2dfdGMW+hd0IUZ2PwK+fGeFkOxZJ0wY+JaQAHzt1Zx8LcvpiZD2NiGkEG8qx0CfkAOr5xt76d1e8vG90g==", "dev": true, "requires": { "chalk": "^1.1.3", @@ -116,7 +145,7 @@ "chalk": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", "dev": true, "requires": { "ansi-styles": "^2.2.1", @@ -129,7 +158,7 @@ "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", "dev": true, "requires": { "ansi-regex": "^2.0.0" @@ -154,9 +183,9 @@ } }, "buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, "call-bind": { @@ -172,7 +201,7 @@ "caller-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", - "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "integrity": "sha512-UJiE1otjXPF5/x+T3zTnSFiTOEmJoGTD9HmBoxnCUwho61a2eSNn/VwtwuIBDAo2SEOv1AJ7ARI5gCmohFLu/g==", "dev": true, "requires": { "callsites": "^0.2.0" @@ -181,7 +210,7 @@ "callsites": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", - "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "integrity": "sha512-Zv4Dns9IbXXmPkgRRUjAaJQgfN4xX5p6+RQFhWUqscdvvK2xK/ZL8b3IXIJsj+4sD+f24NwnWy2BY8AJ82JB0A==", "dev": true }, "chalk": { @@ -218,7 +247,7 @@ "chardet": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", - "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "integrity": "sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg==", "dev": true }, "circular-json": { @@ -230,7 +259,7 @@ "cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", - "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", "dev": true, "requires": { "restore-cursor": "^2.0.0" @@ -245,7 +274,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true }, "color-convert": { @@ -260,13 +289,13 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, "concat-stream": { @@ -282,15 +311,15 @@ } }, "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, "cross-spawn": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", "dev": true, "requires": { "lru-cache": "^4.0.1", @@ -308,18 +337,19 @@ } }, "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, "define-properties": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", - "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", + "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", "dev": true, "requires": { - "object-keys": "^1.0.12" + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" } }, "doctrine": { @@ -332,27 +362,65 @@ } }, "es-abstract": { - "version": "1.18.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.3.tgz", - "integrity": "sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw==", + "version": "1.21.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", + "integrity": "sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==", "dev": true, "requires": { + "array-buffer-byte-length": "^1.0.0", + "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", + "es-set-tostringtag": "^2.0.1", "es-to-primitive": "^1.2.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.1.1", + "function.prototype.name": "^1.1.5", + "get-intrinsic": "^1.2.0", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", "has": "^1.0.3", - "has-symbols": "^1.0.2", - "is-callable": "^1.2.3", - "is-negative-zero": "^2.0.1", - "is-regex": "^1.1.3", - "is-string": "^1.0.6", - "object-inspect": "^1.10.3", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.10", + "is-weakref": "^1.0.2", + "object-inspect": "^1.12.3", "object-keys": "^1.1.1", - "object.assign": "^4.1.2", - "string.prototype.trimend": "^1.0.4", - "string.prototype.trimstart": "^1.0.4", - "unbox-primitive": "^1.0.1" + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.4.3", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.7", + "string.prototype.trimend": "^1.0.6", + "string.prototype.trimstart": "^1.0.6", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.9" + } + }, + "es-set-tostringtag": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", + "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3", + "has": "^1.0.3", + "has-tostringtag": "^1.0.0" + } + }, + "es-shim-unscopables": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", + "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "dev": true, + "requires": { + "has": "^1.0.3" } }, "es-to-primitive": { @@ -369,7 +437,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true }, "eslint": { @@ -427,27 +495,44 @@ "eslint-plugin-jasmine": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/eslint-plugin-jasmine/-/eslint-plugin-jasmine-2.10.1.tgz", - "integrity": "sha1-VzO3CedR9LxA4x4cFpib0s377Jc=", + "integrity": "sha512-dF2siVCguzZpEkqgRaJdR+dsBbXEQKog2tq7A0jYPHK+3qSD+E92f+Sb1jY5y4ua0j18FVIBzEm0yEBID/RdmQ==", "dev": true }, "eslint-plugin-react": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.24.0.tgz", - "integrity": "sha512-KJJIx2SYx7PBx3ONe/mEeMz4YE0Lcr7feJTCMyyKb/341NcjuAgim3Acgan89GfPv7nxXK2+0slu0CWXYM4x+Q==", + "version": "7.32.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", + "integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==", "dev": true, "requires": { - "array-includes": "^3.1.3", - "array.prototype.flatmap": "^1.2.4", + "array-includes": "^3.1.6", + "array.prototype.flatmap": "^1.3.1", + "array.prototype.tosorted": "^1.1.1", "doctrine": "^2.1.0", - "has": "^1.0.3", + "estraverse": "^5.3.0", "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.0.4", - "object.entries": "^1.1.4", - "object.fromentries": "^2.0.4", - "object.values": "^1.1.4", - "prop-types": "^15.7.2", - "resolve": "^2.0.0-next.3", - "string.prototype.matchall": "^4.0.5" + "minimatch": "^3.1.2", + "object.entries": "^1.1.6", + "object.fromentries": "^2.0.6", + "object.hasown": "^1.1.2", + "object.values": "^1.1.6", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.4", + "semver": "^6.3.0", + "string.prototype.matchall": "^4.0.8" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } } }, "eslint-scope": { @@ -483,18 +568,18 @@ "dev": true }, "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, "requires": { "estraverse": "^5.1.0" }, "dependencies": { "estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true } } @@ -509,9 +594,9 @@ }, "dependencies": { "estraverse": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", - "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true } } @@ -542,7 +627,7 @@ "fast-deep-equal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", - "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", + "integrity": "sha512-fueX787WZKCV0Is4/T2cyAdM4+x1S3MXXOAhavE1ys/W42SHAPacLTQhucja22QBYrfGw50M2sRiXPtTGv9Ymw==", "dev": true }, "fast-json-stable-stringify": { @@ -554,13 +639,13 @@ "fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", - "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "integrity": "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==", "dev": true, "requires": { "escape-string-regexp": "^1.0.5" @@ -569,7 +654,7 @@ "file-entry-cache": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", - "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "integrity": "sha512-uXP/zGzxxFvFfcZGgBIwotm+Tdc55ddPAzF7iHshP4YGaXMww7rSF9peD9D1sui5ebONg5UobsZv+FfgEpGv/w==", "dev": true, "requires": { "flat-cache": "^1.2.1", @@ -588,10 +673,19 @@ "write": "^0.2.1" } }, + "for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "requires": { + "is-callable": "^1.1.3" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "function-bind": { @@ -600,33 +694,61 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "function.prototype.name": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", + "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0", + "functions-have-names": "^1.2.2" + } + }, "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", - "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true + }, + "functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, "get-intrinsic": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", - "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", + "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", "dev": true, "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.1" + "has-symbols": "^1.0.3" + } + }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" } }, "glob": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", - "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } @@ -637,10 +759,28 @@ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, + "globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, "graceful-fs": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", - "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==", + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, "has": { @@ -655,30 +795,54 @@ "has-ansi": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", "dev": true, "requires": { "ansi-regex": "^2.0.0" } }, "has-bigints": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz", - "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", "dev": true }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.1" + } + }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", "dev": true }, "has-symbols": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", - "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "dev": true }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, + "requires": { + "has-symbols": "^1.0.2" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -697,13 +861,13 @@ "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "dev": true, "requires": { "once": "^1.3.0", @@ -739,78 +903,99 @@ } }, "internal-slot": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", - "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", + "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", "dev": true, "requires": { - "get-intrinsic": "^1.1.0", + "get-intrinsic": "^1.2.0", "has": "^1.0.3", "side-channel": "^1.0.4" } }, + "is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + } + }, "is-bigint": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz", - "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==", - "dev": true + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "requires": { + "has-bigints": "^1.0.1" + } }, "is-boolean-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.1.tgz", - "integrity": "sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", "dev": true, "requires": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" } }, "is-callable": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz", - "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true }, "is-core-module": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz", - "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "requires": { "has": "^1.0.3" } }, "is-date-object": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.4.tgz", - "integrity": "sha512-/b4ZVsG7Z5XVtIxs/h9W8nvfLgSAyKYdtGWQLbqy6jA1icmgjf8WCoTKgeS4wy5tYaPePouzFMANbnj94c2Z+A==", - "dev": true + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", "dev": true }, "is-negative-zero": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz", - "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", "dev": true }, "is-number-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.5.tgz", - "integrity": "sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw==", - "dev": true + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } }, "is-regex": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz", - "integrity": "sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dev": true, "requires": { "call-bind": "^1.0.2", - "has-symbols": "^1.0.2" + "has-tostringtag": "^1.0.0" } }, "is-resolvable": { @@ -819,11 +1004,23 @@ "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", "dev": true }, + "is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, "is-string": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz", - "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==", - "dev": true + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "requires": { + "has-tostringtag": "^1.0.0" + } }, "is-symbol": { "version": "1.0.4", @@ -834,22 +1031,44 @@ "has-symbols": "^1.0.2" } }, + "is-typed-array": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", + "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" + } + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2" + } + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "integrity": "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg==", "dev": true }, "js-yaml": { @@ -865,29 +1084,29 @@ "json-schema-traverse": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", - "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", + "integrity": "sha512-4JD/Ivzg7PoW8NzdrBSr3UFwC9mHgvI7Z6z3QGBsSHgKaRTUDmyZAAKJo2UbG1kUVfS9WS8bi36N49U1xw43DA==", "dev": true }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, "jsx-ast-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz", - "integrity": "sha512-EIsmt3O3ljsU6sot/J4E1zDRxfBNrhjyf/OKjlydwgEimQuznlM4Wv7U+ueONJMyEn1WRE0K8dhi3dVAXYT24Q==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", + "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", "dev": true, "requires": { - "array-includes": "^3.1.2", - "object.assign": "^4.1.2" + "array-includes": "^3.1.5", + "object.assign": "^4.1.3" } }, "levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", "dev": true, "requires": { "prelude-ls": "~1.1.2", @@ -926,27 +1145,27 @@ "dev": true }, "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true }, "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "requires": { - "minimist": "^1.2.5" + "minimist": "^1.2.6" } }, "ms": { @@ -958,25 +1177,25 @@ "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", - "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==", "dev": true }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true }, "object-inspect": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz", - "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==", + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", "dev": true }, "object-keys": { @@ -986,55 +1205,64 @@ "dev": true }, "object.assign": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz", - "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", "dev": true, "requires": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3", - "has-symbols": "^1.0.1", + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", "object-keys": "^1.1.1" } }, "object.entries": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.4.tgz", - "integrity": "sha512-h4LWKWE+wKQGhtMjZEBud7uLGhqyLwj8fpHOarZhD2uY3C9cRtk57VQ89ke3moByLXMedqs3XCHzyb4AmA2DjA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", + "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.2" + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" } }, "object.fromentries": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.4.tgz", - "integrity": "sha512-EsFBshs5RUUpQEY1D4q/m59kMfz4YJvxuNCJcv/jWwOJr34EaVnG11ZrZa0UHB3wnzV1wx8m58T4hQL8IuNXlQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", + "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.0-next.2", - "has": "^1.0.3" + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + } + }, + "object.hasown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", + "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", + "dev": true, + "requires": { + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" } }, "object.values": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.4.tgz", - "integrity": "sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", + "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.2" + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" } }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "requires": { "wrappy": "1" @@ -1043,7 +1271,7 @@ "onetime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", - "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", "dev": true, "requires": { "mimic-fn": "^1.0.0" @@ -1066,19 +1294,19 @@ "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true }, "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true }, "path-is-inside": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", - "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", "dev": true }, "path-parse": { @@ -1096,7 +1324,7 @@ "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", "dev": true }, "process-nextick-args": { @@ -1112,20 +1340,20 @@ "dev": true }, "prop-types": { - "version": "15.7.2", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", - "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", - "react-is": "^16.8.1" + "react-is": "^16.13.1" } }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", "dev": true }, "react-is": { @@ -1135,9 +1363,9 @@ "dev": true }, "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "requires": { "core-util-is": "~1.0.0", @@ -1150,13 +1378,14 @@ } }, "regexp.prototype.flags": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz", - "integrity": "sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", + "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3" + "define-properties": "^1.1.3", + "functions-have-names": "^1.2.2" } }, "regexpp": { @@ -1168,7 +1397,7 @@ "require-uncached": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", - "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "integrity": "sha512-Xct+41K3twrbBHdxAgMoOS+cNcoqIjfM2/VxBF4LL2hVph7YsF8VSKyQ3BDFZwEVbok9yeDl2le/qo0S77WG2w==", "dev": true, "requires": { "caller-path": "^0.1.0", @@ -1176,25 +1405,26 @@ } }, "resolve": { - "version": "2.0.0-next.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz", - "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==", + "version": "2.0.0-next.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", + "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", "dev": true, "requires": { - "is-core-module": "^2.2.0", - "path-parse": "^1.0.6" + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" } }, "resolve-from": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", - "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "integrity": "sha512-kT10v4dhrlLNcnO084hEjvXCI1wUG9qZLoz2RogxqDQQYy7IxjI/iMUkOtQTNEh6rzHxvdQWHsJyel1pKOVCxg==", "dev": true }, "restore-cursor": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", - "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", "dev": true, "requires": { "onetime": "^2.0.0", @@ -1219,13 +1449,13 @@ "rx-lite": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", - "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=", + "integrity": "sha512-Cun9QucwK6MIrp3mry/Y7hqD1oFqTYLQ4pGxaHTjIdaFDWRGGLikqp6u8LcWJnzpoALg9hap+JGk8sFIUuEGNA==", "dev": true }, "rx-lite-aggregates": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", - "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "integrity": "sha512-3xPNZGW93oCjiO7PtKxRK6iOVYBWBvtf9QHDfU23Oc+dLIQmAV//UnyXV/yihv81VS/UqoQPk4NegS8EFi55Hg==", "dev": true, "requires": { "rx-lite": "*" @@ -1237,6 +1467,17 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + } + }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1252,7 +1493,7 @@ "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", "dev": true, "requires": { "shebang-regex": "^1.0.0" @@ -1261,7 +1502,7 @@ "shebang-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", "dev": true }, "side-channel": { @@ -1276,9 +1517,9 @@ } }, "signal-exit": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, "slice-ansi": { @@ -1293,7 +1534,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, "string-width": { @@ -1307,39 +1548,52 @@ } }, "string.prototype.matchall": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.5.tgz", - "integrity": "sha512-Z5ZaXO0svs0M2xd/6By3qpeKpLKd9mO4v4q3oMEQrk8Ck4xOD5d5XeBOOjGrmVZZ/AHB1S0CgG4N5r1G9N3E2Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", + "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.18.2", - "get-intrinsic": "^1.1.1", - "has-symbols": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", "internal-slot": "^1.0.3", - "regexp.prototype.flags": "^1.3.1", + "regexp.prototype.flags": "^1.4.3", "side-channel": "^1.0.4" } }, + "string.prototype.trim": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", + "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" + } + }, "string.prototype.trimend": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz", - "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", + "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3" + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" } }, "string.prototype.trimstart": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz", - "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", + "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3" + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4" } }, "string_decoder": { @@ -1354,16 +1608,16 @@ "strip-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", "dev": true, "requires": { "ansi-regex": "^3.0.0" }, "dependencies": { "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", "dev": true } } @@ -1371,7 +1625,7 @@ "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true }, "superdesk-code-style": { @@ -1389,7 +1643,13 @@ "supports-color": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, "table": { @@ -1409,13 +1669,13 @@ "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, "tmp": { @@ -1430,40 +1690,51 @@ "type-check": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", "dev": true, "requires": { "prelude-ls": "~1.1.2" } }, + "typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + } + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, "typescript": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.7.2.tgz", - "integrity": "sha512-ml7V7JfiN2Xwvcer+XAf2csGO1bPBdRbFCkYBczNZggrBZ9c7G3riSUeJmqEU5uOtXNPMhE3n+R4FA/3YOAWOQ==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true }, "unbox-primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", - "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", "dev": true, "requires": { - "function-bind": "^1.1.1", - "has-bigints": "^1.0.1", - "has-symbols": "^1.0.2", + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", "which-boxed-primitive": "^1.0.2" } }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, "which": { @@ -1488,6 +1759,20 @@ "is-symbol": "^1.0.3" } }, + "which-typed-array": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", + "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "dev": true, + "requires": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0", + "is-typed-array": "^1.1.10" + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -1497,13 +1782,13 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, "write": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", - "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "integrity": "sha512-CJ17OoULEKXpA5pef3qLj5AxTJ6mSt7g84he2WIskKwqFO4T97d5V7Tadl0DYDk7qyUOQD5WlUlOMChaYrhxeA==", "dev": true, "requires": { "mkdirp": "^0.5.1" @@ -1512,7 +1797,7 @@ "yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", "dev": true } } diff --git a/client/planning-extension/package.json b/client/planning-extension/package.json index aebdfbc5a..bef7869b3 100644 --- a/client/planning-extension/package.json +++ b/client/planning-extension/package.json @@ -11,7 +11,7 @@ "devDependencies": { "@types/angular": "1.6.50", "superdesk-code-style": "1.3.0", - "typescript": "3.7.2" + "typescript": "~4.9.5" }, "superdeskExtension": { "translations-directory": "./translations" diff --git a/e2e/package.json b/e2e/package.json index 17d36f6a7..206b3fa29 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -2,7 +2,7 @@ "name": "superdesk", "license": "GPL-3.0", "dependencies": { - "superdesk-core": "github:superdesk/superdesk-client-core#v2.5.2", + "superdesk-core": "github:superdesk/superdesk-client-core#hotfix/2.5.3", "superdesk-planning": "file:../" }, "devDependencies": { @@ -11,9 +11,9 @@ "cypress-real-events": "^1.5.0", "cypress-terminal-report": "^3.3.2", "extract-text-webpack-plugin": "3.0.2", - "lodash": "^4.17.15", - "moment": "2.20.1", - "moment-timezone": "0.5.14" + "lodash": "^4.17.19", + "moment": "^2.29.4", + "moment-timezone": "^0.5.41" }, "scripts": { "cypress-ui": "cypress open", diff --git a/e2e/server/requirements.txt b/e2e/server/requirements.txt index adb9b0113..317af5bd7 100644 --- a/e2e/server/requirements.txt +++ b/e2e/server/requirements.txt @@ -1,4 +1,4 @@ gunicorn==19.7.1 honcho==1.0.1 -git+https://github.com/superdesk/superdesk-core.git@v2.5.2#egg=superdesk-core +git+https://github.com/superdesk/superdesk-core.git@hotfix/2.5.4#egg=superdesk-core -e ../../ diff --git a/package.json b/package.json index 1e16f494f..1d272109b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "author": "Edouard Richard", "dependencies": { "dompurify": "^1.0.11", + "moment": "^2.29.4", + "moment-timezone": "^0.5.41", "nominatim-browser": "~2.0.2", "react-bootstrap": "0.32.1", "react-debounce-input": "3.2.0", @@ -32,7 +34,7 @@ "reselect": "~3.0.1", "rrule": "~2.2.9", "ts-loader": "3.5.0", - "typescript": "3.9.7", + "typescript": "~4.9.5", "whatwg-fetch": "~2.0.4" }, "devDependencies": { @@ -64,7 +66,7 @@ "simulant": "^0.2.2", "sinon": "^4.5.0", "superdesk-code-style": "1.5.0", - "superdesk-core": "github:superdesk/superdesk-client-core#v2.5.2", + "superdesk-core": "github:superdesk/superdesk-client-core#hotfix/2.5.3", "ts-node": "~7.0.1", "tslint": "5.11.0", "typescript-eslint-parser": "^18.0.0" @@ -72,8 +74,6 @@ "peerDependencies": { "classnames": "^2.2.5", "lodash": "^4.17.19", - "moment": "^2.20.1", - "moment-timezone": "^0.5.14", "react": "^16.9.0", "superdesk-ui-framework": "^2.4.19" } diff --git a/server/requirements.txt b/server/requirements.txt index 3dcdfd36c..5a3ec6a61 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -18,4 +18,4 @@ pytest==5.2.2 pytest-env==0.6.2 # Install in editable state so we get feature fixtures --e git+https://github.com/superdesk/superdesk-core.git@v2.5.2#egg=superdesk-core +-e git+https://github.com/superdesk/superdesk-core.git@hotfix/2.5.4#egg=superdesk-core From 6bc379fd10b73977210275a2eb748a762b27f136 Mon Sep 17 00:00:00 2001 From: Mark Pittaway Date: Wed, 14 Dec 2022 17:52:41 +1100 Subject: [PATCH 003/261] fix(client): Change moment import to moment-timezone --- client/components/Datetime/index.tsx | 2 +- client/components/Events/EventScheduleInput/index.tsx | 2 +- client/components/UI/Form/DateInput/DateInputPopup.tsx | 2 +- client/utils/events.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/components/Datetime/index.tsx b/client/components/Datetime/index.tsx index 5e8419963..37b1a7a14 100644 --- a/client/components/Datetime/index.tsx +++ b/client/components/Datetime/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import moment from 'moment'; +import moment from 'moment-timezone'; import classNames from 'classnames'; import {appConfig} from 'appConfig'; diff --git a/client/components/Events/EventScheduleInput/index.tsx b/client/components/Events/EventScheduleInput/index.tsx index 34ac1b800..31fd459aa 100644 --- a/client/components/Events/EventScheduleInput/index.tsx +++ b/client/components/Events/EventScheduleInput/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import moment from 'moment'; +import moment from 'moment-timezone'; import {IEventItem, IEventFormProfile} from '../../../interfaces'; import {superdeskApi} from '../../../superdeskApi'; diff --git a/client/components/UI/Form/DateInput/DateInputPopup.tsx b/client/components/UI/Form/DateInput/DateInputPopup.tsx index 453ddd6d4..096b339fb 100644 --- a/client/components/UI/Form/DateInput/DateInputPopup.tsx +++ b/client/components/UI/Form/DateInput/DateInputPopup.tsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import moment from 'moment'; +import moment from 'moment-timezone'; import {Popup, Content, Header, Footer} from '../../Popup'; import {Button} from '../../'; import {DayPicker} from './DayPicker'; diff --git a/client/utils/events.ts b/client/utils/events.ts index 3691a7452..ba48b1264 100644 --- a/client/utils/events.ts +++ b/client/utils/events.ts @@ -1,4 +1,4 @@ -import moment from 'moment'; +import moment from 'moment-timezone'; import RRule from 'rrule'; import {get, map, isNil, sortBy, cloneDeep, omitBy, find, isEqual, pickBy, flatten} from 'lodash'; import {IMenuItem} from 'superdesk-ui-framework/react/components/Menu'; From 76a8ebbc9653c7452bf4eb73e3c5a09d2c82d887 Mon Sep 17 00:00:00 2001 From: Mark Pittaway Date: Wed, 5 Apr 2023 11:35:27 +1000 Subject: [PATCH 004/261] fix lint errors --- .github/workflows/lint-server.yml | 20 ++++++++++++------- .../delete_marked_assignments_test.py | 1 - ...20210128-111254_events_planning_filters.py | 1 - .../00029_20210215-155415_roles.py | 1 - .../00030_20210316-105026_locations.py | 1 - .../00033_20210729-145921_planning_types.py | 1 - server/planning/events/events.py | 1 - .../planning/feed_parsers/event_json_tests.py | 4 ---- server/planning/feed_parsers/ics_2_0.py | 1 - .../event_file_service_tests.py | 1 - .../event_http_service_tests.py | 1 - .../planning/planning_notifications_test.py | 1 - .../planning/search/eventsplanning_search.py | 1 - server/planning/search/planning_search.py | 1 - 14 files changed, 13 insertions(+), 23 deletions(-) diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index 53593f870..02ec7a412 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -6,23 +6,29 @@ jobs: black: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install black + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - run: pip install black~=23.0 - run: black --check server flake8: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' - run: pip install flake8 - run: flake8 server mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' - run: pip install -Ur server/mypy-requirements.txt - run: mypy server diff --git a/server/planning/commands/delete_marked_assignments_test.py b/server/planning/commands/delete_marked_assignments_test.py index 6923d9b27..9a5b3b527 100644 --- a/server/planning/commands/delete_marked_assignments_test.py +++ b/server/planning/commands/delete_marked_assignments_test.py @@ -41,7 +41,6 @@ def setUp(self): self.assignment_service = get_resource_service("assignments") def assertAssignmentDeleted(self, assignment_ids, not_deleted=False): - for assignment_id in assignment_ids: assignment = self.assignment_service.find_one(_id=assignment_id, req=None) if not_deleted: diff --git a/server/planning/data_updates/00027_20210128-111254_events_planning_filters.py b/server/planning/data_updates/00027_20210128-111254_events_planning_filters.py index 4a3393405..b7cdee408 100644 --- a/server/planning/data_updates/00027_20210128-111254_events_planning_filters.py +++ b/server/planning/data_updates/00027_20210128-111254_events_planning_filters.py @@ -14,7 +14,6 @@ # This script converts `events_planning_filters` documents to newer schema # Required after changes in PR: https://github.com/superdesk/superdesk-planning/pull/1511 class DataUpdate(BaseDataUpdate): - resource = "events_planning_filters" def forwards(self, mongodb_collection, mongodb_database): diff --git a/server/planning/data_updates/00029_20210215-155415_roles.py b/server/planning/data_updates/00029_20210215-155415_roles.py index 3713f022e..6da30a0d2 100644 --- a/server/planning/data_updates/00029_20210215-155415_roles.py +++ b/server/planning/data_updates/00029_20210215-155415_roles.py @@ -11,7 +11,6 @@ class DataUpdate(BaseDataUpdate): - resource = "roles" def forwards(self, mongodb_collection, mongodb_database): diff --git a/server/planning/data_updates/00030_20210316-105026_locations.py b/server/planning/data_updates/00030_20210316-105026_locations.py index 33ee7182f..6f8840ad1 100644 --- a/server/planning/data_updates/00030_20210316-105026_locations.py +++ b/server/planning/data_updates/00030_20210316-105026_locations.py @@ -14,7 +14,6 @@ class DataUpdate(BaseDataUpdate): - resource = "locations" def forwards(self, mongodb_collection, mongodb_database): diff --git a/server/planning/data_updates/00033_20210729-145921_planning_types.py b/server/planning/data_updates/00033_20210729-145921_planning_types.py index aedc295b5..1f8f08d29 100644 --- a/server/planning/data_updates/00033_20210729-145921_planning_types.py +++ b/server/planning/data_updates/00033_20210729-145921_planning_types.py @@ -23,7 +23,6 @@ class DataUpdate(BaseDataUpdate): - resource = "planning_types" resource_types = ["event", "planning", "coverage"] diff --git a/server/planning/events/events.py b/server/planning/events/events.py index 78d07cac4..e74f931b3 100644 --- a/server/planning/events/events.py +++ b/server/planning/events/events.py @@ -331,7 +331,6 @@ def on_created(self, docs): event_id = str(doc.get(config.ID_FIELD)) # If we duplicated this event, update the history if doc.get("duplicate_from"): - parent_id = doc["duplicate_from"] parent_event = self.find_one(req=None, _id=parent_id) diff --git a/server/planning/feed_parsers/event_json_tests.py b/server/planning/feed_parsers/event_json_tests.py index 42065e0c8..6a3f67ef8 100644 --- a/server/planning/feed_parsers/event_json_tests.py +++ b/server/planning/feed_parsers/event_json_tests.py @@ -5,7 +5,6 @@ class EventJsonFeedParserTestCase(TestCase): - sample_json = {} def setUp(self): @@ -18,7 +17,6 @@ def test_event_json_feed_parser_can_parse(self): def test_event_json_feed_parser_parse(self): with self.app.app_context(): - random_event = { "is_active": True, "name": "random123", @@ -33,7 +31,6 @@ def test_event_json_feed_parser_parse(self): # add the random event items for above fields. for field in assign_from_local_cv: - self.app.data.insert( "vocabularies", [ @@ -58,7 +55,6 @@ def test_event_json_feed_parser_parse(self): # ignore fields like files as per the ACs in SDNTB-682 self.assertNotIn("files", events[0]) for field in assign_from_local_cv.keys(): - # check if the same random is returned after parsing as inserted above. if events[0].get(field): if field == "occur_status": diff --git a/server/planning/feed_parsers/ics_2_0.py b/server/planning/feed_parsers/ics_2_0.py index ad5650cde..da2bcca0f 100644 --- a/server/planning/feed_parsers/ics_2_0.py +++ b/server/planning/feed_parsers/ics_2_0.py @@ -68,7 +68,6 @@ def parse_http(self, content, provider): return self.parse(cal, provider) def parse(self, cal, provider=None): - try: items = [] diff --git a/server/planning/feeding_services/event_file_service_tests.py b/server/planning/feeding_services/event_file_service_tests.py index b0688372e..3afdb0d1e 100644 --- a/server/planning/feeding_services/event_file_service_tests.py +++ b/server/planning/feeding_services/event_file_service_tests.py @@ -15,7 +15,6 @@ def setUp(self): @patch("planning.feeding_services.event_file_service.get_sorted_files") def test_update(self, mock_os, mock_get_sorted_files): with self.app.app_context(): - service = EventFileFeedingService() provider = {"feed_parser": "ics20", "config": {"path": "/test_file_drop"}} mock_get_sorted_files.return_value = ["file1.txt", "file2.txt", "file3.txt"] diff --git a/server/planning/feeding_services/event_http_service_tests.py b/server/planning/feeding_services/event_http_service_tests.py index 8f744d024..c172c3257 100644 --- a/server/planning/feeding_services/event_http_service_tests.py +++ b/server/planning/feeding_services/event_http_service_tests.py @@ -8,7 +8,6 @@ def setUp(self): def test_update(self): with self.app.app_context(): - service = EventHTTPFeedingService() provider = { "_id": "ics_20", diff --git a/server/planning/planning_notifications_test.py b/server/planning/planning_notifications_test.py index 7cdd5f96e..b817efd92 100644 --- a/server/planning/planning_notifications_test.py +++ b/server/planning/planning_notifications_test.py @@ -14,7 +14,6 @@ class MockSlack: - api_call_OK = True def api_call(self, method, **pars): diff --git a/server/planning/search/eventsplanning_search.py b/server/planning/search/eventsplanning_search.py index 9ae7cb485..57b3e23fb 100644 --- a/server/planning/search/eventsplanning_search.py +++ b/server/planning/search/eventsplanning_search.py @@ -116,7 +116,6 @@ def _get_search_filter(self, repo: str, params: Dict[str, Any]): def _construct_search_query( self, repo: str, params: Dict[str, Any], search_filter: Optional[Dict[str, Any]] ) -> Dict[str, Any]: - if repo == "events": filters = EVENT_SEARCH_FILTERS elif repo == "planning": diff --git a/server/planning/search/planning_search.py b/server/planning/search/planning_search.py index 53e2b9ac3..300585ac1 100644 --- a/server/planning/search/planning_search.py +++ b/server/planning/search/planning_search.py @@ -24,7 +24,6 @@ class PlanningSearchService(superdesk.Service): - repos = ["events", "planning"] @property From e0fec2db58ab59f93f084b5941e74f8facc34022 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Tue, 11 Apr 2023 16:13:13 +1000 Subject: [PATCH 005/261] [SDNTB-804] feature: Add priority to Events, Planning and Coverages (#1772) * ui: PlanningAPI updates * api: Add priority to ContentProfiles * ui: Add priority to ContentProfiles * api/ui: Add priority to search * ui: Default values * ui: fix unit tests * api: Add behave tests for search * fix: Getting profiles by ID not working when using system default profiles --- client/actions/agenda.ts | 16 ++- client/actions/events/ui.ts | 12 +- client/actions/tests/agenda_test.ts | 8 ++ client/api/combined.ts | 3 +- client/api/contentProfiles.ts | 50 ++++++- client/api/events.ts | 3 +- client/api/planning.ts | 3 +- client/api/search.ts | 2 +- client/components/AdvancedSearch/index.tsx | 4 + .../ContentProfiles/FieldTab/FieldEditor.tsx | 2 + .../Coverages/CoverageEditor/CoverageForm.tsx | 1 + .../editor/ProfileFieldDefaultValue.tsx | 27 ++++ .../fields/editor/base/numberSelect.tsx | 125 ++++++++++++++++++ client/components/fields/index.tsx | 4 + client/components/fields/preview/index.ts | 4 + client/components/fields/resources/common.ts | 17 +++ .../components/fields/resources/profiles.ts | 12 ++ client/interfaces.ts | 10 ++ client/selectors/vocabs.ts | 34 ++++- client/utils/contentProfiles.ts | 2 + client/utils/events.ts | 33 +++-- client/utils/planning.ts | 99 ++++++++------ client/utils/search.ts | 2 + client/utils/tests/planning_test.ts | 24 ++-- client/validators/index.ts | 19 ++- server/features/search_events.feature | 16 ++- server/features/search_planning.feature | 14 +- .../content_profiles/profiles/coverage.py | 2 + .../content_profiles/profiles/event.py | 6 + .../content_profiles/profiles/planning.py | 2 + server/planning/events/events_schema.py | 1 + server/planning/planning/planning.py | 1 + server/planning/search/queries/common.py | 9 ++ 33 files changed, 485 insertions(+), 82 deletions(-) create mode 100644 client/components/fields/editor/ProfileFieldDefaultValue.tsx create mode 100644 client/components/fields/editor/base/numberSelect.tsx diff --git a/client/actions/agenda.ts b/client/actions/agenda.ts index 225813f82..c052a8323 100644 --- a/client/actions/agenda.ts +++ b/client/actions/agenda.ts @@ -249,8 +249,10 @@ const createPlanningFromEvent = ( planningDate: Moment = null, agendas: Array = [] ) => ( - (dispatch) => ( - dispatch(planning.api.save({}, { + (dispatch, getState) => { + const defaultPlace = selectors.general.defaultPlaceList(getState()); + const newPlan: Partial = { + ...planningUtils.defaultPlanningValues([], defaultPlace), event_item: event._id, slugline: stringUtils.convertStringFieldForProfileFieldType( 'event', @@ -293,8 +295,14 @@ const createPlanningFromEvent = ( ), agendas: agendas, language: event.language, - })) - ) + }; + + if (event.priority != null) { + newPlan.priority = event.priority; + } + + return dispatch(planning.api.save({}, newPlan)); + } ); /** diff --git a/client/actions/events/ui.ts b/client/actions/events/ui.ts index 3863dddcf..2f2382542 100644 --- a/client/actions/events/ui.ts +++ b/client/actions/events/ui.ts @@ -730,8 +730,11 @@ const receiveEventHistory = (eventHistoryItems) => ({ */ const createEventFromPlanning = (plan: IPlanningItem) => ( (dispatch, getState) => { - const defaultDurationOnChange = selectors.forms.defaultEventDuration(getState()); - const occurStatuses = selectors.vocabs.eventOccurStatuses(getState()); + const state = getState(); + const defaultDurationOnChange = selectors.forms.defaultEventDuration(state); + const occurStatuses = selectors.vocabs.eventOccurStatuses(state); + const defaultCalendar = selectors.events.defaultCalendarValue(state); + const defaultPlace = selectors.general.defaultPlaceList(state); const unplannedStatus = getItemInArrayById(occurStatuses, 'eocstat:eos0', 'qcode') || { label: 'Unplanned event', qcode: 'eocstat:eos0', @@ -739,6 +742,7 @@ const createEventFromPlanning = (plan: IPlanningItem) => ( }; const eventProfile = selectors.forms.eventProfile(getState()); const newEvent: Partial = { + ...eventUtils.defaultEventValues(occurStatuses, defaultCalendar, defaultPlace), dates: { start: moment(plan.planning_date).clone(), end: moment(plan.planning_date) @@ -784,6 +788,10 @@ const createEventFromPlanning = (plan: IPlanningItem) => ( language: plan.language, }; + if (plan.priority != null) { + newEvent.priority = plan.priority; + } + if (get(eventProfile, 'editor.slugline.enabled', false)) { newEvent.slugline = stringUtils.convertStringFieldForProfileFieldType( 'planning', diff --git a/client/actions/tests/agenda_test.ts b/client/actions/tests/agenda_test.ts index 4c10fa37d..1559c9e58 100644 --- a/client/actions/tests/agenda_test.ts +++ b/client/actions/tests/agenda_test.ts @@ -326,6 +326,14 @@ describe('agenda', () => { expect(apiSpy.save.args[0]).toEqual([ {}, { + type: 'planning', + state: 'draft', + item_class: 'plinat:newscoverage', + flags: { + marked_for_not_publication: false, + overide_auto_assign_to_workflow: false, + }, + coverages: [], event_item: events[0]._id, planning_date: events[0].dates.start, slugline: events[0].slugline, diff --git a/client/api/combined.ts b/client/api/combined.ts index daa6d9484..847cfa719 100644 --- a/client/api/combined.ts +++ b/client/api/combined.ts @@ -24,7 +24,8 @@ function convertCombinedParams(params: ISearchParams): Partial calendars: cvsToString(params.calendars), agendas: arrayToString(params.agendas), include_associated_planning: params.include_associated_planning, - source: cvsToString(params.source, 'id') + source: cvsToString(params.source, 'id'), + priority: arrayToString(params.priority), }; } diff --git a/client/api/contentProfiles.ts b/client/api/contentProfiles.ts index fa46bca6e..c0fdb8ebc 100644 --- a/client/api/contentProfiles.ts +++ b/client/api/contentProfiles.ts @@ -1,4 +1,4 @@ -import {IPlanningContentProfile, IPlanningAPI} from '../interfaces'; +import {IPlanningContentProfile, IPlanningAPI, IEventOrPlanningItem, IPlanningCoverageItem} from '../interfaces'; import {planningApi, superdeskApi} from '../superdeskApi'; import {profiles} from '../selectors/forms'; @@ -21,11 +21,45 @@ function getAll() { ) .then((response) => { response._items.forEach(sortProfileGroups); + enablePriorityInSearchProfile(response._items); return response._items; }); } +function enablePriorityInSearchProfile(profiles: Array) { + // Hack to enable/disable priority field in search profiles based on the content profiles + // TODO: Remove this hack when we implement a solution for all searchable fields + const profilesById: {[id: string]: IPlanningContentProfile} = profiles.reduce((profileMap, profile) => { + profileMap[profile._id ?? profile.name] = profile; + + return profileMap; + }, {}); + const searchProfile = profilesById.advanced_search.editor; + const priorityEnabled = { + event: profilesById.event.editor.priority?.enabled === true, + planning: profilesById.planning.editor.priority?.enabled === true, + }; + + const priorityField = { + enabled: true, + index: 5, + group: 'common', + search_enabled: true, + filter_enabled: true, + }; + + if (priorityEnabled.event) { + searchProfile.event.priority = priorityField; + if (priorityEnabled.planning) { + searchProfile.combined.priority = priorityField; + } + } + if (priorityEnabled.planning) { + searchProfile.planning.priority = priorityField; + } +} + function getProfile(contentType: string) { const {getState} = planningApi.redux.store; @@ -134,9 +168,23 @@ function updateProfilesInStore() { }); } +function getDefaultValues(profile: IPlanningContentProfile): DeepPartial { + return Object.keys(profile?.schema ?? {}).reduce( + (defaults, field) => { + if (profile.schema[field]?.default_value != null) { + defaults[field] = profile.schema[field].default_value; + } + + return defaults; + }, + {} + ); +} + export const contentProfiles: IPlanningAPI['contentProfiles'] = { getAll: getAll, get: getProfile, + getDefaultValues: getDefaultValues, patch: patch, showManagePlanningProfileModal: showManagePlanningProfileModal, showManageEventProfileModal: showManageEventProfileModal, diff --git a/client/api/events.ts b/client/api/events.ts index 7271de3b2..6d925f487 100644 --- a/client/api/events.ts +++ b/client/api/events.ts @@ -11,7 +11,7 @@ import {IRestApiResponse} from 'superdesk-api'; import {planningApi, superdeskApi} from '../superdeskApi'; import {EVENTS, TEMP_ID_PREFIX} from '../constants'; -import {convertCommonParams, cvsToString, searchRaw, searchRawGetAll} from './search'; +import {arrayToString, convertCommonParams, cvsToString, searchRaw, searchRawGetAll} from './search'; import {eventUtils, planningUtils} from '../utils'; import {eventProfile, eventSearchProfile} from '../selectors/forms'; import * as actions from '../actions'; @@ -23,6 +23,7 @@ function convertEventParams(params: ISearchParams): Partial { location: params.location?.qcode, calendars: cvsToString(params.calendars), no_calendar_assigned: params.no_calendar_assigned, + priority: arrayToString(params.priority), }; } diff --git a/client/api/planning.ts b/client/api/planning.ts index e87b0f028..7ffd8d2a2 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -30,7 +30,8 @@ function convertPlanningParams(params: ISearchParams): Partial include_scheduled_updates: params.include_scheduled_updates, event_item: arrayToString(params.event_item), g2_content_type: params.g2_content_type?.qcode, - source: cvsToString(params.source, 'id') + source: cvsToString(params.source, 'id'), + priority: arrayToString(params.priority), }; } diff --git a/client/api/search.ts b/client/api/search.ts index 50bcbaa98..6a23a57f4 100644 --- a/client/api/search.ts +++ b/client/api/search.ts @@ -11,7 +11,7 @@ export function cvsToString(items?: Array<{[key: string]: any}>, field: string = ); } -export function arrayToString(items?: Array): string { +export function arrayToString(items?: Array): string { return (items ?? []) .join(','); } diff --git a/client/components/AdvancedSearch/index.tsx b/client/components/AdvancedSearch/index.tsx index 7a5799a5c..27676bb57 100644 --- a/client/components/AdvancedSearch/index.tsx +++ b/client/components/AdvancedSearch/index.tsx @@ -139,6 +139,10 @@ export class AdvancedSearch extends React.PureComponent { location: { disableAddLocation: false, }, + priority: { + multiple: true, + defaultValue: [], + }, }, null, this.props.enabledField diff --git a/client/components/ContentProfiles/FieldTab/FieldEditor.tsx b/client/components/ContentProfiles/FieldTab/FieldEditor.tsx index a5c8ca853..ab202b635 100644 --- a/client/components/ContentProfiles/FieldTab/FieldEditor.tsx +++ b/client/components/ContentProfiles/FieldTab/FieldEditor.tsx @@ -56,6 +56,7 @@ export class FieldEditor extends React.PureComponent { 'schema.vocabularies': {enabled: this.props.item.name === 'custom_vocabularies'}, 'field.all_day.enabled': {enabled: this.props.item.name === 'dates'}, 'field.default_duration_on_change': {enabled: this.props.item.name === 'dates'}, + 'schema.default_value': {enabled: this.props.item.name === 'priority'}, }; const noOptionsAvailable = !( Object.values(fieldProps) @@ -136,6 +137,7 @@ export class FieldEditor extends React.PureComponent { 'schema.vocabularies': {enabled: true, index: 8}, 'field.all_day.enabled': {enabled: true, index: 9}, 'field.default_duration_on_change': {enabled: true, index: 10}, + 'schema.default_value': {enabled: true, index: 11}, }, { item: this.props.item, diff --git a/client/components/Coverages/CoverageEditor/CoverageForm.tsx b/client/components/Coverages/CoverageEditor/CoverageForm.tsx index 31f442d89..2a0950546 100644 --- a/client/components/Coverages/CoverageEditor/CoverageForm.tsx +++ b/client/components/Coverages/CoverageEditor/CoverageForm.tsx @@ -446,6 +446,7 @@ export class CoverageFormComponent extends React.Component { this.props.value.planning?.g2_content_type === 'text' ), }, + priority: {field: 'planning.priority'}, }; const editor = planningApi.editor(this.props.editorType); diff --git a/client/components/fields/editor/ProfileFieldDefaultValue.tsx b/client/components/fields/editor/ProfileFieldDefaultValue.tsx new file mode 100644 index 000000000..4ec5acc2a --- /dev/null +++ b/client/components/fields/editor/ProfileFieldDefaultValue.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +import {IEditorFieldProps, IProfileFieldEntry} from '../../../interfaces'; + +import {renderFieldsForPanel} from '../index'; + +interface IProps extends IEditorFieldProps { + item: IProfileFieldEntry; + onChange(field: string, value: string | number): void; +} + +export function ProfileFieldDefaultValue({item, onChange, ...props}: IProps) { + return renderFieldsForPanel( + 'editor', + {[item.name]: {enabled: true, index: 1}}, + { + item: item, + onChange: onChange, + }, + { + [item.name]: { + ...props, + field: 'schema.default_value', + }, + } + ); +} diff --git a/client/components/fields/editor/base/numberSelect.tsx b/client/components/fields/editor/base/numberSelect.tsx new file mode 100644 index 000000000..8a876378d --- /dev/null +++ b/client/components/fields/editor/base/numberSelect.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import {get} from 'lodash'; + +import {superdeskApi} from '../../../../superdeskApi'; +import {Select, Option, TreeSelect} from 'superdesk-ui-framework/react'; +import {IEditorFieldProps} from '../../../../interfaces'; + +import {Row} from '../../../UI/Form'; + +interface IPropsBase extends IEditorFieldProps { + options: Array; + clearable?: boolean; + readOnly?: boolean; + info?: string; +} + +interface IPropsSingle extends IPropsBase { + multiple: false; + defaultValue?: number; + onChange(field: string, value: number): void; +} + +interface IPropsMultiple extends IPropsBase { + multiple: true; + defaultValue?: Array; + onChange(field: string, value: Array): void; +} + +type IProps = IPropsSingle | IPropsMultiple; + + +export class EditorFieldNumberSelect extends React.PureComponent { + node: React.RefObject; + + constructor(props) { + super(props); + + this.node = React.createRef(); + this.onChangeSingle = this.onChangeSingle.bind(this); + this.onChangeMultiple = this.onChangeMultiple.bind(this); + } + + onChangeSingle(newValue: string) { + if (this.props.multiple === false) { + this.props.onChange(this.props.field, parseInt(newValue, 10)); + } + } + + onChangeMultiple(newValue: Array) { + if (this.props.multiple === true) { + this.props.onChange(this.props.field, newValue); + } + } + + focus() { + if (this.node.current != null) { + this.node.current.getElementsByTagName('select')[0]?.focus(); + } + } + + renderSingle(value: number) { + const {gettext} = superdeskApi.localization; + const error = get(this.props.errors ?? {}, this.props.field); + + return ( + + ); + } + + renderMultiple(values: Array) { + return ( + this.props.options.map((value) => ({ + value: value, + }))} + getLabel={(item) => item.toString(10)} + getId={(item) => item.toString(10)} + value={values} + onChange={this.onChangeMultiple} + allowMultiple={true} + /> + ); + } + + render() { + const value = get(this.props.item, this.props.field, this.props.defaultValue); + + return ( + + {this.props.multiple === false ? + this.renderSingle(value) : + this.renderMultiple(value) + } + + ); + } +} diff --git a/client/components/fields/index.tsx b/client/components/fields/index.tsx index 45928bf5a..6e1d78149 100644 --- a/client/components/fields/index.tsx +++ b/client/components/fields/index.tsx @@ -239,6 +239,7 @@ const PREVIEW_GROUPS: IPreviewGroups = { 'language', 'slugline', 'name', + 'priority', 'definition_short', 'occur_status', 'dates', @@ -268,6 +269,7 @@ const PREVIEW_GROUPS: IPreviewGroups = { 'headline', 'name', 'planning_date', + 'priority', 'description_text', 'internal_note', 'place', @@ -289,6 +291,7 @@ const PREVIEW_GROUPS: IPreviewGroups = { fields: [ 'language', 'slugline', + 'priority', 'ednote', 'keyword', 'internal_note', @@ -302,6 +305,7 @@ const PREVIEW_GROUPS: IPreviewGroups = { 'dates', 'location', 'occur_status', + 'priority', 'definition_short', 'event_contact_info', ], diff --git a/client/components/fields/preview/index.ts b/client/components/fields/preview/index.ts index 36010c652..865eeba66 100644 --- a/client/components/fields/preview/index.ts +++ b/client/components/fields/preview/index.ts @@ -239,6 +239,10 @@ const fieldOptions: {[key: string]: IPreviewHocOptions} = { }), getValue: getPreviewString, }, + priority: { + props: () => ({label: superdeskApi.localization.gettext('Priority:')}), + getValue: getPreviewString, + }, }; let FIELD_TO_PREVIEW_COMPONENT: {[key: string]: any} = {}; diff --git a/client/components/fields/resources/common.ts b/client/components/fields/resources/common.ts index 45e80426d..70071499e 100644 --- a/client/components/fields/resources/common.ts +++ b/client/components/fields/resources/common.ts @@ -2,7 +2,10 @@ import {registerEditorField} from './registerEditorFields'; import {superdeskApi} from '../../../superdeskApi'; +import {getPriorityQcodes} from '../../../selectors/vocabs'; + import {EditorFieldDynamicTextType} from '../editor/base/dynamicTextTypeField'; +import {EditorFieldNumberSelect} from '../editor/base/numberSelect'; import {EditorFieldEventAttachments} from '../editor/EventAttachments'; registerEditorField( @@ -59,3 +62,17 @@ registerEditorField( null, false ); + +registerEditorField( + 'priority', + EditorFieldNumberSelect, + (props) => ({ + label: superdeskApi.localization.gettext('Priority'), + field: 'priority', + multiple: false, + }), + (state) => ({ + options: getPriorityQcodes(state), + }), + false +); diff --git a/client/components/fields/resources/profiles.ts b/client/components/fields/resources/profiles.ts index 91621daf6..0b3286d37 100644 --- a/client/components/fields/resources/profiles.ts +++ b/client/components/fields/resources/profiles.ts @@ -7,6 +7,7 @@ import {EditorFieldToggle} from '../editor/base/toggle'; import {EditorFieldSelect} from '../editor/base/select'; import {EditorFieldCheckbox} from '../editor/base/checkbox'; import {SelectCustomVocabulariesList} from '../editor/SelectCustomVocabulariesList'; +import {ProfileFieldDefaultValue} from '../editor/ProfileFieldDefaultValue'; registerEditorField( 'schema.required', @@ -123,3 +124,14 @@ registerEditorField( null, true ); + +registerEditorField( + 'schema.default_value', + ProfileFieldDefaultValue, + (props) => ({ + label: superdeskApi.localization.gettext('Default Value'), + field: 'schema.default_value', + }), + null, + true +); diff --git a/client/interfaces.ts b/client/interfaces.ts index 9fb3dc050..531d6c1c8 100644 --- a/client/interfaces.ts +++ b/client/interfaces.ts @@ -8,6 +8,7 @@ import { IContentProfile, IArticle, RICH_FORMATTING_OPTION, + IVocabularyItem, } from 'superdesk-api'; import {Dispatch, Store} from 'redux'; import * as moment from 'moment'; @@ -396,6 +397,7 @@ export interface IEventItem extends IBaseRestApiResponse { event_created?: string | Date; event_lastmodified?: string | Date; name?: string; + priority?: number; definition_short?: string; definition_long?: string; internal_note?: string; @@ -556,6 +558,7 @@ export interface ICoveragePlanningDetails { slugline: string; internal_note: string; workflow_status_reason: string; + priority?: number; } export interface ICoverageScheduledUpdate { @@ -845,6 +848,7 @@ export interface ICommonAdvancedSearchParams { id?: string; name?: string; }>; + priority?: Array; } export interface ICommonSearchParams { @@ -920,6 +924,7 @@ interface IBaseProfileSchemaType { validate_on_post?: boolean; minlength?: number; maxlength?: number; + default_value?: string | number; } export interface IProfileSchemaTypeList extends IBaseProfileSchemaType<'list'> { @@ -1288,6 +1293,8 @@ export interface ISearchParams { name?: string; }>; + priority?: Array; + // Event Params reference?: string; location?: IEventLocation; @@ -1338,6 +1345,7 @@ export interface ISearchAPIParams { recurrence_id?: string; filter_id?: ISearchFilter['_id']; source?: string; + priority?: string; // Event Params reference?: string; @@ -1621,6 +1629,7 @@ export interface IPlanningAppState { featuredPlanning: IFeaturedPlanningState; forms: IFormState; session: ISession; + vocabularies: {[id: string]: Array}; } export interface INominatimLocalityFields { @@ -2090,6 +2099,7 @@ export interface IPlanningAPI { contentProfiles: { getAll(): Promise>; get(contentType: string): IPlanningContentProfile; + getDefaultValues(profile: IPlanningContentProfile): DeepPartial; patch(original: IPlanningContentProfile, updates: IPlanningContentProfile): Promise; showManagePlanningProfileModal(): Promise; showManageEventProfileModal(): Promise; diff --git a/client/selectors/vocabs.ts b/client/selectors/vocabs.ts index 770ab30e7..26627b82e 100644 --- a/client/selectors/vocabs.ts +++ b/client/selectors/vocabs.ts @@ -1,10 +1,30 @@ import {get} from 'lodash'; +import {createSelector} from 'reselect'; +import {IVocabularyItem} from 'superdesk-api'; +import {IPlanningAppState} from '../interfaces'; -export const coverageProviders = (state) => get(state, 'vocabularies.coverage_providers', []); -export const locators = (state) => get(state, 'vocabularies.locators', []); -export const categories = (state) => get(state, 'vocabularies.categories', []); -export const subjects = (state) => get(state, 'subjects', []); +const EMPTY_ARRAY = []; + +export const coverageProviders = (state) => get(state, 'vocabularies.coverage_providers', EMPTY_ARRAY); +export const locators = (state) => get(state, 'vocabularies.locators', EMPTY_ARRAY); +export const categories = (state) => get(state, 'vocabularies.categories', EMPTY_ARRAY); +export const subjects = (state) => get(state, 'subjects', EMPTY_ARRAY); export const urgencyLabel = (state) => get(state, 'urgency.label', 'Urgency'); -export const eventOccurStatuses = (state) => get(state, 'vocabularies.eventoccurstatus', []); -export const getContactTypes = (state) => get(state, 'vocabularies.contact_type', []); -export const getLanguages = (state) => get(state, 'vocabularies.languages', []); +export const eventOccurStatuses = (state) => get(state, 'vocabularies.eventoccurstatus', EMPTY_ARRAY); +export const getContactTypes = (state) => get(state, 'vocabularies.contact_type', EMPTY_ARRAY); +export const getLanguages = (state) => get(state, 'vocabularies.languages', EMPTY_ARRAY); + +export const getPriorities = (state: IPlanningAppState) => state.vocabularies.priority ?? EMPTY_ARRAY; + +export const getPriorityQcodes = createSelector< + IPlanningAppState, + Array, + Array +>( + getPriorities, + (priorities) => ( + priorities + .map((item) => parseInt(item.qcode, 10)) + .sort() + ) +); diff --git a/client/utils/contentProfiles.ts b/client/utils/contentProfiles.ts index 3d2bc990c..fe503841e 100644 --- a/client/utils/contentProfiles.ts +++ b/client/utils/contentProfiles.ts @@ -231,6 +231,8 @@ export function getFieldNameTranslated(field: string): string { return gettext('Registration Details'); case 'invitation_details': return gettext('Invitation Details'); + case 'priority': + return gettext('Priority'); } return field; diff --git a/client/utils/events.ts b/client/utils/events.ts index 590d2f348..ed1249bbd 100644 --- a/client/utils/events.ts +++ b/client/utils/events.ts @@ -5,6 +5,7 @@ import {IMenuItem} from 'superdesk-ui-framework/react/components/Menu'; import {appConfig} from 'appConfig'; import {IEventItem, ISession, ILockedItems} from '../interfaces'; +import {planningApi} from '../superdeskApi'; import { PRIVILEGES, @@ -961,30 +962,36 @@ export const shouldLockEventForEdit = (item, privileges) => ( ); const defaultEventValues = (occurStatuses, defaultCalendars, defaultPlaceList) => { + const {contentProfiles} = planningApi; + const defaultValues = contentProfiles.getDefaultValues(contentProfiles.get('event')); const occurStatus = getItemInArrayById(occurStatuses, 'eocstat:eos5', 'qcode') || { label: 'Confirmed', qcode: 'eocstat:eos5', name: 'Planned, occurs certainly', }; - let newEvent = { - type: ITEM_TYPE.EVENT, - occur_status: occurStatus, - dates: { - start: null, - end: null, - tz: timeUtils.localTimeZone(), + let newEvent = Object.assign( + { + type: ITEM_TYPE.EVENT, + occur_status: occurStatus, + dates: { + start: null, + end: null, + tz: timeUtils.localTimeZone(), + }, + calendars: defaultCalendars, + state: 'draft', + _startTime: null, + _endTime: null, + language: getUsersDefaultLanguage(true), }, - calendars: defaultCalendars, - state: 'draft', - _startTime: null, - _endTime: null, - language: getUsersDefaultLanguage(true), - }; + defaultValues + ); if (defaultPlaceList) { newEvent.place = defaultPlaceList; } + return newEvent; }; diff --git a/client/utils/planning.ts b/client/utils/planning.ts index 6b938e47b..865a0c337 100644 --- a/client/utils/planning.ts +++ b/client/utils/planning.ts @@ -642,8 +642,9 @@ const createNewPlanningFromNewsItem = ( user, contentTypes ); - + const {contentProfiles} = planningApi; let newPlanning: Partial = { + ...contentProfiles.getDefaultValues(contentProfiles.get('planning')), type: ITEM_TYPE.PLANNING, slugline: addNewsItemToPlanning.slugline, headline: get(addNewsItemToPlanning, 'headline'), @@ -657,6 +658,10 @@ const createNewPlanningFromNewsItem = ( language: addNewsItemToPlanning.language, }; + if (addNewsItemToPlanning.priority != null) { + newPlanning.priority = addNewsItemToPlanning.priority; + } + if (get(addNewsItemToPlanning, 'flags.marked_for_not_publication')) { newPlanning.flags = {marked_for_not_publication: true}; } @@ -685,6 +690,7 @@ const createCoverageFromNewsItem = ( ); newCoverage.planning = { + ...newCoverage.planning, g2_content_type: get(contentType, 'qcode', PLANNING.G2_CONTENT_TYPE.TEXT), slugline: get(addNewsItemToPlanning, 'slugline', ''), ednote: get(addNewsItemToPlanning, 'ednote', ''), @@ -692,6 +698,10 @@ const createCoverageFromNewsItem = ( .startOf('hour'), }; + if (addNewsItemToPlanning.priority != null) { + newCoverage.planning.priority = addNewsItemToPlanning.priority; + } + if (addNewsItemToPlanning.language != null) { newCoverage.planning.language = addNewsItemToPlanning.language; } @@ -1036,15 +1046,20 @@ const shouldLockPlanningForEdit = (item, privileges) => ( ); const defaultPlanningValues = (currentAgenda, defaultPlaceList) => { - const newPlanning = { - type: ITEM_TYPE.PLANNING, - planning_date: moment(), - agendas: get(currentAgenda, 'is_enabled') ? - [getItemId(currentAgenda)] : [], - state: 'draft', - item_class: 'plinat:newscoverage', - language: getUsersDefaultLanguage(true), - }; + const {contentProfiles} = planningApi; + const defaultValues = contentProfiles.getDefaultValues(contentProfiles.get('planning')); + const newPlanning = Object.assign( + { + type: ITEM_TYPE.PLANNING, + planning_date: moment(), + agendas: get(currentAgenda, 'is_enabled') ? + [getItemId(currentAgenda)] : [], + state: 'draft', + item_class: 'plinat:newscoverage', + language: getUsersDefaultLanguage(true), + }, + defaultValues + ); if (defaultPlaceList) { newPlanning.place = defaultPlaceList; @@ -1063,38 +1078,48 @@ const defaultCoverageValues = ( defaultDesk?: IDesk, preferredCoverageDesks?: {[key: string]: IDesk['_id']}, ): DeepPartial => { - let newCoverage: DeepPartial = { + const {contentProfiles} = planningApi; + const coverageProfile = contentProfiles.get('coverage'); + const defaultValues = (contentProfiles.getDefaultValues(coverageProfile)) as DeepPartial; + const newCoverage: DeepPartial = { coverage_id: generateTempId(), - planning: { - slugline: stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'coverage', - 'slugline', - 'slugline', - planningItem?.slugline - ), - internal_note: stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'coverage', - 'internal_note', - 'internal_note', - planningItem?.internal_note - ), - ednote: stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'coverage', - 'ednote', - 'ednote', - planningItem?.ednote - ), - scheduled: planningItem?.planning_date || moment(), - g2_content_type: g2contentType, - language: planningItem?.language ?? eventItem?.language, - }, + planning: Object.assign( + { + slugline: stringUtils.convertStringFieldForProfileFieldType( + 'planning', + 'coverage', + 'slugline', + 'slugline', + planningItem?.slugline + ), + internal_note: stringUtils.convertStringFieldForProfileFieldType( + 'planning', + 'coverage', + 'internal_note', + 'internal_note', + planningItem?.internal_note + ), + ednote: stringUtils.convertStringFieldForProfileFieldType( + 'planning', + 'coverage', + 'ednote', + 'ednote', + planningItem?.ednote + ), + scheduled: planningItem?.planning_date || moment(), + g2_content_type: g2contentType, + language: planningItem?.language ?? eventItem?.language, + }, + defaultValues + ), news_coverage_status: getDefaultCoverageStatus(newsCoverageStatus), workflow_status: 'draft', }; + if (planningItem?.priority && newCoverage.planning.priority == null) { + newCoverage.planning.priority = planningItem.priority; + } + if (planningItem?._time_to_be_confirmed) { newCoverage._time_to_be_confirmed = planningItem._time_to_be_confirmed; } diff --git a/client/utils/search.ts b/client/utils/search.ts index 6f4fc18c7..dd75c0423 100644 --- a/client/utils/search.ts +++ b/client/utils/search.ts @@ -43,6 +43,7 @@ function commonParamsToSearchParams(params: ICommonSearchParams { ednote: 'edit my note', scheduled: moment().add(1, 'hour') .startOf('hour'), + internal_note: undefined, + language: undefined, }, news_coverage_status: {qcode: 'ncostat:int'}, workflow_status: 'active', @@ -321,6 +323,8 @@ describe('PlanningUtils', () => { ednote: 'edit my note', scheduled: moment(newsItem.firstpublished).add(1, 'hour') .startOf('hour'), + internal_note: undefined, + language: undefined, }, news_coverage_status: {qcode: 'ncostat:int'}, workflow_status: 'active', @@ -357,6 +361,8 @@ describe('PlanningUtils', () => { ednote: 'edit my note', scheduled: moment(newsItem.firstpublished).add(1, 'hour') .startOf('hour'), + internal_note: undefined, + language: undefined, }, news_coverage_status: {qcode: 'ncostat:int'}, workflow_status: 'active', @@ -394,6 +400,8 @@ describe('PlanningUtils', () => { ednote: 'edit my note', scheduled: moment(newsItem.schedule_settings.utc_publish_schedule).add(1, 'hour') .startOf('hour'), + internal_note: undefined, + language: undefined, }, news_coverage_status: {qcode: 'ncostat:int'}, workflow_status: 'active', @@ -455,9 +463,9 @@ describe('PlanningUtils', () => { urgency: 3, description_text: 'some abstractions', place: [{name: 'Australia'}], - coverages: [{ + coverages: [jasmine.objectContaining({ coverage_id: jasmine.any(String), - planning: { + planning: jasmine.objectContaining({ g2_content_type: 'text', slugline: 'slugger', ednote: 'Edit my note!', @@ -465,7 +473,7 @@ describe('PlanningUtils', () => { .startOf('hour'), _scheduledTime: moment().add(1, 'hour') .startOf('hour'), - }, + }), news_coverage_status: {qcode: 'ncostat:int'}, workflow_status: 'active', assigned_to: { @@ -473,7 +481,7 @@ describe('PlanningUtils', () => { user: 'ident1', priority: ASSIGNMENTS.DEFAULT_PRIORITY, }, - }], + })], })); }); @@ -510,9 +518,9 @@ describe('PlanningUtils', () => { urgency: 3, description_text: 'some abstractions', flags: {marked_for_not_publication: true}, - coverages: [{ + coverages: [jasmine.objectContaining({ coverage_id: jasmine.any(String), - planning: { + planning: jasmine.objectContaining({ g2_content_type: 'text', slugline: 'slugger', ednote: 'Edit my note!', @@ -520,7 +528,7 @@ describe('PlanningUtils', () => { .startOf('hour'), _scheduledTime: moment().add(1, 'hour') .startOf('hour'), - }, + }), news_coverage_status: {qcode: 'ncostat:int'}, workflow_status: 'active', assigned_to: { @@ -528,7 +536,7 @@ describe('PlanningUtils', () => { user: 'ident1', priority: ASSIGNMENTS.DEFAULT_PRIORITY, }, - }], + })], })); }); }); diff --git a/client/validators/index.ts b/client/validators/index.ts index 2cda59e1a..2c74a8439 100644 --- a/client/validators/index.ts +++ b/client/validators/index.ts @@ -77,9 +77,22 @@ export const validateItem = ({ switch (true) { case schema.required: - if (isEmpty(diff[key]) && isEmpty(getSubject(diff, key)) - && fieldsToValidate == null - || (Array.isArray(fieldsToValidate) && fieldsToValidate.includes(key))) { + if ( + (( + schema.type !== 'integer' && + isEmpty(diff[key]) + ) || + ( + schema.type === 'integer' && + diff[key] == null + )) && + isEmpty(getSubject(diff, key)) && + fieldsToValidate == null || + ( + Array.isArray(fieldsToValidate) && + fieldsToValidate.includes(key) + ) + ) { errors[key] = gettext('This field is required'); messages.push(gettext('{{ key }} is a required field', {key: key.toUpperCase()})); } else if (errors[key]) { diff --git a/server/features/search_events.feature b/server/features/search_events.feature index e2aad22fd..71d98519d 100644 --- a/server/features/search_events.feature +++ b/server/features/search_events.feature @@ -68,7 +68,8 @@ Feature: Event Search "world_region": "Asia", "country": "" } - ] + ], + "priority": 2 }, { "guid": "event_786", @@ -87,7 +88,8 @@ Feature: Event Search "end": "2016-01-03T00:00:00+0000" }, "subject": [{"qcode": "test qcode 2", "name": "test name"}], - "lock_session": "ident1" + "lock_session": "ident1", + "priority": 7 } ] """ @@ -210,6 +212,16 @@ Feature: Event Search {"_id": "event_456"} ]} """ + When we get "/events_planning_search?repo=events&only_future=false&priority=2,7" + Then we get list with 2 items + """ + {"_items": [ + {"_id": "event_456"}, + {"_id": "event_786"} + ]} + """ + When we get "/events_planning_search?repo=events&only_future=false&priority=1" + Then we get list with 0 items @auth Scenario: Search by event specific parameters diff --git a/server/features/search_planning.feature b/server/features/search_planning.feature index 7ff5e5946..b232d3a2b 100644 --- a/server/features/search_planning.feature +++ b/server/features/search_planning.feature @@ -111,7 +111,8 @@ Feature: Planning Search } } ], - "urgency": 2 + "urgency": 2, + "priority": 2 }, { "guid": "planning_3", @@ -136,6 +137,7 @@ Feature: Planning Search } ], "urgency": 2, + "priority": 7, "featured": false }, { @@ -301,6 +303,16 @@ Feature: Planning Search {"_id": "planning_6"} ]} """ + When we get "/events_planning_search?repo=planning&only_future=false&priority=2,7" + Then we get list with 2 items + """ + {"_items": [ + {"_id": "planning_2"}, + {"_id": "planning_3"} + ]} + """ + When we get "/events_planning_search?repo=planning&only_future=false&priority=1" + Then we get list with 0 items @auth Scenario: Search by planning specific parameters diff --git a/server/planning/content_profiles/profiles/coverage.py b/server/planning/content_profiles/profiles/coverage.py index fd21d0e29..360c39754 100644 --- a/server/planning/content_profiles/profiles/coverage.py +++ b/server/planning/content_profiles/profiles/coverage.py @@ -29,6 +29,7 @@ class CoverageSchema(BaseSchema): xmp_file = schema.DictField() no_content_linking = BooleanField() scheduled_updates = schema.ListField() + priority = schema.IntegerField() DEFAULT_COVERAGE_PROFILE = { @@ -73,6 +74,7 @@ class CoverageSchema(BaseSchema): "headline": {"enabled": False}, "keyword": {"enabled": False}, "files": {"enabled": False}, + "priority": {"enabled": False}, # Requires `PLANNING_LINK_UPDATES_TO_COVERAGES` enabled in config "no_content_linking": {"enabled": False}, }, diff --git a/server/planning/content_profiles/profiles/event.py b/server/planning/content_profiles/profiles/event.py index 26e6ebccf..10ff609b9 100644 --- a/server/planning/content_profiles/profiles/event.py +++ b/server/planning/content_profiles/profiles/event.py @@ -48,6 +48,7 @@ class EventSchema(BaseSchema): related_plannings.schema["read_only"] = False registration_details = TextField(field_type="multi_line") invitation_details = TextField(field_type="multi_line") + priority = schema.IntegerField() DEFAULT_EVENT_PROFILE = { @@ -107,6 +108,11 @@ class EventSchema(BaseSchema): "group": "description", "index": 8, }, + "priority": { + "enabled": False, + "group": "description", + "index": 9, + }, # Location Group "location": { "enabled": True, diff --git a/server/planning/content_profiles/profiles/planning.py b/server/planning/content_profiles/profiles/planning.py index bf5e29e72..909034c2f 100644 --- a/server/planning/content_profiles/profiles/planning.py +++ b/server/planning/content_profiles/profiles/planning.py @@ -34,6 +34,7 @@ class PlanningSchema(BaseSchema): slugline = schema.StringField(required=True) subject = subjectField urgency = schema.IntegerField() + priority = schema.IntegerField() custom_vocabularies = schema.ListField() associated_event = schema.NoneField() coverages = schema.ListField() @@ -140,6 +141,7 @@ class PlanningSchema(BaseSchema): "group": "coverages", "index": 1, }, + "priority": {"enabled": False, "group": "details", "index": 8}, }, "schema": dict(PlanningSchema), # type: ignore "groups": { diff --git a/server/planning/events/events_schema.py b/server/planning/events/events_schema.py index c781602b2..657d1d0e6 100644 --- a/server/planning/events/events_schema.py +++ b/server/planning/events/events_schema.py @@ -88,6 +88,7 @@ }, }, "links": {"type": "list", "nullable": True}, + "priority": metadata_schema["priority"], # NewsML-G2 Event properties See IPTC-G2-Implementation_Guide 15.4.3 "dates": { "type": "dict", diff --git a/server/planning/planning/planning.py b/server/planning/planning/planning.py index c062fcc50..cbf8f15c4 100644 --- a/server/planning/planning/planning.py +++ b/server/planning/planning/planning.py @@ -1425,6 +1425,7 @@ def duplicate_xmp_file(self, coverage): "subject": metadata_schema["subject"], "internal_note": {"type": "string"}, "workflow_status_reason": {"type": "string", "nullable": True}, + "priority": metadata_schema["priority"], }, # end planning dict schema }, # end planning "news_coverage_status": { diff --git a/server/planning/search/queries/common.py b/server/planning/search/queries/common.py index 1bf908276..078329f4d 100644 --- a/server/planning/search/queries/common.py +++ b/server/planning/search/queries/common.py @@ -394,6 +394,13 @@ def search_source(params: Dict[str, Any], query: elastic.ElasticQuery): query.must.append(elastic.terms(field="ingest_provider", values=sources)) +def search_priority(params: Dict[str, Any], query: elastic.ElasticQuery): + priorities = [str(qcode) for qcode in str_to_array(params.get("priority"))] + + if len(priorities): + query.must.append(elastic.terms(field="priority", values=priorities)) + + COMMON_SEARCH_FILTERS: List[Callable[[Dict[str, Any], elastic.ElasticQuery], None]] = [ search_item_ids, search_name, @@ -409,6 +416,7 @@ def search_source(params: Dict[str, Any], query: elastic.ElasticQuery): restrict_items_to_user_only, search_original_creator, search_source, + search_priority, ] @@ -443,4 +451,5 @@ def search_source(params: Dict[str, Any], query: elastic.ElasticQuery): "sort_field", "original_creator", "source", + "priority", ] From 8e67be38df9488ad78858602c1ace9643592da48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Wed, 12 Apr 2023 11:01:20 +0200 Subject: [PATCH 006/261] change limit when calling onclusive api (#1783) * change limit when calling onclusive api use 1000 instead of 100 to do less pagination, also fetch some extra items to avoid possible gaps. SDCP-684 --- server/planning/feeding_services/onclusive_api_service.py | 4 ++-- .../feeding_services/onclusive_api_service_tests.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/server/planning/feeding_services/onclusive_api_service.py b/server/planning/feeding_services/onclusive_api_service.py index d7a444151..0cb9a2938 100644 --- a/server/planning/feeding_services/onclusive_api_service.py +++ b/server/planning/feeding_services/onclusive_api_service.py @@ -74,7 +74,7 @@ def _update(self, provider, update): :return: a list of events which can be saved. """ URL = provider["config"]["url"] - LIMIT = 100 + LIMIT = 1000 MAX_OFFSET = int(app.config.get("ONCLUSIVE_MAX_OFFSET", 100000)) self.session = requests.Session() parser = self.get_feed_parser(provider) @@ -113,7 +113,7 @@ def _update(self, provider, update): params = dict( startDate=start.strftime("%Y%m%d"), endDate=end.strftime("%Y%m%d"), - limit=LIMIT, + limit=LIMIT + 100, # add some overlap to hopefully avoid missing events ) logger.info("ingest from onclusive %s with params %s", url, params) try: diff --git a/server/planning/feeding_services/onclusive_api_service_tests.py b/server/planning/feeding_services/onclusive_api_service_tests.py index e3a032fd0..eb52750e2 100644 --- a/server/planning/feeding_services/onclusive_api_service_tests.py +++ b/server/planning/feeding_services/onclusive_api_service_tests.py @@ -41,8 +41,11 @@ def test_update(self): "refreshToken": "refresh", }, ) - m.get("https://api.abc.com/api/v2/events/between?offset=0", json=[{}]) # first returns an item - m.get("https://api.abc.com/api/v2/events/between?offset=100", json=[]) # second will make it stop + m.get( + "https://api.abc.com/api/v2/events/between?offset=0", + json=[{"versioncreated": event["versioncreated"].isoformat()}], + ) # first returns an item + m.get("https://api.abc.com/api/v2/events/between?offset=1000", json=[]) # second will make it stop list(service._update(provider, updates)) self.assertIn("tokens", updates) self.assertEqual("refresh", updates["tokens"]["refreshToken"]) From 9f0ab26c3e0bf7517bb44a42000335a4d3109302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Fri, 14 Apr 2023 09:56:03 +0200 Subject: [PATCH 007/261] fix date filtering for all day events (#1782) * fix date filtering for all day events when making a query also do a query for all_day events using only date part of the given date, but it must be first converted to local time using client timezone so it will the date user sees in the UI. also tweak the way the all_day events are assigned to dates in the UI based on newsroom code. SDCP-682 --- client/api/search.ts | 2 + client/components/Events/EventDateTime.tsx | 5 +- client/interfaces.ts | 2 + client/utils/events.ts | 62 +++-- client/utils/index.ts | 12 +- client/utils/search.ts | 3 +- .../events/event_action_create_planning.cy.ts | 15 +- e2e/cypress/e2e/search/search_combined.cy.ts | 4 +- e2e/cypress/e2e/search/search_events.cy.ts | 4 +- e2e/cypress/e2e/search/search_filters.cy.ts | 12 +- e2e/cypress/e2e/search/search_planning.cy.ts | 4 +- e2e/cypress/fixtures/events.ts | 32 +-- e2e/cypress/fixtures/planning.ts | 14 +- e2e/cypress/support/utils/time.ts | 14 +- e2e/package.json | 6 +- e2e/server/gunicorn_config.py | 3 +- package-lock.json | 262 ++++++++++++------ package.json | 10 +- .../planning/search/eventsplanning_filters.py | 1 + server/planning/search/queries/common.py | 17 +- server/planning/search/queries/elastic.py | 40 ++- server/planning/search/queries/events.py | 41 ++- server/planning/search/queries/planning.py | 10 +- 23 files changed, 363 insertions(+), 212 deletions(-) diff --git a/client/api/search.ts b/client/api/search.ts index 6a23a57f4..66cd2b1e3 100644 --- a/client/api/search.ts +++ b/client/api/search.ts @@ -2,6 +2,7 @@ import {ISearchAPIParams, ISearchParams} from '../interfaces'; import {superdeskApi} from '../superdeskApi'; import {IRestApiResponse} from 'superdesk-api'; import {getDateTimeElasticFormat, getTimeZoneOffset} from '../utils'; +import {default as timeUtils} from '../utils/time'; export function cvsToString(items?: Array<{[key: string]: any}>, field: string = 'qcode'): string { @@ -48,6 +49,7 @@ export function convertCommonParams(params: ISearchParams): Partial { render() { const {gettext} = superdeskApi.localization; const {item, ignoreAllDay, displayLocalTimezone} = this.props; - const start = moment(item.dates.start); - const end = moment(item.dates.end); + const start = eventUtils.getStartDate(item); + const end = eventUtils.getEndDate(item); const isAllDay = eventUtils.isEventAllDay(start, end); const multiDay = !eventUtils.isEventSameDay(start, end); const isRemoteTimeZone = timeUtils.isEventInDifferentTimeZone(item); diff --git a/client/interfaces.ts b/client/interfaces.ts index 531d6c1c8..e082a8455 100644 --- a/client/interfaces.ts +++ b/client/interfaces.ts @@ -1264,6 +1264,7 @@ export interface ISearchParams { item_ids?: Array; name?: string; tz_offset?: string; + time_zone?: string; full_text?: string; anpa_category?: Array; subject?: Array; @@ -1326,6 +1327,7 @@ export interface ISearchAPIParams { item_ids?: string; name?: string; tz_offset?: string; + time_zone?: string; full_text?: string; anpa_category?: string; subject?: string; diff --git a/client/utils/events.ts b/client/utils/events.ts index ed1249bbd..09d711c4a 100644 --- a/client/utils/events.ts +++ b/client/utils/events.ts @@ -741,15 +741,30 @@ const getFlattenedEventsByDate = (events, startDate, endDate) => { return flatten(sortBy(eventsList, [(e) => (e.date)]).map((e) => e.events.map((k) => [e.date, k._id]))); }; + +const getStartDate = (event: IEventItem) => ( + event.dates?.all_day ? moment.utc(event.dates.start) : moment(event.dates?.start) +); + +const getEndDate = (event: IEventItem) => ( + event.dates?.all_day ? moment.utc(event.dates.end) : moment(event.dates?.end) +); + /* * Groups the events by date */ -const getEventsByDate = (events, startDate, endDate) => { +const getEventsByDate = (events: Array, startDate, endDate) => { if (!get(events, 'length', 0)) return []; // check if search exists // order by date - let sortedEvents = events.sort((a, b) => a.dates.start - b.dates.start); - let maxStartDate = sortedEvents[sortedEvents.length - 1].dates.start; + let sortedEvents = events.sort((a, b) => { + const startA = getStartDate(a); + const startB = getStartDate(b); + + return startA.diff(startB); + }); + + let maxStartDate = getStartDate(sortedEvents[sortedEvents.length - 1]); if (startDate.isAfter(maxStartDate, 'day')) { maxStartDate = startDate; @@ -757,21 +772,22 @@ const getEventsByDate = (events, startDate, endDate) => { const days = {}; - function addEventToDate(event, date) { - let eventDate = date || event.dates.start; - let eventStart = event.dates.start; - let eventEnd = event.dates.end; + function addEventToDate(event, date?: moment.Moment) { + let eventDate = date || getStartDate(event); + let eventStart = getStartDate(event); + let eventEnd = getEndDate(event); - if (!event.dates.start.isSame(event.dates.end, 'day')) { + if (!eventStart.isSame(eventEnd, 'day') && !event.dates.all_day) { eventStart = eventDate; - eventEnd = event.dates.end.isSame(eventDate, 'day') ? - event.dates.end : moment(eventDate.format('YYYY-MM-DD'), 'YYYY-MM-DD').add(86399, 'seconds'); + eventEnd = eventEnd.isSame(eventDate, 'day') ? + eventEnd : + moment(eventDate.format('YYYY-MM-DD'), 'YYYY-MM-DD').add(86399, 'seconds'); } - if (!(isDateInRange(startDate, eventStart, eventEnd) || - isDateInRange(endDate, eventStart, eventEnd))) { - if (!isDateInRange(eventStart, startDate, endDate) && - !isDateInRange(eventEnd, startDate, endDate)) { + if (!(isDateInRange(startDate, eventStart, eventEnd, event.dates.all_day) || + isDateInRange(endDate, eventStart, eventEnd, event.dates.all_day))) { + if (!isDateInRange(eventStart, startDate, endDate, event.dates.all_day) && + !isDateInRange(eventEnd, startDate, endDate, event.dates.all_day)) { return; } } @@ -784,23 +800,23 @@ const getEventsByDate = (events, startDate, endDate) => { let evt = cloneDeep(event); - evt._sortDate = eventDate; - + evt._sortDate = eventStart; days[eventDateFormatted].push(evt); } sortedEvents.forEach((event) => { // compute the number of days of the event - let ending = event.actioned_date ? event.actioned_date : event.dates.end; + const ending = event.actioned_date ? event.actioned_date : getEndDate(event); + const startDate = getStartDate(event); - if (!event.dates.start.isSame(ending, 'day')) { - let deltaDays = Math.max(Math.ceil(ending.diff(event.dates.start, 'days', true)), 1); + if (!startDate.isSame(ending, 'day')) { + let deltaDays = Math.max(Math.ceil(ending.diff(startDate, 'days', true)), 1); // if the event happens during more that one day, add it to every day // add the event to the other days for (let i = 1; i <= deltaDays; i++) { - // clone the date - const newDate = moment(event.dates.start.format('YYYY-MM-DD'), 'YYYY-MM-DD', true); + // clone the date + const newDate = moment(startDate.format('YYYY-MM-DD'), 'YYYY-MM-DD', true); newDate.add(i, 'days'); @@ -812,7 +828,7 @@ const getEventsByDate = (events, startDate, endDate) => { // add event to its initial starting date // add an event only if it's not actioned or actioned after this event's start date - if (!event.actioned_date || event.actioned_date.isSameOrAfter(event.dates.start, 'date')) { + if (!event.actioned_date || event.actioned_date.isSameOrAfter(startDate, 'date')) { addEventToDate(event); } }); @@ -1203,6 +1219,8 @@ const self = { getFlattenedEventsByDate, isEventCompleted, fillEventTime, + getStartDate, + getEndDate, }; export default self; diff --git a/client/utils/index.ts b/client/utils/index.ts index 6750410de..80e2acbe0 100644 --- a/client/utils/index.ts +++ b/client/utils/index.ts @@ -672,11 +672,20 @@ export const onEventCapture = (event) => { } }; -export const isDateInRange = (inputDate, startDate, endDate) => { +export const isDateInRange = (inputDate, startDate, endDate, allDay = false) => { if (!inputDate) { return false; } + if (allDay) { + // if passed as string so inBetween will convert those to local dates + // from utc dates which are used for all day events + const startDay = startDate.format('YYYY-MM-DD'); + const endDay = (endDate || inputDate).format('YYYY-MM-DD'); + + return moment(inputDate).isBetween(startDay, endDay, 'day', '[]'); + } + if (startDate && moment(inputDate).isBefore(startDate, 'millisecond') || endDate && moment(inputDate).isSameOrAfter(endDate, 'millisecond')) { return false; @@ -945,6 +954,7 @@ export const sortBasedOnTBC = (days) => { } pushEventsForTheDay(days); + return sortBy(sortable, [(e) => (e.date)]); }; diff --git a/client/utils/search.ts b/client/utils/search.ts index dd75c0423..9032b8799 100644 --- a/client/utils/search.ts +++ b/client/utils/search.ts @@ -13,7 +13,7 @@ import { SORT_ORDER, } from '../interfaces'; import {MAIN} from '../constants'; -import {getTimeZoneOffset} from './index'; +import {getTimeZoneOffset, timeUtils} from './index'; function commonParamsToSearchParams(params: ICommonSearchParams): ISearchParams { return { @@ -29,6 +29,7 @@ function commonParamsToSearchParams(params: ICommonSearchParams { @@ -16,10 +16,8 @@ describe('Planning.Events: create planning action', () => { const expectedValues = { slugline: 'Original', - 'planning_date.date': '12/12/2045', - 'planning_date.time': moment('2045-12-11' + TIME_STRINGS[0]) - .tz('Australia/Sydney') - .format('HH:00'), + 'planning_date.date': '12/12/2025', + 'planning_date.time': '01:00', description_text: 'Desc.', ednote: 'Ed. Note', anpa_category: ['Finance'], @@ -28,6 +26,7 @@ describe('Planning.Events: create planning action', () => { beforeEach(() => { setup({fixture_profile: 'planning_prepopulate_data'}, '/#/planning'); + const start = moment.tz("2025-12-12 01:00", TIMEZONE).utc(); addItems('events', [{ slugline: 'Original', definition_short: 'Desc.', @@ -37,9 +36,9 @@ describe('Planning.Events: create planning action', () => { qcode: 'eocstat:eos5', }, dates: { - start: '2045-12-11' + TIME_STRINGS[0], - end: '2045-12-11' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + start: start.format("YYYY-MM-DDTHH:mm:ss+0000"), + end: start.add(1, 'h').format('YYYY-MM-DDTHH:mm:ss+0000'), + tz: TIMEZONE, }, anpa_category: [{is_active: true, name: 'Finance', qcode: 'f', subject: '04000000'}], subject: [{parent: '15000000', name: 'sports awards', qcode: '15103000'}], diff --git a/e2e/cypress/e2e/search/search_combined.cy.ts b/e2e/cypress/e2e/search/search_combined.cy.ts index 1252703db..64aa06fbe 100644 --- a/e2e/cypress/e2e/search/search_combined.cy.ts +++ b/e2e/cypress/e2e/search/search_combined.cy.ts @@ -122,8 +122,8 @@ describe('Search.Combined: searching events and planning', () => { ], }, { params: { - 'start_date.date': '12/12/2025', - 'end_date.date': '12/12/2025', + 'start_date.date': '12/12/2045', + 'end_date.date': '12/12/2045', }, expectedCount: 0, clearAfter: true, diff --git a/e2e/cypress/e2e/search/search_events.cy.ts b/e2e/cypress/e2e/search/search_events.cy.ts index 7eaf5d37f..ae1c0cb35 100644 --- a/e2e/cypress/e2e/search/search_events.cy.ts +++ b/e2e/cypress/e2e/search/search_events.cy.ts @@ -156,8 +156,8 @@ describe('Search.Events: searching events', () => { ] }, { params: { - 'start_date.date': '12/12/2025', - 'end_date.date': '12/12/2025', + 'start_date.date': '12/12/2045', + 'end_date.date': '12/12/2045', }, expectedCount: 0, clearAfter: true, diff --git a/e2e/cypress/e2e/search/search_filters.cy.ts b/e2e/cypress/e2e/search/search_filters.cy.ts index ce8275993..55038d6dc 100644 --- a/e2e/cypress/e2e/search/search_filters.cy.ts +++ b/e2e/cypress/e2e/search/search_filters.cy.ts @@ -27,8 +27,8 @@ describe('Search.Filters: creating search filters', () => { state: ['Postponed'], only_posted: true, lock_state: 'Locked', - 'start_date.date': '12/12/2025', - 'end_date.date': '12/12/2025', + 'start_date.date': '12/12/2045', + 'end_date.date': '12/12/2045', calendars: ['Finance'], agendas: ['Sports'], }); @@ -78,8 +78,8 @@ describe('Search.Filters: creating search filters', () => { state: ['Postponed'], only_posted: true, lock_state: 'Locked', - 'start_date.date': '12/12/2025', - 'end_date.date': '12/12/2025', + 'start_date.date': '12/12/2045', + 'end_date.date': '12/12/2045', calendars: ['Sport'], source: ['aap'], location: 'Sydney Opera House', @@ -131,8 +131,8 @@ describe('Search.Filters: creating search filters', () => { state: ['Postponed'], only_posted: true, lock_state: 'Locked', - 'start_date.date': '12/12/2025', - 'end_date.date': '12/12/2025', + 'start_date.date': '12/12/2045', + 'end_date.date': '12/12/2045', agendas: ['Sports'], urgency: '3', g2_content_type: 'Video', diff --git a/e2e/cypress/e2e/search/search_planning.cy.ts b/e2e/cypress/e2e/search/search_planning.cy.ts index 70034b15c..e41e54445 100644 --- a/e2e/cypress/e2e/search/search_planning.cy.ts +++ b/e2e/cypress/e2e/search/search_planning.cy.ts @@ -127,8 +127,8 @@ describe('Search.Planning: searching planning items', () => { ], }, { params: { - 'start_date.date': '12/12/2025', - 'end_date.date': '12/12/2025', + 'start_date.date': '12/12/2045', + 'end_date.date': '12/12/2045', }, expectedCount: 0, clearAfter: true, diff --git a/e2e/cypress/fixtures/events.ts b/e2e/cypress/fixtures/events.ts index 07658bfa7..f68b297ef 100644 --- a/e2e/cypress/fixtures/events.ts +++ b/e2e/cypress/fixtures/events.ts @@ -1,4 +1,4 @@ -import {getDateStringFor, TIME_STRINGS} from '../support/utils/time'; +import {getDateStringFor, TIME_STRINGS, TIMEZONE} from '../support/utils/time'; export const LOCATIONS = { sydney_opera_house: { @@ -47,7 +47,7 @@ export const TEST_EVENTS = { dates: { start: '2045-12-11' + TIME_STRINGS[0], end: '2045-12-11' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + tz: TIMEZONE, }, name: 'Test', slugline: 'Original', @@ -73,7 +73,7 @@ export const TEST_EVENTS = { dates: { start: '2045-12-11' + TIME_STRINGS[0], end: '2045-12-11' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + tz: TIMEZONE, }, state: 'spiked', name: 'Spiker', @@ -82,9 +82,9 @@ export const TEST_EVENTS = { date_01_02_2045: { ...BASE_EVENT, dates: { - start: '2045-01-31' + TIME_STRINGS[0], - end: '2045-01-31' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + start: '2045-02-01T00:00:00+0000', + end: '2045-02-01T01:00:00+0000', + tz: 'UTC', }, name: 'February 1st 2045', slugline: 'Event Feb 1', @@ -92,9 +92,9 @@ export const TEST_EVENTS = { date_02_02_2045: { ...BASE_EVENT, dates: { - start: '2045-02-01' + TIME_STRINGS[0], - end: '2045-02-01' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + start: '2045-02-02T00:00:00+0000', + end: '2045-02-02T01:00:00+0000', + tz: 'UTC', }, name: 'February 2nd 2045', slugline: 'Event Feb 2', @@ -102,9 +102,9 @@ export const TEST_EVENTS = { date_03_02_2045: { ...BASE_EVENT, dates: { - start: '2045-02-02' + TIME_STRINGS[0], - end: '2045-02-02' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + start: '2045-02-03T00:00:00+0000', + end: '2045-02-03T01:00:00+0000', + tz: 'UTC', }, name: 'February 3rd 2045', slugline: 'Event Feb 3', @@ -112,9 +112,9 @@ export const TEST_EVENTS = { date_04_02_2045: { ...BASE_EVENT, dates: { - start: '2045-02-03' + TIME_STRINGS[0], - end: '2045-02-03' + TIME_STRINGS[1], - tz: 'Australia/Sydney', + start: '2045-02-04T00:00:00+0000', + end: '2045-02-04T01:00:00+0000', + tz: 'UTC', }, name: 'February 4th 2045', slugline: 'Event Feb 4', @@ -127,7 +127,7 @@ function getEventForDate(dateString: string, metadata: {[key: string]: any} = {} dates: { start: dateString + TIME_STRINGS[0], end: dateString + TIME_STRINGS[1], - tz: 'Australia/Sydney', + tz: TIMEZONE, }, ...metadata, }; diff --git a/e2e/cypress/fixtures/planning.ts b/e2e/cypress/fixtures/planning.ts index 573e61971..707b257fc 100644 --- a/e2e/cypress/fixtures/planning.ts +++ b/e2e/cypress/fixtures/planning.ts @@ -15,7 +15,7 @@ export const TEST_PLANNINGS = { draft: { ...BASE_PLANNING, slugline: 'Original', - planning_date: '2045-12-11' + TIME_STRINGS[1], + planning_date: '2045-12-11T01:00:00+0000', anpa_category: [ {name: 'Overseas Sport', qcode: 's'}, {name: 'International News', qcode: 'i'}, @@ -28,34 +28,34 @@ export const TEST_PLANNINGS = { spiked: { ...BASE_PLANNING, slugline: 'Spiker', - planning_date: '2045-12-11' + TIME_STRINGS[1], + planning_date: '2045-12-11T01:00:00+0000', state: 'spiked', }, featured: { ...BASE_PLANNING, slugline: 'Featured Planning', - planning_date: '2045-12-12' + TIME_STRINGS[1], + planning_date: '2045-12-12T01:00:00+0000', featured: true, }, plan_date_01_02_2045: { ...BASE_PLANNING, slugline: 'Plan Feb 1', - planning_date: '2045-01-31' + TIME_STRINGS[1], + planning_date: '2045-02-01T01:00:00+0000', }, plan_date_02_02_2045: { ...BASE_PLANNING, slugline: 'Plan Feb 2', - planning_date: '2045-02-01' + TIME_STRINGS[1], + planning_date: '2045-02-02T01:00:00+0000', }, plan_date_03_02_2045: { ...BASE_PLANNING, slugline: 'Plan Feb 3', - planning_date: '2045-02-02' + TIME_STRINGS[1], + planning_date: '2045-02-03T01:00:00+0000', }, plan_date_04_02_2045: { ...BASE_PLANNING, slugline: 'Plan Feb 4', - planning_date: '2045-02-03' + TIME_STRINGS[1], + planning_date: '2045-02-04T01:00:00+0000', }, }; diff --git a/e2e/cypress/support/utils/time.ts b/e2e/cypress/support/utils/time.ts index 7d2ba5aef..bd37fc049 100644 --- a/e2e/cypress/support/utils/time.ts +++ b/e2e/cypress/support/utils/time.ts @@ -1,8 +1,10 @@ import moment from 'moment-timezone'; +export const TIMEZONE = moment.tz.guess(); + export function getStartOfNextWeek(): moment.Moment { const startOfWeek = 0; - let current = (moment.tz('Australia/Sydney')).set({ + let current = (moment()).set({ hour: 0, minute: 0, second: 0, @@ -26,19 +28,16 @@ export function getStartOfNextWeek(): moment.Moment { } export const getDateStringFor = { - today: () => moment - .tz('Australia/Sydney') + today: () => moment() .set({hour: 0}) .utc() .format('YYYY-MM-DD'), - yesterday: () => moment - .tz('Australia/Sydney') + yesterday: () => moment() .set({hour: 0}) .utc() .subtract(1, 'd') .format('YYYY-MM-DD'), - tomorrow: () => moment - .tz('Australia/Sydney') + tomorrow: () => moment() .set({hour: 0}) .utc() .add(1, 'd') @@ -49,7 +48,6 @@ export const getDateStringFor = { export function getTimeStringForHour(hour: number): string { return moment() - .tz('Australia/Sydney') .set({hour: hour}) .utc() .format('THH:00:00+0000'); diff --git a/e2e/package.json b/e2e/package.json index 6cb11ed53..eedf2e192 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -2,7 +2,7 @@ "name": "superdesk", "license": "GPL-3.0", "dependencies": { - "superdesk-core": "github:superdesk/superdesk-client-core#hotfix/2.6.1", + "superdesk-core": "github:superdesk/superdesk-client-core#v2.6.2", "superdesk-planning": "file:../" }, "devDependencies": { @@ -12,8 +12,8 @@ "cypress-terminal-report": "^4.1.2", "extract-text-webpack-plugin": "3.0.2", "lodash": "^4.17.15", - "moment": "2.20.1", - "moment-timezone": "0.5.14" + "moment": "^2.29.4", + "moment-timezone": "^0.5.42" }, "scripts": { "cypress-ui": "cypress open", diff --git a/e2e/server/gunicorn_config.py b/e2e/server/gunicorn_config.py index 403e8e599..c0db34090 100644 --- a/e2e/server/gunicorn_config.py +++ b/e2e/server/gunicorn_config.py @@ -1,8 +1,7 @@ import os -import multiprocessing bind = '0.0.0.0:5000' -workers = int(os.environ.get('WEB_CONCURRENCY', multiprocessing.cpu_count() * 2 + 1)) +workers = 3 loglevel = 'warning' diff --git a/package-lock.json b/package-lock.json index e5917c11b..b8283bee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,9 +31,9 @@ } }, "@babel/parser": { - "version": "7.20.15", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.20.15.tgz", - "integrity": "sha512-DI4a1oZuf8wC+oAJA9RW6ga3Zbe8RZFt7kD9i4qAspz3I/yHet1VvC3DiSy/fsUvv5pvJuNPh0LPOdCcqinDPg==", + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.4.tgz", + "integrity": "sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==", "dev": true }, "@babel/runtime": { @@ -204,9 +204,9 @@ } }, "@types/jasmine": { - "version": "3.10.6", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.10.6.tgz", - "integrity": "sha512-twY9adK/vz72oWxCWxzXaxoDtF9TpfEEsxvbc1ibjF3gMD/RThSuSud/GKUTR3aJnfbivAbC/vLqhY+gdWCHfA==", + "version": "3.10.7", + "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-3.10.7.tgz", + "integrity": "sha512-brLuHhITMz4YV2IxLstAJtyRJgtWfLqFKiqiJFvFWMSmydpAmn42CE4wfw7ywkSk02UrufhtzipTcehk8FctoQ==", "dev": true }, "@types/json-schema": { @@ -222,9 +222,9 @@ "dev": true }, "@types/node": { - "version": "18.13.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.13.0.tgz", - "integrity": "sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==", + "version": "18.15.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", + "integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==", "dev": true }, "@types/prop-types": { @@ -356,9 +356,9 @@ } }, "@xmldom/xmldom": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.6.tgz", - "integrity": "sha512-HHXP9hskkFQHy8QxxUXkS7946FFIhYVfGqsk0WLwllmexN9x/+R4UBLvurHEuyXRfVEObVR8APuQehykLviwSQ==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.6.tgz", + "integrity": "sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==", "dev": true }, "@yarnpkg/lockfile": { @@ -1291,9 +1291,9 @@ } }, "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -1541,14 +1541,14 @@ } }, "array.prototype.find": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.0.tgz", - "integrity": "sha512-sn40qmUiLYAcRb/1HsIQjTTZ1kCy8II8VtZJpMn2Aoen9twULhbWXisfh3HimGqMlHGUul0/TfKCnXg42LuPpQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.1.tgz", + "integrity": "sha512-I2ri5Z9uMpMvnsNrHre9l3PaX+z9D0/z6F7Yt2u15q7wt0I62g5kX6xUKR1SJiefgG+u2/gJUmM8B47XRvQR6w==", "dev": true, "requires": { "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.4", + "define-properties": "^1.1.4", + "es-abstract": "^1.20.4", "es-shim-unscopables": "^1.0.0" } }, @@ -2245,9 +2245,9 @@ }, "dependencies": { "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -2483,9 +2483,9 @@ } }, "caniuse-db": { - "version": "1.0.30001452", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001452.tgz", - "integrity": "sha512-W+sJxuU8qlfQ8o5zwrBjgPahjIlxbL3F0G7NkqYelskQF1Y4OHb15fuL8LwR1VYX1KBzqo64Z9lB3h2e61mvEA==", + "version": "1.0.30001478", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001478.tgz", + "integrity": "sha512-t80eKm8nWCTTWVzfScEB0e8ltCXx1d0nFwyCrFAwQW2gh/vRrZ4BtOaWirzBNDTvK/YuDtlKtJA2O19izdQAQQ==", "dev": true }, "caseless": { @@ -4144,9 +4144,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.4.296", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.296.tgz", - "integrity": "sha512-i/6Q+Y9bluDa2a0NbMvdtG5TuS/1Fr3TKK8L+7UUL9QjRS5iFJzCC3r70xjyOnLiYG8qGV4/mMpe6HuAbdJW4w==", + "version": "1.4.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.361.tgz", + "integrity": "sha512-VocVwjPp05HUXzf3xmL0boRn5b0iyqC7amtDww84Jb1QJNPBc7F69gJyEeXRoriLBC4a5pSyckdllrXAg4mmRA==", "dev": true }, "elliptic": { @@ -4347,34 +4347,46 @@ } }, "enzyme-adapter-react-16": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.6.tgz", - "integrity": "sha512-yFlVJCXh8T+mcQo8M6my9sPgeGzj85HSHi6Apgf1Cvq/7EL/J9+1JoJmJsRxZgyTvPMAqOEpRSu/Ii/ZpyOk0g==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.7.tgz", + "integrity": "sha512-LtjKgvlTc/H7adyQcj+aq0P0H07LDL480WQl1gU512IUyaDo/sbOaNDdZsJXYW2XaoPqrLLE9KbZS+X2z6BASw==", "dev": true, "requires": { - "enzyme-adapter-utils": "^1.14.0", - "enzyme-shallow-equal": "^1.0.4", + "enzyme-adapter-utils": "^1.14.1", + "enzyme-shallow-equal": "^1.0.5", "has": "^1.0.3", - "object.assign": "^4.1.2", - "object.values": "^1.1.2", - "prop-types": "^15.7.2", + "object.assign": "^4.1.4", + "object.values": "^1.1.5", + "prop-types": "^15.8.1", "react-is": "^16.13.1", "react-test-renderer": "^16.0.0-0", "semver": "^5.7.0" + }, + "dependencies": { + "enzyme-shallow-equal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.5.tgz", + "integrity": "sha512-i6cwm7hN630JXenxxJFBKzgLC3hMTafFQXflvzHgPmDhOBhxUWDe8AeRv1qp2/uWJ2Y8z5yLWMzmAfkTOiOCZg==", + "dev": true, + "requires": { + "has": "^1.0.3", + "object-is": "^1.1.5" + } + } } }, "enzyme-adapter-utils": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.0.tgz", - "integrity": "sha512-F/z/7SeLt+reKFcb7597IThpDp0bmzcH1E9Oabqv+o01cID2/YInlqHbFl7HzWBl4h3OdZYedtwNDOmSKkk0bg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.1.tgz", + "integrity": "sha512-JZgMPF1QOI7IzBj24EZoDpaeG/p8Os7WeBZWTJydpsH7JRStc7jYbHE4CmNQaLqazaGFyLM8ALWA3IIZvxW3PQ==", "dev": true, "requires": { "airbnb-prop-types": "^2.16.0", - "function.prototype.name": "^1.1.3", + "function.prototype.name": "^1.1.5", "has": "^1.0.3", - "object.assign": "^4.1.2", - "object.fromentries": "^2.0.3", - "prop-types": "^15.7.2", + "object.assign": "^4.1.4", + "object.fromentries": "^2.0.5", + "prop-types": "^15.8.1", "semver": "^5.7.1" } }, @@ -7035,9 +7047,9 @@ } }, "minimist": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.2.3.tgz", - "integrity": "sha512-cdOrRjzm/cI4sG1c1Kzgo5kpFQm61wrgADF89L2ONgCqlwWNCJ3L4DoOLamFIagKhdnRuC+4eWgdRB4OoibyuQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.2.4.tgz", + "integrity": "sha512-Pkrrm8NjyQ8yVt8Am9M+yUt74zE3iokhzbG1bFVNjLB92vwM71hf40RkEsryg98BujhVOncKm/C1xROxZ030LQ==", "dev": true }, "readable-stream": { @@ -7399,9 +7411,9 @@ }, "dependencies": { "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", @@ -8324,14 +8336,27 @@ } }, "is-array-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.1.tgz", - "integrity": "sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", "dev": true, "requires": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "get-intrinsic": "^1.2.0", "is-typed-array": "^1.1.10" + }, + "dependencies": { + "get-intrinsic": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", + "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + } + } } }, "is-arrayish": { @@ -8831,12 +8856,12 @@ "dev": true }, "jasmine-reporters": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/jasmine-reporters/-/jasmine-reporters-2.5.0.tgz", - "integrity": "sha512-J69peyTR8j6SzvIPP6aO1Y00wwCqXuIvhwTYvE/di14roCf6X3wDZ4/cKGZ2fGgufjhP2FKjpgrUIKjwau4e/Q==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jasmine-reporters/-/jasmine-reporters-2.5.2.tgz", + "integrity": "sha512-qdewRUuFOSiWhiyWZX8Yx3YNQ9JG51ntBEO4ekLQRpktxFTwUHy24a86zD/Oi2BRTKksEdfWQZcQFqzjqIkPig==", "dev": true, "requires": { - "@xmldom/xmldom": "^0.7.3", + "@xmldom/xmldom": "^0.8.5", "mkdirp": "^1.0.4" }, "dependencies": { @@ -10390,9 +10415,9 @@ "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" }, "moment-timezone": { - "version": "0.5.41", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.41.tgz", - "integrity": "sha512-e0jGNZDOHfBXJGz8vR/sIMXvBIGJJcqFjmlg9lmE+5KX1U7/RZNMswfD8nKnNCnQdKTIj50IaRKwl1fvMLyyRg==", + "version": "0.5.43", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", + "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", "requires": { "moment": "^2.29.4" } @@ -13316,7 +13341,7 @@ "reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", - "integrity": "sha1-dJrO7H8/34tj+SegSAnpDFwLNGA=", + "integrity": "sha512-qOLsBKHCpSOFKK1NUOCGC5VyeufB6lEsFe92AL2bhIJsacZS1qdoOZSbPk3MYKuT2cFlRDnulKXuuElIrMjGUg==", "dev": true }, "regenerator-runtime": { @@ -14670,9 +14695,9 @@ "dev": true }, "spdx-correct": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", - "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, "requires": { "spdx-expression-parse": "^3.0.0", @@ -14696,9 +14721,9 @@ } }, "spdx-license-ids": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz", - "integrity": "sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==", + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", + "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==", "dev": true }, "spdy": { @@ -15477,8 +15502,8 @@ } }, "superdesk-core": { - "version": "github:superdesk/superdesk-client-core#2e037583bccc89e1b78dfbc2108e6728ddfc3ba4", - "from": "github:superdesk/superdesk-client-core#hotfix/2.6.1", + "version": "github:superdesk/superdesk-client-core#7b2fbe1b1d714cb7a6207e21faf9a408ae88b24a", + "from": "github:superdesk/superdesk-client-core#v2.6.2", "dev": true, "requires": { "@metadata/exif": "github:superdesk/exif#431066d", @@ -15544,8 +15569,8 @@ "medium-editor": "5.23.3", "medium-editor-tables": "0.6.1", "ment.io": "0.9.23", - "moment": "2.20.1", - "moment-timezone": "0.5.14", + "moment": "2.29.4", + "moment-timezone": "0.5.41", "ng-file-upload": "12.2.13", "node-sass": "4.14.0", "owl.carousel": "2.2.0", @@ -15572,7 +15597,7 @@ "sass-loader": "6.0.6", "shortid": "2.2.8", "style-loader": "0.20.2", - "superdesk-ui-framework": "3.0.0-rc12", + "superdesk-ui-framework": "3.0.9", "ts-loader": "3.5.0", "tslint": "5.11.0", "typescript": "3.9.7", @@ -15588,6 +15613,21 @@ "integrity": "sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==", "dev": true }, + "@superdesk/primereact": { + "version": "5.0.2-11", + "resolved": "https://registry.npmjs.org/@superdesk/primereact/-/primereact-5.0.2-11.tgz", + "integrity": "sha512-Dbya04bogmc+BZTAunTLYsV6AghvEbUiJ7yehqz6pwLvJ5j6N+V0PdafoYcu7zSwmWt3FYTaXsEMMImJqJd1Jw==", + "dev": true, + "requires": { + "react-transition-group": "^4.4.1" + } + }, + "@types/node": { + "version": "14.18.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.42.tgz", + "integrity": "sha512-xefu+RBie4xWlK8hwAzGh3npDz/4VhF6icY/shU+zv/1fNn+ZVG7T7CRwe9LId9sAYRPxI+59QBPuKL3WpyGRg==", + "dev": true + }, "@typescript-eslint/experimental-utils": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.16.0.tgz", @@ -15632,6 +15672,12 @@ "integrity": "sha512-DTt3GhOUDKhh4ONwIJW4lmhyotQmV2LjNlGK/J2hkwUcqcbKkCLAdJPtxQnxnlc7SR3f1CEXCyMmc7WLUsWbNA==", "dev": true }, + "csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "dev": true + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -15641,6 +15687,16 @@ "ms": "2.1.2" } }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "json5": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", @@ -15653,19 +15709,13 @@ "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, - "moment": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", - "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==", - "dev": true - }, "moment-timezone": { - "version": "0.5.14", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.14.tgz", - "integrity": "sha512-4RkNPVuQ/ClAXqd3T+tkBy85tEUxnNNIaG4hbviFp7vZ2hRY0mjHGRIWG/NdkUzSaH36nchdBXyvPwrODjPzUA==", + "version": "0.5.41", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.41.tgz", + "integrity": "sha512-e0jGNZDOHfBXJGz8vR/sIMXvBIGJJcqFjmlg9lmE+5KX1U7/RZNMswfD8nKnNCnQdKTIj50IaRKwl1fvMLyyRg==", "dev": true, "requires": { - "moment": ">= 2.9.0" + "moment": "^2.29.4" } }, "ms": { @@ -15720,6 +15770,31 @@ } } }, + "react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "dependencies": { + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + } + } + }, "redux": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", @@ -15736,6 +15811,27 @@ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true }, + "superdesk-ui-framework": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/superdesk-ui-framework/-/superdesk-ui-framework-3.0.9.tgz", + "integrity": "sha512-6b3seE2uA2JRTSV/pkREGbS/+67BIt6Gu8ILKqUFmsBrCZSluJ6DH1wSbvP8pDrFwN9IUOyEJqwMco8Q+L2R4A==", + "dev": true, + "requires": { + "@material-ui/lab": "^4.0.0-alpha.56", + "@popperjs/core": "^2.4.0", + "@superdesk/primereact": "^5.0.2-10", + "@types/node": "^14.10.2", + "chart.js": "^2.9.3", + "date-fns": "2.7.0", + "moment": "^2.29.3", + "popper.js": "1.14.4", + "primeicons": "2.0.0", + "react-beautiful-dnd": "^13.0.0", + "react-id-generator": "^3.0.0", + "react-popper": "^2.2.3", + "react-scrollspy": "^3.4.3" + } + }, "typescript": { "version": "3.9.7", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", diff --git a/package.json b/package.json index 045d5ef39..1afc79298 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "dompurify": "^1.0.11", "moment": "^2.29.4", - "moment-timezone": "^0.5.41", + "moment-timezone": "^0.5.43", "nominatim-browser": "~2.0.2", "react-bootstrap": "0.32.1", "react-debounce-input": "3.2.0", @@ -40,7 +40,7 @@ "whatwg-fetch": "~2.0.4" }, "devDependencies": { - "@types/jasmine": "^3.5.11", + "@types/jasmine": "^3.10.7", "@types/lodash": "4.14.117", "@types/react": "16.8.23", "@types/react-dom": "16.8.0", @@ -50,11 +50,11 @@ "btoa": "^1.1.2", "cheerio": "1.0.0-rc.10", "enzyme": "~3.11.0", - "enzyme-adapter-react-16": "^1.15.5", + "enzyme-adapter-react-16": "^1.15.7", "eslint": "6.6.0", "eslint-plugin-react": "7.16.0", "jasmine": "^2.99.0", - "jasmine-reporters": "^2.3.0", + "jasmine-reporters": "^2.5.2", "karma": "^2.0.0", "karma-chrome-launcher": "2.2.0", "karma-cli": "^1.0.1", @@ -67,7 +67,7 @@ "simulant": "^0.2.2", "sinon": "^4.5.0", "superdesk-code-style": "1.5.0", - "superdesk-core": "github:superdesk/superdesk-client-core#hotfix/2.6.1", + "superdesk-core": "github:superdesk/superdesk-client-core#v2.6.2", "ts-node": "~7.0.1", "tslint": "5.11.0", "typescript-eslint-parser": "^18.0.0" diff --git a/server/planning/search/eventsplanning_filters.py b/server/planning/search/eventsplanning_filters.py index 3080ad282..e3fc42a4e 100644 --- a/server/planning/search/eventsplanning_filters.py +++ b/server/planning/search/eventsplanning_filters.py @@ -137,6 +137,7 @@ def cv(qcode_type="string", extra_fields=None): "item_ids": list_strings(), "name": string(), "tz_offset": string(), + "time_zone": string(), "full_text": string(), "anpa_category": list_cvs(), "subject": list_cvs(), diff --git a/server/planning/search/queries/common.py b/server/planning/search/queries/common.py index 078329f4d..ee32f6773 100644 --- a/server/planning/search/queries/common.py +++ b/server/planning/search/queries/common.py @@ -12,11 +12,9 @@ import logging from datetime import datetime -from flask import current_app as app from eve.utils import str_to_date as _str_to_date, date_to_str from superdesk import get_resource_service -from superdesk.utc import get_timezone_offset, utcnow from superdesk.errors import SuperdeskApiError from superdesk.default_settings import strtobool as _strtobool from superdesk.users.services import current_user_has_privilege @@ -29,13 +27,9 @@ logger = logging.getLogger(__name__) -def get_time_zone(params: Dict[str, Any]): - return params.get("tz_offset") or get_timezone_offset(app.config["DEFAULT_TIMEZONE"], utcnow()) - - def get_date_params(params: Dict[str, Any]): date_filter = (params.get("date_filter") or "").strip().lower() - tz_offset = get_time_zone(params) + time_zone = params.get("time_zone") try: start_date = params.get("start_date") @@ -66,7 +60,7 @@ def get_date_params(params: Dict[str, Any]): logger.exception(e) raise SuperdeskApiError.badRequestError("Invalid value for end date") - return date_filter, start_date, end_date, tz_offset + return date_filter, start_date, end_date, time_zone def str_to_array(arg: Optional[Union[List[str], str]] = None) -> List[str]: @@ -268,16 +262,16 @@ def search_date_non_schedule(params: Dict[str, Any], query: elastic.ElasticQuery if not field_name or field_name == "schedule": return - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) if not date_filter and not start_date and not end_date: query.filter.append( - elastic.date_range(elastic.ElasticRangeParams(field=field_name, lte="now/d", time_zone=tz_offset)) + elastic.date_range(elastic.ElasticRangeParams(field=field_name, lte="now/d", time_zone=time_zone)) ) else: base_query = elastic.ElasticRangeParams( field=field_name, - time_zone=tz_offset, + time_zone=time_zone, start_of_week=int(params.get("start_of_week") or 0), ) @@ -424,6 +418,7 @@ def search_priority(params: Dict[str, Any], query: elastic.ElasticQuery): "item_ids", "name", "tz_offset", + "time_zone", "full_text", "anpa_category", "subject", diff --git a/server/planning/search/queries/elastic.py b/server/planning/search/queries/elastic.py index 93f4555b7..20b546c3e 100644 --- a/server/planning/search/queries/elastic.py +++ b/server/planning/search/queries/elastic.py @@ -8,13 +8,16 @@ # AUTHORS and LICENSE files distributed with this source code, or # at https://www.sourcefabric.org/superdesk/license +import pytz + +from datetime import datetime + from typing import Any, List, NamedTuple, Dict, Optional from datetime import timedelta from flask import current_app as app from eve.utils import str_to_date -from superdesk.utc import get_timezone_offset, utcnow from planning.common import get_start_of_next_week, sanitize_query_text @@ -112,7 +115,7 @@ def __init__( self.lt = lt self.lte = lte self.value_format = value_format - self.time_zone = time_zone if time_zone else get_timezone_offset(app.config["DEFAULT_TIMEZONE"], utcnow()) + self.time_zone = time_zone or app.config.get("DEFAULT_TIMEZONE") self.start_of_week = int(start_of_week or 0) self.date_range = date_range self.date = str_to_date(date) if date else None @@ -200,6 +203,35 @@ def field_range(query: ElasticRangeParams): if query.time_zone: params["time_zone"] = query.time_zone + if query.field in ("dates.start", "dates.end"): + # handle also all day events + # there we get value which is in utc, + # so we first convert it to local timezone + # and then we take only date part of it + local_params = params.copy() + local_params.pop("time_zone", None) + for key in ("gt", "gte", "lt", "lte"): + if local_params.get(key) and "T" in local_params[key] and query.time_zone: + tz = pytz.timezone(query.time_zone) + utc_value = datetime.fromisoformat(local_params[key].replace("+0000", "+00:00")) + local_value = utc_value.astimezone(tz) + local_params[key] = local_value.strftime("%Y-%m-%d") + return { + "bool": { + "should": [ + {"range": {query.field: params}}, + { + "bool": { + "must": [ + {"term": {"dates.all_day": True}}, + {"range": {query.field: local_params}}, + ], + } + }, + ], + }, + } + return {"range": {query.field: params}} @@ -243,7 +275,7 @@ def range_this_week(query: ElasticRangeParams): return field_range( ElasticRangeParams( field=query.field, - time_zone=query.time_zone or app.config["DEFAULT_TIMEZONE"], + time_zone=query.time_zone, value_format=query.value_format, gte=start_of_this_week(query.start_of_week), lt=start_of_next_week(query.start_of_week), @@ -255,7 +287,7 @@ def range_next_week(query: ElasticRangeParams): return field_range( ElasticRangeParams( field=query.field, - time_zone=query.time_zone or app.config["DEFAULT_TIMEZONE"], + time_zone=query.time_zone, value_format=query.value_format, gte=start_of_next_week(query.start_of_week), lt=end_of_next_week(query.start_of_week), diff --git a/server/planning/search/queries/events.py b/server/planning/search/queries/events.py index 0f7b38353..500744298 100644 --- a/server/planning/search/queries/events.py +++ b/server/planning/search/queries/events.py @@ -12,7 +12,6 @@ from planning.search.queries import elastic from .common import ( - get_time_zone, get_date_params, COMMON_SEARCH_FILTERS, COMMON_PARAMS, @@ -72,7 +71,7 @@ def search_no_calendar_assigned(params: Dict[str, Any], query: elastic.ElasticQu def search_date_today(params: Dict[str, Any], query: elastic.ElasticQuery): if params.get("date_filter") == elastic.DATE_RANGE.TODAY: - time_zone = get_time_zone(params) + time_zone = params.get("time_zone") query.filter.append( elastic.bool_or( @@ -104,7 +103,7 @@ def search_date_today(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_tomorrow(params: Dict[str, Any], query: elastic.ElasticQuery): if params.get("date_filter") == elastic.DATE_RANGE.TOMORROW: - time_zone = get_time_zone(params) + time_zone = params.get("time_zone") query.filter.append( elastic.bool_or( @@ -136,7 +135,7 @@ def search_date_tomorrow(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_last_24_hours(params: Dict[str, Any], query: elastic.ElasticQuery): if params.get("date_filter") == elastic.DATE_RANGE.LAST_24: - time_zone = get_time_zone(params) + time_zone = params.get("time_zone") query.filter.append( elastic.bool_or( @@ -164,7 +163,7 @@ def search_date_last_24_hours(params: Dict[str, Any], query: elastic.ElasticQuer def search_date_this_week(params: Dict[str, Any], query: elastic.ElasticQuery): if params.get("date_filter") == elastic.DATE_RANGE.THIS_WEEK: - time_zone = get_time_zone(params) + time_zone = params.get("time_zone") start_of_week = int(params.get("start_of_week") or 0) query.filter.append( @@ -209,7 +208,7 @@ def search_date_this_week(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_next_week(params: Dict[str, Any], query: elastic.ElasticQuery): if params.get("date_filter") == elastic.DATE_RANGE.NEXT_WEEK: - time_zone = get_time_zone(params) + time_zone = params.get("time_zone") start_of_week = int(params.get("start_of_week") or 0) query.filter.append( @@ -253,17 +252,17 @@ def search_date_next_week(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_start(params: Dict[str, Any], query: elastic.ElasticQuery): - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) if not date_filter and start_date and not end_date: query.filter.append( elastic.bool_or( [ elastic.date_range( - elastic.ElasticRangeParams(field="dates.start", gte=start_date, time_zone=tz_offset) + elastic.ElasticRangeParams(field="dates.start", gte=start_date, time_zone=time_zone) ), elastic.date_range( - elastic.ElasticRangeParams(field="dates.end", gte=start_date, time_zone=tz_offset) + elastic.ElasticRangeParams(field="dates.end", gte=start_date, time_zone=time_zone) ), ] ) @@ -271,17 +270,17 @@ def search_date_start(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_end(params: Dict[str, Any], query: elastic.ElasticQuery): - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) if not date_filter and not start_date and end_date: query.filter.append( elastic.bool_or( [ elastic.date_range( - elastic.ElasticRangeParams(field="dates.start", lte=end_date, time_zone=tz_offset) + elastic.ElasticRangeParams(field="dates.start", lte=end_date, time_zone=time_zone) ), elastic.date_range( - elastic.ElasticRangeParams(field="dates.end", lte=end_date, time_zone=tz_offset) + elastic.ElasticRangeParams(field="dates.end", lte=end_date, time_zone=time_zone) ), ] ) @@ -289,7 +288,7 @@ def search_date_end(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_range(params: Dict[str, Any], query: elastic.ElasticQuery): - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) if not date_filter and start_date and end_date: query.filter.append( @@ -301,11 +300,11 @@ def search_date_range(params: Dict[str, Any], query: elastic.ElasticQuery): elastic.ElasticRangeParams( field="dates.start", gte=start_date, - time_zone=tz_offset, + time_zone=time_zone, ) ), elastic.date_range( - elastic.ElasticRangeParams(field="dates.end", lte=end_date, time_zone=tz_offset) + elastic.ElasticRangeParams(field="dates.end", lte=end_date, time_zone=time_zone) ), ] ), @@ -315,11 +314,11 @@ def search_date_range(params: Dict[str, Any], query: elastic.ElasticQuery): elastic.ElasticRangeParams( field="dates.start", lt=start_date, - time_zone=tz_offset, + time_zone=time_zone, ) ), elastic.date_range( - elastic.ElasticRangeParams(field="dates.end", gt=end_date, time_zone=tz_offset) + elastic.ElasticRangeParams(field="dates.end", gt=end_date, time_zone=time_zone) ), ] ), @@ -330,7 +329,7 @@ def search_date_range(params: Dict[str, Any], query: elastic.ElasticQuery): field="dates.start", gte=start_date, lte=end_date, - time_zone=tz_offset, + time_zone=time_zone, ) ), elastic.date_range( @@ -338,7 +337,7 @@ def search_date_range(params: Dict[str, Any], query: elastic.ElasticQuery): field="dates.end", gte=start_date, lte=end_date, - time_zone=tz_offset, + time_zone=time_zone, ) ), ] @@ -349,12 +348,12 @@ def search_date_range(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_default(params: Dict[str, Any], query: elastic.ElasticQuery): - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) only_future = strtobool(params.get("only_future", True)) if not date_filter and not start_date and not end_date and only_future: query.filter.append( - elastic.date_range(elastic.ElasticRangeParams(field="dates.end", gte="now/d", time_zone=tz_offset)) + elastic.date_range(elastic.ElasticRangeParams(field="dates.end", gte="now/d", time_zone=time_zone)) ) diff --git a/server/planning/search/queries/planning.py b/server/planning/search/queries/planning.py index 4b6859c3d..94532b151 100644 --- a/server/planning/search/queries/planning.py +++ b/server/planning/search/queries/planning.py @@ -132,13 +132,13 @@ def search_by_events(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date(params: Dict[str, Any], query: elastic.ElasticQuery): - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) if date_filter or start_date or end_date: field_name = "_planning_schedule.scheduled" base_query = elastic.ElasticRangeParams( field=field_name, - time_zone=tz_offset, + time_zone=time_zone, start_of_week=int(params.get("start_of_week") or 0), ) @@ -181,7 +181,7 @@ def search_date(params: Dict[str, Any], query: elastic.ElasticQuery): ) query.extra["sort_filter"] = elastic.date_range( - elastic.ElasticRangeParams(field=field_name, gte="now/d", time_zone=tz_offset) + elastic.ElasticRangeParams(field=field_name, gte="now/d", time_zone=time_zone) ) else: query.filter.append(planning_schedule) @@ -189,7 +189,7 @@ def search_date(params: Dict[str, Any], query: elastic.ElasticQuery): def search_date_default(params: Dict[str, Any], query: elastic.ElasticQuery): - date_filter, start_date, end_date, tz_offset = get_date_params(params) + date_filter, start_date, end_date, time_zone = get_date_params(params) only_future = strtobool(params.get("only_future", True)) if not date_filter and not start_date and not end_date and only_future: @@ -198,7 +198,7 @@ def search_date_default(params: Dict[str, Any], query: elastic.ElasticQuery): elastic.ElasticRangeParams( field=field_name, gte="now/d", - time_zone=tz_offset, + time_zone=time_zone, ) ) From 6afa221b6d89a92627d5c2df2c89bffba1fab063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Mon, 17 Apr 2023 08:48:44 +0200 Subject: [PATCH 008/261] avoid dynamic mapping for event calendars (#1784) SDESK-6876 --- server/planning/events/events_schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/planning/events/events_schema.py b/server/planning/events/events_schema.py index 657d1d0e6..54134d973 100644 --- a/server/planning/events/events_schema.py +++ b/server/planning/events/events_schema.py @@ -249,6 +249,7 @@ "nullable": True, "mapping": { "type": "object", + "dynamic": False, "properties": { "qcode": not_analyzed, "name": not_analyzed, From ce4288a62effebaba5a5702b526ed760e7a97d24 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Tue, 18 Apr 2023 09:19:56 +1000 Subject: [PATCH 009/261] [SDNTB-804] fix(ui): Load ContentProfiles using its name not _id (#1785) --- client/api/contentProfiles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/api/contentProfiles.ts b/client/api/contentProfiles.ts index c0fdb8ebc..872c28dcf 100644 --- a/client/api/contentProfiles.ts +++ b/client/api/contentProfiles.ts @@ -31,7 +31,7 @@ function enablePriorityInSearchProfile(profiles: Array) // Hack to enable/disable priority field in search profiles based on the content profiles // TODO: Remove this hack when we implement a solution for all searchable fields const profilesById: {[id: string]: IPlanningContentProfile} = profiles.reduce((profileMap, profile) => { - profileMap[profile._id ?? profile.name] = profile; + profileMap[profile.name] = profile; return profileMap; }, {}); From 96aee191dc2d5d23f788950ca66fc29cb4156ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Thu, 20 Apr 2023 09:20:30 +0200 Subject: [PATCH 010/261] fix onclusive ingest (#1786) - keep timestamp for next run based on current start - look for timezone not only using name but also offset - increase buffer when getting updates (not clear if the timestamps should be utc or local, so add some extra time - for events with no end time set it to start time and not some fake end time which might make it go visible on a next day SDCP-688 SDCP-690 --- server/planning/events/events.py | 4 ++- server/planning/feed_parsers/onclusive.py | 14 ++++---- .../planning/feed_parsers/onclusive_tests.py | 28 +++++++++++++++- .../feeding_services/onclusive_api_service.py | 32 ++++++++----------- .../onclusive_api_service_tests.py | 3 +- 5 files changed, 53 insertions(+), 28 deletions(-) diff --git a/server/planning/events/events.py b/server/planning/events/events.py index 60a4ed6fc..59bfc53b4 100644 --- a/server/planning/events/events.py +++ b/server/planning/events/events.py @@ -17,6 +17,7 @@ import copy import pytz import re +from datetime import timedelta from eve.methods.common import resolve_document_etag from eve.utils import config, date_to_str from flask import current_app as app @@ -862,7 +863,8 @@ def setRecurringMode(event): def overwrite_event_expiry_date(event): if "expiry" in event: - event["expiry"] = event["dates"]["end"] + expiry_minutes = app.settings.get("PLANNING_EXPIRY_MINUTES", None) + event["expiry"] = event["dates"]["end"] + timedelta(minutes=expiry_minutes or 0) def generate_recurring_events(event): diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index ee9c60c7e..e34427269 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -73,7 +73,7 @@ def parse(self, content, provider=None): except EmbargoedException: logger.info("Ignoring embargoed event %s", event["itemId"]) except (KeyError, IndexError, TypeError) as error: - logger.exception("error %s ingesting event %s", error, event) + logger.exception("error %s when ingesting event", error, extra=dict(event=event)) return all_events except Exception as ex: @@ -116,13 +116,13 @@ def parse_item_meta(self, event, item): def parse_event_details(self, event, item): if event.get("time"): start_date = self.datetime(event["startDate"], event.get("time"), event["timezone"]) - end_date = self.datetime(event["endDate"], "23:59:59", event["timezone"]) + end_date = self.datetime(event["endDate"], timezone=event["timezone"]) tz = self.parse_timezone(start_date, event) item["dates"] = dict( start=start_date, - end=end_date, - tz=tz, + end=max(start_date, end_date), no_end_time=True, + tz=tz, ) else: item["dates"] = dict( @@ -136,12 +136,14 @@ def parse_timezone(self, start_date, event): timezones = app.config.get("ONCLUSIVE_TIMEZONES", self.ONCLUSIVE_TIMEZONES) + pytz.common_timezones for tzname in timezones: try: - date = start_date.astimezone(pytz.timezone(tzname)) + tz = pytz.timezone(tzname) + date = start_date.astimezone(tz) except pytz.exceptions.UnknownTimeZoneError: logger.error("Unknown Timezone %s", tzname) continue abbr = date.strftime("%Z") - if abbr == event["timezone"]["timezoneAbbreviation"]: + offset = tz.utcoffset(start_date.replace(tzinfo=None)).total_seconds() / 3600 + if abbr == event["timezone"]["timezoneAbbreviation"] and offset == event["timezone"]["timezoneOffset"]: return tzname else: logger.warning("Could not find timezone for %s", event["timezone"]["timezoneAbbreviation"]) diff --git a/server/planning/feed_parsers/onclusive_tests.py b/server/planning/feed_parsers/onclusive_tests.py index b56f9d62e..aea1db029 100644 --- a/server/planning/feed_parsers/onclusive_tests.py +++ b/server/planning/feed_parsers/onclusive_tests.py @@ -67,7 +67,7 @@ def test_content(self): self.assertIn("https://www.canadianinstitute.com/anti-money-laundering-financial-crime/", item["links"]) self.assertEqual(item["dates"]["start"], datetime.datetime(2022, 6, 15, 10, 30, tzinfo=datetime.timezone.utc)) - self.assertEqual(item["dates"]["end"], datetime.datetime(2022, 6, 16, 3, 59, 59, tzinfo=datetime.timezone.utc)) + self.assertEqual(item["dates"]["end"], datetime.datetime(2022, 6, 15, 10, 30, tzinfo=datetime.timezone.utc)) self.assertEqual(item["dates"]["tz"], "US/Eastern") self.assertEqual(item["dates"]["no_end_time"], True) @@ -112,6 +112,32 @@ def test_unknown_timezone(self): OnclusiveFeedParser().parse([self.data]) self.assertIn("ERROR:planning.feed_parsers.onclusive:Unknown Timezone FOO", logger.output) + def test_cst_timezone(self): + data = self.data.copy() + data.update( + { + "startDate": "2023-04-18T00:00:00.0000000", + "endDate": "2023-04-18T00:00:00.0000000", + "time": "10:00", + "timezone": { + "timezoneID": 24, + "timezoneAbbreviation": "CST", + "timezoneName": "(CST) China Standard Time : Beijing, Taipei", + "timezoneOffset": 8.00, + }, + } + ) + item = OnclusiveFeedParser().parse([data])[0] + self.assertEqual( + { + "start": datetime.datetime(2023, 4, 18, 2, tzinfo=datetime.timezone.utc), + "end": datetime.datetime(2023, 4, 18, 2, tzinfo=datetime.timezone.utc), + "no_end_time": True, + "tz": "Asia/Macau", + }, + item["dates"], + ) + def test_embargoed(self): data = self.data.copy() data["embargoTime"] = "2022-12-07T09:00:00" diff --git a/server/planning/feeding_services/onclusive_api_service.py b/server/planning/feeding_services/onclusive_api_service.py index 0cb9a2938..e339deb4b 100644 --- a/server/planning/feeding_services/onclusive_api_service.py +++ b/server/planning/feeding_services/onclusive_api_service.py @@ -2,8 +2,8 @@ import requests from typing import Optional -from datetime import timedelta -from flask import current_app as app, json +from datetime import timedelta, datetime +from flask import current_app as app from flask_babel import lazy_gettext from superdesk.io.registry import register_feeding_service_parser from superdesk.io.feeding_services.http_base_service import HTTPFeedingServiceBase @@ -88,9 +88,10 @@ def _update(self, provider, update): ) # next time start from here, onclusive api does not use seconds if update["tokens"].get("import_finished"): url = urljoin(URL, "/api/v2/events/latest") - start = update["tokens"]["import_finished"] - timedelta( - hours=1 - ) # add 1h buffer to avoid missing events + start = update["tokens"]["next_start"] - timedelta( + hours=3, # add a buffer, also not sure about timezone there + ) + update["tokens"]["next_start"] = update["last_updated"] logger.info("Fetching updates since %s", start.isoformat()) start_offset = 0 params = dict( @@ -102,12 +103,12 @@ def _update(self, provider, update): days = int(provider["config"].get("days_to_ingest") or 365) logger.info("Fetching %d days", days) url = urljoin(URL, "/api/v2/events/between") - start = update["tokens"].get("start_date") or update["last_updated"] - update["tokens"]["start_date"] = start # store for next time + update["tokens"].setdefault("start_date", update["last_updated"]) # keep for next round + update["tokens"].setdefault("next_start", update["last_updated"]) # after import continue from start + update["tokens"].setdefault("start_offset", 0) + start = update["tokens"]["start_date"] end = start + timedelta(days=days) - start_offset = ( - update["tokens"].get("start_offset") or 0 - ) # allow to continue in case this won't fininsh in single run + start_offset = update["tokens"]["start_offset"] if start_offset: logger.info("Continuing from %d", start_offset) params = dict( @@ -117,22 +118,15 @@ def _update(self, provider, update): ) logger.info("ingest from onclusive %s with params %s", url, params) try: - last_updated = None for offset in range(start_offset, MAX_OFFSET, LIMIT): params["offset"] = offset logger.debug("params %s", params) content = self._fetch(url, params, provider, update["tokens"]) if not content: - logger.info("done ingesting offset=%d last_updated=%s", offset, last_updated) - if last_updated: - update["tokens"]["import_finished"] = last_updated + logger.info("done ingesting offset=%d last_updated=%s", offset, update["last_updated"]) + update["tokens"].setdefault("import_finished", utcnow()) break items = parser.parse(content, provider) - for item in items: - if item.get("versioncreated"): - last_updated = ( - max(last_updated, item["versioncreated"]) if last_updated else item["versioncreated"] - ) yield items update["tokens"]["start_offset"] = offset else: diff --git a/server/planning/feeding_services/onclusive_api_service_tests.py b/server/planning/feeding_services/onclusive_api_service_tests.py index eb52750e2..01450d9af 100644 --- a/server/planning/feeding_services/onclusive_api_service_tests.py +++ b/server/planning/feeding_services/onclusive_api_service_tests.py @@ -49,7 +49,8 @@ def test_update(self): list(service._update(provider, updates)) self.assertIn("tokens", updates) self.assertEqual("refresh", updates["tokens"]["refreshToken"]) - self.assertEqual(event["versioncreated"], updates["tokens"]["import_finished"]) + self.assertIn("import_finished", updates["tokens"]) + self.assertEqual(updates["last_updated"], updates["tokens"]["next_start"]) provider.update(updates) updates = {} From a357a540de8a02f5f9abe134f87649c03d36e3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Mon, 24 Apr 2023 08:41:12 +0200 Subject: [PATCH 011/261] check all timezones when parsing onclusive (#1787) SDCP-688 --- server/planning/feed_parsers/onclusive.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index e34427269..903fdea4a 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -133,7 +133,11 @@ def parse_event_details(self, event, item): def parse_timezone(self, start_date, event): if event.get("timezone"): - timezones = app.config.get("ONCLUSIVE_TIMEZONES", self.ONCLUSIVE_TIMEZONES) + pytz.common_timezones + timezones = ( + app.config.get("ONCLUSIVE_TIMEZONES", self.ONCLUSIVE_TIMEZONES) + + pytz.common_timezones + + pytz.all_timezones + ) for tzname in timezones: try: tz = pytz.timezone(tzname) From f2b3a967d62fdaf3ae2326facb3143917fd1ea60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Mon, 24 Apr 2023 11:32:59 +0200 Subject: [PATCH 012/261] fix events not visible in the UI (#1790) mostly related to events with `no_end_time` flag which have same end timestamp as start one. SDCP-680 --- client/utils/events.ts | 56 +++++++++++++++++++++++++++++++----------- client/utils/index.ts | 11 +-------- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/client/utils/events.ts b/client/utils/events.ts index 09d711c4a..56c26b612 100644 --- a/client/utils/events.ts +++ b/client/utils/events.ts @@ -747,9 +747,39 @@ const getStartDate = (event: IEventItem) => ( ); const getEndDate = (event: IEventItem) => ( - event.dates?.all_day ? moment.utc(event.dates.end) : moment(event.dates?.end) + (event.dates?.all_day || event.dates?.no_end_time) ? moment.utc(event.dates.end) : moment(event.dates?.end) ); +const isEventInRange = ( + event: IEventItem, + eventStart: moment.Moment, + eventEnd: moment.Moment, + start: moment.Moment, + end?: moment.Moment, +) => { + let localStart = eventStart; + let localEnd = eventEnd; + let startUnit : moment.unitOfTime.StartOf = 'second'; + let endUnit : moment.unitOfTime.StartOf = 'second'; + + if (event.dates?.all_day) { + // we have only dates in utc + localStart = moment(eventStart.format('YYYY-MM-DD')); + localEnd = moment(eventEnd.format('YYYY-MM-DD')); + startUnit = 'day'; + endUnit = 'day'; + } + + if (event.dates?.no_end_time) { + // we have time for start, but only date for end + localStart = moment(eventStart); + localEnd = moment(eventEnd.format('YYYY-MM-DD')); + endUnit = 'day'; + } + + return localEnd.isSameOrAfter(start, endUnit) && (end == null || localStart.isSameOrBefore(end, startUnit)); +}; + /* * Groups the events by date */ @@ -777,19 +807,15 @@ const getEventsByDate = (events: Array, startDate, endDate) => { let eventStart = getStartDate(event); let eventEnd = getEndDate(event); - if (!eventStart.isSame(eventEnd, 'day') && !event.dates.all_day) { + if (!eventStart.isSame(eventEnd, 'day') && !event.dates.all_day && !event.dates.no_end_time) { eventStart = eventDate; eventEnd = eventEnd.isSame(eventDate, 'day') ? eventEnd : moment(eventDate.format('YYYY-MM-DD'), 'YYYY-MM-DD').add(86399, 'seconds'); } - if (!(isDateInRange(startDate, eventStart, eventEnd, event.dates.all_day) || - isDateInRange(endDate, eventStart, eventEnd, event.dates.all_day))) { - if (!isDateInRange(eventStart, startDate, endDate, event.dates.all_day) && - !isDateInRange(eventEnd, startDate, endDate, event.dates.all_day)) { - return; - } + if (!isEventInRange(event, eventDate, eventEnd, startDate, endDate)) { + return; } let eventDateFormatted = eventDate.format('YYYY-MM-DD'); @@ -806,21 +832,21 @@ const getEventsByDate = (events: Array, startDate, endDate) => { sortedEvents.forEach((event) => { // compute the number of days of the event - const ending = event.actioned_date ? event.actioned_date : getEndDate(event); - const startDate = getStartDate(event); + const eventEndDate = event.actioned_date ? event.actioned_date : getEndDate(event); + const eventStartDate = getStartDate(event); - if (!startDate.isSame(ending, 'day')) { - let deltaDays = Math.max(Math.ceil(ending.diff(startDate, 'days', true)), 1); + if (!eventStartDate.isSame(eventEndDate, 'day')) { + let deltaDays = Math.max(Math.ceil(eventEndDate.diff(eventStartDate, 'days', true)), 1); // if the event happens during more that one day, add it to every day // add the event to the other days for (let i = 1; i <= deltaDays; i++) { // clone the date - const newDate = moment(startDate.format('YYYY-MM-DD'), 'YYYY-MM-DD', true); + const newDate = moment(eventStartDate.format('YYYY-MM-DD'), 'YYYY-MM-DD', true); newDate.add(i, 'days'); - if (maxStartDate.isSameOrAfter(newDate, 'day') && newDate.isSameOrBefore(ending, 'day')) { + if (maxStartDate.isSameOrAfter(newDate, 'day') && newDate.isSameOrBefore(eventEndDate, 'day')) { addEventToDate(event, newDate); } } @@ -828,7 +854,7 @@ const getEventsByDate = (events: Array, startDate, endDate) => { // add event to its initial starting date // add an event only if it's not actioned or actioned after this event's start date - if (!event.actioned_date || event.actioned_date.isSameOrAfter(startDate, 'date')) { + if (!event.actioned_date || event.actioned_date.isSameOrAfter(eventStartDate, 'date')) { addEventToDate(event); } }); diff --git a/client/utils/index.ts b/client/utils/index.ts index 80e2acbe0..b770fb5de 100644 --- a/client/utils/index.ts +++ b/client/utils/index.ts @@ -672,20 +672,11 @@ export const onEventCapture = (event) => { } }; -export const isDateInRange = (inputDate, startDate, endDate, allDay = false) => { +export const isDateInRange = (inputDate, startDate, endDate) => { if (!inputDate) { return false; } - if (allDay) { - // if passed as string so inBetween will convert those to local dates - // from utc dates which are used for all day events - const startDay = startDate.format('YYYY-MM-DD'); - const endDay = (endDate || inputDate).format('YYYY-MM-DD'); - - return moment(inputDate).isBetween(startDay, endDay, 'day', '[]'); - } - if (startDate && moment(inputDate).isBefore(startDate, 'millisecond') || endDate && moment(inputDate).isSameOrAfter(endDate, 'millisecond')) { return false; From 3a3c76c242a832805bdd9b12f98b502610c7c68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Tue, 25 Apr 2023 08:31:38 +0200 Subject: [PATCH 013/261] there was an error in timezone detection (#1792) when the event started during the hour when there was daylight savings switch for a timezone, so rather work with utc date. SDCP-688 --- server/planning/feed_parsers/onclusive.py | 50 +++++++++---------- .../planning/feed_parsers/onclusive_tests.py | 19 +++++++ .../feeding_services/onclusive_api_service.py | 2 + 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index 903fdea4a..5fdc26c3e 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -51,33 +51,29 @@ def can_parse(self, content): return False def parse(self, content, provider=None): - try: - all_events = [] - for event in content: - guid = "urn:onclusive:{}".format(event["itemId"]) - - item = { - GUID_FIELD: guid, - ITEM_TYPE: CONTENT_TYPE.EVENT, - "state": CONTENT_STATE.INGESTED, - } - - try: - self.set_occur_status(item) - self.parse_item_meta(event, item) - self.parse_location(event, item) - self.parse_event_details(event, item) - self.parse_category(event, item) - self.parse_contact_info(event, item) - all_events.append(item) - except EmbargoedException: - logger.info("Ignoring embargoed event %s", event["itemId"]) - except (KeyError, IndexError, TypeError) as error: - logger.exception("error %s when ingesting event", error, extra=dict(event=event)) - return all_events + all_events = [] + for event in content: + guid = "urn:onclusive:{}".format(event["itemId"]) + + item = { + GUID_FIELD: guid, + ITEM_TYPE: CONTENT_TYPE.EVENT, + "state": CONTENT_STATE.INGESTED, + } - except Exception as ex: - raise ParserError.parseMessageError(ex, provider) + try: + self.set_occur_status(item) + self.parse_item_meta(event, item) + self.parse_location(event, item) + self.parse_event_details(event, item) + self.parse_category(event, item) + self.parse_contact_info(event, item) + all_events.append(item) + except EmbargoedException: + logger.info("Ignoring embargoed event %s", event["itemId"]) + except Exception as error: + logger.exception("error %s when parsing event %s", error, event["itemId"], extra=dict(event=event)) + return all_events def set_occur_status(self, item): eocstat_map = get_resource_service("vocabularies").find_one(req=None, _id="eventoccurstatus") @@ -146,7 +142,7 @@ def parse_timezone(self, start_date, event): logger.error("Unknown Timezone %s", tzname) continue abbr = date.strftime("%Z") - offset = tz.utcoffset(start_date.replace(tzinfo=None)).total_seconds() / 3600 + offset = date.utcoffset().total_seconds() / 3600 if abbr == event["timezone"]["timezoneAbbreviation"] and offset == event["timezone"]["timezoneOffset"]: return tzname else: diff --git a/server/planning/feed_parsers/onclusive_tests.py b/server/planning/feed_parsers/onclusive_tests.py index aea1db029..8a8203c00 100644 --- a/server/planning/feed_parsers/onclusive_tests.py +++ b/server/planning/feed_parsers/onclusive_tests.py @@ -162,3 +162,22 @@ def test_embargoed(self): utcnow_mock.return_value = datetime.datetime.fromisoformat("2022-12-07T18:00:00+00:00") parsed = OnclusiveFeedParser().parse([data]) self.assertEqual(1, len(parsed)) + + def test_timezone_ambigous_time_error(self): + data = self.data.copy() + data.update( + { + "startDate": "2023-10-27T00:00:00.0000000", + "time": "08:30", + "timezone": { + "timezoneID": 27, + "timezoneAbbreviation": "JST", + "timezoneName": "(JST) Japan Standard Time : Tokyo", + "timezoneOffset": 9.00, + "timezoneIdentity": None, + }, + } + ) + + item = OnclusiveFeedParser().parse([data])[0] + assert item["dates"]["tz"] == "Asia/Tokyo" diff --git a/server/planning/feeding_services/onclusive_api_service.py b/server/planning/feeding_services/onclusive_api_service.py index e339deb4b..ff1f41804 100644 --- a/server/planning/feeding_services/onclusive_api_service.py +++ b/server/planning/feeding_services/onclusive_api_service.py @@ -87,6 +87,8 @@ def _update(self, provider, update): second=0 ) # next time start from here, onclusive api does not use seconds if update["tokens"].get("import_finished"): + # populate it for cases when import was done before we introduced the field + update["tokens"].setdefault("next_start", update["tokens"]["import_finished"] - timedelta(hours=8)) url = urljoin(URL, "/api/v2/events/latest") start = update["tokens"]["next_start"] - timedelta( hours=3, # add a buffer, also not sure about timezone there From 869860ad2b2753d394e410b43b78fd4b642b2ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Tue, 25 Apr 2023 08:47:50 +0200 Subject: [PATCH 014/261] fix event date not visible in preview (#1791) * fix event date not visible in preview that was the case for events with no end time and same timezone as local one for user. SDCP-690 * add missing end date for multi day events with no end time --- .../Events/EventScheduleSummary/index.tsx | 3 +- client/utils/events.ts | 65 ++++++------------- 2 files changed, 22 insertions(+), 46 deletions(-) diff --git a/client/components/Events/EventScheduleSummary/index.tsx b/client/components/Events/EventScheduleSummary/index.tsx index 75a7779f5..eb8e6ee59 100644 --- a/client/components/Events/EventScheduleSummary/index.tsx +++ b/client/components/Events/EventScheduleSummary/index.tsx @@ -20,8 +20,9 @@ export const EventScheduleSummary = ({ forUpdating = false, useEventTimezone = false }: IProps) => { - if (!event) + if (!event) { return null; + } const eventSchedule: IEventItem['dates'] = get(event, 'dates', {}); const doesRepeat = get(eventSchedule, 'recurring_rule', null) !== null; diff --git a/client/utils/events.ts b/client/utils/events.ts index 56c26b612..f57188d4e 100644 --- a/client/utils/events.ts +++ b/client/utils/events.ts @@ -420,70 +420,45 @@ const getDateStringForEvent = (event, dateOnly = false, useLocal = true, withTim // !! Note - expects event dates as instance of moment() !! // const dateFormat = appConfig.planning.dateformat; const timeFormat = appConfig.planning.timeformat; - const start = get(event.dates, 'start'); - const end = get(event.dates, 'end'); + const start = getStartDate(event); + const end = getEndDate(event); const tz = get(event.dates, 'tz'); const localStart = timeUtils.getLocalDate(start, tz); + const isFullDay = event?.dates?.all_day; + const noEndTime = event?.dates?.no_end_time; + const multiDay = !start.isSame(end, 'day'); + let dateString, timezoneString = ''; - let timezoneForEvents = ''; - if (!start || !end) + if (!start || !end) { return; + } dateString = getTBCDateString(event, ' @ ', dateOnly); if (!dateString) { - if (start.isSame(end, 'day')) { - if (dateOnly) { + if (!multiDay) { + if (dateOnly || isFullDay) { dateString = start.format(dateFormat); + } else if (noEndTime) { + dateString = getDateTimeString(start, dateFormat, timeFormat, ' @ ', false); } else { dateString = getDateTimeString(start, dateFormat, timeFormat, ' @ ', false) + ' - ' + end.format(timeFormat); } - } else if (dateOnly) { + } else if (dateOnly || isFullDay) { dateString = start.format(dateFormat) + ' - ' + end.format(dateFormat); + } else if (noEndTime) { + dateString = getDateTimeString(start, dateFormat, timeFormat, ' @ ', false) + ' - ' + + end.format(dateFormat); } else { dateString = getDateTimeString(start, dateFormat, timeFormat, ' @ ', false) + ' - ' + - getDateTimeString(end, dateFormat, timeFormat, ' @ ', false); + getDateTimeString(end, dateFormat, timeFormat, ' @ ', false); } } - const isFullDay = event?.dates?.all_day; - const noEndTime = event?.dates?.no_end_time; - - const multiDay = !isEventSameDay(start, end); - - if (isFullDay && !multiDay) { - if (get(event.dates, 'all_day')) { - // use UTC mode to avoid any date conversion - return moment.utc(start).format(dateFormat); - } - - return start.format(dateFormat); - } else if (noEndTime && !multiDay) { - if (withTimezone) { - if (!useLocal) { - timezoneForEvents = - `(${getDateTimeString(start, dateFormat, timeFormat, ' @ ', true, tz ? tz : 'utc')})`; - } else { - timezoneForEvents = getDateTimeString(start, dateFormat, timeFormat, ' @ ', true); - } - } - return timezoneForEvents; - } else if (isFullDay && multiDay) { - return timezoneForEvents = start.format(dateFormat) + ' - ' + end.format(dateFormat); - } else if (noEndTime && multiDay) { - if (withTimezone) { - if (!useLocal && tz) { - timezoneForEvents = - `(${getDateTimeString(start, dateFormat, timeFormat, ' @ ', true, tz) + ' - ' + - end.format(dateFormat)})`; - } else { - timezoneForEvents = - getDateTimeString(start, dateFormat, timeFormat, ' @ ', true) + ' - ' + - moment.utc(end).format(dateFormat); - } - } - return timezoneForEvents; + // no timezone info needed + if (isFullDay || dateOnly) { + return multiDay ? start.format(dateFormat) + ' - ' + end.format(dateFormat) : start.format(dateFormat); } if (withTimezone) { From 9ef228a0a90ff18bcdd5da319bb4bbbac96f357e Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Tue, 25 Apr 2023 16:07:58 +0530 Subject: [PATCH 015/261] Fix : Minor UI issue in multilingual fields, spacing needed in between [SDESK-6870] (#1793) --- client/components/fields/editor/base/multilingualText.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/fields/editor/base/multilingualText.tsx b/client/components/fields/editor/base/multilingualText.tsx index bb7baf0af..ac4bf6cc9 100644 --- a/client/components/fields/editor/base/multilingualText.tsx +++ b/client/components/fields/editor/base/multilingualText.tsx @@ -146,7 +146,7 @@ export class EditorFieldMultilingualText extends React.Component ))} From 4949713ed122a40b912fab6a14c6103e98113400 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Wed, 26 Apr 2023 08:42:00 +0530 Subject: [PATCH 016/261] Fix : Some events ingested from Onclusive do not contain the complete location info [SDCP-692] (#1794) * fix : Some events ingested from Onclusive do not contain the complete location info [SDCP-692] * update testcases * parse location.location field * fix black * update logic --- server/planning/feed_parsers/onclusive.py | 9 +++++++-- server/planning/feed_parsers/onclusive_sample.json | 4 ++-- server/planning/feed_parsers/onclusive_tests.py | 5 +++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index 5fdc26c3e..409d9cbf2 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -149,9 +149,9 @@ def parse_timezone(self, start_date, event): logger.warning("Could not find timezone for %s", event["timezone"]["timezoneAbbreviation"]) def parse_location(self, event, item): - if event.get("venue") and event.get("venueData"): + if event.get("venue"): try: - venue_data = event["venueData"][0] + venue_data = event.get("venueData", [])[0] except (IndexError, KeyError): venue_data = {} item["location"] = [ @@ -161,6 +161,11 @@ def parse_location(self, event, item): "address": self.parse_address(event), } ] + if venue_data.get("locationLon") or venue_data.get("locationLat"): + item["location"][0].setdefault( + "location", {"lat": venue_data.get("locationLat"), "lon": venue_data.get("locationLon")} + ) + elif event.get("countryName"): item["location"] = [ { diff --git a/server/planning/feed_parsers/onclusive_sample.json b/server/planning/feed_parsers/onclusive_sample.json index 614a82b0d..ed0c7a07f 100644 --- a/server/planning/feed_parsers/onclusive_sample.json +++ b/server/planning/feed_parsers/onclusive_sample.json @@ -13,7 +13,7 @@ "timezoneName": "(EDT) Eastern Daylight Savings Time", "timezoneOffset": -4.00 }, - "venue": "One King West Hotel & Residence, 1 King St W, Toronto", + "venue": "Karuizawa", "tbcVenue": false, "venueData": [ { @@ -58,7 +58,7 @@ "linkedInPage": "", "regionId": 0, "countryId": 38, - "countryName": "Canada", + "countryName": "Japan", "indicator": null, "period": null, "pressContacts": [ diff --git a/server/planning/feed_parsers/onclusive_tests.py b/server/planning/feed_parsers/onclusive_tests.py index 8a8203c00..7bf428d51 100644 --- a/server/planning/feed_parsers/onclusive_tests.py +++ b/server/planning/feed_parsers/onclusive_tests.py @@ -74,8 +74,9 @@ def test_content(self): self.assertEqual(item["name"], "Annual Forum on Anti-Money Laundering and Financial Crime") self.assertEqual(item["definition_short"], "") - self.assertEqual(item["location"][0]["name"], "One King West Hotel & Residence, 1 King St W, Toronto") - self.assertEqual(item["location"][0]["address"]["country"], "Canada") + self.assertEqual(item["location"][0]["name"], "Karuizawa") + self.assertEqual(item["location"][0]["address"]["country"], "Japan") + self.assertEqual(item["location"][0]["location"], {"lat": 43.64894, "lon": -79.378086}) self.assertEqual(1, len(item["event_contact_info"])) self.assertIsInstance(item["event_contact_info"][0], bson.ObjectId) From 59b51cdb5436d36781306ebd3fce8ec638cf4c37 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Fri, 28 Apr 2023 12:12:24 +0530 Subject: [PATCH 017/261] Fix : When using multilingual option for event fields, all required language fields should be validated [SDESK-6869] (#1788) * Fix : When using multilingual option for event fields, all required language fields should be validated [SDESK-6869] * refactore code * Address comment * Optimize multilingual validation --- client/validators/profile.ts | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/client/validators/profile.ts b/client/validators/profile.ts index 9e0fe31ba..b4d849945 100644 --- a/client/validators/profile.ts +++ b/client/validators/profile.ts @@ -1,7 +1,7 @@ import {get, isEmpty} from 'lodash'; import {gettext} from '../utils'; -export const formProfile = ({field, value, profile, errors, messages}) => { +export const formProfile = ({field, value, profile, errors, messages, diff}) => { // If the field is not enabled or no schema defined, then simply return if (!get(profile, `editor.${field}.enabled`, false) || !get(profile, `schema.${field}`)) { return; @@ -35,9 +35,41 @@ export const formProfile = ({field, value, profile, errors, messages}) => { errors[field] = gettext('Too long'); messages.push(gettext('{{ name }} is too long', {name: fieldLabel})); } - } else if (schema.required && (typeof fieldValue === 'number' ? !fieldValue : isEmpty(fieldValue))) { + } else if (schema.required && !schema.multilingual && ( + typeof fieldValue === 'number' ? !fieldValue : isEmpty(fieldValue))) { errors[field] = gettext('This field is required'); messages.push(gettext('{{ name }} is a required field', {name: fieldLabel})); + } else if (schema.required && schema.multilingual && field !== 'language') { + const multilingualField = diff?.translations?.filter((e) => e.field === field) || []; + const missingLangs = diff?.languages?.filter((lang) => !multilingualField.some(( + obj) => obj.language === lang)) || []; + const emptyValues = multilingualField.filter((obj) => obj.value === ''); + + missingLangs.forEach((qcode) => { + const name = `${fieldLabel} (${qcode})`; + const fieldError = `${field}.${qcode}`; + + errors[fieldError] = gettext('This field is required'); + messages.push(gettext('{{ name }} is a required field', {name})); + }); + + emptyValues.forEach(({language}) => { + const name = `${fieldLabel} (${language})`; + const fieldError = `${field}.${language}`; + + errors[fieldError] = gettext('This field is required'); + messages.push(gettext('{{ name }} is a required field', {name})); + }); + + Object.keys(errors).forEach((fieldError) => { + const [fieldName, lang] = fieldError.split('.'); + + if (fieldName === field && diff.languages.includes(lang)) { + if (!missingLangs.includes(lang) && !emptyValues.some((obj) => obj.language === lang)) { + delete errors[fieldError]; + } + } + }); } else if (get(schema, 'minlength', 0) > 0 && get(fieldValue, 'length', 0) < schema.minlength) { if (get(schema, 'type', 'string') === 'list') { errors[field] = gettext('Not enough'); From e4b0419f6684d9ec4ec56fc76a540ff1379eedf7 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:03:36 +0530 Subject: [PATCH 018/261] Add confirmation when removing coverage or scheduled update [SDESK-4865] (#1795) * Add confirmation when removing coverage or scheduled update [SDESK-4865] * remove second argument * refactored code --- .../components/UI/Form/InputArray/index.tsx | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/client/components/UI/Form/InputArray/index.tsx b/client/components/UI/Form/InputArray/index.tsx index d2abc3ca1..a0572fa65 100644 --- a/client/components/UI/Form/InputArray/index.tsx +++ b/client/components/UI/Form/InputArray/index.tsx @@ -6,6 +6,7 @@ import {get} from 'lodash'; import {Button} from '../../'; import {Row, LineInput} from '../'; import './style.scss'; +import {superdeskApi} from '../../../../superdeskApi'; interface IProps { field: string; @@ -58,10 +59,22 @@ export class InputArray extends React.PureComponent { } remove(index: number) { - this.props.onChange( - this.props.field, - (this.props.value ?? []).filter((value, i) => i !== index) - ); + const {gettext} = superdeskApi.localization; + const {confirm, notify} = superdeskApi.ui; + + confirm( + gettext('Remove Coverage') + ).then((response) => { + if (response) { + this.props.onChange( + this.props.field, + (this.props.value ?? []).filter((value, i) => i !== index) + ); + notify.success( + gettext('The coverage has been removed') + ); + } + }); } renderButton() { From f50bd3b3a24931e7f5e0769965e9fc50fa065f59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Fri, 28 Apr 2023 12:07:39 +0200 Subject: [PATCH 019/261] set language for onclusive events based on product (#1796) SDCP-696 --- server/planning/feed_parsers/onclusive.py | 3 ++- server/planning/feed_parsers/onclusive_tests.py | 2 +- .../feeding_services/onclusive_api_service.py | 11 +++++++++++ .../feeding_services/onclusive_api_service_tests.py | 4 +++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index 409d9cbf2..55259169b 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -102,7 +102,8 @@ def parse_item_meta(self, event, item): ) item["links"] = [event[key] for key in ("website", "website2") if event.get(key)] - item["language"] = event.get("locale") or self.default_locale + if event.get("locale"): + item["language"] = event["locale"].split("-")[0] if event.get("embargoTime") and event.get("timezone") and event["timezone"].get("timezoneOffset"): tz = datetime.timezone(datetime.timedelta(hours=event["timezone"]["timezoneOffset"])) embargoed = datetime.datetime.fromisoformat(event["embargoTime"]).replace(tzinfo=tz) diff --git a/server/planning/feed_parsers/onclusive_tests.py b/server/planning/feed_parsers/onclusive_tests.py index 7bf428d51..9a53b92f5 100644 --- a/server/planning/feed_parsers/onclusive_tests.py +++ b/server/planning/feed_parsers/onclusive_tests.py @@ -62,7 +62,7 @@ def test_content(self): ) self.assertEqual(item["occur_status"]["qcode"], "eocstat:eos5") - self.assertEqual(item["language"], "en-CA") + self.assertEqual(item["language"], "en") self.assertIn("https://www.canadianinstitute.com/anti-money-laundering-financial-crime/", item["links"]) diff --git a/server/planning/feeding_services/onclusive_api_service.py b/server/planning/feeding_services/onclusive_api_service.py index ff1f41804..b822c70f3 100644 --- a/server/planning/feeding_services/onclusive_api_service.py +++ b/server/planning/feeding_services/onclusive_api_service.py @@ -77,6 +77,7 @@ def _update(self, provider, update): LIMIT = 1000 MAX_OFFSET = int(app.config.get("ONCLUSIVE_MAX_OFFSET", 100000)) self.session = requests.Session() + self.language = "en" # make sure there is some default parser = self.get_feed_parser(provider) update["tokens"] = provider.get("tokens") or {} with timer("onclusive:update"): @@ -129,6 +130,8 @@ def _update(self, provider, update): update["tokens"].setdefault("import_finished", utcnow()) break items = parser.parse(content, provider) + for item in items: + item.setdefault("language", self.language) yield items update["tokens"]["start_offset"] = offset else: @@ -177,6 +180,7 @@ def credentials(self, provider, tokens) -> Optional[str]: data = resp.json() if data.get("refreshToken"): tokens[REFRESH_TOKEN_KEY] = data["refreshToken"] + self.set_language(data) if data.get("token"): self.token = data["token"] return self.token @@ -200,7 +204,14 @@ def renew_token(self, provider, tokens): if new_token.get("refreshToken"): tokens[REFRESH_TOKEN_KEY] = new_token["refreshToken"] self.token = new_token["token"] + self.set_language(new_token) return self.token + def set_language(self, data): + if data.get("productId") and data["productId"] == 10: + self.language = "fr" + else: + self.language = "en" + register_feeding_service_parser(OnclusiveApiService.NAME, OnclusiveApiService.FeedParser) diff --git a/server/planning/feeding_services/onclusive_api_service_tests.py b/server/planning/feeding_services/onclusive_api_service_tests.py index 01450d9af..2f624256d 100644 --- a/server/planning/feeding_services/onclusive_api_service_tests.py +++ b/server/planning/feeding_services/onclusive_api_service_tests.py @@ -39,6 +39,7 @@ def test_update(self): json={ "token": "tok", "refreshToken": "refresh", + "productId": 10, }, ) m.get( @@ -46,11 +47,12 @@ def test_update(self): json=[{"versioncreated": event["versioncreated"].isoformat()}], ) # first returns an item m.get("https://api.abc.com/api/v2/events/between?offset=1000", json=[]) # second will make it stop - list(service._update(provider, updates)) + items = list(service._update(provider, updates)) self.assertIn("tokens", updates) self.assertEqual("refresh", updates["tokens"]["refreshToken"]) self.assertIn("import_finished", updates["tokens"]) self.assertEqual(updates["last_updated"], updates["tokens"]["next_start"]) + self.assertEqual("fr", items[0][0]["language"]) provider.update(updates) updates = {} From 32f25d60a0f30c116861d554ea3e62b459fb5f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Fri, 28 Apr 2023 13:32:20 +0200 Subject: [PATCH 020/261] fix timestamps from onclusive (#1797) those are currently using London timezone, not utc. --- server/planning/feed_parsers/onclusive.py | 17 ++++++++++++++--- server/planning/feed_parsers/onclusive_tests.py | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index 55259169b..185727a59 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -12,7 +12,7 @@ CONTENT_STATE, ) from superdesk.errors import ParserError -from superdesk.utc import utcnow +from superdesk.utc import utcnow, local_to_utc from planning.common import POST_STATE from flask import current_app as app @@ -92,8 +92,8 @@ def set_occur_status(self, item): def parse_item_meta(self, event, item): item["pubstatus"] = POST_STATE.CANCELLED if event.get("deleted") else POST_STATE.USABLE - item["versioncreated"] = self.datetime(event["lastEditDate"]) - item["firstcreated"] = self.datetime(event["createdDate"]) + item["versioncreated"] = self.server_datetime(event["lastEditDate"]) + item["firstcreated"] = self.server_datetime(event["createdDate"]) item["name"] = ( event["summary"] if (event["summary"] is not None and event["summary"] != "") else event["description"] ) @@ -218,6 +218,17 @@ def datetime(self, date, time=None, timezone=None, tzinfo=None): parsed = parsed.replace(hour=parsed_time.hour, minute=parsed_time.minute, second=parsed_time.second) return parsed.replace(microsecond=0).astimezone(datetime.timezone.utc) + def server_datetime(self, date): + """Convert datetime from server timezone to utc. + + Eventually this will be in utc, so make it configurable. + """ + timezone = app.config.get("ONCLUSIVE_SERVER_TIMEZONE", "Europe/London") + parsed = datetime.datetime.fromisoformat(date.split(".")[0]).replace(microsecond=0) + if timezone: + return local_to_utc(timezone, parsed) + return parsed + def parse_contact_info(self, event, item): for contact_info in event.get("pressContacts"): item.setdefault("event_contact_info", []) diff --git a/server/planning/feed_parsers/onclusive_tests.py b/server/planning/feed_parsers/onclusive_tests.py index 9a53b92f5..a48c85f8c 100644 --- a/server/planning/feed_parsers/onclusive_tests.py +++ b/server/planning/feed_parsers/onclusive_tests.py @@ -56,9 +56,9 @@ def test_content(self): self.assertEqual(item[GUID_FIELD], "urn:onclusive:4112034") self.assertEqual(item[ITEM_TYPE], CONTENT_TYPE.EVENT) self.assertEqual(item["state"], CONTENT_STATE.INGESTED) - self.assertEqual(item["firstcreated"], datetime.datetime(2021, 5, 4, 21, 19, 10, tzinfo=datetime.timezone.utc)) + self.assertEqual(item["firstcreated"], datetime.datetime(2021, 5, 4, 20, 19, 10, tzinfo=datetime.timezone.utc)) self.assertEqual( - item["versioncreated"], datetime.datetime(2022, 5, 10, 13, 14, 34, tzinfo=datetime.timezone.utc) + item["versioncreated"], datetime.datetime(2022, 5, 10, 12, 14, 34, tzinfo=datetime.timezone.utc) ) self.assertEqual(item["occur_status"]["qcode"], "eocstat:eos5") From 6b10ddaa065a65984c06587aa4220fd9a545b2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Tue, 2 May 2023 09:07:55 +0200 Subject: [PATCH 021/261] use languages with -CA for onclusive events (#1798) so it's consistent with manually created events SDCP-696 --- server/planning/feeding_services/onclusive_api_service.py | 6 +++--- .../feeding_services/onclusive_api_service_tests.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/planning/feeding_services/onclusive_api_service.py b/server/planning/feeding_services/onclusive_api_service.py index b822c70f3..632444b09 100644 --- a/server/planning/feeding_services/onclusive_api_service.py +++ b/server/planning/feeding_services/onclusive_api_service.py @@ -77,7 +77,7 @@ def _update(self, provider, update): LIMIT = 1000 MAX_OFFSET = int(app.config.get("ONCLUSIVE_MAX_OFFSET", 100000)) self.session = requests.Session() - self.language = "en" # make sure there is some default + self.language = "en-CA" # make sure there is some default parser = self.get_feed_parser(provider) update["tokens"] = provider.get("tokens") or {} with timer("onclusive:update"): @@ -209,9 +209,9 @@ def renew_token(self, provider, tokens): def set_language(self, data): if data.get("productId") and data["productId"] == 10: - self.language = "fr" + self.language = "fr-CA" else: - self.language = "en" + self.language = "en-CA" register_feeding_service_parser(OnclusiveApiService.NAME, OnclusiveApiService.FeedParser) diff --git a/server/planning/feeding_services/onclusive_api_service_tests.py b/server/planning/feeding_services/onclusive_api_service_tests.py index 2f624256d..a55ad93a1 100644 --- a/server/planning/feeding_services/onclusive_api_service_tests.py +++ b/server/planning/feeding_services/onclusive_api_service_tests.py @@ -52,7 +52,7 @@ def test_update(self): self.assertEqual("refresh", updates["tokens"]["refreshToken"]) self.assertIn("import_finished", updates["tokens"]) self.assertEqual(updates["last_updated"], updates["tokens"]["next_start"]) - self.assertEqual("fr", items[0][0]["language"]) + self.assertEqual("fr-CA", items[0][0]["language"]) provider.update(updates) updates = {} From bb139824fcb796d87e167ec720eea234c22f0265 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Wed, 10 May 2023 12:07:20 +0530 Subject: [PATCH 022/261] As a Planning editor I want to filter the events with coverages based on the assignement status [SDESK-6743] (#1799) * create a component for assigned coverages filter * call component and define new params * reafctored code and update naming convention * add backend updation for the filter * add queries for filtering the planning items * refactored code and update queries * remove unwanted code * Fix mypy * Fix mypy issues in bool_query function * using gettext in the option --- client/api/planning.ts | 1 + .../fields/editor/AssignedCoverage.tsx | 42 +++++++++++++++++++ client/components/fields/editor/index.tsx | 2 + client/components/fields/preview/index.ts | 4 ++ client/interfaces.ts | 11 ++++- client/utils/search.ts | 2 + .../profiles/advanced_search.py | 7 ++++ server/planning/search/planning_search.py | 3 +- server/planning/search/queries/elastic.py | 29 +++++++++++++ server/planning/search/queries/planning.py | 32 ++++++++++++++ 10 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 client/components/fields/editor/AssignedCoverage.tsx diff --git a/client/api/planning.ts b/client/api/planning.ts index 1996683e9..719747e6d 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -32,6 +32,7 @@ function convertPlanningParams(params: ISearchParams): Partial g2_content_type: params.g2_content_type?.qcode, source: cvsToString(params.source, 'id'), coverage_user_id: params.coverage_user_id, + coverage_assignment_status: params.coverage_assignment_status }; } diff --git a/client/components/fields/editor/AssignedCoverage.tsx b/client/components/fields/editor/AssignedCoverage.tsx new file mode 100644 index 000000000..1adae4ec2 --- /dev/null +++ b/client/components/fields/editor/AssignedCoverage.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import {superdeskApi} from '../../../superdeskApi'; +import {ICoverageAssigned, IEditorFieldProps} from '../../../interfaces'; +import {EditorFieldSelect} from './base/select'; + +interface IProps extends IEditorFieldProps { + contentTypes: Array; + clearable?: boolean; // defaults to true + defaultValue?: ICoverageAssigned; // defaults to {} + valueAsString?: boolean; +} + +export class EditorFieldAssignedCoverageComponent extends React.PureComponent { + render() { + const {gettext} = superdeskApi.localization; + + const coverageOption = [ + {qcode: 'null', name: gettext('No Coverage Assigned')}, + {qcode: 'some', name: gettext('Some Coverages Assigned')}, + {qcode: 'all', name: gettext('All Coverages Assigned')} + ]; + const { + refNode, + ...props + } = this.props; + + return ( + + ); + } +} + diff --git a/client/components/fields/editor/index.tsx b/client/components/fields/editor/index.tsx index ec22c044f..cf6e82e91 100644 --- a/client/components/fields/editor/index.tsx +++ b/client/components/fields/editor/index.tsx @@ -53,6 +53,7 @@ import {EditorFieldCoverageContact} from './CoverageContact'; import {EditorFieldXMPFile} from './XMPFile'; import {EditorFieldScheduledUpdates} from './ScheduledUpdates'; import {EditorFieldCustomVocabularies} from './CustomVocabularies'; +import {EditorFieldAssignedCoverageComponent} from './AssignedCoverage'; export const FIELD_TO_EDITOR_COMPONENT = { anpa_category: EditorFieldCategories, @@ -142,6 +143,7 @@ export const FIELD_TO_EDITOR_COMPONENT = { xmp_file: EditorFieldXMPFile, scheduled_updates: EditorFieldScheduledUpdates, custom_vocabularies: EditorFieldCustomVocabularies, + coverage_assignment_status: EditorFieldAssignedCoverageComponent, }; // Import resource fields so that registration happens after the above diff --git a/client/components/fields/preview/index.ts b/client/components/fields/preview/index.ts index 0ba27e139..c0faf764b 100644 --- a/client/components/fields/preview/index.ts +++ b/client/components/fields/preview/index.ts @@ -38,6 +38,10 @@ const fieldOptions: {[key: string]: IPreviewHocOptions} = { props: () => ({label: superdeskApi.localization.gettext('Ad Hoc Planning')}), getValue: getPreviewBooleanString, }, + coverage_assignment_status: { + props: () => ({label: superdeskApi.localization.gettext('Coverage Assignment Status')}), + getValue: getPreviewString, + }, agendas: { props: () => ({ label: superdeskApi.localization.gettext('Agendas'), diff --git a/client/interfaces.ts b/client/interfaces.ts index 45096e351..e7643a474 100644 --- a/client/interfaces.ts +++ b/client/interfaces.ts @@ -45,7 +45,10 @@ export interface IUrgency { qcode: number; name: string; } - +export interface ICoverageAssigned { + qcode: string; + name: string; +} export interface IAssignmentPriority { name: string; qcode: number; @@ -880,6 +883,7 @@ export interface ICommonSearchParams { sortField?: SORT_FIELD; source?:string; coverage_user_id?:string; + coverage_assignment_status?:ICoverageAssigned['qcode']; } export interface IEventSearchParams extends ICommonSearchParams { @@ -901,6 +905,7 @@ export interface IPlanningSearchParams extends ICommonSearchParams; coverage_user_id?:string; - // Event Params reference?: string; location?: IEventLocation; @@ -1330,6 +1335,7 @@ export interface ISearchParams { no_coverage?: boolean; urgency?: IUrgency; g2_content_type?: IG2ContentType; + coverage_assignment_status?: ICoverageAssigned['qcode']; featured?: boolean; include_scheduled_updates?: boolean; event_item?: Array; @@ -1386,6 +1392,7 @@ export interface ISearchAPIParams { featured?: boolean; include_scheduled_updates?: boolean; event_item?: string; + coverage_assignment_status?:ICoverageAssigned['qcode'] // Combined Params include_associated_planning?: boolean; diff --git a/client/utils/search.ts b/client/utils/search.ts index 2301b6b0b..9fd885841 100644 --- a/client/utils/search.ts +++ b/client/utils/search.ts @@ -94,6 +94,7 @@ export function planningParamsToSearchParams(params: IPlanningSearchParams): ISe no_agenda_assigned: params.noAgendaAssigned, agendas: params.agendas, coverage_user_id: params.coverage_user_id, + coverage_assignment_status: params.coverage_assignment_status }; } @@ -105,6 +106,7 @@ export function searchParamsToPlanningParams(params: ISearchParams): IPlanningSe agendas: params.agendas, noAgendaAssigned: params.no_agenda_assigned, adHocPlanning: params.ad_hoc_planning, + coverage_assignment_status: params.coverage_assignment_status, excludeRescheduledAndCancelled: params.exclude_rescheduled_and_cancelled, featured: params.featured, includeScheduledUpdates: params.include_scheduled_updates, diff --git a/server/planning/content_profiles/profiles/advanced_search.py b/server/planning/content_profiles/profiles/advanced_search.py index 678f67c22..8c1fb97d7 100644 --- a/server/planning/content_profiles/profiles/advanced_search.py +++ b/server/planning/content_profiles/profiles/advanced_search.py @@ -318,6 +318,13 @@ "search_enabled": True, "filter_enabled": True, }, + "coverage_assignment_status": { + "enabled": True, + "index": 6, + "group": "planning", + "search_enabled": True, + "filter_enabled": True, + }, "ad_hoc_planning": { "enabled": True, "index": 6, diff --git a/server/planning/search/planning_search.py b/server/planning/search/planning_search.py index 300585ac1..f307df72c 100644 --- a/server/planning/search/planning_search.py +++ b/server/planning/search/planning_search.py @@ -19,6 +19,7 @@ from planning.planning.planning import planning_schema from planning.events.events_schema import events_schema +from typing import Any, Dict logger = logging.getLogger(__name__) @@ -87,7 +88,7 @@ def get(self, req, lookup): def _get_date_fields(self, resource: str): datasource = self.elastic.get_datasource(resource) - schema = {} + schema: Dict[str, Any] = {} schema.update(app.config["DOMAIN"][datasource[0]].get("schema", {})) schema.update(app.config["DOMAIN"][resource].get("schema", {})) return get_dates(schema) diff --git a/server/planning/search/queries/elastic.py b/server/planning/search/queries/elastic.py index cd43ec2d6..adca47082 100644 --- a/server/planning/search/queries/elastic.py +++ b/server/planning/search/queries/elastic.py @@ -293,3 +293,32 @@ def date_range(query: ElasticRangeParams): return range_date(query) else: return field_range(query) + + +def nested(path: str, query: Dict[str, Any], score_mode: Optional[str] = None) -> Dict[str, Any]: + nested_query = {"path": path, "query": query} + if score_mode is not None: + nested_query["score_mode"] = score_mode + return {"nested": nested_query} + + +def bool_query( + must: List[Dict[str, Any]] = [], + must_not: List[Dict[str, Any]] = [], + should: List[Dict[str, Any]] = [], + filter: List[Dict[str, Any]] = [], +) -> Dict[str, Any]: + bool_query_dict: Dict[str, Any] = {} + if must: + bool_query_dict["must"] = must + if must_not: + bool_query_dict["must_not"] = must_not + if should: + bool_query_dict["should"] = should + if filter: + bool_query_dict["filter"] = filter + return {"bool": bool_query_dict} + + +def exists(field: str) -> Dict[str, Any]: + return {"exists": {"field": field}} diff --git a/server/planning/search/queries/planning.py b/server/planning/search/queries/planning.py index b69e0c336..8d4418534 100644 --- a/server/planning/search/queries/planning.py +++ b/server/planning/search/queries/planning.py @@ -264,6 +264,36 @@ def set_search_sort(params: Dict[str, Any], query: elastic.ElasticQuery): query.sort.append({field: {"order": order}}) +def search_coverage_assignment_status(params: Dict[str, Any], query: elastic.ElasticQuery): + if params.get("coverage_assignment_status") and not strtobool(params.get("no_coverage", False)): + if params["coverage_assignment_status"] == "null": + query.must_not.append( + elastic.nested( + path="coverages", + query=elastic.bool_query(must=[elastic.exists(field="coverages.assigned_to.assignment_id")]), + ) + ) + elif params["coverage_assignment_status"] == "some": + query.must.append( + elastic.nested( + path="coverages", + query=elastic.bool_query(must=[elastic.exists(field="coverages.assigned_to.assignment_id")]), + ) + ) + elif params["coverage_assignment_status"] == "all": + query.must.append( + elastic.nested( + path="coverages", query=elastic.bool_query(must=[elastic.exists("coverages.coverage_id")]) + ) + ) + query.must_not.append( + elastic.nested( + path="coverages", + query=elastic.bool_query(must_not=[elastic.exists("coverages.assigned_to.assignment_id")]), + ) + ) + + PLANNING_SEARCH_FILTERS: List[Callable[[Dict[str, Any], elastic.ElasticQuery], None]] = [ search_planning, search_agendas, @@ -279,6 +309,7 @@ def set_search_sort(params: Dict[str, Any], query: elastic.ElasticQuery): search_dates, set_search_sort, search_coverage_assigned_user, + search_coverage_assignment_status, ] PLANNING_SEARCH_FILTERS.extend(COMMON_SEARCH_FILTERS) @@ -295,6 +326,7 @@ def set_search_sort(params: Dict[str, Any], query: elastic.ElasticQuery): "include_scheduled_updates", "event_item", "coverage_user_id", + "coverage_assignment_status", ] PLANNING_PARAMS.extend(COMMON_PARAMS) From b7bda8fbf1a172f4347bac50cbaa9b1e0610f62e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Wed, 10 May 2023 09:25:46 +0200 Subject: [PATCH 023/261] use /date api for initial import from onclusive (#1801) use `/date` api instead of `/between` and log imported events into a text file so we can check that if needed. SDCP-699 --- server/planning/feed_parsers/onclusive.py | 61 +++++++++++-------- .../feed_parsers/onclusive_sample.json | 6 +- .../feeding_services/onclusive_api_service.py | 49 +++++++-------- .../onclusive_api_service_tests.py | 9 ++- 4 files changed, 66 insertions(+), 59 deletions(-) diff --git a/server/planning/feed_parsers/onclusive.py b/server/planning/feed_parsers/onclusive.py index 185727a59..50ea82213 100644 --- a/server/planning/feed_parsers/onclusive.py +++ b/server/planning/feed_parsers/onclusive.py @@ -52,27 +52,30 @@ def can_parse(self, content): def parse(self, content, provider=None): all_events = [] - for event in content: - guid = "urn:onclusive:{}".format(event["itemId"]) + with open("/tmp/onclusive.txt", "+a") as debug_output: + for event in content: + print(event["itemId"], event["startDate"], event["summary"], file=debug_output) - item = { - GUID_FIELD: guid, - ITEM_TYPE: CONTENT_TYPE.EVENT, - "state": CONTENT_STATE.INGESTED, - } + guid = "urn:onclusive:{}".format(event["itemId"]) - try: - self.set_occur_status(item) - self.parse_item_meta(event, item) - self.parse_location(event, item) - self.parse_event_details(event, item) - self.parse_category(event, item) - self.parse_contact_info(event, item) - all_events.append(item) - except EmbargoedException: - logger.info("Ignoring embargoed event %s", event["itemId"]) - except Exception as error: - logger.exception("error %s when parsing event %s", error, event["itemId"], extra=dict(event=event)) + item = { + GUID_FIELD: guid, + ITEM_TYPE: CONTENT_TYPE.EVENT, + "state": CONTENT_STATE.INGESTED, + } + + try: + self.set_occur_status(item) + self.parse_item_meta(event, item) + self.parse_location(event, item) + self.parse_event_details(event, item) + self.parse_category(event, item) + self.parse_contact_info(event, item) + all_events.append(item) + except EmbargoedException: + logger.info("Ignoring embargoed event %s", event["itemId"]) + except Exception as error: + logger.exception("error %s when parsing event %s", error, event["itemId"], extra=dict(event=event)) return all_events def set_occur_status(self, item): @@ -92,8 +95,8 @@ def set_occur_status(self, item): def parse_item_meta(self, event, item): item["pubstatus"] = POST_STATE.CANCELLED if event.get("deleted") else POST_STATE.USABLE - item["versioncreated"] = self.server_datetime(event["lastEditDate"]) - item["firstcreated"] = self.server_datetime(event["createdDate"]) + item["versioncreated"] = self.server_datetime(event["lastEditDate"], event.get("lastEditDateUtc")) + item["firstcreated"] = self.server_datetime(event["createdDate"], event.get("createdDateUtc")) item["name"] = ( event["summary"] if (event["summary"] is not None and event["summary"] != "") else event["description"] ) @@ -147,7 +150,11 @@ def parse_timezone(self, start_date, event): if abbr == event["timezone"]["timezoneAbbreviation"] and offset == event["timezone"]["timezoneOffset"]: return tzname else: - logger.warning("Could not find timezone for %s", event["timezone"]["timezoneAbbreviation"]) + logger.warning( + "Could not find timezone for %s event %s", + event["timezone"]["timezoneAbbreviation"], + event["itemId"], + ) def parse_location(self, event, item): if event.get("venue"): @@ -218,16 +225,20 @@ def datetime(self, date, time=None, timezone=None, tzinfo=None): parsed = parsed.replace(hour=parsed_time.hour, minute=parsed_time.minute, second=parsed_time.second) return parsed.replace(microsecond=0).astimezone(datetime.timezone.utc) - def server_datetime(self, date): + def server_datetime(self, date, date_utc=None): """Convert datetime from server timezone to utc. Eventually this will be in utc, so make it configurable. """ - timezone = app.config.get("ONCLUSIVE_SERVER_TIMEZONE", "Europe/London") + if date_utc: + return ( + datetime.datetime.fromisoformat(date_utc.split(".")[0]).replace(microsecond=0).replace(tzinfo=pytz.utc) + ) parsed = datetime.datetime.fromisoformat(date.split(".")[0]).replace(microsecond=0) + timezone = app.config.get("ONCLUSIVE_SERVER_TIMEZONE", "Europe/London") if timezone: return local_to_utc(timezone, parsed) - return parsed + return parsed.replace(tzinfo=pytz.utc) def parse_contact_info(self, event, item): for contact_info in event.get("pressContacts"): diff --git a/server/planning/feed_parsers/onclusive_sample.json b/server/planning/feed_parsers/onclusive_sample.json index ed0c7a07f..fa499611b 100644 --- a/server/planning/feed_parsers/onclusive_sample.json +++ b/server/planning/feed_parsers/onclusive_sample.json @@ -46,8 +46,10 @@ "plannedBy": [ 4708 ], - "createdDate": "2021-05-04T21:19:10.2", - "lastEditDate": "2022-05-10T13:14:34.873", + "createdDate": "2021-05-04T22:19:10.2", + "createdDateUtc": "2021-05-04T20:19:10.2", + "lastEditDate": "2022-05-10T15:14:34.873", + "lastEditDateUtc": "2022-05-10T12:14:34.873", "deleted": false, "deletionDate": null, "website": "https://www.canadianinstitute.com/anti-money-laundering-financial-crime/", diff --git a/server/planning/feeding_services/onclusive_api_service.py b/server/planning/feeding_services/onclusive_api_service.py index 632444b09..66e116e59 100644 --- a/server/planning/feeding_services/onclusive_api_service.py +++ b/server/planning/feeding_services/onclusive_api_service.py @@ -73,9 +73,8 @@ def _update(self, provider, update): :type update: dict :return: a list of events which can be saved. """ - URL = provider["config"]["url"] - LIMIT = 1000 - MAX_OFFSET = int(app.config.get("ONCLUSIVE_MAX_OFFSET", 100000)) + BASE_URL = provider["config"]["url"] + LIMIT = 2000 self.session = requests.Session() self.language = "en-CA" # make sure there is some default parser = self.get_feed_parser(provider) @@ -89,56 +88,52 @@ def _update(self, provider, update): ) # next time start from here, onclusive api does not use seconds if update["tokens"].get("import_finished"): # populate it for cases when import was done before we introduced the field - update["tokens"].setdefault("next_start", update["tokens"]["import_finished"] - timedelta(hours=8)) - url = urljoin(URL, "/api/v2/events/latest") + update["tokens"].setdefault("next_start", update["tokens"]["import_finished"] - timedelta(hours=5)) + url = urljoin(BASE_URL, "/api/v2/events/latest") start = update["tokens"]["next_start"] - timedelta( hours=3, # add a buffer, also not sure about timezone there ) update["tokens"]["next_start"] = update["last_updated"] logger.info("Fetching updates since %s", start.isoformat()) - start_offset = 0 params = dict( date=start.strftime("%Y%m%d"), time=start.strftime("%H%M"), limit=LIMIT, ) + iterations = range(0, LIMIT, LIMIT) + iterations_param = "offset" else: + iterations_param = "date" days = int(provider["config"].get("days_to_ingest") or 365) logger.info("Fetching %d days", days) - url = urljoin(URL, "/api/v2/events/between") + url = urljoin(BASE_URL, "/api/v2/events/date") update["tokens"].setdefault("start_date", update["last_updated"]) # keep for next round update["tokens"].setdefault("next_start", update["last_updated"]) # after import continue from start - update["tokens"].setdefault("start_offset", 0) - start = update["tokens"]["start_date"] - end = start + timedelta(days=days) - start_offset = update["tokens"]["start_offset"] - if start_offset: - logger.info("Continuing from %d", start_offset) + start_date = update["tokens"]["start_date"].date() params = dict( - startDate=start.strftime("%Y%m%d"), - endDate=end.strftime("%Y%m%d"), - limit=LIMIT + 100, # add some overlap to hopefully avoid missing events + limit=LIMIT, + ) + processed_date = update["tokens"].get(iterations_param, "") + iterations = ( + date + for date in ((start_date + timedelta(days=i)).strftime("%Y%m%d") for i in range(0, days)) + if date > processed_date # when continuing skip previously ingested days ) logger.info("ingest from onclusive %s with params %s", url, params) try: - for offset in range(start_offset, MAX_OFFSET, LIMIT): - params["offset"] = offset - logger.debug("params %s", params) + for i in iterations: + params[iterations_param] = i + logger.info("Onclusive PARAMS %s", params) content = self._fetch(url, params, provider, update["tokens"]) - if not content: - logger.info("done ingesting offset=%d last_updated=%s", offset, update["last_updated"]) - update["tokens"].setdefault("import_finished", utcnow()) - break items = parser.parse(content, provider) + logger.info("Onclusive returned %d items", len(items)) for item in items: item.setdefault("language", self.language) yield items - update["tokens"]["start_offset"] = offset - else: - logger.warning("some items were not fetched due to the limit") + update["tokens"][iterations_param] = i + update["tokens"].setdefault("import_finished", utcnow()) except SoftTimeLimitExceeded: logger.warning("stopped due to time limit, tokens=%s", update["tokens"]) - # let it finish the current job and update the start_offset for next time def _fetch(self, url, params, provider, tokens): for i in range(5): diff --git a/server/planning/feeding_services/onclusive_api_service_tests.py b/server/planning/feeding_services/onclusive_api_service_tests.py index a55ad93a1..243864d02 100644 --- a/server/planning/feeding_services/onclusive_api_service_tests.py +++ b/server/planning/feeding_services/onclusive_api_service_tests.py @@ -1,10 +1,8 @@ from planning.feed_parsers.onclusive import OnclusiveFeedParser -from planning.tests import TestCase from .onclusive_api_service import OnclusiveApiService from unittest.mock import MagicMock -from datetime import datetime +from datetime import datetime, timedelta -import os import flask import unittest import requests_mock @@ -21,6 +19,7 @@ def setUp(self) -> None: def test_update(self): event = {"versioncreated": datetime.fromisoformat("2023-03-01T08:00:00")} with self.app.app_context(): + now = datetime.utcnow() service = OnclusiveApiService() service.get_feed_parser = MagicMock(return_value=parser) parser.parse.return_value = [event] @@ -43,10 +42,10 @@ def test_update(self): }, ) m.get( - "https://api.abc.com/api/v2/events/between?offset=0", + "https://api.abc.com/api/v2/events/date?date={}".format(now.strftime("%Y%m%d")), json=[{"versioncreated": event["versioncreated"].isoformat()}], ) # first returns an item - m.get("https://api.abc.com/api/v2/events/between?offset=1000", json=[]) # second will make it stop + m.get("https://api.abc.com/api/v2/events/date", json=[]) # ones won't items = list(service._update(provider, updates)) self.assertIn("tokens", updates) self.assertEqual("refresh", updates["tokens"]["refreshToken"]) From 33ba5281c0635344c9c62ba2b59827fdc529aec2 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Tue, 16 May 2023 14:48:05 +1000 Subject: [PATCH 024/261] [SDESK-6829] fix(ingest_rule): Skip autopost action if item is already posted (#1802) Multiple posts will still occur if a Calendar or Agenda is to be added in the ingest rule (due to the separation of ingest, and executing routing rules) --- server/planning/io/ingest_rule_handler.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/planning/io/ingest_rule_handler.py b/server/planning/io/ingest_rule_handler.py index b593cf7fb..0a0462210 100644 --- a/server/planning/io/ingest_rule_handler.py +++ b/server/planning/io/ingest_rule_handler.py @@ -75,18 +75,14 @@ def apply_rule(self, rule: Dict[str, Any], ingest_item: Dict[str, Any], routing_ if updates is not None: ingest_item.update(updates) - if attributes.get("autopost", False) and (updates is None or not self._is_original_posted(ingest_item)): - # Only autopost if: - # * The original has not been posted yet - # * Or there are no updates applied (from assigning Calendar/Agenda to the item) - # because updating the Calendar/Agenda will automatically re-post the item for us + if attributes.get("autopost", False): self.process_autopost(ingest_item) def _is_original_posted(self, ingest_item: Dict[str, Any]): service = get_resource_service("events" if ingest_item[ITEM_TYPE] == CONTENT_TYPE.EVENT else "planning") original = service.find_one(req=None, _id=ingest_item.get(config.ID_FIELD)) - return original.get("pubstatus") in [POST_STATE.USABLE, POST_STATE.CANCELLED] + return original is not None and original.get("pubstatus") in [POST_STATE.USABLE, POST_STATE.CANCELLED] def add_event_calendars(self, ingest_item: Dict[str, Any], attributes: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Add Event Calendars from Routing Rule Action onto the ingested item""" @@ -169,9 +165,13 @@ def add_planning_agendas(self, ingest_item: Dict[str, Any], attributes: Dict[str def process_autopost(self, ingest_item: Dict[str, Any]): """Automatically post this item""" - logger.info(ingest_item) + if self._is_original_posted(ingest_item): + # No need to autopost this item + # As the original is already posted + # And any updates from ingest should automatically re-post this item + return + item_id = ingest_item.get(config.ID_FIELD) - logger.info(f"Posting item {item_id}") update_post_item( { "pubstatus": ingest_item.get("pubstatus") or POST_STATE.USABLE, From f2f5e4f9828da8a5bbe632036ab464f9c3aaecba Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Wed, 17 May 2023 10:51:26 +0530 Subject: [PATCH 025/261] update label name (#1805) --- client/apps/Planning/PlanningListSubNav.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/apps/Planning/PlanningListSubNav.tsx b/client/apps/Planning/PlanningListSubNav.tsx index 7b3d05f9d..b3f893b24 100644 --- a/client/apps/Planning/PlanningListSubNav.tsx +++ b/client/apps/Planning/PlanningListSubNav.tsx @@ -191,7 +191,7 @@ class PlanningListSubNavComponent extends React.Component { {this.props.activefilter == PLANNING_VIEW.EVENTS ? ' ' : (
- {gettext('Assigned Coverages Items :')} + {gettext('Assigned to: ')} {this.props.users.find( From c596b2e554f81022a4a1ffbee9c8c0f5654c55f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Ja=C5=A1ek?= Date: Wed, 17 May 2023 10:04:31 +0200 Subject: [PATCH 026/261] fix server requirements (#1804) * fix server requirements * fix e2e core server version --- e2e/server/requirements.txt | 2 +- server/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/server/requirements.txt b/e2e/server/requirements.txt index 3fe295541..1e1bc85a1 100644 --- a/e2e/server/requirements.txt +++ b/e2e/server/requirements.txt @@ -1,4 +1,4 @@ gunicorn==19.7.1 honcho==1.0.1 -git+https://github.com/superdesk/superdesk-core.git@hotfix/2.6.3#egg=superdesk-core +git+https://github.com/superdesk/superdesk-core.git@v2.6.4#egg=superdesk-core -e ../../ diff --git a/server/requirements.txt b/server/requirements.txt index 90080cda5..5b296b546 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -19,4 +19,4 @@ pytest-env -e . # Install in editable state so we get feature fixtures --e git+https://github.com/superdesk/superdesk-core.git@hotfix/2.6.3#egg=superdesk-core +-e git+https://github.com/superdesk/superdesk-core.git@v2.6.4#egg=superdesk-core From 6d5a6a56f45a4c6ee18a63764ba71c53f8c6a1a9 Mon Sep 17 00:00:00 2001 From: Petr Jasek Date: Wed, 17 May 2023 11:16:00 +0200 Subject: [PATCH 027/261] release 2.6.2 --- package-lock.json | 2 +- package.json | 2 +- server/planning/__init__.py | 2 +- setup.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8283bee7..999d9a9e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "superdesk-planning", - "version": "2.6.0", + "version": "2.6.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 1afc79298..149816456 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superdesk-planning", - "version": "2.6.0", + "version": "2.6.2", "license": "AGPL-3.0", "description": "", "repository": { diff --git a/server/planning/__init__.py b/server/planning/__init__.py index 15bcdd2ca..cd2ed8487 100644 --- a/server/planning/__init__.py +++ b/server/planning/__init__.py @@ -74,7 +74,7 @@ import planning.io # noqa from planning.planning_download import init_app as init_planning_download_app -__version__ = "2.6.0" +__version__ = "2.6.2" _SERVER_PATH = os.path.dirname(os.path.realpath(__file__)) diff --git a/setup.py b/setup.py index c089fd7f1..a42543d0b 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name="superdesk-planning", - version="2.6.0", + version="2.6.2", description=DESCRIPTION, long_description=DESCRIPTION, package_dir={'': 'server'}, From 4c531f8e81176e32d5bad81902ba45cc840bc4b7 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Thu, 18 May 2023 15:26:33 +0530 Subject: [PATCH 028/261] Fix: Update "Some Coverage Assigned" Filter to Include Items with At Least One Coverage is not Assigned [SDESK-6743] (#1803) * As a Planning editor I want to filter the events with coverages based on the assignement status[SDESK-6743] * added comment * update comments * fix black * refactored code using black * fix flake8 --- server/planning/search/queries/planning.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/server/planning/search/queries/planning.py b/server/planning/search/queries/planning.py index 8d4418534..b6ae60660 100644 --- a/server/planning/search/queries/planning.py +++ b/server/planning/search/queries/planning.py @@ -274,12 +274,28 @@ def search_coverage_assignment_status(params: Dict[str, Any], query: elastic.Ela ) ) elif params["coverage_assignment_status"] == "some": + """ + Add a nested query to filter documents where at + least one coverage has assigned_to.assignment_id present + """ query.must.append( elastic.nested( path="coverages", query=elastic.bool_query(must=[elastic.exists(field="coverages.assigned_to.assignment_id")]), ) ) + + """ + Add a nested query to filter documents where at least + one coverage does not have assigned_to.assignment_id + """ + query.must.append( + elastic.nested( + path="coverages", + query=elastic.bool_query(must_not=[elastic.exists(field="coverages.assigned_to.assignment_id")]), + ) + ) + elif params["coverage_assignment_status"] == "all": query.must.append( elastic.nested( From bc24ffbde4b9e1f363d3d30d312c8d9b61030a80 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Fri, 19 May 2023 19:19:59 +0530 Subject: [PATCH 029/261] Improvement the behavior to remove the date label and day browser on small screens [SDESK-6930] (#1806) * remove the date label and day browser on small screens * address comment * update class name * use container query instead of media * address comment * refactore code * minor change * add span for filter name --- client/apps/Planning/PlanningListSubNav.tsx | 29 ++++++++++----------- client/apps/Planning/SubNavDatePicker.tsx | 2 +- client/apps/style.scss | 13 +++++++++ 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/client/apps/Planning/PlanningListSubNav.tsx b/client/apps/Planning/PlanningListSubNav.tsx index b3f893b24..4f47a17de 100644 --- a/client/apps/Planning/PlanningListSubNav.tsx +++ b/client/apps/Planning/PlanningListSubNav.tsx @@ -188,22 +188,21 @@ class PlanningListSubNavComponent extends React.Component { + {this.props.activefilter == PLANNING_VIEW.EVENTS ? ' ' : ( +
+ {gettext('Assigned to:')} + + + {this.props.users.find( + (user) => user._id == this.props.coverageUser + )?.display_name ?? gettext('ALL')} + + + +
+ )}
- {this.props.activefilter == PLANNING_VIEW.EVENTS ? ' ' : ( -
- {gettext('Assigned to: ')} - - - {this.props.users.find( - (user) => user._id == this.props.coverageUser)?.display_name ?? gettext('ALL')} - - - - -
- )} - - + {this.props.listViewType === LIST_VIEW_TYPE.LIST ? (
{ render() { return ( - + Date: Mon, 22 May 2023 12:37:05 +0530 Subject: [PATCH 030/261] update elastic queries to disregard cancelled coverages [SDESK-6743] (#1808) --- server/planning/search/queries/planning.py | 36 +++++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/server/planning/search/queries/planning.py b/server/planning/search/queries/planning.py index b6ae60660..6ecbe48c4 100644 --- a/server/planning/search/queries/planning.py +++ b/server/planning/search/queries/planning.py @@ -270,7 +270,16 @@ def search_coverage_assignment_status(params: Dict[str, Any], query: elastic.Ela query.must_not.append( elastic.nested( path="coverages", - query=elastic.bool_query(must=[elastic.exists(field="coverages.assigned_to.assignment_id")]), + query=elastic.bool_query( + must=[elastic.exists(field="coverages.assigned_to.assignment_id")], + must_not=[elastic.term("coverages.workflow_status", "cancelled")], + ), + ) + ) + query.must.append( + elastic.nested( + path="coverages", + query=elastic.bool_query(must_not=[elastic.term("coverages.workflow_status", "cancelled")]), ) ) elif params["coverage_assignment_status"] == "some": @@ -281,7 +290,10 @@ def search_coverage_assignment_status(params: Dict[str, Any], query: elastic.Ela query.must.append( elastic.nested( path="coverages", - query=elastic.bool_query(must=[elastic.exists(field="coverages.assigned_to.assignment_id")]), + query=elastic.bool_query( + must=[elastic.exists(field="coverages.assigned_to.assignment_id")], + must_not=[elastic.term("coverages.workflow_status", "cancelled")], + ), ) ) @@ -292,20 +304,34 @@ def search_coverage_assignment_status(params: Dict[str, Any], query: elastic.Ela query.must.append( elastic.nested( path="coverages", - query=elastic.bool_query(must_not=[elastic.exists(field="coverages.assigned_to.assignment_id")]), + query=elastic.bool_query( + must_not=[ + elastic.exists(field="coverages.assigned_to.assignment_id"), + elastic.term("coverages.workflow_status", "cancelled"), + ] + ), ) ) elif params["coverage_assignment_status"] == "all": query.must.append( elastic.nested( - path="coverages", query=elastic.bool_query(must=[elastic.exists("coverages.coverage_id")]) + path="coverages", + query=elastic.bool_query( + must=[elastic.exists("coverages.assigned_to.assignment_id")], + must_not=[elastic.term("coverages.workflow_status", "cancelled")], + ), ) ) query.must_not.append( elastic.nested( path="coverages", - query=elastic.bool_query(must_not=[elastic.exists("coverages.assigned_to.assignment_id")]), + query=elastic.bool_query( + must_not=[ + elastic.exists("coverages.assigned_to.assignment_id"), + elastic.term("coverages.workflow_status", "cancelled"), + ] + ), ) ) From bf82c0a48221459a58a7c11a378947a85250a210 Mon Sep 17 00:00:00 2001 From: MarkLark86 Date: Tue, 23 May 2023 13:35:00 +1000 Subject: [PATCH 031/261] [SDESK-6851] Refactor locking mechanism (#1789) * [SDCP-667] New endpoint to get all item locks * ui: Refactor lock code / generic * ui: Refactor lock code / events * ui: Refactor lock code / planning * ui: Refactor lock code / assignments * ui: Refactor lock code / modals * ui: fix tests * fix lint error * fix tests, add tests * fix lint * fix: Exceptions thrown when trying to create new FeaturedStory * Add unit tests for locking api * Rename Redux lock actions to SET_ITEM_AS --- client/actions/assignments/api.ts | 81 +-- client/actions/assignments/notifications.ts | 35 +- client/actions/assignments/tests/api_test.ts | 51 +- .../assignments/tests/notification_test.ts | 28 +- client/actions/assignments/tests/ui_test.ts | 296 +-------- client/actions/assignments/ui.ts | 235 ++----- client/actions/events/api.ts | 114 +--- client/actions/events/notifications.ts | 17 +- client/actions/events/tests/api_test.ts | 97 --- .../events/tests/notifications_test.ts | 18 +- client/actions/events/tests/ui_test.ts | 39 +- client/actions/events/ui.ts | 31 +- client/actions/index.ts | 2 - client/actions/locks.ts | 154 ----- client/actions/main.ts | 56 +- client/actions/multiSelect.ts | 6 +- client/actions/planning/api.ts | 110 +-- client/actions/planning/featuredPlanning.ts | 35 +- client/actions/planning/notifications.ts | 13 +- client/actions/planning/tests/api_test.ts | 97 --- .../planning/tests/notifications_test.ts | 18 +- client/actions/planning/tests/ui_test.ts | 130 ++-- client/actions/planning/ui.ts | 53 +- client/actions/tests/locks_test.ts | 94 --- client/actions/tests/main_test.ts | 57 +- client/api/assignments.ts | 10 + client/api/autosave.ts | 10 + client/api/events.ts | 10 - client/api/featured.ts | 26 +- client/api/index.ts | 4 + client/api/locks.ts | 319 +++++++++ client/api/planning.ts | 31 - client/api/tests/api_locks_test.ts | 290 ++++++++ client/apps/Assignments/AssignmentPreview.tsx | 5 +- client/components/Events/EventItem.tsx | 6 +- .../EventMetadata/RelatedEventListItem.tsx | 4 +- .../components/Events/EventPreviewHeader.tsx | 11 +- .../forms/assignCalendarForm.tsx | 6 +- .../forms/cancelEventForm.tsx | 3 +- .../forms/cancelPlanningCoveragesForm.tsx | 5 +- .../forms/convertToRecurringEventForm.tsx | 3 +- .../forms/editPriorityForm.tsx | 3 +- .../forms/postponeEventForm.tsx | 4 +- .../forms/rescheduleEventForm.tsx | 3 +- .../forms/updateAssignmentForm.tsx | 3 +- .../forms/updateEventRepetitionsForm.tsx | 3 +- .../forms/updateRecurringEventsForm.tsx | 6 +- .../forms/updateTimeForm.tsx | 3 +- .../Main/ItemEditor/EditorHeader.tsx | 2 +- .../Main/ItemEditor/EditorItemActions.tsx | 2 + .../components/Main/ItemEditor/ItemManager.ts | 55 +- .../Main/ItemEditor/tests/ItemManager_test.ts | 185 ++---- .../FeaturedPlanning/FeaturedPlanningItem.tsx | 3 +- .../Planning/PlanningEditor/index.tsx | 7 +- client/components/Planning/PlanningItem.tsx | 3 +- .../Planning/PlanningPreviewHeader.tsx | 11 +- .../RelatedPlanningListItem.tsx | 4 +- .../WorkqueueContainer_test.tsx | 57 +- client/constants/locks.ts | 2 + .../controllers/AddToPlanningController.tsx | 3 +- client/controllers/AssignmentController.tsx | 4 +- .../AssignmentPreviewController.tsx | 4 +- client/controllers/PlanningController.tsx | 3 +- client/interfaces.ts | 114 +++- client/reducers/events.ts | 9 - client/reducers/locks.ts | 114 ++-- client/reducers/planning.ts | 9 - client/reducers/tests/locks_test.ts | 145 ++-- client/selectors/locks.ts | 103 ++- client/selectors/tests/locks_test.ts | 33 + client/utils/assignments.ts | 120 ++-- client/utils/events.ts | 483 +++++++++----- client/utils/index.ts | 2 +- client/utils/locks.ts | 145 ++-- client/utils/planning.ts | 624 ++++++++++++------ client/utils/testApi.ts | 5 + client/utils/testData.ts | 82 ++- client/utils/tests/events_test.ts | 52 +- client/utils/tests/locks_test.ts | 56 +- client/utils/tests/planning_test.ts | 145 ++-- server/planning/__init__.py | 2 + server/planning/assignments/assignments.py | 1 + server/planning/item_lock.py | 21 +- server/planning/planning/planning_featured.py | 4 +- server/planning/planning_locks.py | 148 +++++ 85 files changed, 2789 insertions(+), 2608 deletions(-) delete mode 100644 client/actions/locks.ts delete mode 100644 client/actions/tests/locks_test.ts create mode 100644 client/api/assignments.ts create mode 100644 client/api/locks.ts create mode 100644 client/api/tests/api_locks_test.ts create mode 100644 server/planning/planning_locks.py diff --git a/client/actions/assignments/api.ts b/client/actions/assignments/api.ts index b451ccf8f..35c520c1d 100644 --- a/client/actions/assignments/api.ts +++ b/client/actions/assignments/api.ts @@ -3,12 +3,13 @@ import {get, cloneDeep, has, pick} from 'lodash'; import {appConfig} from 'appConfig'; import {IAssignmentItem} from '../../interfaces'; +import {planningApi} from '../../superdeskApi'; import * as selectors from '../../selectors'; import * as actions from '../'; import {ASSIGNMENTS, ALL_DESKS, SORT_DIRECTION} from '../../constants'; import planningUtils from '../../utils/planning'; -import {lockUtils, getErrorMessage, isExistingItem, gettext} from '../../utils'; +import {getErrorMessage, isExistingItem, gettext} from '../../utils'; import planning from '../planning'; import {assignmentsViewRequiresArchiveItems} from '../../components/Assignments/AssignmentItem/fields'; @@ -240,24 +241,6 @@ const fetchAssignmentById = (id, force = false, recieve = true) => ( } ); -/** - * Action dispatcher to query the API for all Assignments that are currently locked - * @return Array of locked Assignments - */ -const queryLockedAssignments = () => ( - (dispatch, getState, {api}) => ( - api('assignments').query({ - source: JSON.stringify( - {query: {constant_score: {filter: {exists: {field: 'lock_session'}}}}} - ), - }) - .then( - (data) => Promise.resolve(data._items), - (error) => Promise.reject(error) - ) - ) -); - /** * Action to receive the list of Assignments and store them in the store * Also loads all the associated contacts (if any) @@ -394,53 +377,6 @@ const revert = (item) => ( ) ); -/** - * Action to lock an assignment - * @param {IAssignmentItem} assignment - Assignment to be unlocked - * @param {String} action - The action to assign to the lock - * @return Promise - */ -const lock = (assignment: IAssignmentItem, action: string = 'edit') => ( - (dispatch, getState, {api, notify}) => { - if (lockUtils.isItemLockedInThisSession( - assignment, - selectors.general.session(getState()), - selectors.locks.getLockedItems(getState()) - )) { - return Promise.resolve(assignment); - } - - return api('assignments_lock', assignment).save({}, {lock_action: action}) - .then( - (lockedItem: IAssignmentItem) => lockedItem, - (error) => { - const msg = get(error, 'data._message') || 'Could not lock the assignment.'; - - notify.error(msg); - if (error) throw error; - }); - } -); - -/** - * Action to unlock an assignment - * @param {IAssignmentItem} assignment - Assignment to be unlocked - * @return Promise - */ -const unlock = (assignment: IAssignmentItem) => ( - (dispatch, getState, {api, notify}) => ( - api('assignments_unlock', assignment).save({}) - .then( - (unlockedItem: IAssignmentItem) => unlockedItem, - (error) => { - const msg = get(error, 'data._message') || 'Could not unlock the assignment.'; - - notify.error(msg); - throw error; - }) - ) -); - /** * Fetch history of an assignment * @param {object} assignment - The Assignment to load history for @@ -605,21 +541,21 @@ const removeAssignment = (assignment) => ( ) ); -const unlink = (assignment) => ( - (dispatch, getState, {api, notify}) => ( +function unlink(assignment: IAssignmentItem) { + return (dispatch, getState, {api, notify}) => ( api('assignments_unlink').save({}, { assignment_id: assignment._id, item_id: get(assignment, 'item_ids[0]'), }) .then(() => { notify.success(gettext('Assignment reverted.')); - return dispatch(self.unlock(assignment)); + return planningApi.locks.unlockItem(assignment); }, (error) => { notify.error(get(error, 'data._message') || gettext('Could not unlock the assignment.')); throw error; }) - ) -); + ); +} // eslint-disable-next-line consistent-this const self = { @@ -631,9 +567,6 @@ const self = { createFromTemplateAndShow, complete, revert, - lock, - unlock, - queryLockedAssignments, loadPlanningAndEvent, loadArchiveItems, loadArchiveItem, diff --git a/client/actions/assignments/notifications.ts b/client/actions/assignments/notifications.ts index bc2489b50..ce0bb89f0 100644 --- a/client/actions/assignments/notifications.ts +++ b/client/actions/assignments/notifications.ts @@ -1,10 +1,15 @@ +import {get, cloneDeep} from 'lodash'; + +import {IWebsocketMessageData} from '../../interfaces'; + +import {planningApi} from '../../superdeskApi'; +import {ASSIGNMENTS, WORKSPACE, MODALS} from '../../constants'; +import {lockUtils, assignmentUtils, gettext, isExistingItem} from '../../utils'; + import * as selectors from '../../selectors'; import assignments from './index'; import main from '../main'; -import {get, cloneDeep} from 'lodash'; import planning from '../planning'; -import {ASSIGNMENTS, WORKSPACE, MODALS} from '../../constants'; -import {lockUtils, assignmentUtils, gettext, isExistingItem} from '../../utils'; import {hideModal, showModal} from '../index'; import * as actions from '../../actions'; @@ -149,6 +154,12 @@ const onAssignmentUpdated = (_e, data) => ( lock_time: null, }; + planningApi.locks.setItemAsUnlocked({ + item: data.item, + etag: data.etag, + from_ingest: false, + type: 'assignment', + }); dispatch({ type: ASSIGNMENTS.ACTIONS.UNLOCK_ASSIGNMENT, payload: {assignment: item}, @@ -185,9 +196,10 @@ const _updatePlannigRelatedToAssignment = (data) => ( } ); -const onAssignmentLocked = (_e, data) => ( - (dispatch) => { +function onAssignmentLocked(_e, data: IWebsocketMessageData['ITEM_LOCKED']) { + return (dispatch) => { if (get(data, 'item')) { + planningApi.locks.setItemAsLocked(data); return dispatch(assignments.api.fetchAssignmentById(data.item, false)) .then((assignmentInStore) => { let item = { @@ -209,8 +221,8 @@ const onAssignmentLocked = (_e, data) => ( } return Promise.resolve(); - } -); + }; +} /** * WS Action when a Planning item gets unlocked @@ -220,9 +232,10 @@ const onAssignmentLocked = (_e, data) => ( * @param {object} _e - Event object * @param {object} data - Planning and User IDs */ -const onAssignmentUnlocked = (_e, data) => ( - (dispatch, getState) => { +function onAssignmentUnlocked(_e, data: IWebsocketMessageData['ITEM_UNLOCKED']) { + return (dispatch, getState) => { if (get(data, 'item')) { + planningApi.locks.setItemAsUnlocked(data); return dispatch(assignments.api.fetchAssignmentById(data.item, false)) .then((assignmentInStore) => { const locks = selectors.locks.getLockedItems(getState()); @@ -265,8 +278,8 @@ const onAssignmentUnlocked = (_e, data) => ( return Promise.resolve(); }); } - } -); + }; +} /** * WS Action when an Assignment is deleted diff --git a/client/actions/assignments/tests/api_test.ts b/client/actions/assignments/tests/api_test.ts index 7a2f6e48b..784566571 100644 --- a/client/actions/assignments/tests/api_test.ts +++ b/client/actions/assignments/tests/api_test.ts @@ -417,7 +417,7 @@ describe('actions.assignments.api', () => { }); describe('queryLockedAssignments', () => { - it('queries for locked assignments', (done) => ( + xit('queries for locked assignments', (done) => ( store.test(done, assignmentsApi.queryLockedAssignments()) .then(() => { const query = {constant_score: {filter: {exists: {field: 'lock_session'}}}}; @@ -528,55 +528,6 @@ describe('actions.assignments.api', () => { }); }); - describe('assignments_lock', () => { - beforeEach(() => { - services.api('assignments_lock').save = sinon.spy(() => Promise.resolve(data.assignments[0])); - services.api('assignments_unlock').save = sinon.spy(() => Promise.resolve(data.assignments[0])); - }); - - afterEach(() => { - restoreSinonStub(services.api('assignments_lock').save); - restoreSinonStub(services.api('assignments_unlock').save); - }); - - it('calls lock endpoint if assignment not locked', (done) => { - store.test(done, assignmentsApi.lock(data.assignments[0])) - .then(() => { - expect(services.api('assignments_lock').save.callCount).toBe(1); - expect(services.api('assignments_lock').save.args[0]).toEqual([ - {}, - {lock_action: 'edit'}, - ]); - done(); - }) - .catch(done.fail); - }); - - it('does not call lock endpoint if assignment already locked', (done) => { - store.initialState.assignment.assignments['1'] = { - ...store.initialState.assignment.assignments['1'], - lock_user: 'ident1', - lock_session: 'session1', - }; - store.test(done, assignmentsApi.lock(store.initialState.assignment.assignments['1'])) - .then((item) => { - expect(services.api('assignments_lock').save.callCount).toBe(0); - expect(item).toEqual(store.initialState.assignment.assignments[1]); - done(); - }) - .catch(done.fail); - }); - - it('calls unlock endpoint', (done) => { - store.test(done, assignmentsApi.unlock(data.assignments[0])) - .then(() => { - expect(services.api('assignments_unlock').save.callCount).toBe(1); - done(); - }) - .catch(done.fail); - }); - }); - it('removeAssignment', (done) => ( store.test(done, assignmentsApi.removeAssignment(data.assignments[0])) .then(() => { diff --git a/client/actions/assignments/tests/notification_test.ts b/client/actions/assignments/tests/notification_test.ts index ba1560e99..7750e90d3 100644 --- a/client/actions/assignments/tests/notification_test.ts +++ b/client/actions/assignments/tests/notification_test.ts @@ -1,5 +1,6 @@ import sinon from 'sinon'; +import {planningApi} from '../../../superdeskApi'; import {getTestActionStore, restoreSinonStub} from '../../../utils/testUtils'; import {createTestStore, assignmentUtils} from '../../../utils'; import {registerNotifications} from '../../../utils/notifications'; @@ -8,7 +9,7 @@ import assignmentsUi from '../ui'; import assignmentsApi from '../api'; import main from '../../main'; import assignmentNotifications from '../notifications'; -import planningApi from '../../planning/api'; +import planningApis from '../../planning/api'; describe('actions.assignments.notification', () => { let store; @@ -134,7 +135,7 @@ describe('actions.assignments.notification', () => { () => () => Promise.resolve() ); sinon.stub(assignmentUtils, 'getCurrentSelectedDeskId').returns('desk1'); - sinon.stub(planningApi, 'loadPlanningByIds').callsFake( + sinon.stub(planningApis, 'loadPlanningByIds').callsFake( () => () => Promise.resolve() ); }); @@ -143,7 +144,7 @@ describe('actions.assignments.notification', () => { restoreSinonStub(assignmentsUi.reloadAssignments); restoreSinonStub(assignmentUtils.getCurrentSelectedDeskId); restoreSinonStub(main.fetchItemHistory); - restoreSinonStub(planningApi.loadPlanningByIds); + restoreSinonStub(planningApis.loadPlanningByIds); }); it('update planning on assignment update', (done) => { @@ -164,8 +165,8 @@ describe('actions.assignments.notification', () => { testStore.dispatch(assignmentNotifications.onAssignmentUpdated({}, payload)) .then(() => { - expect(planningApi.loadPlanningByIds.callCount).toBe(1); - expect(planningApi.loadPlanningByIds.args).toEqual([ + expect(planningApis.loadPlanningByIds.callCount).toBe(1); + expect(planningApis.loadPlanningByIds.args).toEqual([ [['p1']], ]); expect(assignmentsUi.reloadAssignments.callCount).toBe(2); @@ -234,11 +235,15 @@ describe('actions.assignments.notification', () => { describe('`assignment lock`', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsLocked').returns(undefined); + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); sinon.stub(assignmentsApi, 'fetchAssignmentById').callsFake(() => ( Promise.resolve(store.initialState.assignment.assignments.as1))); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsLocked); + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(assignmentsApi.fetchAssignmentById); }); @@ -254,6 +259,7 @@ describe('actions.assignments.notification', () => { return store.test(done, assignmentNotifications.onAssignmentLocked({}, payload)) .then(() => { + expect(planningApi.locks.setItemAsLocked.callCount).toBe(1); expect(store.dispatch.callCount).toBe(2); expect(assignmentsApi.fetchAssignmentById.callCount).toBe(1); expect(store.dispatch.args[1]).toEqual([{ @@ -282,6 +288,7 @@ describe('actions.assignments.notification', () => { return store.test(done, assignmentNotifications.onAssignmentUnlocked({}, payload)) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); expect(store.dispatch.callCount).toBe(2); expect(assignmentsApi.fetchAssignmentById.callCount).toBe(1); expect(store.dispatch.args[1]).toEqual([{ @@ -305,20 +312,22 @@ describe('actions.assignments.notification', () => { describe('`assignment:completed`', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); sinon.stub(assignmentsUi, 'queryAndGetMyAssignments').callsFake( () => () => (Promise.resolve()) ); sinon.stub(assignmentUtils, 'getCurrentSelectedDeskId').returns('desk1'); - sinon.stub(planningApi, 'loadPlanningByIds').callsFake( + sinon.stub(planningApis, 'loadPlanningByIds').callsFake( () => () => (Promise.resolve()) ); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(assignmentsUi.reloadAssignments); restoreSinonStub(assignmentsUi.queryAndGetMyAssignments); restoreSinonStub(assignmentUtils.getCurrentSelectedDeskId); - restoreSinonStub(planningApi.loadPlanningByIds); + restoreSinonStub(planningApis.loadPlanningByIds); }); it('update planning on assignment complete', (done) => { @@ -344,8 +353,8 @@ describe('actions.assignments.notification', () => { .then(() => { coverage1 = getCoverage(payload); - expect(planningApi.loadPlanningByIds.callCount).toBe(1); - expect(planningApi.loadPlanningByIds.args).toEqual([ + expect(planningApis.loadPlanningByIds.callCount).toBe(1); + expect(planningApis.loadPlanningByIds.args).toEqual([ [['p1']], ]); expect(assignmentsUi.reloadAssignments.callCount).toBe(2); @@ -375,6 +384,7 @@ describe('actions.assignments.notification', () => { return store.test(done, assignmentNotifications.onAssignmentUpdated({}, payload)) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); expect(assignmentsApi.fetchAssignmentById.callCount).toBe(1); expect(store.dispatch.args[5]).toEqual([{ type: 'UNLOCK_ASSIGNMENT', diff --git a/client/actions/assignments/tests/ui_test.ts b/client/actions/assignments/tests/ui_test.ts index 1a11f60be..4073ee67a 100644 --- a/client/actions/assignments/tests/ui_test.ts +++ b/client/actions/assignments/tests/ui_test.ts @@ -1,8 +1,8 @@ import sinon from 'sinon'; +import {planningApi} from '../../../superdeskApi'; import assignmentsUi from '../ui'; import assignmentsApi from '../api'; -import planningApi from '../../planning/api'; import {getTestActionStore, restoreSinonStub} from '../../../utils/testUtils'; import * as testData from '../../../utils/testData'; import {ASSIGNMENTS, ALL_DESKS} from '../../../constants'; @@ -19,22 +19,17 @@ describe('actions.assignments.ui', () => { services = store.services; data = store.data; + sinon.stub(planningApi.locks, 'lockItem').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(assignmentsApi, 'link').callsFake(() => (Promise.resolve())); - sinon.stub(assignmentsApi, 'lock').callsFake((item) => (Promise.resolve(item))); - sinon.stub(assignmentsApi, 'unlock').callsFake((item) => (Promise.resolve(item))); sinon.stub(assignmentsApi, 'query').callsFake(() => (Promise.resolve({_items: []}))); - - sinon.stub(planningApi, 'lock').callsFake((item) => Promise.resolve(item)); - sinon.stub(planningApi, 'unlock').callsFake((item) => Promise.resolve(item)); }); afterEach(() => { + restoreSinonStub(planningApi.locks.lockItem); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(assignmentsApi.link); - restoreSinonStub(assignmentsApi.lock); - restoreSinonStub(assignmentsApi.unlock); restoreSinonStub(assignmentsApi.query); - restoreSinonStub(planningApi.lock); - restoreSinonStub(planningApi.unlock); }); describe('onFulFilAssignment', () => { @@ -302,285 +297,20 @@ describe('actions.assignments.ui', () => { }); }); - describe('lockPlanning', () => { - it('Locks the planning item associated with the Assignment', (done) => ( - store.test(done, assignmentsUi.lockPlanning({planning_item: 'plan1'}, 'locker')) - .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([{_id: 'plan1'}, 'locker']); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the planning lock fails', (done) => { - restoreSinonStub(planningApi.lock); - sinon.stub(planningApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockPlanning( - {planning_item: 'plan1'}, - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('lockAssignment', () => { - it('Locks the Assignment', (done) => ( - store.test(done, assignmentsUi.lockAssignment(data.assignments[0], 'locker')) - .then(() => { - expect(assignmentsApi.lock.callCount).toBe(1); - expect(assignmentsApi.lock.args[0]).toEqual([data.assignments[0], 'locker']); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the assignment lock fails', (done) => { - restoreSinonStub(assignmentsApi.lock); - sinon.stub(assignmentsApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockAssignment( - data.assignments[0], - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('unlockPlanning', () => { - it('Unlocks the planning item associated with the Assignment', (done) => ( - store.test(done, assignmentsUi.unlockPlanning({planning_item: 'plan1'})) - .then(() => { - expect(planningApi.unlock.callCount).toBe(1); - expect(planningApi.unlock.args[0]).toEqual([{_id: 'plan1'}]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the planning unlock fails', (done) => { - restoreSinonStub(planningApi.unlock); - sinon.stub(planningApi, 'unlock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockPlanning({planning_item: 'plan1'})) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('unlockAssignment', () => { - it('Unlocks the Assignment', (done) => ( - store.test(done, assignmentsUi.unlockAssignment(data.assignments[0])) - .then(() => { - expect(assignmentsApi.unlock.callCount).toBe(1); - expect(assignmentsApi.unlock.args[0]).toEqual([data.assignments[0]]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the assignment unlock fails', (done) => { - restoreSinonStub(assignmentsApi.unlock); - sinon.stub(assignmentsApi, 'unlock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockAssignment(data.assignments[0])) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('lockAssignmentAndPlanning', () => { - beforeEach(() => { - sinon.stub(assignmentsUi, 'lockAssignment').callsFake((item) => Promise.resolve(item)); - sinon.stub(assignmentsUi, 'lockPlanning').callsFake((item) => Promise.resolve( - {_id: item.planning_item} - )); - }); - - afterEach(() => { - restoreSinonStub(assignmentsUi.lockAssignment); - restoreSinonStub(assignmentsUi.lockPlanning); - }); - - it('locks both Assignment and Planning and returns the locked Assignment', (done) => ( - store.test(done, assignmentsUi.lockAssignmentAndPlanning(data.assignments[0], 'locker')) - .then((item) => { - expect(item).toEqual(data.assignments[0]); - - expect(assignmentsUi.lockPlanning.callCount).toBe(1); - expect(assignmentsUi.lockPlanning.args[0]).toEqual([data.assignments[0], 'locker']); - - expect(assignmentsUi.lockAssignment.callCount).toBe(1); - expect(assignmentsUi.lockAssignment.args[0]).toEqual([ - data.assignments[0], - 'locker', - ]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if locking Assignment fails', (done) => { - restoreSinonStub(assignmentsUi.lockAssignment); - restoreSinonStub(assignmentsUi.lockPlanning); - - restoreSinonStub(assignmentsApi.lock); - sinon.stub(assignmentsApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockAssignmentAndPlanning( - data.assignments[0], - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - - it('Notifies the user if locking Planning fails', (done) => { - restoreSinonStub(assignmentsUi.lockAssignment); - restoreSinonStub(assignmentsUi.lockPlanning); - - restoreSinonStub(planningApi.lock); - sinon.stub(planningApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockAssignmentAndPlanning( - data.assignments[0], - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('unlockAssignmentAndPlanning', () => { - beforeEach(() => { - sinon.stub(assignmentsUi, 'unlockAssignment').callsFake( - (item) => Promise.resolve(item) - ); - sinon.stub(assignmentsUi, 'unlockPlanning').callsFake((item) => Promise.resolve( - {_id: item.planning_item} - )); - }); - - afterEach(() => { - restoreSinonStub(assignmentsUi.unlockAssignment); - restoreSinonStub(assignmentsUi.unlockPlanning); - }); - - it('unlocks both Assignment and Planning and returns the locked Assignment', (done) => ( - store.test(done, assignmentsUi.unlockAssignmentAndPlanning(data.assignments[0])) - .then((item) => { - expect(item).toEqual(data.assignments[0]); - - expect(assignmentsUi.unlockPlanning.callCount).toBe(1); - expect(assignmentsUi.unlockPlanning.args[0]).toEqual([data.assignments[0]]); - - expect(assignmentsUi.unlockAssignment.callCount).toBe(1); - expect(assignmentsUi.unlockAssignment.args[0]).toEqual([data.assignments[0]]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if unlocking Assignment fails', (done) => { - restoreSinonStub(assignmentsUi.unlockAssignment); - restoreSinonStub(assignmentsUi.unlockPlanning); - - restoreSinonStub(assignmentsApi.unlock); - sinon.stub(assignmentsApi, 'unlock').returns(Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockAssignmentAndPlanning(data.assignments[0])) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - - it('Notifies the user if unlocking Planning fails', (done) => { - restoreSinonStub(assignmentsUi.unlockAssignment); - restoreSinonStub(assignmentsUi.unlockPlanning); - - restoreSinonStub(planningApi.unlock); - sinon.stub(planningApi, 'unlock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockAssignmentAndPlanning(data.assignments[0])) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - describe('showRemoveAssignmentModal', () => { - beforeEach(() => { - sinon.stub(assignmentsUi, 'lockAssignment').callsFake( - (item) => Promise.resolve(item) - ); - }); - - afterEach(() => { - restoreSinonStub(assignmentsUi.lockAssignment); - }); - it('locks only Assignment and displays the confirmation dialog', (done) => ( store.test(done, assignmentsUi.showRemoveAssignmentModal(data.assignments[0])) .then((item) => { expect(item).toEqual(data.assignments[0]); - expect(assignmentsUi.lockAssignment.callCount).toBe(1); - expect(assignmentsUi.lockAssignment.args[0]).toEqual([ + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([ data.assignments[0], - 'remove_assignment', + 'remove_assignment' ]); - expect(store.dispatch.callCount).toBe(2); - expect(store.dispatch.args[1]).toEqual([{ + expect(store.dispatch.callCount).toBe(1); + expect(store.dispatch.args[0]).toEqual([{ type: 'SHOW_MODAL', modalType: 'CONFIRMATION', modalProps: jasmine.objectContaining( @@ -594,10 +324,8 @@ describe('actions.assignments.ui', () => { ).catch(done.fail)); it('returns Promise.reject on locking error', (done) => { - restoreSinonStub(assignmentsUi.lockAssignment); - sinon.stub(assignmentsUi, 'lockAssignment').returns( - Promise.reject(errorMessage) - ); + restoreSinonStub(planningApi.locks.lockItem); + sinon.stub(planningApi.locks, 'lockItem').returns(Promise.reject(errorMessage)); return store.test(done, assignmentsUi.showRemoveAssignmentModal(data.assignments[0])) .then(() => { /* no-op */ }, (error) => { diff --git a/client/actions/assignments/ui.ts b/client/actions/assignments/ui.ts index ef788479c..c02827932 100644 --- a/client/actions/assignments/ui.ts +++ b/client/actions/assignments/ui.ts @@ -1,8 +1,11 @@ import {get, cloneDeep, forEach} from 'lodash'; import moment from 'moment'; + +import {planningApi} from '../../superdeskApi'; +import {IAssignmentItem} from '../../interfaces'; + import {showModal} from '../index'; import assignments from './index'; -import planningApi from '../planning/api'; import * as selectors from '../../selectors'; import * as actions from '../../actions'; import {ASSIGNMENTS, MODALS, WORKSPACE, ALL_DESKS} from '../../constants'; @@ -462,9 +465,9 @@ const onFulFilAssignment = (assignment) => ( } ); -const complete = (item) => ( - (dispatch, getState, {notify}) => ( - dispatch(self.lockAssignment(item, 'complete')) +function complete(item: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( + planningApi.locks.lockItem(item, 'complete') .then((lockedItem) => { dispatch(assignments.api.complete(lockedItem)) .then((lockedItem) => { @@ -474,15 +477,15 @@ const complete = (item) => ( notify.error(getErrorMessage(error, 'Failed to complete the assignment.')); // unlock the assignment - return dispatch(self.unlockAssignment(lockedItem)); + return planningApi.locks.unlockItem(lockedItem); }); }, (error) => Promise.reject(error)) - ) -); + ); +} -const revert = (item) => ( - (dispatch, getState, {notify}) => ( - dispatch(self.lockAssignment(item, 'revert')) +function revert(item: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( + planningApi.locks.lockItem(item, 'revert') .then((lockedItem) => { const contentTypes = selectors.general.contentTypes(getState()); @@ -503,15 +506,15 @@ const revert = (item) => ( modalProps: { body: gettext('This will unlink the text item associated with the assignment. Are you sure ?'), action: () => dispatch(assignments.api.unlink(lockedItem)), - onCancel: () => dispatch(self.unlockAssignment(lockedItem)), + onCancel: () => planningApi.locks.unlockItem(lockedItem), autoClose: true, }, })); return Promise.resolve(); }, (error) => Promise.reject(error)) - ) -); + ); +} /** * Action for launching the modal form for fulfil assignment and add to planning @@ -569,11 +572,11 @@ const canLinkItem = (item) => ( ) ); -const validateStartWorkingOnScheduledUpdate = (assignment) => ( - (dispatch, getState, {notify}) => ( +function validateStartWorkingOnScheduledUpdate(assignment: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( // Validate the coverage to see if all preceeding scheduled_updates / coverage // is linked to an item - dispatch(planningApi.loadPlanningByIds([get(assignment, 'planning_item')], false)).then( + planningApi.planning.getById(assignment.planning_item, false).then( (plannings) => { const planning = get(plannings, '[0]'); @@ -611,11 +614,11 @@ const validateStartWorkingOnScheduledUpdate = (assignment) => ( return Promise.resolve(); } ) - ) -); + ); +} -const startWorking = (assignment) => ( - (dispatch, getState, {templates, session, desks, notify}) => { +function startWorking(assignment: IAssignmentItem) { + return (dispatch, getState, {templates, session, desks, notify}) => { let promise = Promise.resolve(); if (get(assignment, 'scheduled_update_id')) { @@ -623,7 +626,7 @@ const startWorking = (assignment) => ( } promise.then(() => - (dispatch(self.lockAssignment(assignment, 'start_working')) + (planningApi.locks.lockItem(assignment, 'start_working') .then((lockedAssignment) => { const currentDesk = assignmentUtils.getCurrentSelectedDesk(desks, getState()); const defaultTemplateId = get(currentDesk, 'default_content_template') || null; @@ -654,15 +657,12 @@ const startWorking = (assignment) => ( assignment._id, template.template_name )).catch((error) => { - dispatch(self.unlockAssignment(assignment)); + planningApi.locks.unlockItem(assignment); notify.error(getErrorMessage(error, gettext('Failed to create an archive item.'))); return Promise.reject(error); }) ); - - const onCancel = () => ( - dispatch(assignments.api.unlock(lockedAssignment)) - ); + const onCancel = () => planningApi.locks.unlockItem(lockedAssignment); return dispatch(showModal({ modalType: MODALS.SELECT_DESK_TEMPLATE, @@ -678,12 +678,12 @@ const startWorking = (assignment) => ( }, (error) => Promise.reject(error)) ), (error) => Promise.resolve() ); - } -); + }; +} -const _openActionModal = (assignment, action, lockAction = null) => ( - (dispatch) => ( - dispatch(self.lockAssignment(assignment, lockAction)) +function _openActionModal(assignment: IAssignmentItem, action: string, lockAction: string = 'edit') { + return (dispatch) => ( + planningApi.locks.lockItem(assignment, lockAction) .then((lockedAssignment) => ( dispatch(showModal({ modalType: MODALS.ITEM_ACTIONS_MODAL, @@ -693,149 +693,8 @@ const _openActionModal = (assignment, action, lockAction = null) => ( }, })) ), (error) => Promise.reject(error)) - ) -); - -/** - * Utility Action to lock the Assignment, and display a notification - * to the user if the lock fails - * @param {object} assignment - The Assignment to lock - * @param {string} action - The action for the lock - * @return Promise - The locked Assignment item, otherwise the API error - */ -const lockAssignment = (assignment, action) => ( - (dispatch, getState, {notify}) => ( - dispatch(assignments.api.lock(assignment, action)) - .then( - (lockedAssignment) => Promise.resolve(lockedAssignment), - (error) => { - notify.error( - getErrorMessage(error, 'Failed to lock the Assignment.') - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to lock a Planning item associated with an Assignment, and - * displays a notification to the user if the lock fails - * @param {object} assignment - The Assignment for the associated Planning item - * @param {string} action - The action for the lock - * @return Promise - The locked Planning item, otherwise the API error - */ -const lockPlanning = (assignment, action) => ( - (dispatch, getState, {notify}) => ( - dispatch(actions.planning.api.lock({_id: get(assignment, 'planning_item')}, action)) - .then( - (lockedPlanning) => Promise.resolve(lockedPlanning), - (error) => { - notify.error( - getErrorMessage(error, 'Failed to lock the Planning item.') - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to lock both the Assignment and it's associated Planning item - * @param {object} assignment - The Assignment to lock for - * @param {string} action - The action for the lock - * @return Promise - The locked Assignment item, otherwise the API error - */ -const lockAssignmentAndPlanning = (assignment, action) => ( - (dispatch) => { - let planning = null; - - return dispatch(self.lockPlanning(assignment, action)) - .then( - (lockedPlan) => { - planning = lockedPlan; - return dispatch(self.lockAssignment(assignment, action)); - } - ) - .then( - (lockedItem) => Promise.resolve(lockedItem), - (error) => { - if (!planning) { - return Promise.reject(error); - } - return dispatch(self.unlockPlanning(assignment)) - .then( - () => Promise.reject(error), - () => Promise.reject(error) - ); - } - ); - } -); - -/** - * Utility Action to unlock an Assignment and display a notification - * if the unlock fails - * @param {object} assignment - The Assignment to unlock - * @return Promise - The unlocked Assignment item, otherwise the API error - */ -const unlockAssignment = (assignment) => ( - (dispatch, getState, {notify}) => ( - dispatch(assignments.api.unlock(assignment)) - .then( - (unlockedAssignment) => Promise.resolve(unlockedAssignment), - (error) => { - notify.error( - getErrorMessage(error, gettext('Failed to unlock the Assignment')) - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to unlock a Planning item associated with an Assignment, and - * display a notification to the user if the unlock fails - * @param assignment - * @return Promise - The unlocked Planning item, otherwise the API error - */ -const unlockPlanning = (assignment) => ( - (dispatch, getState, {notify}) => ( - dispatch(actions.planning.api.unlock({_id: get(assignment, 'planning_item')})) - .then( - (unlockedPlanning) => Promise.resolve(unlockedPlanning), - (error) => { - notify.error( - getErrorMessage(error, 'Failed to unlock the Planning item') - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to unlock both the Assignment and it's associated Planning item - * @param {object} assignment - The Assignment to lock for - * @return Promise - The unlocked Assignment item, otherwise the API error - */ -const unlockAssignmentAndPlanning = (assignment) => ( - (dispatch) => ( - Promise.all([ - dispatch(self.unlockAssignment(assignment)), - dispatch(self.unlockPlanning(assignment)), - ]) - .then( - (data) => Promise.resolve(data[0]), - (error) => Promise.reject(error) - ) - ) -); + ); +} /** * Action to display the 'Remove Assignment' confirmation modal @@ -844,9 +703,9 @@ const unlockAssignmentAndPlanning = (assignment) => ( * @param {object} assignment - The Assignment item intended for deletion * @return Promise - Locked Assignment, otherwise the Lock API error */ -const showRemoveAssignmentModal = (assignment) => ( - (dispatch) => ( - dispatch(self.lockAssignment(assignment, ASSIGNMENTS.ITEM_ACTIONS.REMOVE.lock_action)) +function showRemoveAssignmentModal(assignment: IAssignmentItem) { + return (dispatch) => ( + planningApi.locks.lockItem(assignment, ASSIGNMENTS.ITEM_ACTIONS.REMOVE.lock_action) .then((lockedAssignment) => { dispatch(showModal({ modalType: MODALS.CONFIRMATION, @@ -854,7 +713,7 @@ const showRemoveAssignmentModal = (assignment) => ( body: gettext('This will also remove other linked assignments (if any, for story updates). ' + 'Are you sure?'), action: () => dispatch(self.removeAssignment(lockedAssignment)), - onCancel: () => dispatch(self.unlockAssignment(lockedAssignment)), + onCancel: () => planningApi.locks.unlockItem(lockedAssignment), autoClose: true, }, })); @@ -862,16 +721,16 @@ const showRemoveAssignmentModal = (assignment) => ( return Promise.resolve(lockedAssignment); }, (error) => Promise.reject(error) ) - ) -); + ); +} /** * Action to delete the Assignment item * @param {object} assignment - The Assignment item to remove * @return Promise - Empty promise, otherwise the API error */ -const removeAssignment = (assignment) => ( - (dispatch, getState, {notify}) => ( +function removeAssignment(assignment: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( dispatch(assignments.api.removeAssignment(assignment)) .then(() => { notify.success('Assignment removed'); @@ -880,11 +739,11 @@ const removeAssignment = (assignment) => ( notify.error( getErrorMessage(error, 'Failed to remove the Assignment') ); - dispatch(self.unlockAssignment(assignment)); + planningApi.locks.unlockItem(assignment); return Promise.reject(error); }) - ) -); + ); +} const setListGroups = (groupKeys) => ({ type: ASSIGNMENTS.ACTIONS.SET_GROUP_KEYS, @@ -1032,12 +891,6 @@ const self = { showRemoveAssignmentModal, removeAssignment, updatePreviewItemOnRouteUpdate, - lockAssignment, - lockPlanning, - lockAssignmentAndPlanning, - unlockAssignment, - unlockPlanning, - unlockAssignmentAndPlanning, openArchivePreview, setMyAssignmentsTotal, setListGroups, diff --git a/client/actions/events/api.ts b/client/actions/events/api.ts index 6bb6ec1ab..24cf37a19 100644 --- a/client/actions/events/api.ts +++ b/client/actions/events/api.ts @@ -1,8 +1,8 @@ import {get, isEqual, cloneDeep, pickBy, has, find, every} from 'lodash'; +import {planningApi} from '../../superdeskApi'; import {ISearchSpikeState, IEventSearchParams, IEventItem, IPlanningItem} from '../../interfaces'; import {appConfig} from 'appConfig'; -import {planningApis} from '../../api'; import { EVENTS, @@ -13,7 +13,6 @@ import { import * as selectors from '../../selectors'; import { eventUtils, - lockUtils, getErrorMessage, isExistingItem, isValidFileInput, @@ -23,7 +22,7 @@ import { getTimeZoneOffset, } from '../../utils'; -import planningApi from '../planning/api'; +import planningApis from '../planning/api'; import eventsUi from './ui'; import main from '../main'; import {eventParamsToSearchParams} from '../../utils/search'; @@ -45,7 +44,7 @@ const loadEventsByRecurrenceId = ( loadToStore: boolean = true ) => ( (dispatch) => ( - planningApis.events.search({ + planningApi.events.search({ recurrence_id: rid, spike_state: spikeState, page: page, @@ -144,7 +143,7 @@ function query( } } - return planningApis.events.search(eventParamsToSearchParams({ + return planningApi.events.search(eventParamsToSearchParams({ ...params, itemIds: itemIds, filter_id: params.filter_id || selectors.main.currentSearchFilterId(getState()), @@ -203,7 +202,7 @@ function loadEventDataForAction( _plannings: Array; _relatedPlannings: Array; }> { - return planningApis.combined.getRecurringEventsAndPlanningItems(event, loadPlanning, loadEvents) + return planningApi.combined.getRecurringEventsAndPlanningItems(event, loadPlanning, loadEvents) .then((items) => ({ ...event, _recurring: items.events, @@ -230,7 +229,7 @@ const loadAssociatedPlannings = (event) => ( return Promise.resolve([]); } - return dispatch(planningApi.loadPlanningByEventId(event._id)); + return dispatch(planningApis.loadPlanningByEventId(event._id)); } ); @@ -267,68 +266,6 @@ const receiveEvents = (events, skipEvents: Array = []) => ({ receivedAt: Date.now(), }); -/** - * Action to lock an Event - * @param {Object} event - Event to be unlocked - * @param {String} action - The lock action - * @return Promise - */ -const lock = (event, action = 'edit') => ( - (dispatch, getState, {api, notify}) => { - if (action === null || - lockUtils.isItemLockedInThisSession( - event, - selectors.general.session(getState()), - selectors.locks.getLockedItems(getState()) - ) - ) { - return Promise.resolve(event); - } - - return api('events_lock', event).save({}, {lock_action: action}) - .then( - (item) => { - // On lock, file object in the event is lost, so, replace it from original event - item.files = event.files; - eventUtils.modifyForClient(item); - - dispatch({ - type: EVENTS.ACTIONS.LOCK_EVENT, - payload: {event: item}, - }); - - return Promise.resolve(item); - }, (error) => { - const msg = get(error, 'data._message') || 'Could not lock the event.'; - - notify.error(msg); - if (error) throw error; - }); - } -); - -const unlock = (event) => ( - (dispatch, getState, {api, notify}) => ( - api('events_unlock', event).save({}) - .then( - (item) => { - dispatch({ - type: EVENTS.ACTIONS.UNLOCK_EVENT, - payload: {event: item}, - }); - - return Promise.resolve(item); - }, - (error) => { - notify.error( - getErrorMessage(error, 'Could not unlock the event') - ); - return Promise.reject(error); - } - ) - ) -); - /** * Action Dispatcher to fetch events from the server, * and add them to the store without adding them to the events list @@ -343,7 +280,7 @@ function silentlyFetchEventsById( saveToStore: boolean = true ) { return (dispatch) => ( - planningApis.events.getByIds( + planningApi.events.getByIds( ids.filter((v, i, a) => (a.indexOf(v) === i)), spikeState ) @@ -379,7 +316,7 @@ const fetchById = (eventId, {force = false, saveToStore = true, loadPlanning = t if (has(storedEvents, eventId) && !force) { promise = Promise.resolve(storedEvents[eventId]); } else { - promise = planningApis.events.getById(eventId) + promise = planningApi.events.getById(eventId) .then((event) => { if (saveToStore) { dispatch(self.receiveEvents([event])); @@ -516,14 +453,27 @@ const markEventCancelled = (eventId, etag, reason, occurStatus, cancelledItems, }, }); -const markEventPostponed = (event, reason, actionedDate) => ({ - type: EVENTS.ACTIONS.MARK_EVENT_POSTPONED, - payload: { - event: event, - reason: reason, - actionedDate: actionedDate, - }, -}); +function markEventPostponed(event: IEventItem, reason: string, actionedDate: string) { + return (dispatch) => { + planningApi.locks.setItemAsUnlocked({ + item: event._id, + type: event.type, + recurrence_id: event.recurrence_id, + etag: event._etag, + from_ingest: false, + user: event.lock_user, + lock_session: event.lock_session, + }); + dispatch({ + type: EVENTS.ACTIONS.MARK_EVENT_POSTPONED, + payload: { + event: event, + reason: reason, + actionedDate: actionedDate, + }, + }); + }; +} const markEventHasPlannings = (event, planning) => ({ type: EVENTS.ACTIONS.MARK_EVENT_HAS_PLANNINGS, @@ -633,8 +583,8 @@ const save = (original, updates) => ( EVENTS.UPDATE_METHODS[0].value; return originalEvent?._id != null ? - planningApis.events.update(originalItem, eventUpdates) : - planningApis.events.create(eventUpdates); + planningApi.events.update(originalItem, eventUpdates) : + planningApi.events.create(eventUpdates); }); } ); @@ -771,8 +721,6 @@ const self = { query, refetch, receiveEvents, - lock, - unlock, silentlyFetchEventsById, cancelEvent, markEventCancelled, diff --git a/client/actions/events/notifications.ts b/client/actions/events/notifications.ts index 89f9ca0c1..a2c749832 100644 --- a/client/actions/events/notifications.ts +++ b/client/actions/events/notifications.ts @@ -1,12 +1,15 @@ +import {get} from 'lodash'; + +import {planningApi} from '../../superdeskApi'; import {IWebsocketMessageData, ITEM_TYPE} from '../../interfaces'; import * as selectors from '../../selectors'; -import {WORKFLOW_STATE, EVENTS} from '../../constants'; +import {WORKFLOW_STATE, EVENTS, LOCKS} from '../../constants'; +import {gettext, dispatchUtils, getErrorMessage, lockUtils} from '../../utils'; + import eventsApi from './api'; import eventsUi from './ui'; import main from '../main'; -import planningApi from '../planning/api'; -import {get} from 'lodash'; -import {gettext, dispatchUtils, getErrorMessage, lockUtils} from '../../utils'; +import planningApis from '../planning/api'; import eventsPlanning from '../eventsPlanning'; /** @@ -31,6 +34,8 @@ const onEventCreated = (_e, data) => ( function onEventUnlocked(_e: {}, data: IWebsocketMessageData['ITEM_UNLOCKED']) { return (dispatch, getState) => { if (data?.item != null) { + planningApi.locks.setItemAsUnlocked(data); + const state = getState(); const events = selectors.events.storedEvents(state); let eventInStore = get(events, data.item, {}); @@ -69,6 +74,8 @@ function onEventUnlocked(_e: {}, data: IWebsocketMessageData['ITEM_UNLOCKED']) { const onEventLocked = (_e, data) => ( (dispatch, getState) => { if (data && data.item) { + planningApi.locks.setItemAsLocked(data); + const sessionId = selectors.general.session(getState()).sessionId; return dispatch(eventsApi.getEvent(data.item, false)) @@ -217,7 +224,7 @@ const onEventPostChanged = (e, data) => ( const storedEvent = selectors.events.storedEvents(getState())[data.item]; if (!posted && get(storedEvent, 'planning_ids.length', 0) > 0) { - dispatch(planningApi.loadPlanningByEventId(data.item)); + dispatch(planningApis.loadPlanningByEventId(data.item)); } } return Promise.resolve(); diff --git a/client/actions/events/tests/api_test.ts b/client/actions/events/tests/api_test.ts index a6e315000..49bee1d10 100644 --- a/client/actions/events/tests/api_test.ts +++ b/client/actions/events/tests/api_test.ts @@ -825,101 +825,4 @@ describe('actions.events.api', () => { .catch(done.fail); }); }); - - describe('lock/unlock', () => { - let mockStore; - let mocks; - let getLocks = () => selectors.locks.getLockedItems(mockStore.getState()); - - beforeEach(() => { - mocks = { - api: sinon.spy(() => mocks), - save: sinon.spy((original, updates = {}) => Promise.resolve({ - ...data.events[0], - ...updates, - })), - }; - - store.init(); - }); - - it('calls lock endpoint and updates the redux store', (done) => { - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().event).toEqual({}); - - mockStore.dispatch(eventsApi.lock(data.events[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'events_lock', - data.events[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([ - {}, - {lock_action: 'edit'}, - ]); - - expect(getLocks().event).toEqual({ - e1: jasmine.objectContaining({ - action: 'edit', - item_type: 'event', - item_id: 'e1', - }), - }); - - done(); - }) - .catch(done.fail); - }); - - it('calls unlock endpoint and updates the redux store', (done) => { - store.initialState.locks.event = { - e1: { - action: 'edit', - item_type: 'event', - item_id: 'e1', - }, - }; - - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().event).toEqual({ - e1: jasmine.objectContaining({ - action: 'edit', - item_type: 'event', - item_id: 'e1', - }), - }); - - mockStore.dispatch(eventsApi.unlock(data.events[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'events_unlock', - data.events[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([{}]); - - expect(getLocks().event).toEqual({}); - - done(); - }) - .catch(done.fail); - }); - }); }); diff --git a/client/actions/events/tests/notifications_test.ts b/client/actions/events/tests/notifications_test.ts index 8925064f2..4ab954bfe 100644 --- a/client/actions/events/tests/notifications_test.ts +++ b/client/actions/events/tests/notifications_test.ts @@ -1,7 +1,8 @@ +import {planningApi} from '../../../superdeskApi'; import eventsApi from '../api'; import eventsUi from '../ui'; import eventsPlanningUi from '../../eventsPlanning/ui'; -import planningApi from '../../planning/api'; +import planningApis from '../../planning/api'; import main from '../../main'; import sinon from 'sinon'; import {registerNotifications} from '../../../utils'; @@ -266,13 +267,13 @@ describe('actions.events.notifications', () => { describe('onEventPostChanged', () => { beforeEach(() => { restoreSinonStub(eventsNotifications.onEventPostChanged); - sinon.stub(planningApi, 'loadPlanningByEventId').callsFake( + sinon.stub(planningApis, 'loadPlanningByEventId').callsFake( () => (Promise.resolve()) ); }); afterEach(() => { - restoreSinonStub(planningApi.loadPlanningByEventId); + restoreSinonStub(planningApis.loadPlanningByEventId); }); xit('dispatches `MARK_EVENT_POSTED`', (done) => ( @@ -443,7 +444,7 @@ describe('actions.events.notifications', () => { pubstatus: 'cancelled', }, }]); - expect(planningApi.loadPlanningByEventId.callCount).toBe(1); + expect(planningApis.loadPlanningByEventId.callCount).toBe(1); done(); }) ).catch(done.fail)); @@ -460,7 +461,7 @@ describe('actions.events.notifications', () => { )) .then(() => { expect(store.dispatch.callCount).toBe(6); - expect(planningApi.loadPlanningByEventId.callCount).toBe(1); + expect(planningApis.loadPlanningByEventId.callCount).toBe(1); done(); }) ).catch(done.fail)); @@ -468,10 +469,12 @@ describe('actions.events.notifications', () => { describe('onEventLocked', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsLocked').returns(undefined); sinon.stub(eventsApi, 'getEvent').returns(Promise.resolve(data.events[0])); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsLocked); restoreSinonStub(eventsApi.getEvent); }); @@ -488,6 +491,8 @@ describe('actions.events.notifications', () => { } )) .then(() => { + expect(planningApi.locks.setItemAsLocked.callCount).toBe(1); + expect(eventsApi.getEvent.callCount).toBe(1); expect(eventsApi.getEvent.args[0]).toEqual([ 'e1', @@ -514,6 +519,7 @@ describe('actions.events.notifications', () => { describe('onEventUnlocked', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); store.initialState.events.events.e1.lock_user = 'ident1'; store.initialState.events.events.e1.lock_session = 'session1'; store.initialState.events.events.e1.lock_time = '2022-06-15T13:01:11+0000'; @@ -521,6 +527,7 @@ describe('actions.events.notifications', () => { }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(main.changeEditorAction); }); @@ -553,6 +560,7 @@ describe('actions.events.notifications', () => { } )) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); const modalStr = 'The Event you were editing was unlocked by "{{ userName }}"'; expect(store.dispatch.args[2][0].type).toEqual('AUTOSAVE_REMOVE'); diff --git a/client/actions/events/tests/ui_test.ts b/client/actions/events/tests/ui_test.ts index 7061caa8d..e38f64d35 100644 --- a/client/actions/events/tests/ui_test.ts +++ b/client/actions/events/tests/ui_test.ts @@ -2,10 +2,11 @@ import {omit} from 'lodash'; import sinon from 'sinon'; import moment from 'moment'; +import {planningApi} from '../../../superdeskApi'; import {LIST_VIEW_TYPE} from '../../../interfaces'; import eventsApi from '../api'; import eventsUi from '../ui'; -import planningApi from '../../planning/api'; +import planningApis from '../../planning/api'; import {main} from '../../'; import {MAIN, EVENTS, ITEM_TYPE} from '../../../constants'; @@ -43,19 +44,19 @@ describe('actions.events.ui', () => { sinon.stub(eventsUi, 'refetch').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'loadPlanningByEventId').callsFake( + sinon.stub(planningApis, 'loadPlanningByEventId').callsFake( () => (Promise.resolve(data.plannings)) ); - sinon.stub(planningApi, 'fetch').callsFake(() => (Promise.resolve([]))); + sinon.stub(planningApis, 'fetch').callsFake(() => (Promise.resolve([]))); sinon.stub(eventsUi, 'setEventsList').callsFake(() => (Promise.resolve())); sinon.stub(eventsApi, 'loadEventDataForAction').callsFake( (event) => (Promise.resolve(event)) ); - sinon.stub(eventsApi, 'lock').callsFake((item) => (Promise.resolve(item))); - sinon.stub(eventsApi, 'unlock').callsFake((item) => (Promise.resolve(item))); + sinon.stub(planningApi.locks, 'lockItem').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(eventsApi, 'rescheduleEvent').callsFake(() => (Promise.resolve())); @@ -72,11 +73,11 @@ describe('actions.events.ui', () => { restoreSinonStub(eventsUi.refetch); restoreSinonStub(eventsUi.setEventsList); restoreSinonStub(eventsApi.loadEventDataForAction); - restoreSinonStub(eventsApi.lock); - restoreSinonStub(eventsApi.unlock); + restoreSinonStub(planningApi.locks.lockItem); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(eventsApi.rescheduleEvent); - restoreSinonStub(planningApi.loadPlanningByEventId); - restoreSinonStub(planningApi.fetch); + restoreSinonStub(planningApis.loadPlanningByEventId); + restoreSinonStub(planningApis.fetch); restoreSinonStub(eventsUi._openActionModalFromEditor); }); @@ -187,8 +188,8 @@ describe('actions.events.ui', () => { true, false )).then(() => { - expect(eventsApi.lock.callCount).toBe(1); - expect(eventsApi.lock.args[0]).toEqual([data.events[1], 'cancel']); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([data.events[1], 'cancel']); expect(eventsApi.loadEventDataForAction.callCount).toBe(1); expect(eventsApi.loadEventDataForAction.args[0]).toEqual([ @@ -198,8 +199,8 @@ describe('actions.events.ui', () => { true, ]); - expect(store.dispatch.callCount).toBe(2); - expect(store.dispatch.args[1]).toEqual([{ + expect(store.dispatch.callCount).toBe(1); + expect(store.dispatch.args[0]).toEqual([{ type: 'SHOW_MODAL', modalType: 'ITEM_ACTIONS_MODAL', modalProps: { @@ -215,8 +216,8 @@ describe('actions.events.ui', () => { ).catch(done.fail)); it('openActionModal displays error message if lock fails', (done) => { - restoreSinonStub(eventsApi.lock); - sinon.stub(eventsApi, 'lock').callsFake(() => (Promise.reject(errorMessage))); + restoreSinonStub(planningApi.locks.lockItem); + sinon.stub(planningApi.locks, 'lockItem').callsFake(() => Promise.reject(errorMessage)); return store.test(done, eventsUi._openActionModal( data.events[1], 'Cancel Event', @@ -483,14 +484,12 @@ describe('actions.events.ui', () => { describe('rescheduleEvent', () => { beforeEach(() => { - // sinon.stub(main, 'lockAndEdit').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'openForEdit'); sinon.stub(eventsApi, 'fetchById').callsFake(() => Promise.resolve(data.events[1])); }); afterEach(() => { restoreSinonStub(eventsApi.rescheduleEvent); - // restoreSinonStub(main.lockAndEdit); restoreSinonStub(main.openForEdit); restoreSinonStub(eventsApi.fetchById); }); @@ -631,12 +630,10 @@ describe('actions.events.ui', () => { describe('createEventFromPlanning', () => { beforeEach(() => { - sinon.stub(planningApi, 'lock').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'createNew').callsFake((item) => Promise.resolve(item)); }); afterEach(() => { - restoreSinonStub(planningApi.lock); restoreSinonStub(main.createNew); }); @@ -645,8 +642,8 @@ describe('actions.events.ui', () => { store.test(done, eventsUi.createEventFromPlanning(plan)) .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([plan, 'add_as_event']); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([plan, 'add_as_event']); expect(main.createNew.callCount).toBe(1); const args = main.createNew.args[0]; diff --git a/client/actions/events/ui.ts b/client/actions/events/ui.ts index 3863dddcf..9f63d3aeb 100644 --- a/client/actions/events/ui.ts +++ b/client/actions/events/ui.ts @@ -2,12 +2,12 @@ import {get} from 'lodash'; import moment from 'moment-timezone'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../superdeskApi'; import {IPlanningItem, IEventItem} from '../../interfaces'; -import {showModal, main, locks, addEventToCurrentAgenda} from '../index'; +import {showModal, main, addEventToCurrentAgenda} from '../index'; import {EVENTS, MODALS, SPIKED_STATE, MAIN, ITEM_TYPE, POST_STATE} from '../../constants'; import eventsApi from './api'; -import planningApi from '../planning/api'; import * as selectors from '../../selectors'; import { eventUtils, @@ -406,7 +406,7 @@ const _openActionModalFromEditor = ({ promise.then((refetchedEvent) => ( (openInEditor || openInModal) ? dispatch(main.openForEdit(refetchedEvent, !openInModal, openInModal)) : - dispatch(locks.lock(refetchedEvent, previousLock.action)) + planningApi.locks.lockItem(refetchedEvent, previousLock.action) ), () => Promise.reject()); } @@ -432,7 +432,7 @@ const _openActionModal = ( modalProps = {} ) => ( (dispatch, getState, {notify}) => ( - dispatch(eventsApi.lock(original, lockAction)) + planningApi.locks.lockItem(original, lockAction) .then((lockedEvent) => ( eventsApi.loadEventDataForAction(lockedEvent, loadPlannings, post, loadEvents) .then((eventDetail) => ( @@ -795,7 +795,7 @@ const createEventFromPlanning = (plan: IPlanningItem) => ( } return Promise.all([ - dispatch(planningApi.lock(plan, 'add_as_event')), + planningApi.locks.lockItem(plan, 'add_as_event'), dispatch(main.createNew(ITEM_TYPE.EVENT, newEvent)), ]); } @@ -829,7 +829,8 @@ const selectCalendar = (calendarId = '', params = {}) => ( const onEventEditUnlock = (event) => ( (dispatch) => ( - get(event, '_planning_item') ? dispatch(planningApi.unlock({_id: event._planning_item})) : + get(event, '_planning_item') ? + planningApi.locks.unlockItemById(event._planning_item, 'planning') : Promise.resolve() ) ); @@ -852,7 +853,7 @@ const lockAndSaveUpdates = ( } // Otherwise lock, save and unlock this Event - return dispatch(locks.lock(event, lockAction)) + planningApi.locks.lockItem(event, lockAction) .then((original) => ( dispatch(main.saveAndUnlockItem(original, updates, true)) .then((item) => { @@ -966,7 +967,7 @@ const onMarkEventCompleted = (event, editor = false) => ( event, gettext('Save changes before marking event as complete ?'), (unlockedItem, previousLock, openInEditor, openInModal) => ( - dispatch(locks.lock(unlockedItem, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action)) + planningApi.locks.lockItem(unlockedItem, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action) .then((lockedItem) => ( dispatch(showModal({ modalType: MODALS.CONFIRMATION, @@ -976,15 +977,15 @@ const onMarkEventCompleted = (event, editor = false) => ( dispatch(main.saveAndUnlockItem(lockedItem, updates, true)).then((result) => { if (get(previousLock, 'action') && (openInEditor || openInModal)) { dispatch(main.openForEdit(result, true, openInModal)); - dispatch(locks.lock(result, previousLock.action)); + planningApi.locks.lockItem(result, previousLock.action); } }, (error) => { - dispatch(locks.unlock(lockedItem)); + planningApi.locks.unlockItem(lockedItem); }), - onCancel: () => dispatch(locks.unlock(lockedItem)).then((result) => { + onCancel: () => planningApi.locks.unlockItem(lockedItem).then((result) => { if (get(previousLock, 'action') && (openInEditor || openInModal)) { dispatch(main.openForEdit(result, true, openInModal)); - dispatch(locks.lock(result, previousLock.action)); + planningApi.locks.lockItem(result, previousLock.action); } }), autoClose: true, @@ -996,16 +997,16 @@ const onMarkEventCompleted = (event, editor = false) => ( } // If actioned on list / preview - return dispatch(locks.lock(event, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action)) + return planningApi.locks.lockItem(event, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action) .then((original) => ( dispatch(showModal({ modalType: MODALS.CONFIRMATION, modalProps: { body: gettext('Are you sure you want to mark this event as complete?'), action: () => dispatch(main.saveAndUnlockItem(original, updates, true)).catch((error) => { - dispatch(locks.unlock(original)); + planningApi.locks.unlockItem(original); }), - onCancel: () => dispatch(locks.unlock(original)), + onCancel: () => planningApi.locks.unlockItem(original), autoClose: true, }, }))), (error) => { diff --git a/client/actions/index.ts b/client/actions/index.ts index f3ccd726c..35d79b4bb 100644 --- a/client/actions/index.ts +++ b/client/actions/index.ts @@ -5,7 +5,6 @@ import * as editors from './editor'; import planning from './planning/index'; import events from './events/index'; -import locks from './locks'; import assignments from './assignments/index'; import autosave from './autosave'; import main from './main'; @@ -60,7 +59,6 @@ export { events, resetStore, initStore, - locks, assignments, autosave, main, diff --git a/client/actions/locks.ts b/client/actions/locks.ts deleted file mode 100644 index ee726e5a4..000000000 --- a/client/actions/locks.ts +++ /dev/null @@ -1,154 +0,0 @@ -import {get} from 'lodash'; -import * as selectors from '../selectors'; -import {LOCKS, ITEM_TYPE, WORKSPACE, PLANNING, FEATURED_PLANNING} from '../constants'; -import {planning, events, assignments, autosave, main} from './index'; -import {lockUtils, getItemType, gettext, isExistingItem, modifyForClient} from '../utils'; -import {planningApi} from '../superdeskApi'; -import featuredPlanning from './planning/featuredPlanning'; - -/** - * Action Dispatcher to load all Event and Planning locks - * Then send them to the lock reducer for processing and storage - */ -const loadAllLocks = () => ( - (dispatch) => ( - Promise.all([ - planningApi.events.getLocked(), - planningApi.planning.getLocked(), - planningApi.planning.getLockedFeatured(), - ]) - .then((data) => { - const payload = { - events: data[0], - plans: data[1], - }; - - dispatch({ - type: LOCKS.ACTIONS.RECEIVE, - payload: payload, - }); - - // If featured stories are locked - if (get(data, '[2][0].lock_user')) { - dispatch(featuredPlanning.setLockUser( - data[2][0].lock_user, - data[2][0].lock_session - )); - } - - return Promise.resolve(payload); - }, (error) => Promise.reject(error)) - ) -); - -/** - * Action Dispatcher to load Assignment locks - * Then send them to the lock reducer for processing and storage - */ -const loadAssignmentLocks = () => ( - (dispatch) => ( - dispatch(assignments.api.queryLockedAssignments()) - .then((data) => { - const payload = {assignments: data}; - - dispatch({ - type: LOCKS.ACTIONS.RECEIVE, - payload: payload, - }); - return Promise.resolve(payload); - }, (error) => Promise.reject(error)) - ) -); - -/** - * Action Dispatcher to release the lock an a chain of Events and/or Planning items - * It retrieves the lock from the Redux store for the item provided - * and calls the appropriate unlock method on the item that is actually locked - * @param {object} item - The Event or Planning item chain to unlock - */ -const unlock = (item) => ( - (dispatch, getState, {notify}) => { - if (!isExistingItem(item)) { - if (get(item, '_planning_item')) { - dispatch(planning.api.unlock({_id: item._planning_item})); - } - - return dispatch(autosave.removeById(item.type, item._id)); - } - - const locks = selectors.locks.getLockedItems(getState()); - const currentLock = lockUtils.getLock(item, locks); - - if (currentLock === null) { - const errorMessage = gettext('Failed to unlock the item. Lock not found!'); - - notify.error(errorMessage); - return Promise.reject(errorMessage); - } - - let promise = Promise.resolve(item); - - switch (currentLock.item_type) { - case 'planning': - promise = dispatch(planning.api.unlock({_id: currentLock.item_id})); - break; - case 'event': - promise = dispatch(events.api.unlock({_id: currentLock.item_id})); - break; - } - - return promise; - } -); - -const lock = (item, lockAction = 'edit') => ( - (dispatch, getState, {notify}) => { - const itemType = getItemType(item); - const currentWorkspace = selectors.general.currentWorkspace(getState()); - - switch (itemType) { - case ITEM_TYPE.EVENT: - return dispatch(events.api.lock(item, lockAction)); - case ITEM_TYPE.PLANNING: - return dispatch(planning.api.lock( - item, - currentWorkspace === WORKSPACE.AUTHORING ? - PLANNING.ITEM_ACTIONS.ADD_TO_PLANNING.lock_action : - lockAction - )); - } - - const errorMessage = gettext('Failed to lock the item, could not determine item type!'); - - notify.error(errorMessage); - return Promise.reject(errorMessage); - } -); - -const unlockThenLock = (item, modal) => ( - (dispatch) => ( - dispatch(self.unlock(item)) - .then( - (unlockedItem) => ( - dispatch(main.openForEdit( - modifyForClient(item._id !== unlockedItem._id ? - item : - unlockedItem - ), true, modal - )) - ), - (error) => Promise.reject(error) - ) - ) -); - -// eslint-disable-next-line consistent-this -const self = { - lock, - unlock, - loadAllLocks, - loadAssignmentLocks, - unlockThenLock, -}; - -export default self; diff --git a/client/actions/main.ts b/client/actions/main.ts index 23da557d1..2c067acbd 100644 --- a/client/actions/main.ts +++ b/client/actions/main.ts @@ -3,7 +3,7 @@ import moment from 'moment'; import {appConfig} from 'appConfig'; import {IUser} from 'superdesk-api'; -import {planningApi as planningApis, superdeskApi} from '../superdeskApi'; +import {planningApi, superdeskApi} from '../superdeskApi'; import { EDITOR_TYPE, ICombinedEventOrPlanningSearchParams, @@ -32,7 +32,7 @@ import { } from '../constants'; import {activeFilter, lastRequestParams} from '../selectors/main'; import planningUi from './planning/ui'; -import planningApi from './planning/api'; +import planningApis from './planning/api'; import eventsUi from './events/ui'; import eventsApi from './events/api'; import autosave from './autosave'; @@ -68,8 +68,8 @@ import * as selectors from '../selectors'; import {validateItem} from '../validators'; import {searchParamsToOld} from '../utils/search'; -const openForEdit = (item, updateUrl = true, modal = false) => ( - (dispatch, getState) => { +function openForEdit(item: IEventOrPlanningItem, updateUrl: boolean = true, modal: boolean = false) { + return (dispatch, getState) => { if (!isExistingItem(item)) { return dispatch( self.openEditorAction(item, 'create', updateUrl, modal) @@ -95,8 +95,8 @@ const openForEdit = (item, updateUrl = true, modal = false) => ( dispatch( self.openEditorAction(item, action, updateUrl, modal) ); - } -); + }; +} function openEditorAction( item: IEventOrPlanningItem, @@ -199,7 +199,7 @@ const unlockAndCancel = (item, ignoreSession = false) => ( selectors.locks.getLockedItems(state), ignoreSession )) { - promise = dispatch(locks.unlock(item)); + promise = planningApi.locks.unlockItem(item); if (isExistingItem(item)) { promise.then( () => dispatch(autosave.removeById(itemType, itemId)) @@ -301,7 +301,7 @@ const save = (original, updates, withConfirmation = true) => ( break; case ITEM_TYPE.PLANNING: dispatch( - planningApi.receivePlannings([savedItem]) + planningApis.receivePlannings([savedItem]) ); break; } @@ -341,7 +341,7 @@ const unpost = (original, updates = {}, withConfirmation = true) => ( break; case ITEM_TYPE.PLANNING: confirmation = false; - promise = dispatch(planningApi.unpost(original, updates)); + promise = dispatch(planningApis.unpost(original, updates)); break; default: promise = Promise.reject( @@ -403,7 +403,7 @@ const post = (original, updates = {}, withConfirmation = true) => ( null, {}, original, - planningApi.post.bind(null, original, updates))); + planningApis.post.bind(null, original, updates))); break; default: promise = Promise.reject( @@ -529,7 +529,7 @@ const openActionModalFromEditor = (original, title, action) => ( // This helps to clear the Editor states before performing the action const unlockAndSetEditorReadOnly = (itemToUnlock) => ( Promise.all([ - dispatch(locks.unlock(itemToUnlock)), + planningApi.locks.unlockItem(itemToUnlock), (isOpenInEditor || isOpenInModal) ? dispatch(self.changeEditorAction('read', isOpenInModal)) : Promise.resolve(), @@ -624,6 +624,18 @@ const openActionModalFromEditor = (original, title, action) => ( } ); +interface IOpenIgnoreCancelSaveModalProps { + itemId: IEventOrPlanningItem['_id']; + itemType: IEventOrPlanningItem['type']; + onCancel?(): void; + onIgnore(): void; + onSave?(): void; + onGoTo(): void; + onSaveAndPost?(): void; + title?: string; + autoClose?: boolean; +} + const openIgnoreCancelSaveModal = ({ itemId, itemType, @@ -634,7 +646,7 @@ const openIgnoreCancelSaveModal = ({ onSaveAndPost, title, autoClose = true, -}) => ( +}: IOpenIgnoreCancelSaveModalProps) => ( (dispatch, getState) => { const autosaveData = getAutosaveItem( selectors.forms.autosaves(getState()), @@ -807,7 +819,7 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear const currentFilterId: ISearchFilter['_id'] = urlParams.getString('eventsPlanningFilter'); if (currentFilterId != undefined || filterType === PLANNING_VIEW.COMBINED) { - promise = planningApis.ui.list.changeFilterId(currentFilterId, params); + promise = planningApi.ui.list.changeFilterId(currentFilterId, params); } else if (filterType === PLANNING_VIEW.EVENTS) { const calendar = urlParams.getString('calendar') || lastParams?.calendars?.[0] || @@ -823,7 +835,7 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear EVENTS.FILTER.ALL_CALENDARS ); - promise = planningApis.ui.list.changeCalendarId( + promise = planningApi.ui.list.changeCalendarId( calender, params ); @@ -835,7 +847,7 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear AGENDA.FILTER.ALL_PLANNING ); - promise = planningApis.ui.list.changeAgendaId( + promise = planningApi.ui.list.changeAgendaId( searchAgenda, params ); @@ -997,7 +1009,7 @@ const closeEditor = (modal = false) => ( payload: modal, }); - planningApis.editor(modal ? EDITOR_TYPE.POPUP : EDITOR_TYPE.INLINE) + planningApi.editor(modal ? EDITOR_TYPE.POPUP : EDITOR_TYPE.INLINE) .events.onEditorClosed(); if (!modal) { @@ -1078,7 +1090,7 @@ const fetchById = (itemId, itemType, force = false) => ( if (itemType === ITEM_TYPE.EVENT) { return dispatch(eventsApi.fetchById(itemId, {force})); } else if (itemType === ITEM_TYPE.PLANNING) { - return dispatch(planningApi.fetchById(itemId, {force})); + return dispatch(planningApis.fetchById(itemId, {force})); } } @@ -1281,7 +1293,7 @@ const fetchItemHistory = (item) => ( historyDispatch = eventsApi.fetchEventHistory; break; case ITEM_TYPE.PLANNING: - historyDispatch = planningApi.fetchPlanningHistory; + historyDispatch = planningApis.fetchPlanningHistory; break; } @@ -1451,7 +1463,7 @@ const fetchQueueItem = (item) => ( dispatch(eventsApi.receiveEvents([publishedItem])); } else { planningUtils.modifyForClient(publishedItem); - dispatch(planningApi.receivePlannings([publishedItem])); + dispatch(planningApis.receivePlannings([publishedItem])); } } return Promise.resolve(publishedItem); @@ -1528,7 +1540,7 @@ const spikeAfterUnlock = (unlockedItem, previousLock, openInEditor, openInModal) ); } - return dispatch(locks.lock(updatedItem, previousLock.action)); + return planningApi.locks.lockItem(updatedItem, previousLock.action); } }; const dispatchCall = getItemType(unlockedItem) === ITEM_TYPE.PLANNING ? @@ -1569,12 +1581,12 @@ const saveAndUnlockItem = (original, updates, ignoreRecurring = false) => ( break; case ITEM_TYPE.PLANNING: dispatch( - planningApi.receivePlannings([savedItem]) + planningApis.receivePlannings([savedItem]) ); break; } - return dispatch(locks.unlock(get(savedItem, '[0]', savedItem))) + return planningApi.locks.unlockItem(get(savedItem, '[0]', savedItem)) .then((unlockedItem) => Promise.resolve(unlockedItem)) .catch(() => { notify.error(gettext('Could not unlock the item.')); diff --git a/client/actions/multiSelect.ts b/client/actions/multiSelect.ts index dcc39b6eb..834de3dd6 100644 --- a/client/actions/multiSelect.ts +++ b/client/actions/multiSelect.ts @@ -7,7 +7,7 @@ import {showModal} from './index'; import {MULTISELECT, ITEM_TYPE, MODALS} from '../constants'; import eventsUi from './events/ui'; import planningUi from './planning/ui'; -import {getItemType, gettext, planningUtils, eventUtils, getItemInArrayById, getErrorMessage} from '../utils'; +import {getItemType, gettext, getItemInArrayById, getErrorMessage, lockUtils} from '../utils'; /** * Action Dispatcher to select an/all Event(s) @@ -193,11 +193,9 @@ const exportAsArticle = (items = [], download) => ( const sortableItems = []; const label = (item) => item.headline || item.slugline || item.description_text || item.name; const locks = selectors.locks.getLockedItems(state); - const isLockedCheck = isPlanning ? planningUtils.isPlanningLocked : - eventUtils.isEventLocked; items.forEach((item) => { - const isLocked = isLockedCheck(item, locks); + const isLocked = lockUtils.isItemLocked(item, locks); const isNotForPublication = get(item, 'flags.marked_for_not_publication'); if (isLocked || isNotForPublication) { diff --git a/client/actions/planning/api.ts b/client/actions/planning/api.ts index 798bf661f..3fa242e1c 100644 --- a/client/actions/planning/api.ts +++ b/client/actions/planning/api.ts @@ -2,14 +2,13 @@ import {get, cloneDeep, pickBy, has, every} from 'lodash'; import {IEventItem, IPlanningSearchParams, IPlanningItem} from '../../interfaces'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../superdeskApi'; import * as actions from '../../actions'; import * as selectors from '../../selectors'; import { getErrorMessage, - getTimeZoneOffset, planningUtils, - lockUtils, isExistingItem, isPublishedItemId, isValidFileInput, @@ -24,7 +23,6 @@ import { TO_BE_CONFIRMED_FIELD, } from '../../constants'; import main from '../main'; -import {planningApi} from '../../superdeskApi'; import {planningParamsToSearchParams} from '../../utils/search'; /** @@ -498,77 +496,6 @@ const receivePlannings = (plannings) => ( } ); -/** - * Action dispatcher that attempts to unlock a Planning item through the API - * @param {object} item - The Planning item to unlock - * @return Promise - */ -const unlock = (item) => ( - (dispatch, getState, {api}) => ( - api('planning_unlock', item).save({}) - ) - .then((item) => { - planningUtils.modifyForClient(item); - - dispatch({ - type: PLANNING.ACTIONS.UNLOCK_PLANNING, - payload: {plan: item}, - }); - - return Promise.resolve(item); - }, (error) => Promise.reject(error)) -); - -/** - * Action dispatcher that attempts to lock a Planning item through the API - * @param {object} planning - The Planning item to lock - * @param {String} lockAction - The lock action - * @return Promise - */ -const lock = (planning, lockAction = 'edit') => ( - (dispatch, getState, {api}) => { - if (lockAction === null || - lockUtils.isItemLockedInThisSession( - planning, - selectors.general.session(getState()), - selectors.locks.getLockedItems(getState()) - ) - ) { - return Promise.resolve(planning); - } - - return api('planning_lock', planning).save({}, {lock_action: lockAction}) - .then((item) => { - planningUtils.modifyForClient(item); - - dispatch({ - type: PLANNING.ACTIONS.LOCK_PLANNING, - payload: {plan: item}, - }); - - return Promise.resolve(item); - }, (error) => Promise.reject(error)); - } -); - -/** - * Locks featured stories action - * @return Promise - */ -function lockFeaturedPlanning() { - return (dispatch, getState, {notify}) => ( - planningApi.planning.featured.lock() - .catch((error) => { - notify.error( - getErrorMessage( - error, - gettext('Failed to lock featured story action!') - ) - ); - }) - ); -} - const fetchPlanningFiles = (planning) => ( (dispatch, getState) => { if (!planningUtils.shouldFetchFilesForPlanning(planning)) { @@ -605,36 +532,6 @@ const getFiles = (files) => ( ) ); - -/** - * Action dispatcher to save the featured planning record through the API - * @param {object} updates - updates to save - * @return Promise - */ -const saveFeaturedPlanning = (updates) => ( - (dispatch, getState, {api}) => { - const item = selectors.featuredPlanning.featuredPlanningItem(getState()) || {}; - - return api('planning_featured').save(cloneDeep(item), {...updates}) - .then((savedItem) => savedItem); - } -); - - -/** - * Unlocks featured planning action - * @return Promise - */ -function unlockFeaturedPlanning() { - return (dispatch, getState, {notify}) => ( - planningApi.planning.featured.unlock() - .catch((error) => { - notify.error( - getErrorMessage(error, gettext('Failed to unlock featured story action!'))); - }) - ); -} - const markPlanningCancelled = (plan, reason, coverageState, eventCancellation) => ({ type: PLANNING.ACTIONS.MARK_PLANNING_CANCELLED, payload: { @@ -732,8 +629,6 @@ const self = { save, fetchById, fetchPlanningsEvents, - unlock, - lock, loadPlanningById, loadPlanningByIds, fetchPlanningHistory, @@ -750,9 +645,6 @@ const self = { loadPlanningByRecurrenceId, cancel, cancelAllCoverage, - lockFeaturedPlanning, - unlockFeaturedPlanning, - saveFeaturedPlanning, fetchPlanningFiles, uploadFiles, removeFile, diff --git a/client/actions/planning/featuredPlanning.ts b/client/actions/planning/featuredPlanning.ts index cc325d849..798766402 100644 --- a/client/actions/planning/featuredPlanning.ts +++ b/client/actions/planning/featuredPlanning.ts @@ -4,9 +4,7 @@ import {cloneDeep, some} from 'lodash'; import {appConfig} from 'appConfig'; import {IUser} from 'superdesk-api'; import {IFeaturedPlanningItem, IFeaturedPlanningSaveItem, IPlanningItem, ISearchParams} from '../../interfaces'; -import {planningApi as planningApis, superdeskApi} from '../../superdeskApi'; -import planningApi from './api'; -import {locks} from '../index'; +import {planningApi, superdeskApi} from '../../superdeskApi'; import main from '../main'; import {MODALS, FEATURED_PLANNING, TIME_COMPARISON_GRANULARITY} from '../../constants'; @@ -134,7 +132,7 @@ function movePlanningToUnselectedList(item: IPlanningItem) { function getAndUpdateStoredPlanningItem(itemId: IPlanningItem['_id']) { return (dispatch, getState) => { if (selectors.featuredPlanning.inUse(getState())) { - planningApis.planning.getById(itemId, false, true).then((item) => { + planningApi.planning.getById(itemId, false, true).then((item) => { dispatch({ type: FEATURED_PLANNING.ACTIONS.UPDATE_PLANNING_AND_LISTS, payload: item, @@ -147,7 +145,7 @@ function getAndUpdateStoredPlanningItem(itemId: IPlanningItem['_id']) { function updatePlanningMetadata(itemId: IPlanningItem['_id']) { return (dispatch, getState) => { if (selectors.featuredPlanning.inUse(getState())) { - planningApis.planning.getById(itemId, false, true).then((item) => { + planningApi.planning.getById(itemId, false, true).then((item) => { dispatch({ type: FEATURED_PLANNING.ACTIONS.UPDATE_PLANNING_METADATA, payload: item, @@ -159,7 +157,7 @@ function updatePlanningMetadata(itemId: IPlanningItem['_id']) { function getFeaturedPlanningItem(date: moment.Moment) { return (dispatch) => ( - planningApis.planning.featured.getByDate(date) + planningApi.planning.featured.getByDate(date) .then((item) => { dispatch(setFeaturedPlanningItem(item)); @@ -173,10 +171,10 @@ function fetchToList(params: ISearchParams = {}, featuredItem?: IFeaturedPlannin dispatch(setCurrentSearchParams(params)); return (featuredItem?.items?.length ? - planningApis.planning.getByIds(featuredItem?.items, 'both', {include_killed: true}) : + planningApi.planning.getByIds(featuredItem?.items, 'both', {include_killed: true}) : Promise.resolve>([]) ).then((currentFeaturedItems) => ( - planningApis.planning.searchGetAll(params) + planningApi.planning.searchGetAll(params) .then((searchResults) => ({ current: currentFeaturedItems, search: searchResults, @@ -265,7 +263,7 @@ function openFeaturedPlanningModal() { dispatch(setInUse(true)); dispatch(showModal({modalType: MODALS.FEATURED_STORIES})); - dispatch(planningApi.lockFeaturedPlanning()) + planningApi.locks.lockFeaturedPlanning() .then(() => ( dispatch(loadFeaturedPlanningsData(currentSearchDate)) )) @@ -291,7 +289,7 @@ function modifyPlanningFeatured(original: IPlanningItem, remove: boolean = false dispatch(_modifyPlanningFeatured(unlockedItem, remove)) .then((updatedItem) => { if (previousLock?.action) { - dispatch(locks.lock(updatedItem, previousLock.action)) + planningApi.locks.lockItem(updatedItem, previousLock.action) .then((updatedUnlockedItem) => { if (openInEditor || openInModal) { dispatch(main.openForEdit(updatedUnlockedItem, !openInModal, openInModal)); @@ -306,7 +304,7 @@ function modifyPlanningFeatured(original: IPlanningItem, remove: boolean = false function _modifyPlanningFeatured(item: IPlanningItem, remove: boolean = false) { return (dispatch) => ( - dispatch(locks.lock(item, remove ? 'remove_featured' : 'add_featured')) + planningApi.locks.lockItem(item, remove ? 'remove_featured' : 'add_featured') .then((original: IPlanningItem) => { const updates = cloneDeep(original); const {gettext} = superdeskApi.localization; @@ -334,12 +332,12 @@ function _modifyPlanningFeatured(item: IPlanningItem, remove: boolean = false) { ); } -function saveFeaturedPlanningForDate(updates: IFeaturedPlanningSaveItem, reloadFeaturedItem: boolean) { +function saveFeaturedPlanningForDate(updates: Partial, reloadFeaturedItem: boolean) { const {gettext} = superdeskApi.localization; const {notify} = superdeskApi.ui; return (dispatch, getState) => ( - dispatch(planningApi.saveFeaturedPlanning(updates)) + planningApi.planning.featured.save(updates) .then( (item: IFeaturedPlanningItem) => { if (item.posted) { @@ -366,7 +364,7 @@ function unsetFeaturePlanningInUse(unlock: boolean = true) { dispatch(setInUse(false)); if (unlock) { - return dispatch(planningApi.unlockFeaturedPlanning()) + planningApi.locks.unlockFeaturedPlanning() .then(() => { dispatch(hideModal()); return Promise.resolve(); @@ -379,13 +377,12 @@ function unsetFeaturePlanningInUse(unlock: boolean = true) { function forceUnlock() { return (dispatch) => ( - dispatch(planningApi.unlockFeaturedPlanning()) - .then(() => { + planningApi.locks.unlockFeaturedPlanning() + .then(() => ( // Set unlocked here so the websocket notification doesn't think // the current session is getting unlocked by another user/session - dispatch(self.setUnlocked()); - return dispatch(self.openFeaturedPlanningModal()); - }) + dispatch(self.openFeaturedPlanningModal()) + )) ); } diff --git a/client/actions/planning/notifications.ts b/client/actions/planning/notifications.ts index e25863db1..5cefe0631 100644 --- a/client/actions/planning/notifications.ts +++ b/client/actions/planning/notifications.ts @@ -1,12 +1,17 @@ import {get} from 'lodash'; + import {IWebsocketMessageData, ITEM_TYPE} from '../../interfaces'; +import {planningApi} from '../../superdeskApi'; + +import {gettext, lockUtils} from '../../utils'; +import {PLANNING, MODALS, WORKFLOW_STATE, WORKSPACE} from '../../constants'; + import planning from './index'; import assignments from '../assignments/index'; -import {gettext, lockUtils} from '../../utils'; + import * as selectors from '../../selectors'; import {events, fetchAgendas} from '../index'; import main from '../main'; -import {PLANNING, MODALS, WORKFLOW_STATE, WORKSPACE} from '../../constants'; import {showModal, hideModal} from '../index'; import eventsPlanning from '../eventsPlanning'; @@ -95,6 +100,8 @@ const onPlanningUpdated = (_e, data) => ( const onPlanningLocked = (e, data) => ( (dispatch, getState) => { if (get(data, 'item')) { + planningApi.locks.setItemAsLocked(data); + const sessionId = selectors.general.session(getState()).sessionId; return dispatch(planning.api.getPlanning(data.item, false)) @@ -139,6 +146,8 @@ const onPlanningLocked = (e, data) => ( function onPlanningUnlocked(_e: {}, data: IWebsocketMessageData['ITEM_UNLOCKED']) { return (dispatch, getState) => { if (data?.item != null) { + planningApi.locks.setItemAsUnlocked(data); + const state = getState(); let planningItem = selectors.planning.storedPlannings(state)[data.item]; const isCurrentlyLocked = lockUtils.isItemLocked(planningItem, selectors.locks.getLockedItems(state)); diff --git a/client/actions/planning/tests/api_test.ts b/client/actions/planning/tests/api_test.ts index 8e52bacc0..a3d48acfa 100644 --- a/client/actions/planning/tests/api_test.ts +++ b/client/actions/planning/tests/api_test.ts @@ -672,101 +672,4 @@ describe('actions.planning.api', () => { .catch(done.fail); }); }); - - describe('lock/unlock', () => { - let mockStore; - let mocks; - let getLocks = () => selectors.locks.getLockedItems(mockStore.getState()); - - beforeEach(() => { - mocks = { - api: sinon.spy(() => mocks), - save: sinon.spy((original, updates = {}) => Promise.resolve({ - ...data.plannings[0], - ...updates, - })), - }; - - store.init(); - }); - - it('calls lock endpoint and updates the redux store', (done) => { - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().planning).toEqual({}); - - mockStore.dispatch(planningApi.lock(data.plannings[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'planning_lock', - data.plannings[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([ - {}, - {lock_action: 'edit'}, - ]); - - expect(getLocks().planning).toEqual({ - p1: jasmine.objectContaining({ - action: 'edit', - item_type: 'planning', - item_id: 'p1', - }), - }); - - done(); - }) - .catch(done.fail); - }); - - it('calls unlock endpoint and updates the redux store', (done) => { - store.initialState.locks.planning = { - p1: { - action: 'edit', - item_type: 'planning', - item_id: 'p1', - }, - }; - - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().planning).toEqual({ - p1: jasmine.objectContaining({ - action: 'edit', - item_type: 'planning', - item_id: 'p1', - }), - }); - - mockStore.dispatch(planningApi.unlock(data.plannings[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'planning_unlock', - data.plannings[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([{}]); - - expect(getLocks().planning).toEqual({}); - - done(); - }) - .catch(done.fail); - }); - }); }); diff --git a/client/actions/planning/tests/notifications_test.ts b/client/actions/planning/tests/notifications_test.ts index b0fbb7c19..79fcfbff2 100644 --- a/client/actions/planning/tests/notifications_test.ts +++ b/client/actions/planning/tests/notifications_test.ts @@ -1,4 +1,5 @@ -import planningApi from '../api'; +import {planningApi} from '../../../superdeskApi'; +import planningApis from '../api'; import planningUi from '../ui'; import featuredPlanning from '../featuredPlanning'; import eventsPlanningUi from '../../eventsPlanning/ui'; @@ -195,11 +196,13 @@ describe('actions.planning.notifications', () => { describe('onPlanningLocked', () => { beforeEach(() => { - sinon.stub(planningApi, 'getPlanning').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApi.locks, 'setItemAsLocked').returns(undefined); + sinon.stub(planningApis, 'getPlanning').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { - restoreSinonStub(planningApi.getPlanning); + restoreSinonStub(planningApi.locks.setItemAsLocked); + restoreSinonStub(planningApis.getPlanning); }); it('calls getPlanning and dispatches the LOCK_PLANNING action', (done) => ( @@ -215,8 +218,9 @@ describe('actions.planning.notifications', () => { } )) .then(() => { - expect(planningApi.getPlanning.callCount).toBe(1); - expect(planningApi.getPlanning.args[0]).toEqual([ + expect(planningApi.locks.setItemAsLocked.callCount).toBe(1); + expect(planningApis.getPlanning.callCount).toBe(1); + expect(planningApis.getPlanning.args[0]).toEqual([ 'p1', false, ]); @@ -245,10 +249,12 @@ describe('actions.planning.notifications', () => { store.initialState.planning.plannings.p1.lock_user = 'ident1'; store.initialState.planning.plannings.p1.lock_session = 'session1'; store.initialState.planning.plannings.p1.lock_time = '2022-06-15T13:01:11+0000'; + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); sinon.stub(main, 'changeEditorAction').callsFake(() => Promise.resolve()); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(main.changeEditorAction); }); @@ -278,6 +284,7 @@ describe('actions.planning.notifications', () => { user: 'ident2', })) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); const modalStr = 'The Planning item you were editing was unlocked by "{{ userName }}"'; expect(store.dispatch.args[2][0].type).toEqual('AUTOSAVE_REMOVE'); @@ -305,6 +312,7 @@ describe('actions.planning.notifications', () => { etag: 'e123', })) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); expect(store.dispatch.args[1]).toEqual([{ type: 'UNLOCK_PLANNING', payload: { diff --git a/client/actions/planning/tests/ui_test.ts b/client/actions/planning/tests/ui_test.ts index f9619749c..f459f9d18 100644 --- a/client/actions/planning/tests/ui_test.ts +++ b/client/actions/planning/tests/ui_test.ts @@ -1,7 +1,8 @@ +import {planningApi} from '../../../superdeskApi'; import planningUi from '../ui'; -import planningApi from '../api'; +import planningApis from '../api'; import assignmentApi from '../../assignments/api'; -import {main, locks} from '../../'; +import {main} from '../../'; import sinon from 'sinon'; import {MAIN, WORKSPACE} from '../../../constants'; import {getTestActionStore, restoreSinonStub} from '../../../utils/testUtils'; @@ -19,13 +20,13 @@ describe('actions.planning.ui', () => { services = store.services; data = store.data; - sinon.stub(planningApi, 'spike').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'unspike').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'fetch').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'refetch').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'save').callsFake((item) => (Promise.resolve(item))); - sinon.stub(planningApi, 'lock').callsFake((item) => (Promise.resolve(item))); - sinon.stub(planningApi, 'unlock').callsFake(() => (Promise.resolve(data.plannings[0]))); + sinon.stub(planningApis, 'spike').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'unspike').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'fetch').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'refetch').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'save').callsFake((item) => (Promise.resolve(item))); + sinon.stub(planningApi.locks, 'lockItem').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(planningUi, 'requestPlannings').callsFake(() => (Promise.resolve())); sinon.stub(planningUi, 'clearList').callsFake(() => ({type: 'clearList'})); @@ -39,18 +40,16 @@ describe('actions.planning.ui', () => { sinon.stub(main, 'closePreviewAndEditorForItems').callsFake(() => (Promise.resolve())); sinon.stub(main, 'openForEdit'); - sinon.stub(locks, 'lock').callsFake((item) => (Promise.resolve(item))); }); afterEach(() => { - restoreSinonStub(planningApi.spike); - restoreSinonStub(planningApi.unspike); - restoreSinonStub(planningApi.fetch); - restoreSinonStub(planningApi.refetch); - restoreSinonStub(planningApi.save); - restoreSinonStub(planningApi.lock); - restoreSinonStub(planningApi.unlock); - + restoreSinonStub(planningApis.spike); + restoreSinonStub(planningApis.unspike); + restoreSinonStub(planningApis.fetch); + restoreSinonStub(planningApis.refetch); + restoreSinonStub(planningApis.save); + restoreSinonStub(planningApi.locks.lockItem); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(planningUi.requestPlannings); restoreSinonStub(planningUi.clearList); restoreSinonStub(planningUi.setInList); @@ -63,12 +62,11 @@ describe('actions.planning.ui', () => { restoreSinonStub(main.closePreviewAndEditorForItems); restoreSinonStub(main.openForEdit); - restoreSinonStub(locks.lock); }); describe('spike', () => { afterEach(() => { - restoreSinonStub(planningApi.refetch); + restoreSinonStub(planningApis.refetch); restoreSinonStub(planningUi.refetch); }); @@ -78,8 +76,8 @@ describe('actions.planning.ui', () => { expect(item).toEqual(data.plannings[1]); // Calls api.spike - expect(planningApi.spike.callCount).toBe(1); - expect(planningApi.spike.args[0]).toEqual([data.plannings[1]]); + expect(planningApis.spike.callCount).toBe(1); + expect(planningApis.spike.args[0]).toEqual([data.plannings[1]]); // Notifies end user of success expect(services.notify.success.callCount).toBe(1); @@ -104,8 +102,8 @@ describe('actions.planning.ui', () => { }); it('ui.spike notifies end user on failure to spike', (done) => { - restoreSinonStub(planningApi.spike); - sinon.stub(planningApi, 'spike').callsFake(() => (Promise.reject(errorMessage))); + restoreSinonStub(planningApis.spike); + sinon.stub(planningApis, 'spike').callsFake(() => (Promise.reject(errorMessage))); return store.test(done, planningUi.spike(data.plannings[1])) .then(() => { /* no-op */ }, (error) => { expect(error).toEqual(errorMessage); @@ -132,8 +130,8 @@ describe('actions.planning.ui', () => { expect(item).toEqual(data.plannings[1]); // Calls api.unspike - expect(planningApi.unspike.callCount).toBe(1); - expect(planningApi.unspike.args[0]).toEqual([data.plannings[1]]); + expect(planningApis.unspike.callCount).toBe(1); + expect(planningApis.unspike.args[0]).toEqual([data.plannings[1]]); // Notified end user of success expect(services.notify.success.callCount).toBe(1); @@ -148,8 +146,8 @@ describe('actions.planning.ui', () => { ).catch(done.fail)); it('ui.unspike notifies end user on failure to unspike', (done) => { - restoreSinonStub(planningApi.unspike); - sinon.stub(planningApi, 'unspike').callsFake(() => (Promise.reject(errorMessage))); + restoreSinonStub(planningApis.unspike); + sinon.stub(planningApis, 'unspike').callsFake(() => (Promise.reject(errorMessage))); return store.test(done, planningUi.unspike(data.plannings[1])) .then(() => { /* no-op */ }, (error) => { expect(error).toEqual(errorMessage); @@ -174,8 +172,8 @@ describe('actions.planning.ui', () => { .then((item) => { expect(item).toEqual(data.plannings[1]); - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[1], {slugline: 'New Slugger'}, ]); @@ -186,8 +184,8 @@ describe('actions.planning.ui', () => { ); it('on save fail notifies the end user', (done) => { - restoreSinonStub(planningApi.save); - sinon.stub(planningApi, 'save').callsFake( + restoreSinonStub(planningApis.save); + sinon.stub(planningApis, 'save').callsFake( () => (Promise.reject(errorMessage)) ); @@ -232,8 +230,8 @@ describe('actions.planning.ui', () => { it('fetchToList', (done) => { restoreSinonStub(planningUi.fetchToList); - restoreSinonStub(planningApi.fetch); - sinon.stub(planningApi, 'fetch').callsFake( + restoreSinonStub(planningApis.fetch); + sinon.stub(planningApis, 'fetch').callsFake( () => (Promise.resolve(data.plannings)) ); @@ -244,8 +242,8 @@ describe('actions.planning.ui', () => { expect(planningUi.requestPlannings.callCount).toBe(1); expect(planningUi.requestPlannings.args[0]).toEqual([params]); - expect(planningApi.fetch.callCount).toBe(1); - expect(planningApi.fetch.args[0]).toEqual([params]); + expect(planningApis.fetch.callCount).toBe(1); + expect(planningApis.fetch.args[0]).toEqual([params]); expect(planningUi.setInList.callCount).toBe(1); expect(planningUi.setInList.args[0]).toEqual([['p1', 'p2']]); @@ -265,8 +263,8 @@ describe('actions.planning.ui', () => { }; restoreSinonStub(planningUi.loadMore); - restoreSinonStub(planningApi.fetch); - sinon.stub(planningApi, 'fetch').callsFake( + restoreSinonStub(planningApis.fetch); + sinon.stub(planningApis, 'fetch').callsFake( () => (Promise.resolve(data.plannings)) ); @@ -280,8 +278,8 @@ describe('actions.planning.ui', () => { .then(() => { expect(planningUi.requestPlannings.callCount).toBe(0); - expect(planningApi.fetch.callCount).toBe(1); - expect(planningApi.fetch.args[0]).toEqual([expectedParams]); + expect(planningApis.fetch.callCount).toBe(1); + expect(planningApis.fetch.args[0]).toEqual([expectedParams]); expect(planningUi.addToList.callCount).toBe(1); expect(planningUi.addToList.args[0]).toEqual([['p1', 'p2']]); @@ -301,8 +299,8 @@ describe('actions.planning.ui', () => { }; restoreSinonStub(planningUi.loadMore); - restoreSinonStub(planningApi.fetch); - sinon.stub(planningApi, 'fetch').callsFake( + restoreSinonStub(planningApis.fetch); + sinon.stub(planningApis, 'fetch').callsFake( () => (Promise.resolve(Array.from(Array(MAIN.PAGE_SIZE).keys()))) ); @@ -317,8 +315,8 @@ describe('actions.planning.ui', () => { expect(planningUi.requestPlannings.callCount).toBe(1); expect(planningUi.requestPlannings.args[0]).toEqual([expectedParams]); - expect(planningApi.fetch.callCount).toBe(1); - expect(planningApi.fetch.args[0]).toEqual([expectedParams]); + expect(planningApis.fetch.callCount).toBe(1); + expect(planningApis.fetch.args[0]).toEqual([expectedParams]); expect(planningUi.addToList.callCount).toBe(1); @@ -352,8 +350,6 @@ describe('actions.planning.ui', () => { modalType: 'ADD_TO_PLANNING', modalProps: {newsItem}, }; - - sinon.stub(locks, 'unlock').callsFake((item) => (Promise.resolve(item))); }); it('unlocks current planning opens the new planning', (done) => { @@ -363,8 +359,8 @@ describe('actions.planning.ui', () => { store.initialState.planning.plannings.p1 )) .then(() => { - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([ + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([ store.initialState.planning.plannings.p2, ]); @@ -374,10 +370,6 @@ describe('actions.planning.ui', () => { }) .catch(done.fail); }); - - afterEach(() => { - restoreSinonStub(locks.unlock); - }); }); describe('saveFromAuthoring', () => { @@ -414,16 +406,16 @@ describe('actions.planning.ui', () => { {...data.plannings[0], slugline: 'New Slugger'} )); - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[0], {...data.plannings[0], slugline: 'New Slugger'}, ]); }); it('notifies user if save fails', (done) => { - restoreSinonStub(planningApi.save); - sinon.stub(planningApi, 'save').callsFake(() => Promise.reject(errorMessage)); + restoreSinonStub(planningApis.save); + sinon.stub(planningApis, 'save').callsFake(() => Promise.reject(errorMessage)); store.test(done, planningUi.saveFromAuthoring(data.plannings[0])) .then(() => { /* no-op */ }, () => { @@ -463,8 +455,8 @@ describe('actions.planning.ui', () => { {...data.plannings[0], slugline: 'New Slugger'} )) .then(() => { - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[0], {...data.plannings[0], slugline: 'New Slugger'}, ]); @@ -502,17 +494,17 @@ describe('actions.planning.ui', () => { describe('duplicate', () => { afterEach(() => { - restoreSinonStub(planningApi.duplicate); + restoreSinonStub(planningApis.duplicate); }); it('duplicate calls planning.api.duplicate and notifies the user of success', (done) => { - sinon.stub(planningApi, 'duplicate').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApis, 'duplicate').callsFake((item) => Promise.resolve(item)); store.test(done, planningUi.duplicate(data.plannings[0])) .then((item) => { expect(item).toEqual(data.plannings[0]); - expect(planningApi.duplicate.callCount).toBe(1); - expect(planningApi.duplicate.args[0]).toEqual([data.plannings[0]]); + expect(planningApis.duplicate.callCount).toBe(1); + expect(planningApis.duplicate.args[0]).toEqual([data.plannings[0]]); expect(services.notify.error.callCount).toBe(0); expect(services.notify.success.callCount).toBe(1); @@ -527,7 +519,7 @@ describe('actions.planning.ui', () => { }); it('on duplicate error notify the user of the failure', (done) => { - sinon.stub(planningApi, 'duplicate').callsFake(() => Promise.reject(errorMessage)); + sinon.stub(planningApis, 'duplicate').callsFake(() => Promise.reject(errorMessage)); store.test(done, planningUi.duplicate(data.plannings[0])) .then(null, (error) => { expect(error).toEqual(errorMessage); @@ -547,12 +539,10 @@ describe('actions.planning.ui', () => { sinon.stub(planningUi, 'save').callsFake( (item, updates) => Promise.resolve({...item, ...updates}) ); - sinon.stub(locks, 'unlock').callsFake((item) => (Promise.resolve(item))); }); afterEach(() => { restoreSinonStub(planningUi.save); - restoreSinonStub(locks.unlock); }); it('assignToAgenda adds and agenda to planning item and calls save and unlocks item', (done) => { @@ -574,8 +564,10 @@ describe('actions.planning.ui', () => { expect(services.notify.success.args[0]).toEqual( ['Agenda assigned to the planning item.']); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([planningUtils.modifyForClient(planningWithAgenda)]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([ + planningUtils.modifyForClient(planningWithAgenda), + ]); done(); }) @@ -607,8 +599,8 @@ describe('actions.planning.ui', () => { 0 )) .then(() => { - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[0], { coverages: [ diff --git a/client/actions/planning/ui.ts b/client/actions/planning/ui.ts index 7b0d751c9..73083fd68 100644 --- a/client/actions/planning/ui.ts +++ b/client/actions/planning/ui.ts @@ -1,7 +1,8 @@ import {IPlanningSearchParams} from '../../interfaces'; +import {planningApi} from '../../superdeskApi'; + import {showModal} from '../index'; -import planningApi from './api'; -import {locks} from '../index'; +import planningApis from './api'; import main from '../main'; import eventsUi from '../events/ui'; import {ITEM_TYPE} from '../../constants'; @@ -28,7 +29,7 @@ import {get, orderBy, cloneDeep} from 'lodash'; */ const spike = (item) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.spike(item)) + dispatch(planningApis.spike(item)) .then((items) => { notify.success(gettext('The Planning Item(s) has been spiked.')); dispatch(main.closePreviewAndEditorForItems(items)); @@ -49,7 +50,7 @@ const spike = (item) => ( */ const unspike = (item) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.unspike(item)) + dispatch(planningApis.unspike(item)) .then((items) => { dispatch(main.closePreviewAndEditorForItems(items)); notify.success(gettext('The Planning Item(s) has been unspiked.')); @@ -93,7 +94,7 @@ const addToList = (ids) => ({ function fetchToList(params: IPlanningSearchParams) { return (dispatch) => { dispatch(self.requestPlannings(params)); - return dispatch(planningApi.fetch(params)) + return dispatch(planningApis.fetch(params)) .then((items) => (dispatch(self.setInList( items.map((p) => p._id) )))); @@ -120,7 +121,7 @@ const loadMore = () => ( page: get(previousParams, 'page', 1) + 1, }; - return dispatch(planningApi.fetch(params)) + return dispatch(planningApis.fetch(params)) .then((items) => { if (get(items, 'length', 0) === MAIN.PAGE_SIZE) { dispatch(self.requestPlannings(params)); @@ -145,7 +146,7 @@ const refetch = () => ( dispatch(main.fetchItemHistory({_id: previewId, type: ITEM_TYPE.PLANNING})); } - return dispatch(planningApi.refetch()) + return dispatch(planningApis.refetch()) .then( (items) => { dispatch(self.setInList(items.map((p) => p._id))); @@ -182,7 +183,7 @@ const scheduleRefetch = () => ( */ const assignToAgenda = (item, agenda) => ( (dispatch, getState, {notify}) => ( - dispatch(locks.lock(item, 'assign_agenda')) + planningApi.locks.lockItem(item, 'assign_agenda') .then((original) => { const updates = cloneDeep(original); @@ -202,7 +203,7 @@ const assignToAgenda = (item, agenda) => ( const duplicate = (plan) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.duplicate(plan)) + dispatch(planningApis.duplicate(plan)) .then((newPlan) => { notify.success(gettext('Planning duplicated')); const openInModal = selectors.forms.currentItemIdModal(getState()); @@ -228,7 +229,7 @@ const duplicate = (plan) => ( const cancelPlanning = (original, updates) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.cancel(original, updates)) + dispatch(planningApis.cancel(original, updates)) .then((plan) => { notify.success(gettext('Planning Item has been cancelled')); dispatch(main.closePreviewAndEditorForItems([plan], null, '_id', true)); @@ -248,7 +249,7 @@ const cancelAllCoverage = (original, updates) => ( // delete _cancelAllCoverage used for UI purposes delete original._cancelAllCoverage; - return dispatch(planningApi.cancelAllCoverage(original, updates)) + return dispatch(planningApis.cancelAllCoverage(original, updates)) .then((plan) => { notify.success(gettext('All Coverage has been cancelled')); return Promise.resolve(plan); @@ -271,7 +272,7 @@ const openFeaturedPlanningModal = () => ( return dispatch(showModal({modalType: MODALS.UNLOCK_FEATURED_STORIES})); } - return dispatch(planningApi.lockFeaturedPlanning()) + return planningApi.locks.lockFeaturedPlanning() .then(() => dispatch(showModal({ modalType: MODALS.FEATURED_STORIES, })), @@ -292,7 +293,7 @@ const modifyPlanningFeatured = (item, remove = false) => ( dispatch(self._modifyPlanningFeatured(unlockedItem, remove)) .then((updatedItem) => { if (get(previousLock, 'action')) { - return dispatch(locks.lock(updatedItem, previousLock.action)); + return planningApi.locks.lockItem(updatedItem, previousLock.action); } }) ) @@ -307,7 +308,7 @@ const modifyPlanningFeatured = (item, remove = false) => ( */ const _modifyPlanningFeatured = (item, remove = false) => ( (dispatch, getState, {api, notify}) => ( - dispatch(locks.lock(item, remove ? 'remove_featured' : 'add_featured')) + planningApi.locks.lockItem(item, remove ? 'remove_featured' : 'add_featured') .then((lockedItem) => { lockedItem.featured = !remove; return dispatch(self.saveAndUnlockPlanning(lockedItem)).then((updatedItem) => { @@ -373,7 +374,7 @@ const _openActionModal = ( modalProps = {} ) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.lock(plan, lockAction)) + planningApi.locks.lockItem(plan, lockAction) .then((lockedPlanning) => { lockedPlanning._post = post; return dispatch(showModal({ @@ -409,9 +410,9 @@ const save = (original, updates) => ( null, {}, original, - planningApi.save.bind(null, original, updates))); + planningApis.save.bind(null, original, updates))); } - return dispatch(planningApi.save(original, updates)); + return dispatch(planningApis.save(original, updates)); } } ); @@ -435,19 +436,19 @@ const onAddCoverageClick = (item) => ( const currentItem = selectors.forms.currentItem(state); if (currentItem && getItemId(item) !== getItemId(currentItem)) { - dispatch(locks.unlock(currentItem)); + planningApi.locks.unlockItem(currentItem); } // If it is an existing item and the item is not locked // then lock the item, otherwise return the existing item if (isExistingItem(item) && !lockUtils.getLock(item, lockedItems)) { - promise = dispatch(locks.lock(item)); + promise = planningApi.locks.lockItem(item); } else { promise = Promise.resolve(item); } return promise.then((lockedItem) => { - dispatch(planningApi.receivePlannings([lockedItem])); + dispatch(planningApis.receivePlannings([lockedItem])); dispatch(main.closeEditor()); dispatch(main.openForEdit(lockedItem)); return Promise.resolve(lockedItem); @@ -466,7 +467,7 @@ const saveFromAuthoring = (original, updates) => ( dispatch(actions.actionInProgress(true)); let resolved = true; - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((newPlan) => { const newsItem = get(selectors.general.modalProps(getState()), 'newsItem') || get(selectors.general.previousModalProps(getState()), 'newsItem'); @@ -523,7 +524,7 @@ const addCoverageToWorkflow = (original, updatedCoverage, index) => ( updates.coverages[index] = planningUtils.getActiveCoverage(updatedCoverage, selectors.general.newsCoverageStatus(getState())); - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Coverage added to workflow.')); return dispatch(self.updateItemOnSave(savedItem)); @@ -539,7 +540,7 @@ const addScheduledUpdateToWorkflow = (original, coverage, coverageIndex, schedul coverage.scheduled_updates[index] = planningUtils.getActiveCoverage(scheduledUpdate, selectors.general.newsCoverageStatus(getState())); - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Scheduled update added to workflow.')); return dispatch(self.updateItemOnSave(savedItem)); @@ -560,7 +561,7 @@ const removeAssignment = (original, updatedCoverage, index) => ( updates.coverages[index] = coverage; - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Removed assignment from coverage.')); return dispatch(self.updateItemOnSave(savedItem)); @@ -572,7 +573,7 @@ const updateItemOnSave = (savedItem) => ( (dispatch) => { const modifiedItem = planningUtils.modifyForClient(savedItem); - dispatch(planningApi.receivePlannings([modifiedItem])); + dispatch(planningApis.receivePlannings([modifiedItem])); return Promise.resolve(modifiedItem); } ); @@ -612,7 +613,7 @@ const cancelCoverage = (original, updatedCoverage, index, scheduledUpdate, sched updates.coverages[index].scheduled_updates[scheduledUpdateIndex] = cloneDeep(scheduledUpdate); } - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Coverage cancelled.')); return dispatch(self.updateItemOnSave(savedItem)); diff --git a/client/actions/tests/locks_test.ts b/client/actions/tests/locks_test.ts deleted file mode 100644 index d1deab127..000000000 --- a/client/actions/tests/locks_test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import sinon from 'sinon'; - -import {WORKSPACE} from '../../constants'; -import {getTestActionStore, restoreSinonStub} from '../../utils/testUtils'; - -import locks from '../locks'; -import assignmentsApi from '../assignments/api'; - -import eventsApi from '../events/api'; -import planningApi from '../planning/api'; - -describe('actions.locks', () => { - let store; - let data; - let services; - - beforeEach(() => { - store = getTestActionStore(); - data = store.data; - services = store.services; - - sinon.stub(assignmentsApi, 'queryLockedAssignments').callsFake( - () => (Promise.resolve(['as']))); - - sinon.stub(eventsApi, 'lock').callsFake((item) => Promise.resolve(item)); - sinon.stub(planningApi, 'lock').callsFake((item) => Promise.resolve(item)); - }); - - afterEach(() => { - restoreSinonStub(assignmentsApi.queryLockedAssignments); - restoreSinonStub(eventsApi.lock); - restoreSinonStub(planningApi.lock); - }); - - describe('loadAssignmentLocks', () => { - it('queries locked assignments and dispatches RECEIVE_LOCKS', (done) => { - store.test(done, locks.loadAssignmentLocks()) - .then(() => { - expect(assignmentsApi.queryLockedAssignments.callCount).toBe(1); - expect(store.dispatch.args[1]).toEqual([{ - type: 'RECEIVE_LOCKS', - payload: {assignments: ['as']}, - }]); - done(); - }) - .catch(done.fail); - }); - }); - - describe('lock', () => { - it('determines the item type and calls the appropriate lock action', (done) => ( - store.test(done, locks.lock(data.events[0])) - .then(() => { - expect(eventsApi.lock.callCount).toBe(1); - expect(eventsApi.lock.args[0]).toEqual([data.events[0], 'edit']); - - return store.test(done, locks.lock(data.plannings[0])); - }) - .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([data.plannings[0], 'edit']); - - done(); - }) - ).catch(done.fail)); - - it('Uses add_to_planning if in AUTHORING workspace', (done) => { - store.initialState.workspace.currentWorkspace = WORKSPACE.AUTHORING; - return store.test(done, locks.lock(data.plannings[0])) - .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([data.plannings[0], 'add_to_planning']); - - done(); - }) - .catch(done.fail); - }); - - it('Returns Promise.reject if could not determine item type', (done) => ( - store.test(done, locks.lock({test: 'something'})) - .then(null, (error) => { - expect(error).toBe( - 'Failed to lock the item, could not determine item type!' - ); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual( - ['Failed to lock the item, could not determine item type!'] - ); - - done(); - }) - ).catch(done.fail)); - }); -}); diff --git a/client/actions/tests/main_test.ts b/client/actions/tests/main_test.ts index d99a0329f..d8be274db 100644 --- a/client/actions/tests/main_test.ts +++ b/client/actions/tests/main_test.ts @@ -1,6 +1,7 @@ import sinon from 'sinon'; import moment from 'moment'; +import {planningApi} from '../../superdeskApi'; import {getTestActionStore, restoreSinonStub} from '../../utils/testUtils'; import {removeAutosaveFields, modifyForClient} from '../../utils'; import {main} from '../'; @@ -8,7 +9,7 @@ import {AGENDA, POST_STATE} from '../../constants'; import eventsUi from '../events/ui'; import eventsApi from '../events/api'; import planningUi from '../planning/ui'; -import planningApi from '../planning/api'; +import planningApis from '../planning/api'; import eventsPlanningUi from '../eventsPlanning/ui'; import {locks} from '../'; @@ -56,12 +57,12 @@ describe('actions.main', () => { describe('post', () => { beforeEach(() => { sinon.stub(eventsApi, 'post').returns(Promise.resolve(data.events[0])); - sinon.stub(planningApi, 'post').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApis, 'post').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { restoreSinonStub(eventsApi.post); - restoreSinonStub(planningApi.post); + restoreSinonStub(planningApis.post); }); it('calls events.ui.post', (done) => ( @@ -81,8 +82,8 @@ describe('actions.main', () => { it('calls planning.ui.post', (done) => ( store.test(done, main.post(data.plannings[0])) .then(() => { - expect(planningApi.post.callCount).toBe(1); - expect(planningApi.post.args[0]).toEqual([ + expect(planningApis.post.callCount).toBe(1); + expect(planningApis.post.args[0]).toEqual([ data.plannings[0], {pubstatus: POST_STATE.USABLE}, ]); @@ -109,12 +110,12 @@ describe('actions.main', () => { describe('unpost', () => { beforeEach(() => { sinon.stub(eventsApi, 'unpost').returns(Promise.resolve(data.events[0])); - sinon.stub(planningApi, 'unpost').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApis, 'unpost').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { restoreSinonStub(eventsApi.unpost); - restoreSinonStub(planningApi.unpost); + restoreSinonStub(planningApis.unpost); }); it('calls events.ui.unpost', (done) => ( @@ -134,8 +135,8 @@ describe('actions.main', () => { it('calls planning.ui.unpost', (done) => ( store.test(done, main.unpost(data.plannings[0])) .then(() => { - expect(planningApi.unpost.callCount).toBe(1); - expect(planningApi.unpost.args[0]).toEqual([ + expect(planningApis.unpost.callCount).toBe(1); + expect(planningApis.unpost.args[0]).toEqual([ data.plannings[0], {pubstatus: POST_STATE.CANCELLED}, ]); @@ -323,12 +324,12 @@ describe('actions.main', () => { describe('loadItem', () => { beforeEach(() => { sinon.stub(eventsApi, 'fetchById').returns(Promise.resolve(data.events[0])); - sinon.stub(planningApi, 'fetchById').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApis, 'fetchById').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { restoreSinonStub(eventsApi.fetchById); - restoreSinonStub(planningApi.fetchById); + restoreSinonStub(planningApis.fetchById); }); it('loads an Event for preview', (done) => ( @@ -373,8 +374,8 @@ describe('actions.main', () => { expect(store.dispatch.callCount).toBe(4); expect(store.dispatch.args[0]).toEqual([{type: 'MAIN_PREVIEW_LOADING_START'}]); - expect(planningApi.fetchById.callCount).toBe(1); - expect(planningApi.fetchById.args[0]).toEqual(['p1', {force: false}]); + expect(planningApis.fetchById.callCount).toBe(1); + expect(planningApis.fetchById.args[0]).toEqual(['p1', {force: false}]); expect(store.dispatch.args[3]).toEqual([{type: 'MAIN_PREVIEW_LOADING_COMPLETE'}]); @@ -390,8 +391,8 @@ describe('actions.main', () => { expect(store.dispatch.callCount).toBe(4); expect(store.dispatch.args[0]).toEqual([{type: 'MAIN_EDIT_LOADING_START'}]); - expect(planningApi.fetchById.callCount).toBe(1); - expect(planningApi.fetchById.args[0]).toEqual(['p1', {force: false}]); + expect(planningApis.fetchById.callCount).toBe(1); + expect(planningApis.fetchById.args[0]).toEqual(['p1', {force: false}]); expect(store.dispatch.args[3]).toEqual([{type: 'MAIN_EDIT_LOADING_COMPLETE'}]); @@ -515,16 +516,14 @@ describe('actions.main', () => { beforeEach(() => { actionCallback = sinon.stub().returns(Promise.resolve()); - sinon.stub(locks, 'unlock').callsFake((item) => Promise.resolve(item)); - // sinon.stub(main, 'lockAndEdit').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'openForEdit'); sinon.stub(main, 'saveAutosave').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'openIgnoreCancelSaveModal').callsFake((item) => Promise.resolve(item)); }); afterEach(() => { - restoreSinonStub(locks.unlock); - // restoreSinonStub(main.lockAndEdit); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(main.openForEdit); restoreSinonStub(main.saveAutosave); restoreSinonStub(main.openIgnoreCancelSaveModal); @@ -554,8 +553,8 @@ describe('actions.main', () => { return store.test(done, main.openActionModalFromEditor(data.events[0], 'title', actionCallback)) .then(() => { - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([data.events[0]]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([data.events[0]]); expect(actionCallback.callCount).toBe(1); expect(actionCallback.args[0]).toEqual([ @@ -572,8 +571,8 @@ describe('actions.main', () => { return store.test(done, main.openActionModalFromEditor(data.events[0], 'title', actionCallback)); }) .then(() => { - expect(locks.unlock.callCount).toBe(2); - expect(locks.unlock.args[1]).toEqual([data.events[0]]); + expect(planningApi.locks.unlockItem.callCount).toBe(2); + expect(planningApi.locks.unlockItem.args[1]).toEqual([data.events[0]]); expect(actionCallback.callCount).toBe(2); expect(actionCallback.args[1]).toEqual([ @@ -868,7 +867,7 @@ describe('actions.main', () => { beforeEach(() => { sinon.stub(planningUi, 'save').callsFake((item) => (Promise.resolve(item))); sinon.stub(eventsUi, 'saveWithConfirmation').callsFake((item) => (Promise.resolve(item))); - sinon.stub(locks, 'unlock').callsFake((item) => (Promise.resolve(item))); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); }); it('saves and unlocks planning item', (done) => @@ -883,8 +882,8 @@ describe('actions.main', () => { {...data.plannings[0], slugline: 'New Slugger'}, ]); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([modifyForClient(data.plannings[0])]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([modifyForClient(data.plannings[0])]); done(); }) @@ -902,8 +901,8 @@ describe('actions.main', () => { false, ]); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([modifyForClient(data.events[0])]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([modifyForClient(data.events[0])]); done(); }) @@ -912,7 +911,7 @@ describe('actions.main', () => { afterEach(() => { restoreSinonStub(planningUi.save); - restoreSinonStub(locks.unlock); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(eventsUi.saveWithConfirmation); }); }); diff --git a/client/api/assignments.ts b/client/api/assignments.ts new file mode 100644 index 000000000..ef355c8d5 --- /dev/null +++ b/client/api/assignments.ts @@ -0,0 +1,10 @@ +import {superdeskApi} from '../superdeskApi'; +import {IPlanningAPI, IAssignmentItem} from '../interfaces'; + +function getAssignmentById(assignmentId: IAssignmentItem['_id']): Promise { + return superdeskApi.dataApi.findOne('assignments', assignmentId); +} + +export const assignments: IPlanningAPI['assignments'] = { + getById: getAssignmentById, +}; diff --git a/client/api/autosave.ts b/client/api/autosave.ts index 7376a6363..8a8f6ac2c 100644 --- a/client/api/autosave.ts +++ b/client/api/autosave.ts @@ -26,8 +26,18 @@ function deleteAutosaveItem(item: IEventOrPlanningItem): Promise { ); } +function deleteAutosaveItemById( + itemType: IEventOrPlanningItem['type'], + itemId: IEventOrPlanningItem['_id'] +): Promise { + return planningApi.redux.store.dispatch( + actions.autosave.removeById(itemType, itemId) + ); +} + export const autosave: IPlanningAPI['autosave'] = { getById: getAutosaveItemById, save: saveAutosaveItem, delete: deleteAutosaveItem, + deleteById: deleteAutosaveItemById, }; diff --git a/client/api/events.ts b/client/api/events.ts index 7271de3b2..4d14a5b96 100644 --- a/client/api/events.ts +++ b/client/api/events.ts @@ -109,15 +109,6 @@ export function getEventByIds( .then((response) => response._items); } -export function getLockedEvents(): Promise> { - return searchEventsGetAll({ - lock_state: LOCK_STATE.LOCKED, - directly_locked: true, - only_future: false, - include_killed: true, - }); -} - function getEventEditorProfile() { return eventProfile(planningApi.redux.store.getState()); } @@ -190,7 +181,6 @@ export const events: IPlanningAPI['events'] = { searchGetAll: searchEventsGetAll, getById: getEventById, getByIds: getEventByIds, - getLocked: getLockedEvents, getEditorProfile: getEventEditorProfile, getSearchProfile: getEventSearchProfile, create: create, diff --git a/client/api/featured.ts b/client/api/featured.ts index a519405db..1b19916c1 100644 --- a/client/api/featured.ts +++ b/client/api/featured.ts @@ -1,18 +1,10 @@ import moment from 'moment'; -import {IPlanningAPI, IFeaturedPlanningItem, IFeaturedPlanningLock} from '../interfaces'; -import {superdeskApi} from '../superdeskApi'; +import {IPlanningAPI, IFeaturedPlanningItem, IFeaturedPlanningSaveItem} from '../interfaces'; +import {planningApi, superdeskApi} from '../superdeskApi'; import {getIdForFeauturedPlanning} from '../utils'; - -function lockFeaturedPlanning(): Promise> { - return superdeskApi.dataApi.create>('planning_featured_lock', {}); -} - -function unlockFeaturedPlanning(): Promise { - return superdeskApi.dataApi.create('planning_featured_unlock', {}) - .then(() => undefined); -} +import {featuredPlanningItem} from '../selectors/featuredPlanning'; function fetchFeaturedPlanningItemById(id: string): Promise { return superdeskApi.dataApi.findOne('planning_featured', id); @@ -24,9 +16,17 @@ function fetchFeaturedPlanningItemByDate(date: moment.Moment): Promise): Promise { + const {getState} = planningApi.redux.store; + const original = featuredPlanningItem(getState()); + + return original == null ? + superdeskApi.dataApi.create('planning_featured', {...updates}) : + superdeskApi.dataApi.patch('planning_featured', original, {...updates}); +} + export const featured: IPlanningAPI['planning']['featured'] = { - lock: lockFeaturedPlanning, - unlock: unlockFeaturedPlanning, getById: fetchFeaturedPlanningItemById, getByDate: fetchFeaturedPlanningItemByDate, + save: saveFeaturedPlanning, }; diff --git a/client/api/index.ts b/client/api/index.ts index 2fcf451d2..7f5230308 100644 --- a/client/api/index.ts +++ b/client/api/index.ts @@ -9,10 +9,13 @@ import {locations} from './locations'; import {autosave} from './autosave'; import {editor} from './editor'; import {contentProfiles} from './contentProfiles'; +import {locks} from './locks'; +import {assignments} from './assignments'; export const planningApis: Omit = { events, planning, + assignments, combined, coverages, search, @@ -21,4 +24,5 @@ export const planningApis: Omit = { autosave, editor, contentProfiles, + locks, }; diff --git a/client/api/locks.ts b/client/api/locks.ts new file mode 100644 index 000000000..d45e1642c --- /dev/null +++ b/client/api/locks.ts @@ -0,0 +1,319 @@ +import { + ILock, + ILockedItems, + IPlanningAPI, + IWebsocketMessageData, + IAssignmentOrPlanningItem, + IFeaturedPlanningLock, IAssignmentItem, +} from '../interfaces'; +import {planningApi, superdeskApi} from '../superdeskApi'; + +import {EVENTS, LOCKS, PLANNING, WORKSPACE, ASSIGNMENTS} from '../constants'; + +import featuredPlanning from '../actions/planning/featuredPlanning'; +import {lockUtils, getErrorMessage, eventUtils, planningUtils, isExistingItem} from '../utils'; +import {currentWorkspace as getCurrentWorkspace} from '../selectors/general'; +import {getLockedItems} from '../selectors/locks'; + +function loadLockedItems(types?: Array<'events_and_planning' | 'featured_planning' | 'assignments'>): Promise { + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + let url = 'planning_locks'; + + if ((types?.length ?? 0) > 0) { + url += `?repos=${types.join(',')}`; + } + + return superdeskApi.dataApi.queryRawJson(url).then( + (locks) => { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.RECEIVE, + payload: locks, + }); + + // If `featured_planning` lock was retrieved, then update it's lock now + if (types == null || types.includes('featured_planning')) { + if (locks.featured?.user != null) { + dispatch(featuredPlanning.setLockUser(locks.featured.user, locks.featured.session)); + } else { + dispatch(featuredPlanning.setUnlocked()); + } + } + + // Make sure that all items that are locked are loaded into the store + return planningApi.combined.searchGetAll({ + item_ids: lockUtils.getLockedItemIds(locks), + only_future: false, + include_killed: true, + spike_state: 'draft', + exclude_rescheduled_and_cancelled: false, + include_associated_planning: true, + }).then( + (items) => { + dispatch({ + type: EVENTS.ACTIONS.ADD_EVENTS, + payload: items.filter((item) => item.type === 'event'), + }); + dispatch({ + type: PLANNING.ACTIONS.RECEIVE_PLANNINGS, + payload: items.filter((item) => item.type === 'planning'), + }); + }, + (error) => { + notify.error(getErrorMessage(error, gettext('Failed to load locked items'))); + + return Promise.reject(error); + } + ); + }, + (error) => { + notify.error(getErrorMessage(error, gettext('Failed to load item locks'))); + + return Promise.reject(error); + } + ); +} + +function setItemAsLocked(data: IWebsocketMessageData['ITEM_LOCKED']): void { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: data, + }); +} + +function setItemAsUnlocked(data: IWebsocketMessageData['ITEM_UNLOCKED']): void { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: data, + }); +} + +function getLockResourceName(itemType: IAssignmentOrPlanningItem['type']) { + switch (itemType) { + case 'event': + return 'events'; + case 'planning': + return 'planning'; + case 'assignment': + return 'assignments'; + } +} + +function lockItem(item: T, action?: string): Promise { + const {dispatch, getState} = planningApi.redux.store; + const resource = getLockResourceName(item.type); + const endpoint = `${resource}/${item._id}/lock`; + let lockAction = action; + + if (lockAction == null) { + const currentWorkspace = getCurrentWorkspace(getState()); + + lockAction = currentWorkspace === WORKSPACE.AUTHORING ? + PLANNING.ITEM_ACTIONS.ADD_TO_PLANNING.lock_action : + 'edit'; + } + + // @ts-ignore + return superdeskApi.dataApi.create(endpoint, {lock_action: lockAction}) + .then((lockedItem) => { + // On lock, file object in the item is lost, so replace it from original item + if (lockedItem.type !== 'assignment' && item.type !== 'assignment') { + lockedItem.files = item.files; + } if (lockedItem.type === 'event') { + eventUtils.modifyForClient(lockedItem); + } else if (lockedItem.type === 'planning') { + planningUtils.modifyForClient(lockedItem); + } + + locks.setItemAsLocked({ + item: lockedItem._id, + type: lockedItem.type, + event_item: lockedItem.type === 'planning' ? lockedItem.event_item : undefined, + recurrence_id: lockedItem.type !== 'assignment' ? lockedItem.recurrence_id : undefined, + etag: lockedItem._etag, + user: lockedItem.lock_user, + lock_session: lockedItem.lock_session, + lock_action: lockedItem.lock_action, + lock_time: lockedItem.lock_time, + }); + + if (lockedItem.type === 'event') { + dispatch({ + type: EVENTS.ACTIONS.LOCK_EVENT, + payload: {event: lockedItem}, + }); + } else if (lockedItem.type === 'planning') { + dispatch({ + type: PLANNING.ACTIONS.LOCK_PLANNING, + payload: {plan: lockedItem}, + }); + } else if (lockedItem.type === 'assignment') { + dispatch({ + type: ASSIGNMENTS.ACTIONS.LOCK_ASSIGNMENT, + payload: {assignment: lockedItem}, + }); + } + + return lockedItem; + }, (error) => { + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + + notify.error(getErrorMessage(error, gettext('Failed to lock item'))); + + return Promise.reject(error); + }); +} + +function getItemById( + itemId: T['_id'], + itemType: T['type'] +): Promise { + // TODO: Figure out why this fails ts checks + switch (itemType) { + case 'event': + return planningApi.events.getById(itemId); + case 'planning': + return planningApi.planning.getById(itemId); + case 'assignment': + return planningApi.assignments.getById(itemId); + } +} + +function lockItemById( + itemId: T['_id'], + itemType: T['type'], + action: string +): Promise { + return getItemById(itemId, itemType).then((item) => locks.lockItem(item, action)); +} + +function unlockItem(item: T, reloadLocksIfNotFound: boolean = true): Promise { + if (!isExistingItem(item)) { + const autosaveDeletePromise = item.type === 'assignment' ? + Promise.resolve() : + planningApi.autosave.deleteById(item.type, item._id); + + if (item.type === 'event' && item._planning_item != null) { + return Promise.all([ + autosaveDeletePromise, + unlockItemById(item._planning_item, 'planning'), + ]).then((promiseResponses) => ( + promiseResponses[1] + )); + } + + return autosaveDeletePromise.then(() => item); + } + + const {dispatch, getState} = planningApi.redux.store; + const lockedItems = getLockedItems(getState()); + const currentLock = lockUtils.getLock(item, lockedItems); + + if (currentLock == null) { + if (reloadLocksIfNotFound) { + // The lock was not found in the local store + // Reload the list of locks now, and attempt to unlock again + return loadLockedItems().then(() => unlockItem(item, false)); + } else { + // The lock was still not found for this item + // It's possible that it is not actually locked + return Promise.resolve(item); + } + } + + const lockedItemId = currentLock.item_id; + const resource = getLockResourceName(currentLock.item_type); + const endpoint = `${resource}/${lockedItemId}/unlock`; + + return superdeskApi.dataApi.create(endpoint, {}) + .then((unlockedItem) => { + if (unlockedItem.type === 'event') { + eventUtils.modifyForClient(unlockedItem); + } else if (unlockedItem.type === 'planning') { + planningUtils.modifyForClient(unlockedItem); + } + + locks.setItemAsUnlocked({ + item: unlockedItem._id, + type: unlockedItem.type, + event_item: unlockedItem.type === 'planning' ? unlockedItem.event_item : undefined, + recurrence_id: unlockedItem.type !== 'assignment' ? unlockedItem.recurrence_id : undefined, + etag: unlockedItem._etag, + from_ingest: false, + user: unlockedItem.lock_user, + lock_session: unlockedItem.lock_session, + }); + + if (unlockedItem.type === 'event') { + dispatch({ + type: EVENTS.ACTIONS.UNLOCK_EVENT, + payload: {event: unlockedItem}, + }); + } else if (unlockedItem.type === 'planning') { + dispatch({ + type: PLANNING.ACTIONS.UNLOCK_PLANNING, + payload: {plan: unlockedItem}, + }); + } else if (unlockedItem.type === 'assignment') { + dispatch({ + type: ASSIGNMENTS.ACTIONS.UNLOCK_ASSIGNMENT, + payload: {assignment: unlockedItem}, + }); + } + + return unlockedItem; + }, (error) => { + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + + notify.error(getErrorMessage(error, gettext('Failed to unlock item'))); + + return Promise.reject(error); + }); +} + +function unlockItemById(itemId: T['_id'], itemType: T['type']): Promise { + return getItemById(itemId, itemType).then((item) => unlockItem(item)); +} + +function unlockThenLockItem(item: T, action: string): Promise { + return unlockItem(item).then(() => (lockItem(item, action))); +} + +function lockFeaturedPlanning(): Promise { + return superdeskApi.dataApi.create('planning_featured_lock', {}) + .then((lockDetails) => { + const {dispatch} = planningApi.redux.store; + + dispatch(featuredPlanning.setLockUser(lockDetails.lock_user, lockDetails.lock_session)); + }); +} + +function unlockFeaturedPlanning(): Promise { + return superdeskApi.dataApi.create('planning_featured_unlock', {}) + .then(() => { + const {dispatch} = planningApi.redux.store; + + dispatch(featuredPlanning.setUnlocked()); + }); +} + +export const locks: IPlanningAPI['locks'] = { + loadLockedItems: loadLockedItems, + setItemAsLocked: setItemAsLocked, + setItemAsUnlocked: setItemAsUnlocked, + lockItem: lockItem, + lockItemById: lockItemById, + unlockItem: unlockItem, + unlockItemById: unlockItemById, + unlockThenLockItem: unlockThenLockItem, + lockFeaturedPlanning: lockFeaturedPlanning, + unlockFeaturedPlanning: unlockFeaturedPlanning, +}; diff --git a/client/api/planning.ts b/client/api/planning.ts index 719747e6d..b728a8b8b 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -129,35 +129,6 @@ export function getPlanningByIds( .then((response) => response._items); } -export function getLockedPlanningItems(): Promise> { - return searchPlanningGetAll({ - lock_state: LOCK_STATE.LOCKED, - directly_locked: true, - only_future: false, - include_killed: true, - }); -} - -export function getLockedFeaturedPlanning(): Promise> { - return superdeskApi.dataApi.queryRawJson>( - 'planning_featured_lock', - { - source: JSON.stringify({ - query: { - constant_score: { - filter: { - exists: { - field: 'lock_session', - }, - }, - }, - }, - }) - } - ) - .then((response) => response._items); -} - function getPlanningEditorProfile() { return planningProfile(planningApi.redux.store.getState()); } @@ -226,8 +197,6 @@ export const planning: IPlanningAPI['planning'] = { searchGetAll: searchPlanningGetAll, getById: getPlanningById, getByIds: getPlanningByIds, - getLocked: getLockedPlanningItems, - getLockedFeatured: getLockedFeaturedPlanning, getEditorProfile: getPlanningEditorProfile, getSearchProfile: getPlanningSearchProfile, featured: featured, diff --git a/client/api/tests/api_locks_test.ts b/client/api/tests/api_locks_test.ts new file mode 100644 index 000000000..3f2f74b36 --- /dev/null +++ b/client/api/tests/api_locks_test.ts @@ -0,0 +1,290 @@ +import sinon from 'sinon'; +import {Store} from 'redux'; +import {cloneDeep} from 'lodash'; + +import {superdeskApi, planningApi} from '../../superdeskApi'; +import {IPlanningAppState} from '../../interfaces'; +import {EVENTS, PLANNING, ASSIGNMENTS} from '../../constants'; + +import * as testDataOriginal from '../../utils/testData'; +import {restoreSinonStub} from '../../utils/testUtils'; +import {createTestStore} from '../../utils'; +import * as selectors from '../../selectors'; + +const testData = cloneDeep(testDataOriginal); + +describe('planningApi.locks', () => { + let redux: Store; + + beforeEach(() => { + redux = createTestStore(); + planningApi.redux.store = redux; + sinon.stub(planningApi.redux.store, 'dispatch').callThrough(); + }); + afterEach(() => { + restoreSinonStub(planningApi.redux.store.dispatch); + }); + + it('store locks are managed through setItemAsLocked and setItemAsUnlocked functions', () => { + const itemLock = { + item: testData.events[0]._id, + type: testData.events[0].type, + event_item: undefined, + etag: testData.events[0]._etag, + user: testData.lockedEvents[0].lock_user, + lock_session: testData.lockedEvents[0].lock_session, + }; + + expect(selectors.locks.getLockedItems(redux.getState())).toEqual({ + event: {}, + planning: {}, + assignment: {}, + recurring: {}, + }); + planningApi.locks.setItemAsLocked({ + ...itemLock, + lock_action: testData.lockedEvents[0].lock_action, + lock_time: testData.lockedEvents[0].lock_time, + recurrence_id: undefined, + }); + expect(selectors.locks.getLockedItems(redux.getState())).toEqual({ + event: {[testData.events[0]._id]: testData.eventLocks.e1}, + planning: {}, + assignment: {}, + recurring: {}, + }); + planningApi.locks.setItemAsUnlocked({ + ...itemLock, + from_ingest: false, + }); + expect(selectors.locks.getLockedItems(redux.getState())).toEqual({ + event: {}, + planning: {}, + assignment: {}, + recurring: {}, + }); + }); + + it('lockItemById attempts to load the item by id before locking it', (done) => { + superdeskApi.dataApi.findOne = sinon.stub().callsFake(() => Promise.resolve(testData.events[0])); + sinon.stub(planningApi.locks, 'lockItem').callsFake((e) => e); + + planningApi.locks.lockItemById(testData.events[0]._id, testData.events[0].type, 'cancel').then((lockedItem) => { + expect(lockedItem).toEqual(testData.events[0]); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([testData.events[0], 'cancel']); + done(); + }) + .finally(() => { + restoreSinonStub(planningApi.locks.lockItem); + }); + }); + + describe('locking items', () => { + let mock_api_lock_unlock_data; + let state: IPlanningAppState; + + beforeEach(() => { + superdeskApi.dataApi.create = sinon.stub().callsFake((resource: string, updates) => { + if (resource.endsWith('/lock')) { + return Promise.resolve({ + ...mock_api_lock_unlock_data, + ...updates, + }); + } else if (resource.endsWith('/unlock')) { + return Promise.resolve({ + ...mock_api_lock_unlock_data, + ...updates, + lock_action: null, + lock_user: null, + lock_session: null, + lock_time: null, + }); + } + + return updates; + }); + + superdeskApi.dataApi.queryRawJson = sinon.stub().callsFake((resource: string) => { + if (resource.startsWith('events_planning_search')) { + return Promise.resolve({ + _items: [testData.lockedEvents[0]], + _links: {}, + _meta: {total: 1}, + }); + } else if (resource.startsWith('planning_locks')) { + const lockedEvent = testData.lockedEvents[0]; + + return Promise.resolve({ + assignment: {}, + event: { + [lockedEvent._id]: { + item_id: lockedEvent._id, + item_type: lockedEvent.type, + user: lockedEvent.lock_user, + session: lockedEvent.lock_session, + action: lockedEvent.lock_action, + time: lockedEvent.lock_time, + }, + }, + planning: {}, + recurring: {}, + }); + } + + return Promise.resolve({ + _items: [], + _links: {}, + _meta: {total: 0}, + }); + }); + }); + + it('can lock/unlock an Event', (done) => { + mock_api_lock_unlock_data = testData.lockedEvents[0]; + state = redux.getState(); + + expect(selectors.locks.getLockedItems(state).event.e1).toBeUndefined(); + + planningApi.locks.lockItem(testData.events[0], 'edit') + .then((lockedItem) => { + // `dataApi` was called with the correct URL and params + expect(superdeskApi.dataApi.create.callCount).toBe(1); + expect(superdeskApi.dataApi.create.args[0]).toEqual([ + 'events/e1/lock', + {lock_action: 'edit'}, + ]); + + // Lock is added to the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).event.e1).toEqual(testData.eventLocks.e1); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(2); + expect(redux.dispatch.args[1]).toEqual([{ + type: EVENTS.ACTIONS.LOCK_EVENT, + payload: {event: lockedItem}, + }]); + + return planningApi.locks.unlockItem(testData.events[0]); + }) + .then(() => { + // Lock is removed from the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).event.e1).toBeUndefined(); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(4); + expect(redux.dispatch.args[3]).toEqual([{ + type: EVENTS.ACTIONS.UNLOCK_EVENT, + payload: { + event: jasmine.objectContaining({ + _id: testData.lockedEvents[0]._id, + lock_action: null, + lock_user: null, + lock_session: null, + lock_time: null, + }), + }, + }]); + + done(); + }); + }); + + it('can lock/unlock a Planning item', (done) => { + mock_api_lock_unlock_data = testData.lockedPlannings[0]; + state = redux.getState(); + + expect(selectors.locks.getLockedItems(state).planning.p1).toBeUndefined(); + + planningApi.locks.lockItem(testData.plannings[0], 'cancel') + .then((lockedItem) => { + // `dataApi` was called with the correct URL and params + expect(superdeskApi.dataApi.create.callCount).toBe(1); + expect(superdeskApi.dataApi.create.args[0]).toEqual([ + 'planning/p1/lock', + {lock_action: 'cancel'}, + ]); + + // Lock is added to the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).planning.p1).toEqual({ + ...testData.planningLocks.p1, + action: 'cancel', + }); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(2); + expect(redux.dispatch.args[1]).toEqual([{ + type: PLANNING.ACTIONS.LOCK_PLANNING, + payload: {plan: lockedItem}, + }]); + + return planningApi.locks.unlockItem(testData.plannings[0]); + }) + .then(() => { + // Lock is removed from the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).planning.p1).toBeUndefined(); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(4); + expect(redux.dispatch.args[3]).toEqual([{ + type: PLANNING.ACTIONS.UNLOCK_PLANNING, + payload: { + plan: jasmine.objectContaining({ + _id: testData.lockedPlannings[0]._id, + lock_action: null, + lock_user: null, + lock_session: null, + lock_time: null, + }), + }, + }]); + + done(); + }); + }); + + it('can lock/unlock an Assignment', (done) => { + mock_api_lock_unlock_data = testData.lockedAssignments[0]; + state = redux.getState(); + + expect(selectors.locks.getLockedItems(state).assignment.as1).toBeUndefined(); + + planningApi.locks.lockItem(testData.assignments[0], 'reassign') + .then((lockedItem) => { + // `dataApi` was called with the correct URL and params + expect(superdeskApi.dataApi.create.callCount).toBe(1); + expect(superdeskApi.dataApi.create.args[0]).toEqual([ + 'assignments/as1/lock', + {lock_action: 'reassign'}, + ]); + + // Lock is added to the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).assignment.as1).toEqual({ + ...testData.assignmentLocks.as1, + action: 'reassign', + }); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(2); + expect(redux.dispatch.args[1]).toEqual([{ + type: ASSIGNMENTS.ACTIONS.LOCK_ASSIGNMENT, + payload: {assignment: lockedItem}, + }]); + + return planningApi.locks.unlockItem(testData.assignments[0]); + }) + .then(() => { + // Lock is removed from the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).assignment.as1).toBeUndefined(); + + done(); + }); + }); + }); +}); diff --git a/client/apps/Assignments/AssignmentPreview.tsx b/client/apps/Assignments/AssignmentPreview.tsx index e64469f87..b44fcd59b 100644 --- a/client/apps/Assignments/AssignmentPreview.tsx +++ b/client/apps/Assignments/AssignmentPreview.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get} from 'lodash'; +import {planningApi} from '../../superdeskApi'; import {TOOLTIPS} from '../../constants'; import {gettext, assignmentUtils, lockUtils} from '../../utils'; import * as selectors from '../../selectors'; @@ -78,7 +79,7 @@ export class AssignmentPreviewComponent extends React.Component { } onUnlock() { - this.props.unlockAssignment(this.props.assignment); + planningApi.locks.unlockItem(this.props.assignment); } render() { @@ -145,7 +146,6 @@ AssignmentPreviewComponent.propTypes = { PropTypes.array, PropTypes.object, ]), - unlockAssignment: PropTypes.func, lockedItems: PropTypes.object, hideItemActions: PropTypes.bool, showFulfilAssignment: PropTypes.bool, @@ -160,7 +160,6 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => ({ closePanel: () => dispatch(actions.assignments.ui.closePreview()), - unlockAssignment: (assignment) => dispatch(actions.assignments.ui.unlockAssignment(assignment)), }); export const AssignmentPreview = connect( diff --git a/client/components/Events/EventItem.tsx b/client/components/Events/EventItem.tsx index 1729d3a4d..d338bc4de 100644 --- a/client/components/Events/EventItem.tsx +++ b/client/components/Events/EventItem.tsx @@ -16,6 +16,7 @@ import { isItemExpired, isItemPosted, onEventCapture, + lockUtils, } from '../../utils'; import {renderFields} from '../fields'; import {CreatedUpdatedColumn} from '../UI/List/CreatedUpdatedColumn'; @@ -42,7 +43,8 @@ class EventItemComponent extends React.Component { shouldComponentUpdate(nextProps: Readonly, nextState: Readonly) { return isItemDifferent(this.props, nextProps) || this.state.hover !== nextState.hover || - this.props.minTimeWidth !== nextProps.minTimeWidth; + this.props.minTimeWidth !== nextProps.minTimeWidth || + this.props.lockedItems != nextProps.lockedItems; } onItemHoverOn() { @@ -161,7 +163,7 @@ class EventItemComponent extends React.Component { } const hasPlanning = eventUtils.eventHasPlanning(item); - const isItemLocked = eventUtils.isEventLocked(item, lockedItems); + const isItemLocked = lockUtils.isItemLocked(item, lockedItems); const showRelatedPlanningLink = activeFilter === PLANNING_VIEW.COMBINED && hasPlanning; let borderState: 'locked' | 'active' | false = false; diff --git a/client/components/Events/EventMetadata/RelatedEventListItem.tsx b/client/components/Events/EventMetadata/RelatedEventListItem.tsx index 105abe51f..79521ce94 100644 --- a/client/components/Events/EventMetadata/RelatedEventListItem.tsx +++ b/client/components/Events/EventMetadata/RelatedEventListItem.tsx @@ -5,7 +5,7 @@ import {IEventItem, ILockedItems} from '../../../interfaces'; import {superdeskApi} from '../../../superdeskApi'; import {ICON_COLORS} from '../../../constants'; -import {eventUtils} from '../../../utils'; +import {eventUtils, lockUtils} from '../../../utils'; import * as selectors from '../../../selectors'; import * as List from '../../UI/List'; @@ -33,7 +33,7 @@ const mapStateToProps = (state) => ({ class RelatedEventListItemComponent extends React.PureComponent { render() { - const isItemLocked = eventUtils.isEventLocked( + const isItemLocked = lockUtils.isItemLocked( this.props.item, this.props.lockedItems ); diff --git a/client/components/Events/EventPreviewHeader.tsx b/client/components/Events/EventPreviewHeader.tsx index 0d7935b7a..8cbce62c8 100644 --- a/client/components/Events/EventPreviewHeader.tsx +++ b/client/components/Events/EventPreviewHeader.tsx @@ -1,12 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../superdeskApi'; + import {Tools} from '../UI/SidePanel'; import {ItemActionsMenu, LockContainer, ItemIcon} from '../index'; import {eventUtils, lockUtils, actionUtils} from '../../utils'; import {PRIVILEGES, EVENTS, ICON_COLORS} from '../../constants'; import * as selectors from '../../selectors'; -import * as actions from '../../actions'; import {get} from 'lodash'; export class EventPreviewHeaderComponent extends React.PureComponent { @@ -18,7 +20,6 @@ export class EventPreviewHeaderComponent extends React.PureComponent { item, lockedItems, session, - onUnlock, itemActionDispatches, hideItemActions, } = this.props; @@ -63,7 +64,7 @@ export class EventPreviewHeaderComponent extends React.PureComponent { calendars, }) : null; const lockedUser = lockUtils.getLockedUser(item, lockedItems, users); - const lockRestricted = eventUtils.isEventLockRestricted(item, session, lockedItems); + const lockRestricted = lockUtils.isLockRestricted(item, session, lockedItems); const unlockPrivilege = !!privileges[PRIVILEGES.EVENT_MANAGEMENT]; return ( @@ -81,7 +82,7 @@ export class EventPreviewHeaderComponent extends React.PureComponent { users={users} showUnlock={unlockPrivilege} withLoggedInfo={true} - onUnlock={onUnlock.bind(null, item)} + onUnlock={planningApi.locks.unlockItem.bind(null, item)} small={false} noMargin={true} /> @@ -107,7 +108,6 @@ EventPreviewHeaderComponent.propTypes = { itemActionDispatches: PropTypes.object, hideItemActions: PropTypes.bool, duplicateEvent: PropTypes.func, - onUnlock: PropTypes.func, calendars: PropTypes.array, }; @@ -121,7 +121,6 @@ const mapStateToProps = (state, ownProps) => ({ }); const mapDispatchToProps = (dispatch) => ({ - onUnlock: (event) => dispatch(actions.locks.unlock(event)), itemActionDispatches: actionUtils.getActionDispatches({dispatch: dispatch, eventOnly: true}), }); diff --git a/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx b/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx index 459b5ba7d..64de141ff 100644 --- a/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx +++ b/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; import {EventScheduleSummary} from '../../Events'; @@ -111,10 +113,10 @@ AssignCalendarComponent.propTypes = { const mapDispatchToProps = (dispatch) => ({ onSubmit: (original, updates) => ( dispatch(actions.main.save(original, updates, false)) - .then((savedItem) => dispatch(actions.events.api.unlock(savedItem))) + .then((savedItem) => planningApi.locks.unlockItem(savedItem)) ), onHide: (event) => { - dispatch(actions.events.api.unlock(event)); + planningApi.locks.unlockItem(event); }, }); diff --git a/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx b/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx index f99e5b14f..7e5041c73 100644 --- a/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, cloneDeep, isEmpty} from 'lodash'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {eventUtils, gettext} from '../../../utils'; @@ -199,7 +200,7 @@ const mapDispatchToProps = (dispatch) => ({ ), onHide: (original, modalProps) => { const promise = original.lock_action === EVENTS.ITEM_ACTIONS.CANCEL_EVENT.lock_action ? - dispatch(actions.events.api.unlock(original)) : + planningApi.locks.unlockItem(original) : Promise.resolve(original); if (get(modalProps, 'onCloseModal')) { diff --git a/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx b/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx index 0227bce69..b84c93338 100644 --- a/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx +++ b/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx @@ -3,6 +3,7 @@ import {connect} from 'react-redux'; import {get, cloneDeep, isEmpty} from 'lodash'; import {IPlanningItem, IPlanningProfile} from '../../../interfaces'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; @@ -147,7 +148,7 @@ const mapDispatchToProps = (dispatch) => ({ return dispatch(cancelDispatch(original, updates)) .then((updatedPlan: IPlanningItem) => { if (cancelBasedLocks.includes(original.lock_action) || isItemCancelled(updatedPlan)) { - return dispatch(actions.planning.api.unlock(updatedPlan)); + return planningApi.locks.unlockItem(updatedPlan); } return Promise.resolve(updatedPlan); @@ -156,7 +157,7 @@ const mapDispatchToProps = (dispatch) => ({ onHide: (planning: IPlanningItem) => { if (cancelBasedLocks.includes(planning.lock_action)) { - dispatch(actions.planning.api.unlock(planning)); + planningApi.locks.unlockItem(planning); } }, }); diff --git a/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx b/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx index e2240608c..0ae7bf2fa 100644 --- a/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx @@ -4,6 +4,7 @@ import {connect} from 'react-redux'; import {get, isEqual, cloneDeep} from 'lodash'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import {EventScheduleSummary, EventScheduleInput} from '../../Events'; @@ -209,7 +210,7 @@ const mapDispatchToProps = (dispatch) => ({ onHide: (event) => { if (event.lock_action === EVENTS.ITEM_ACTIONS.CONVERT_TO_RECURRING.lock_action) { - dispatch(actions.events.api.unlock(event)); + planningApi.locks.unlockItem(event); } }, diff --git a/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx b/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx index 024b16805..b6864dfd6 100644 --- a/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx +++ b/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {isEqual, get} from 'lodash'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; @@ -168,7 +169,7 @@ const mapDispatchToProps = (dispatch) => ({ onHide: (assignment) => { if (assignment.lock_action === 'edit_priority') { - dispatch(actions.assignments.api.unlock(assignment)); + planningApi.locks.unlockItem(assignment); } }, }); diff --git a/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx b/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx index 5be819f54..4bcf9d0ae 100644 --- a/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, cloneDeep, isEmpty} from 'lodash'; +import {planningApi} from '../../../superdeskApi'; + import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {gettext} from '../../../utils'; @@ -176,7 +178,7 @@ const mapDispatchToProps = (dispatch) => ({ onHide: (event, modalProps) => { const promise = event.lock_action === EVENTS.ITEM_ACTIONS.POSTPONE_EVENT.lock_action ? - dispatch(actions.events.api.unlock(event)) : + planningApi.locks.unlockItem(event) : Promise.resolve(event); if (get(modalProps, 'onCloseModal')) { diff --git a/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx b/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx index cf5795ed4..72c7a881f 100644 --- a/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx @@ -5,6 +5,7 @@ import {get, isEqual, cloneDeep, omit, isEmpty} from 'lodash'; import moment from 'moment'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import {formProfile, validateItem} from '../../../validators'; @@ -356,7 +357,7 @@ const mapDispatchToProps = (dispatch) => ({ onHide: (event, modalProps) => { const promise = event.lock_action === EVENTS.ITEM_ACTIONS.RESCHEDULE_EVENT.lock_action ? - dispatch(actions.events.api.unlock(event)) : + planningApi.locks.unlockItem(event) : Promise.resolve(event); if (get(modalProps, 'onCloseModal')) { diff --git a/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx b/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx index 2a7f2a9ac..682a8e270 100644 --- a/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, set, isEqual, cloneDeep} from 'lodash'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; @@ -181,7 +182,7 @@ const mapDispatchToProps = (dispatch) => ({ onHide: (assignment) => { if (assignment.lock_action === 'reassign') { - dispatch(actions.assignments.api.unlock(assignment)); + planningApi.locks.unlockItem(assignment); } }, }); diff --git a/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx b/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx index 00195a8ec..827ee8643 100644 --- a/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; @@ -171,7 +172,7 @@ const mapDispatchToProps = (dispatch) => ({ }, onHide: (event, modalProps) => { const promise = event.lock_action === EVENTS.ITEM_ACTIONS.UPDATE_REPETITIONS.lock_action ? - dispatch(actions.events.api.unlock(event)) : + planningApi.locks.unlockItem(event) : Promise.resolve(event); if (get(modalProps, 'onCloseModal')) { diff --git a/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx b/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx index 0a54493ed..cb57d7eac 100644 --- a/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import {get} from 'lodash'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; @@ -136,7 +138,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ dispatch(actions.main.save(original, updates, false)) .then((savedItem) => { if (ownProps.modalProps.unlockOnClose) { - dispatch(actions.events.api.unlock(savedItem)); + planningApi.locks.unlockItem(savedItem); } if (ownProps.resolve) { @@ -146,7 +148,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ ), onHide: (event) => { if (ownProps.modalProps.unlockOnClose) { - dispatch(actions.events.api.unlock(event)); + planningApi.locks.unlockItem(event); } if (ownProps.resolve) { diff --git a/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx b/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx index bf6873b33..93255dd16 100644 --- a/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx @@ -6,6 +6,7 @@ import moment from 'moment'; import {get, set, cloneDeep, isEqual} from 'lodash'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; @@ -331,7 +332,7 @@ const mapDispatchToProps = (dispatch) => ({ }, onHide: (event, modalProps) => { const promise = event.lock_action === EVENTS.ITEM_ACTIONS.UPDATE_TIME.lock_action ? - dispatch(actions.events.api.unlock(event)) : + planningApi.locks.unlockItem(event) : Promise.resolve(event); if (get(modalProps, 'onCloseModal')) { diff --git a/client/components/Main/ItemEditor/EditorHeader.tsx b/client/components/Main/ItemEditor/EditorHeader.tsx index dcc0b45ee..2874078aa 100644 --- a/client/components/Main/ItemEditor/EditorHeader.tsx +++ b/client/components/Main/ItemEditor/EditorHeader.tsx @@ -201,7 +201,7 @@ export class EditorHeader extends React.Component { states.canEditExpired = privileges[PRIVILEGES.EDIT_EXPIRED]; states.itemLock = lockUtils.getLock(initialValues, lockedItems); states.isLockedInContext = addNewsItemToPlanning ? - planningUtils.isLockedForAddToPlanning(initialValues) : + lockUtils.isLockedForAddToPlanning(initialValues, lockedItems) : !!states.itemLock; states.lockedUser = lockUtils.getLockedUser(initialValues, lockedItems, users); diff --git a/client/components/Main/ItemEditor/EditorItemActions.tsx b/client/components/Main/ItemEditor/EditorItemActions.tsx index b36c38ece..47df77cf3 100644 --- a/client/components/Main/ItemEditor/EditorItemActions.tsx +++ b/client/components/Main/ItemEditor/EditorItemActions.tsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; +import {superdeskApi} from '../../../superdeskApi'; import {ITEM_TYPE, EVENTS, PLANNING} from '../../../constants'; import {getItemType, eventUtils, planningUtils} from '../../../utils'; import eventsApi from '../../../actions/events/api'; @@ -10,6 +11,7 @@ import * as allActions from '../../../actions'; import {ItemActionsMenu} from '../../index'; const EditorItemActionsComponent = (props) => { + const {gettext} = superdeskApi.localization; const { item, event, diff --git a/client/components/Main/ItemEditor/ItemManager.ts b/client/components/Main/ItemEditor/ItemManager.ts index a8a74cce3..77f90d5b7 100644 --- a/client/components/Main/ItemEditor/ItemManager.ts +++ b/client/components/Main/ItemEditor/ItemManager.ts @@ -52,7 +52,7 @@ export class ItemManager { this.save = this.save.bind(this); this.saveAndPost = this.saveAndPost.bind(this); this.saveAndUnpost = this.saveAndUnpost.bind(this); - this.lock = this.lock.bind(this); + // this.lock = this.lock.bind(this); this.unlockThenLock = this.unlockThenLock.bind(this); this.changeAction = this.changeAction.bind(this); this.addCoverage = this.addCoverage.bind(this); @@ -358,7 +358,8 @@ export class ItemManager { ) .then((original) => { initialValues = cloneDeep(original); - return this.dispatch(actions.locks.lock(original)); + + return planningApi.locks.lockItem(original); }); } else { // Fetch the latest item from the API to view in read-only mode @@ -704,11 +705,8 @@ export class ItemManager { this.autoSave.remove(); // If event was created by a planning item, unlock the planning item - if (get(updates, '_planning_item')) { - this.dispatch(actions.planning.api.unlock({ - _id: updates._planning_item, - type: ITEM_TYPE.PLANNING, - })); + if (updates.type === 'event' && updates._planning_item != null) { + planningApi.locks.unlockItemById(updates._planning_item, 'planning'); } if (closeAfter) { @@ -810,37 +808,24 @@ export class ItemManager { return this.setState({initialValues}).then(() => this.editor.onChangeHandler(diff, null, false)); } - lock(item: IEventOrPlanningItem) { - return this.dispatch( - actions.locks.lock(item) - ); - } - - unlock() { - const {itemId, itemType} = this.props; - let action = actions.locks.unlock; + // TODO: Is this used anywhere + // lock(item: IEventOrPlanningItem) { + // return planningApi.locks.lockItem(item); + // } - if (!itemId || isTemporaryId(itemId)) { - return Promise.resolve(); - } else if (itemType === ITEM_TYPE.EVENT) { - action = actions.events.api.unlock; - } else if (itemType === ITEM_TYPE.PLANNING) { - action = actions.planning.api.unlock; - } - - return this.dispatch(action({ - _id: itemId, - type: itemType, - })); - } + // TODO: Is this used anywhere + // unlock() { + // return planningApi.locks.unlockItem(this.props.item); + // } unlockThenLock(item: IEventOrPlanningItem) { return this.setState({ itemReady: false, loading: true, }) - .then(() => this.dispatch( - actions.locks.unlockThenLock(item, this.props.inModalView) + .then(() => (planningApi.locks.unlockThenLockItem(item, 'edit'))) + .then((lockedItem) => ( + this.dispatch(actions.main.openForEdit(lockedItem, true, this.props.inModalView)) )); } @@ -850,15 +835,13 @@ export class ItemManager { let promises = []; if (shouldUnLockItem(initialValues, session, currentWorkspace, this.props.lockedItems)) { - promises.push(this.unlock()); + promises.push(planningApi.locks.unlockItem(this.props.item)); + // promises.push(this.unlock()); } // If event was created by a planning item, unlock the planning item if (diff?.type === 'event' && diff._planning_item) { - this.dispatch(actions.planning.api.unlock({ - _id: diff._planning_item, - type: ITEM_TYPE.PLANNING, - })); + planningApi.locks.unlockItemById(diff._planning_item, 'planning'); } promises.push(this.autoSave.remove()); diff --git a/client/components/Main/ItemEditor/tests/ItemManager_test.ts b/client/components/Main/ItemEditor/tests/ItemManager_test.ts index ed3c8ae45..a743e45f6 100644 --- a/client/components/Main/ItemEditor/tests/ItemManager_test.ts +++ b/client/components/Main/ItemEditor/tests/ItemManager_test.ts @@ -4,9 +4,10 @@ import moment from 'moment-timezone'; import {appConfig} from 'appConfig'; -import {main, locks} from '../../../../actions'; +import {planningApi} from '../../../../superdeskApi'; +import {main} from '../../../../actions'; import eventsApi from '../../../../actions/events/api'; -import planningApi from '../../../../actions/planning/api'; +import planningApis from '../../../../actions/planning/api'; import planningUi from '../../../../actions/planning/ui'; import {EVENTS} from '../../../../constants'; @@ -96,6 +97,7 @@ describe('components.Main.ItemManager', () => { defaultCalendar: [], defaultPlace: [], saveDiffToStore: sinon.spy(), + lockedItems: testData.locks, }, state: {}, setState: sinon.spy((newState, cb) => { @@ -426,7 +428,7 @@ describe('components.Main.ItemManager', () => { sinon.spy(manager, 'loadItem'); sinon.spy(manager, 'loadReadOnlyItem'); - sinon.stub(locks, 'lock').callsFake( + sinon.stub(planningApi.locks, 'lockItem').callsFake( (original) => Promise.resolve(original) ); sinon.stub(main, 'fetchById').callsFake((itemId, itemType) => ( @@ -446,7 +448,7 @@ describe('components.Main.ItemManager', () => { restoreSinonStub(manager.loadItem); restoreSinonStub(manager.loadReadOnlyItem); restoreSinonStub(main.fetchById); - restoreSinonStub(locks.lock); + restoreSinonStub(planningApi.locks.lockItem); }); it('createNew Event', (done) => { @@ -553,8 +555,8 @@ describe('components.Main.ItemManager', () => { expect(main.fetchById.callCount).toBe(1); expect(main.fetchById.args[0]).toEqual(['e1', 'event', true]); - expect(locks.lock.callCount).toBe(1); - expect(locks.lock.args[0]).toEqual([testData.events[0]]); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([testData.events[0]]); expect(editor.autoSave.createOrLoadAutosave.callCount).toBe(1); expect(editor.autoSave.createOrLoadAutosave.args[0]).toEqual([ @@ -601,7 +603,7 @@ describe('components.Main.ItemManager', () => { true, ]); - expect(locks.lock.callCount).toBe(0); + expect(planningApi.locks.lockItem.callCount).toBe(0); expectState({ initialValues: testData.events[0], @@ -761,14 +763,14 @@ describe('components.Main.ItemManager', () => { editor.setState(states.loading); sinon.stub(main, 'fetchById').returns(Promise.resolve(testData.events[0])); - sinon.stub(locks, 'lock').returns(Promise.resolve(lockedItem)); + sinon.stub(planningApi.locks, 'lockItem').returns(Promise.resolve(lockedItem)); sinon.stub(manager, 'addCoverage'); sinon.stub(manager, 'changeAction'); }); afterEach(() => { restoreSinonStub(main.fetchById); - restoreSinonStub(locks.lock); + restoreSinonStub(planningApi.locks.lockItem); restoreSinonStub(manager.addCoverage); restoreSinonStub(manager.changeAction); }); @@ -785,7 +787,7 @@ describe('components.Main.ItemManager', () => { manager.loadItem(editor.props) .then(() => { expect(main.fetchById.callCount).toBe(0); - expect(locks.lock.callCount).toBe(0); + expect(planningApi.locks.lockItem.callCount).toBe(0); expect(editor.autoSave.createOrLoadAutosave.callCount).toBe(1); expect(editor.autoSave.createOrLoadAutosave.args[0]).toEqual([ @@ -824,8 +826,8 @@ describe('components.Main.ItemManager', () => { expect(main.fetchById.callCount).toBe(1); expect(main.fetchById.args[0]).toEqual(['e1', 'event', true]); - expect(locks.lock.callCount).toBe(1); - expect(locks.lock.args[0]).toEqual([testData.events[0]]); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([testData.events[0]]); expect(editor.autoSave.createOrLoadAutosave.callCount).toBe(1); expect(editor.autoSave.createOrLoadAutosave.args[0]).toEqual([ @@ -835,7 +837,10 @@ describe('components.Main.ItemManager', () => { expectState({ initialValues: lockedItem, - diff: lockedItem, + diff: { + ...lockedItem, + associated_plannings: [testData.plannings[1]], + }, dirty: false, submitting: false, itemReady: true, @@ -860,11 +865,14 @@ describe('components.Main.ItemManager', () => { .then(() => { expect(main.fetchById.callCount).toBe(1); expect(main.fetchById.args[0]).toEqual(['e1', 'event', true]); - expect(locks.lock.callCount).toBe(0); + expect(planningApi.locks.lockItem.callCount).toBe(0); expectState({ initialValues: testData.events[0], - diff: testData.events[0], + diff: { + ...testData.events[0], + associated_plannings: [testData.plannings[1]], + }, dirty: false, submitting: false, itemReady: true, @@ -911,8 +919,8 @@ describe('components.Main.ItemManager', () => { }); it('changes the editor to read-only on failure', (done) => { - restoreSinonStub(locks.lock); - sinon.stub(locks, 'lock').returns(Promise.reject()); + restoreSinonStub(planningApi.locks.lockItem); + sinon.stub(planningApi.locks, 'lockItem').returns(Promise.reject()); const nextProps = { itemId: 'e1', @@ -1037,7 +1045,8 @@ describe('components.Main.ItemManager', () => { sinon.stub(manager, 'changeAction'); sinon.stub(manager, 'unlockAndCancel'); sinon.stub(manager, '_saveFromAuthoring'); - sinon.stub(planningApi, 'unlock'); + sinon.stub(planningApi.locks, 'unlockItem'); + sinon.stub(planningApi.locks, 'unlockItemById'); }); afterEach(() => { @@ -1045,7 +1054,8 @@ describe('components.Main.ItemManager', () => { restoreSinonStub(manager.changeAction); restoreSinonStub(manager.unlockAndCancel); restoreSinonStub(manager._saveFromAuthoring); - restoreSinonStub(planningApi.unlock); + restoreSinonStub(planningApi.locks.unlockItem); + restoreSinonStub(planningApi.locks.unlockItemById); }); it('returns without saving if there are validation errors', (done) => { @@ -1105,7 +1115,7 @@ describe('components.Main.ItemManager', () => { ]); expect(editor.autoSave.remove.callCount).toBe(1); - expect(planningApi.unlock.callCount).toBe(0); + expect(planningApi.locks.unlockItem.callCount).toBe(0); expect(manager.changeAction.callCount).toBe(1); expect(manager.changeAction.args[0]).toEqual([ 'edit', @@ -1134,11 +1144,8 @@ describe('components.Main.ItemManager', () => { manager._save() .then(() => { - expect(planningApi.unlock.callCount).toBe(1); - expect(planningApi.unlock.args[0]).toEqual([{ - _id: 'p1', - type: 'planning', - }]); + expect(planningApi.locks.unlockItemById.callCount).toBe(1); + expect(planningApi.locks.unlockItemById.args[0]).toEqual(['p1', 'planning']); done(); }) @@ -1195,14 +1202,20 @@ describe('components.Main.ItemManager', () => { expectState({ initialValues: item, - diff: item, + diff: { + ...item, + associated_plannings: [testData.plannings[1]], + }, dirty: false, submitFailed: false, ...states.notLoading, }); expect(editor.autoSave.saveAutosave.callCount).toBe(1); - expect(editor.autoSave.saveAutosave.args[0][1]).toEqual(item); + expect(editor.autoSave.saveAutosave.args[0][1]).toEqual({ + ...item, + associated_plannings: [testData.plannings[1]], + }); expect(editor.autoSave.flushAutosave.callCount).toBe(2); done(); @@ -1502,119 +1515,13 @@ describe('components.Main.ItemManager', () => { .catch(done.fail); }); - describe('lock', () => { - afterEach(() => { - restoreSinonStub(locks.lock); - }); - - it('lock calls locks.lock', () => { - sinon.stub(locks, 'lock'); - manager.lock(testData.events[0]); - expect(locks.lock.callCount).toBe(1); - expect(locks.lock.args[0]).toEqual([testData.events[0]]); - }); - }); - - describe('unlock', () => { - beforeEach(() => { - sinon.stub(locks, 'unlock'); - sinon.stub(locks, 'unlockThenLock'); - sinon.stub(eventsApi, 'unlock'); - sinon.stub(planningApi, 'unlock'); - }); - - afterEach(() => { - restoreSinonStub(locks.unlock); - restoreSinonStub(locks.unlockThenLock); - restoreSinonStub(eventsApi.unlock); - restoreSinonStub(planningApi.unlock); - }); - - it('unlock on unknown item type calls locks.unlock', () => { - updateProps({ - itemId: testData.events[0]._id, - itemType: 'unknown', - }); - manager.unlock(); - - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([{ - _id: testData.events[0]._id, - type: 'unknown', - }]); - }); - - it('unlock doesnt call any action on a temporary item', () => { - // No id specified - updateProps({itemType: newEvent.type}); - manager.unlock(); - expect(locks.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - - // Id is for a temporary item - updateProps({itemId: newEvent._id}); - manager.unlock(); - expect(locks.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - }); - - it('unlock on Event calls events.api.unlock', () => { - updateProps({ - itemId: testData.events[0]._id, - itemType: testData.events[0].type, - }); - manager.unlock(); - - expect(locks.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(1); - expect(eventsApi.unlock.args[0]).toEqual([{ - _id: testData.events[0]._id, - type: testData.events[0].type, - }]); - }); - - it('unlock on Planning calls planning.api.unlock', () => { - updateProps({ - itemId: testData.plannings[0]._id, - itemType: testData.plannings[0].type, - }); - manager.unlock(); - - expect(locks.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(1); - expect(planningApi.unlock.args[0]).toEqual([{ - _id: testData.plannings[0]._id, - type: testData.plannings[0].type, - }]); - }); - - it('unlockThenLock calls locks.unlockThenLock', (done) => { - manager.unlockThenLock(testData.events[0]) - .then(() => { - expect(locks.unlockThenLock.callCount).toBe(1); - expect(locks.unlockThenLock.args[0]).toEqual([testData.events[0], false]); - - done(); - }) - .catch(done.fail); - }); - }); - describe('unlockAndCancel', () => { beforeEach(() => { - sinon.stub(manager, 'unlock').returns(Promise.resolve()); - sinon.stub(planningApi, 'unlock').returns(Promise.resolve()); + sinon.stub(planningApi.locks, 'unlockItem').returns(Promise.resolve()); }); afterEach(() => { - restoreSinonStub(manager.unlock); - restoreSinonStub(planningApi.unlock); + restoreSinonStub(planningApi.locks.unlockItem); }); it('doesnt call unlock if the item isnt locked', (done) => { @@ -1625,7 +1532,7 @@ describe('components.Main.ItemManager', () => { manager.unlockAndCancel() .then(() => { - expect(manager.unlock.callCount).toBe(0); + expect(planningApi.locks.unlockItem.callCount).toBe(0); done(); }) @@ -1648,8 +1555,7 @@ describe('components.Main.ItemManager', () => { manager.unlockAndCancel() .then(() => { - expect(manager.unlock.callCount).toBe(1); - expect(planningApi.unlock.callCount).toBe(0); + expect(planningApi.locks.unlockItem.callCount).toBe(0); expect(editor.autoSave.remove.callCount).toBe(1); expect(editor.closeEditor.callCount).toBe(1); @@ -1671,8 +1577,7 @@ describe('components.Main.ItemManager', () => { manager.unlockAndCancel() .then(() => { - expect(manager.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(1); + expect(planningApi.locks.unlockItem.callCount).toBe(0); done(); }) diff --git a/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx b/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx index b85703fdb..5518c80f7 100644 --- a/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx +++ b/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx @@ -9,6 +9,7 @@ import {renderFields} from '../../fields'; import { planningUtils, + lockUtils, getItemId, isItemExpired, gettext, @@ -36,7 +37,7 @@ export const FeaturedPlanningItem = ({ return null; } - const isItemLocked = planningUtils.isPlanningLocked(item, lockedItems); + const isItemLocked = lockUtils.isItemLocked(item, lockedItems); const isExpired = isItemExpired(item); let borderState = false; diff --git a/client/components/Planning/PlanningEditor/index.tsx b/client/components/Planning/PlanningEditor/index.tsx index 97d9439af..261a0927d 100644 --- a/client/components/Planning/PlanningEditor/index.tsx +++ b/client/components/Planning/PlanningEditor/index.tsx @@ -15,13 +15,14 @@ import { IPlanningFormProfile, IPlanningItem, IPlanningNewsCoverageStatus, + ILockedItems, } from '../../../interfaces'; import {IArticle, IDesk, IUser, IVocabularyItem} from 'superdesk-api'; import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; -import {planningUtils, eventUtils} from '../../../utils'; +import {planningUtils, eventUtils, lockUtils} from '../../../utils'; import {EditorForm} from '../../Editor/EditorForm'; import {PlanningEditorHeader} from './PlanningEditorHeader'; @@ -56,6 +57,7 @@ interface IProps { defaultDesk?: IDesk; preferredCoverageDesks: {[key: string]: string}; files: Array; + lockedItems: ILockedItems; onChangeHandler( field: string | {[key: string]: any}, @@ -90,6 +92,7 @@ const mapStateToProps = (state) => ({ files: selectors.general.files(state), contentTypes: selectors.general.contentTypes(state), formProfile: selectors.forms.planningProfile(state), + lockedItems: selectors.locks.getLockedItems(state), }); const mapDispatchToProps = (dispatch) => ({ @@ -209,7 +212,7 @@ class PlanningEditorComponent extends React.Component { } handleAddToPlanningLoading() { - if ((this.props.itemExists && !planningUtils.isLockedForAddToPlanning(this.props.item)) || + if ((this.props.itemExists && !lockUtils.isLockedForAddToPlanning(this.props.item, this.props.lockedItems)) || (!this.props.itemExists && get(this.props, 'diff.coverages.length', 0) > 0) ) { return; diff --git a/client/components/Planning/PlanningItem.tsx b/client/components/Planning/PlanningItem.tsx index b288336c6..fac214669 100644 --- a/client/components/Planning/PlanningItem.tsx +++ b/client/components/Planning/PlanningItem.tsx @@ -22,6 +22,7 @@ import {CreatedUpdatedColumn} from '../UI/List/CreatedUpdatedColumn'; import { eventUtils, planningUtils, + lockUtils, onEventCapture, isItemPosted, getItemId, @@ -190,7 +191,7 @@ class PlanningItemComponent extends React.Component { } const {gettext} = superdeskApi.localization; - const isItemLocked = planningUtils.isPlanningLocked(item, lockedItems); + const isItemLocked = lockUtils.isItemLocked(item, lockedItems); const event = get(item, 'event'); const borderState = isItemLocked ? 'locked' : false; const isExpired = isItemExpired(item); diff --git a/client/components/Planning/PlanningPreviewHeader.tsx b/client/components/Planning/PlanningPreviewHeader.tsx index 97a8b1be0..d5199bea6 100644 --- a/client/components/Planning/PlanningPreviewHeader.tsx +++ b/client/components/Planning/PlanningPreviewHeader.tsx @@ -1,12 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../superdeskApi'; + import {Tools} from '../UI/SidePanel'; import {ItemActionsMenu, LockContainer, ItemIcon} from '../index'; import {planningUtils, lockUtils, actionUtils} from '../../utils'; import {PLANNING, PRIVILEGES, EVENTS, ICON_COLORS} from '../../constants'; import * as selectors from '../../selectors'; -import * as actions from '../../actions'; import {get} from 'lodash'; export class PlanningPreviewHeaderComponent extends React.Component { @@ -17,7 +19,6 @@ export class PlanningPreviewHeaderComponent extends React.Component { item, lockedItems, session, - onUnlock, showUnlock, hideItemActions, event, @@ -27,7 +28,7 @@ export class PlanningPreviewHeaderComponent extends React.Component { } = this.props; const lockedUser = lockUtils.getLockedUser(item, lockedItems, users); const unlockPrivilege = !!privileges[PRIVILEGES.PLANNING_MANAGEMENT]; - const lockRestricted = planningUtils.isPlanningLockRestricted(item, session, lockedItems); + const lockRestricted = lockUtils.isLockRestricted(item, session, lockedItems); const itemActionsCallBack = { [PLANNING.ITEM_ACTIONS.DUPLICATE.actionName]: @@ -87,7 +88,7 @@ export class PlanningPreviewHeaderComponent extends React.Component { users={users} showUnlock={unlockPrivilege && showUnlock} withLoggedInfo={true} - onUnlock={onUnlock.bind(null, item)} + onUnlock={planningApi.locks.unlockItem.bind(null, item)} small={false} noMargin={true} /> @@ -112,7 +113,6 @@ PlanningPreviewHeaderComponent.propTypes = { agendas: PropTypes.array, lockedItems: PropTypes.object, duplicateEvent: PropTypes.func, - onUnlock: PropTypes.func, event: PropTypes.object, itemActionDispatches: PropTypes.object, showUnlock: PropTypes.bool, @@ -132,7 +132,6 @@ const mapStateToProps = (state) => ({ }); const mapDispatchToProps = (dispatch) => ({ - onUnlock: (planning) => (dispatch(actions.locks.unlock(planning))), itemActionDispatches: actionUtils.getActionDispatches({dispatch: dispatch}), }); diff --git a/client/components/RelatedPlannings/PlanningMetaData/RelatedPlanningListItem.tsx b/client/components/RelatedPlannings/PlanningMetaData/RelatedPlanningListItem.tsx index 23c89004b..10f4afa4e 100644 --- a/client/components/RelatedPlannings/PlanningMetaData/RelatedPlanningListItem.tsx +++ b/client/components/RelatedPlannings/PlanningMetaData/RelatedPlanningListItem.tsx @@ -6,7 +6,7 @@ import {IDesk, IUser} from 'superdesk-api'; import {superdeskApi} from '../../../superdeskApi'; import {ICON_COLORS} from '../../../constants'; -import {planningUtils} from '../../../utils'; +import {planningUtils, lockUtils} from '../../../utils'; import * as selectors from '../../../selectors'; import * as List from '../../UI/List'; @@ -42,7 +42,7 @@ const mapStateToProps = (state) => ({ class RelatedPlanningListItemComponent extends React.PureComponent { render() { const {gettext} = superdeskApi.localization; - const isItemLocked = planningUtils.isPlanningLocked( + const isItemLocked = lockUtils.isItemLocked( this.props.item, this.props.lockedItems ); diff --git a/client/components/WorkqueueContainer/WorkqueueContainer_test.tsx b/client/components/WorkqueueContainer/WorkqueueContainer_test.tsx index 5837845a4..996b928b5 100644 --- a/client/components/WorkqueueContainer/WorkqueueContainer_test.tsx +++ b/client/components/WorkqueueContainer/WorkqueueContainer_test.tsx @@ -9,7 +9,7 @@ describe('', () => { const initialState = { events: { events: { - event1: { + e1: { _id: 'e1', dates: {start: '2016-10-15T13:01:11+0000'}, definition_short: 'definition_short 1', @@ -18,8 +18,9 @@ describe('', () => { lock_action: 'edit', lock_user: 'user123', lock_session: 'session123', + lock_time: '2022-04-28T12:01:11+0000', }, - event2: { + e2: { _id: 'e2', dates: {start: '2016-10-15T13:01:11+0000'}, definition_short: 'definition_short 2', @@ -33,7 +34,7 @@ describe('', () => { }, planning: { plannings: { - planning1: { + p1: { _id: 'p1', slugline: 'Planning1', headline: 'Some Plan 1', @@ -42,8 +43,9 @@ describe('', () => { lock_action: 'edit', lock_user: 'user123', lock_session: 'session123', + lock_time: '2022-04-28T10:01:11+0000', }, - planning2: { + p2: { _id: 'p2', slugline: 'Planning2', headline: 'Some Plan 2', @@ -54,7 +56,7 @@ describe('', () => { lock_user: 'user123', lock_session: 'session123', }, - planning3: { + p3: { _id: 'p3', slugline: 'Planning3', headline: 'Some Plan 3', @@ -81,23 +83,50 @@ describe('', () => { identity: {_id: 'user123'}, sessionId: 'session123', }, + locks: { + event: { + e1: { + item_id: 'e1', + item_type: 'event', + action: 'edit', + user: 'user123', + session: 'session123', + time: '2022-04-28T12:01:11+0000', + }, + }, + planning: { + p1: { + item_id: 'p1', + item_type: 'planning', + action: 'edit', + user: 'user123', + session: 'session123', + time: '2022-04-28T10:01:11+0000', + }, + }, + recurring: {}, + assignment: {}, + }, }; const store = createTestStore({initialState}); - const wrapper = mount( - - - - ); - it('displays WorkqueueList', () => { + const wrapper = mount( + + + + ); + expect(wrapper).toBeDefined(); expect(wrapper.find(WorkqueueContainer).length).toBe(1); }); it('contains locked events and planning items for workqueue items', () => { - expect(selectors.locks.getLockedEvents(store.getState())) - .toEqual([store.getState().events.events['event1']]); - expect(selectors.locks.getLockedPlannings(store.getState()).length).toBe(2); + const state = store.getState(); + const workqueueItems = selectors.locks.workqueueItems(state); + + expect(workqueueItems.length).toBe(2); + expect(workqueueItems[0]).toEqual(state.planning.plannings.p1); + expect(workqueueItems[1]).toEqual(state.events.events.e1); }); }); diff --git a/client/constants/locks.ts b/client/constants/locks.ts index b2af6f8fc..d37f34f7d 100644 --- a/client/constants/locks.ts +++ b/client/constants/locks.ts @@ -2,5 +2,7 @@ export const LOCKS = { ACTIONS: { ADD: 'ADD_LOCKS', RECEIVE: 'RECEIVE_LOCKS', + SET_ITEM_AS_LOCKED: 'SET_ITEM_AS_LOCKED', + SET_ITEM_AS_UNLOCKED: 'SET_ITEM_AS_UNLOCKED', }, }; diff --git a/client/controllers/AddToPlanningController.tsx b/client/controllers/AddToPlanningController.tsx index ec3cab9d6..08ace0817 100644 --- a/client/controllers/AddToPlanningController.tsx +++ b/client/controllers/AddToPlanningController.tsx @@ -10,6 +10,7 @@ import {get, isEmpty, isNumber} from 'lodash'; import {registerNotifications, getErrorMessage, isExistingItem} from '../utils'; import {WORKSPACE, MODALS, MAIN} from '../constants'; import {GET_LABEL_MAP, DEFAULT_SCHEMA} from 'superdesk-core/scripts/apps/workspace/content/constants'; +import {planningApi} from '../superdeskApi'; const DEFAULT_PLANNING_SCHEMA = { anpa_category: {required: true}, @@ -111,7 +112,7 @@ export class AddToPlanningController { return Promise.all([ this.store.dispatch(actions.main.filter(MAIN.FILTERS.PLANNING)), - this.store.dispatch(locks.loadAllLocks()), + planningApi.locks.loadLockedItems(), this.store.dispatch(actions.fetchAgendas()), ]); }); diff --git a/client/controllers/AssignmentController.tsx b/client/controllers/AssignmentController.tsx index 23ce5e0f4..b81467d64 100644 --- a/client/controllers/AssignmentController.tsx +++ b/client/controllers/AssignmentController.tsx @@ -2,6 +2,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {Provider} from 'react-redux'; import {cloneDeep} from 'lodash'; + +import {planningApi} from '../superdeskApi'; import {registerNotifications} from '../utils'; import * as actions from '../actions'; import * as selectors from '../selectors'; @@ -82,7 +84,7 @@ export class AssignmentController { ])); return Promise.all([ - this.store.dispatch(actions.locks.loadAssignmentLocks()), + planningApi.locks.loadLockedItems(), this.store.dispatch(actions.fetchAgendas()), this.store.dispatch(actions.users.fetchAndRegisterUserPreferences()) .then(() => this.store.dispatch(actions.assignments.ui.loadDefaultListSort())) diff --git a/client/controllers/AssignmentPreviewController.tsx b/client/controllers/AssignmentPreviewController.tsx index 8542ca1cb..9828782b6 100644 --- a/client/controllers/AssignmentPreviewController.tsx +++ b/client/controllers/AssignmentPreviewController.tsx @@ -1,6 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {Provider} from 'react-redux'; + +import {planningApi} from '../superdeskApi'; import {registerNotifications} from '../utils'; import * as actions from '../actions'; import {AssignmentPreviewContainer} from '../components/Assignments'; @@ -57,7 +59,7 @@ export class AssignmentPreviewController { registerNotifications(this.$scope, this.store); return this.$q.all({ - locks: this.store.dispatch(actions.locks.loadAssignmentLocks()), + locks: planningApi.locks.loadLockedItems(), agendas: this.store.dispatch(actions.fetchAgendas()), assignment: this.fetchAssignment(this.$scope.vm.item.assignment_id), }) diff --git a/client/controllers/PlanningController.tsx b/client/controllers/PlanningController.tsx index b6fff8c42..bfc12b3c7 100644 --- a/client/controllers/PlanningController.tsx +++ b/client/controllers/PlanningController.tsx @@ -6,6 +6,7 @@ import * as actions from '../actions'; import {WORKSPACE} from '../constants'; import {PlanningApp} from '../apps'; import eventsApi from '../actions/events/api'; +import {planningApi} from '../superdeskApi'; export class PlanningController { @@ -81,7 +82,7 @@ export class PlanningController { this.store.dispatch(actions.main.closePublishQueuePreviewOnWorkspaceChange()); return Promise.all([ - this.store.dispatch(actions.locks.loadAllLocks()), + planningApi.locks.loadLockedItems(), this.store.dispatch(actions.fetchAgendas()), this.store.dispatch(actions.users.fetchAndRegisterUserPreferences()), this.store.dispatch(actions.events.api.fetchCalendars()), diff --git a/client/interfaces.ts b/client/interfaces.ts index e7643a474..e18e0532f 100644 --- a/client/interfaces.ts +++ b/client/interfaces.ts @@ -298,7 +298,7 @@ export interface ISession { identity: IUser; } -export type IPrivileges = {[key: string]: number}; +export type IPrivileges = {[key: string]: number | boolean}; export interface ILocation extends IBaseRestApiResponse { guid: string; @@ -342,12 +342,12 @@ export interface ILocation extends IBaseRestApiResponse { } export interface ILock { - action: string; - item_id: string; - item_type: string; - session: string; - time: string; - user: string; + action: IEventOrPlanningItem['lock_action']; + item_id: IEventOrPlanningItem['_id']; + item_type: IEventOrPlanningItem['type'] | IAssignmentItem['type']; + session: IEventOrPlanningItem['lock_session']; + time: IEventOrPlanningItem['lock_time']; + user: IEventOrPlanningItem['lock_user']; } export interface ILockedItems { @@ -381,6 +381,29 @@ export interface IEventLocation { }; } +export interface IItemAction { + actionName?: string; + label: string; + key?: string; + icon?: string; + inactive?: boolean; + text?: string; + callback?(...args: Array): void; +} + +export interface IItemSubActions { + create: { + current: Array; + past: Array; + }; + createAndOpen: { + current: Array; + past: Array; + }; +} + +export type IDateTime = moment.MomentInput; + export interface IEventItem extends IBaseRestApiResponse { guid?: string; unique_id?: string; @@ -416,8 +439,8 @@ export interface IEventItem extends IBaseRestApiResponse { dates?: { all_day?: boolean; no_end_time?: boolean; - start?: string | Date | moment.Moment; - end?: string | Date | moment.Moment; + start?: IDateTime; + end?: IDateTime; tz?: string; duration?: string; confirmation?: string; @@ -426,7 +449,7 @@ export interface IEventItem extends IBaseRestApiResponse { frequency?: string; interval?: number; endRepeatMode?: 'count' | 'until'; - until?: string | Date | moment.Moment; + until?: IDateTime; count?: number; bymonth?: string; byday?: string; @@ -448,8 +471,8 @@ export interface IEventItem extends IBaseRestApiResponse { byminute?: string; } }; - _startTime?: string | Date | moment.Moment; - _endTime?: string | Date | moment.Moment; + _startTime?: IDateTime; + _endTime?: IDateTime; _planning_schedule?: Array<{ scheduled?: string | Date; }>; @@ -486,7 +509,7 @@ export interface IEventItem extends IBaseRestApiResponse { expired?: boolean; pubstatus?: IPlanningPubstatus; lock_user?: string; - lock_time?: string | Date | moment.Moment; + lock_time?: IDateTime; lock_session?: string; lock_action?: string; update_method?: IEventUpdateMethod; @@ -508,7 +531,7 @@ export interface IEventItem extends IBaseRestApiResponse { planning_ids?: Array; _plannings?: Array; template?: string; - _sortDate?: string | Date | moment.Moment; + _sortDate?: IDateTime; translations?: Array<{ field: string; @@ -519,6 +542,10 @@ export interface IEventItem extends IBaseRestApiResponse { // Used only to add/modify Plannings/Coverages from the Event form // These are only stored with the Autosave and not the actual Event associated_plannings: Array>; + + // Attributes added by API (removed via modifyForClient) + // The `_status` field is available when the item comes from a POST/PATCH request + _status: any; } export interface IEventTemplate extends IBaseRestApiResponse { @@ -536,8 +563,8 @@ export interface ICoveragePlanningDetails { contact_info: string; item_class: string; item_count: string; - scheduled: string | Date | moment.Moment; - _scheduledTime: string | Date | moment.Moment; + scheduled: IDateTime; + _scheduledTime: IDateTime; files: Array; xmp_file: string; service: Array<{ @@ -648,7 +675,7 @@ export interface IPlanningItem extends IBaseRestApiResponse { expired: boolean; featured: boolean; lock_user: string; - lock_time: string | Date | moment.Moment; + lock_time: IDateTime; lock_session: string; lock_action: string; coverages: Array; @@ -660,7 +687,7 @@ export interface IPlanningItem extends IBaseRestApiResponse { scheduled_update_id: string; scheduled: string | Date; }>; - planning_date: string | Date | moment.Moment; + planning_date: IDateTime; flags?: { marked_for_not_publication?: boolean; overide_auto_assign_to_workflow?: boolean; @@ -680,6 +707,9 @@ export interface IPlanningItem extends IBaseRestApiResponse { // Used when showing Associated Planning item for Events _agendas: Array; + // Attributes added by API (removed via modifyForClient) + // The `_status` field is available when the item comes from a POST/PATCH request + _status: any; translations?: Array<{ field: string; language: string; @@ -710,7 +740,7 @@ export interface IFeaturedPlanningSaveItem { export interface IFeaturedPlanningLock extends IBaseRestApiResponse { lock_user: string; - lock_time: string | Date | moment.Moment; + lock_time: IDateTime; lock_session: string; } @@ -795,7 +825,7 @@ export interface IAssignmentItem extends IBaseRestApiResponse { versioncreated: string; type: 'assignment'; lock_user: string; - lock_time: string | Date | moment.Moment; + lock_time: IDateTime; lock_session: string; lock_action: string; _to_delete: boolean; @@ -845,6 +875,7 @@ export interface IDateSearchParams { } export type IEventOrPlanningItem = IEventItem | IPlanningItem; +export type IAssignmentOrPlanningItem = IEventOrPlanningItem | IAssignmentItem; export interface ICommonAdvancedSearchParams { anpa_category?: Array; @@ -1305,8 +1336,8 @@ export interface ISearchParams { spike_state?: ISearchSpikeState; include_killed?: boolean; date_filter?: IDateRange; - start_date?: string | Date | moment.Moment; - end_date?: string | Date | moment.Moment; + start_date?: IDateTime; + end_date?: IDateTime; only_future?: boolean; start_of_week?: number; slugline?: string; @@ -1659,6 +1690,7 @@ export interface IPlanningAppState { featuredPlanning: IFeaturedPlanningState; forms: IFormState; session: ISession; + locks: ILockedItems; } export interface INominatimLocalityFields { @@ -1940,6 +1972,18 @@ export interface IWebsocketMessageData { lock_session?: IEventOrPlanningItem['lock_session']; recurrence_id?: IEventItem['recurrence_id']; event_item?: IEventItem['_id']; + type: IEventOrPlanningItem['type'] | IAssignmentItem['type']; + }; + ITEM_LOCKED: { + item: IEventOrPlanningItem['_id']; + etag: IEventOrPlanningItem['_etag']; + user: IEventOrPlanningItem['lock_user']; + lock_session: IEventOrPlanningItem['lock_session']; + lock_action: IEventOrPlanningItem['lock_action']; + lock_time: IEventOrPlanningItem['lock_time']; + recurrence_id?: IEventOrPlanningItem['recurrence_id']; + type: IEventOrPlanningItem['type'] | IAssignmentItem['type']; + event_item?: IEventItem['_id']; }; } @@ -2036,7 +2080,6 @@ export interface IPlanningAPI { searchGetAll(params: ISearchParams): Promise>; getById(eventId: IEventItem['_id']): Promise; getByIds(eventIds: Array, spikeState?: ISearchSpikeState): Promise>; - getLocked(): Promise>; getEditorProfile(): IEventFormProfile; getSearchProfile(): IEventSearchProfile; create(updates: Partial): Promise>; @@ -2051,15 +2094,12 @@ export interface IPlanningAPI { spikeState?: ISearchSpikeState, params?: ISearchParams ): Promise>; - getLocked(): Promise>; - getLockedFeatured(): Promise>; getEditorProfile(): IPlanningFormProfile; getSearchProfile(): IPlanningSearchProfile; featured: { - lock(): Promise>; - unlock(): Promise; getById(id: string): Promise; getByDate(date: moment.Moment): Promise; + save(updates: Partial): Promise; }; coverages: { setDefaultValues( @@ -2072,6 +2112,9 @@ export interface IPlanningAPI { update(original: IPlanningItem, updates: Partial): Promise; createFromEvent(event: IEventItem, updates: Partial): Promise; }; + assignments: { + getById(assignmentId: IAssignmentItem['_id']): Promise; + }; coverages: { getEditorProfile(): ICoverageFormProfile; }; @@ -2129,6 +2172,7 @@ export interface IPlanningAPI { updates: Partial ): Promise; delete(item: IEventOrPlanningItem): Promise; + deleteById(itemType: IEventOrPlanningItem['type'], itemId: IEventOrPlanningItem['_id']): Promise; }; editor(type: EDITOR_TYPE): IEditorAPI; contentProfiles: { @@ -2149,4 +2193,20 @@ export interface IPlanningAPI { showManageEventProfileModal(): Promise; updateProfilesInStore(): Promise; }; + locks: { + loadLockedItems(types?: Array<'events_and_planning' | 'featured_planning' | 'assignments'>): Promise; + setItemAsLocked(data: IWebsocketMessageData['ITEM_LOCKED']): void; + setItemAsUnlocked(data: IWebsocketMessageData['ITEM_UNLOCKED']): void; + lockItem(item: T, action: string): Promise; + lockItemById( + itemId: T['_id'], + itemType: T['type'], + action: string + ): Promise; + unlockItem(item: T): Promise; + unlockItemById(itemId: T['_id'], itemType: T['type']): Promise; + unlockThenLockItem(item: T, action: string): Promise; + lockFeaturedPlanning(): Promise; + unlockFeaturedPlanning(): Promise; + }; } diff --git a/client/reducers/events.ts b/client/reducers/events.ts index 67db77b5a..01eac34e5 100644 --- a/client/reducers/events.ts +++ b/client/reducers/events.ts @@ -231,15 +231,6 @@ const eventsReducer = createReducer(initialState, { }; }, - [LOCKS.ACTIONS.RECEIVE]: (state, payload) => ( - get(payload, 'events.length', 0) <= 0 ? - state : - eventsReducer(state, { - type: EVENTS.ACTIONS.ADD_EVENTS, - payload: payload.events, - }) - ), - [EVENTS.ACTIONS.SPIKE_EVENT]: (state, payload) => { // If there is only 1 event and that event is not loaded // then disregard this action diff --git a/client/reducers/locks.ts b/client/reducers/locks.ts index 7aca4e8ce..0dededa61 100644 --- a/client/reducers/locks.ts +++ b/client/reducers/locks.ts @@ -1,99 +1,67 @@ +import {ILockedItems, ILock, IWebsocketMessageData} from '../interfaces'; import {createReducer} from './createReducer'; -import {RESET_STORE, INIT_STORE, LOCKS, PLANNING, EVENTS, ASSIGNMENTS} from '../constants'; +import {RESET_STORE, INIT_STORE, LOCKS} from '../constants'; import {cloneDeep, get} from 'lodash'; -const initialLockState = { +const initialLockState: ILockedItems = { event: {}, planning: {}, recurring: {}, assignment: {}, }; -export const convertItemToLock = (item, itemType) => ({ - action: item.lock_action, - session: item.lock_session, - time: item.lock_time, - user: item.lock_user, - item_type: itemType, - item_id: item._id, -}); - -const removeLock = (item, state, itemType) => { - if (get(item, 'recurrence_id')) { - delete state.recurring[item.recurrence_id]; - } else if (get(item, 'event_item')) { - delete state.event[item.event_item]; +function removeLock(state: ILockedItems, data: IWebsocketMessageData['ITEM_UNLOCKED']) { + if (data.recurrence_id != null) { + delete state.recurring[data.recurrence_id]; + } else if (data.event_item != null) { + delete state.event[data.event_item]; + } else { + delete state[data.type][data.item]; } - delete state[itemType][item._id]; return state; -}; - -const addLock = (item, state, itemType) => { - const lock = convertItemToLock(item, itemType); - - if (get(item, 'recurrence_id')) { - state.recurring[item.recurrence_id] = lock; - } else if (get(item, 'event_item')) { - state.event[item.event_item] = lock; +} + +function addLock(state: ILockedItems, data: IWebsocketMessageData['ITEM_LOCKED']) { + const lockData: ILock = { + action: data.lock_action, + item_id: data.item, + session: data.lock_session, + time: data.lock_time, + user: data.user, + item_type: data.type, + }; + + if (data.recurrence_id != null) { + state.recurring[data.recurrence_id] = lockData; + } else if (data.event_item != null) { + state.event[data.event_item] = lockData; } else { - state[itemType][item._id] = lock; + state[data.type][data.item] = lockData; } return state; -}; +} export default createReducer(initialLockState, { - [RESET_STORE]: () => (null), - - [INIT_STORE]: () => (initialLockState), - - [PLANNING.ACTIONS.UNLOCK_PLANNING]: (state, payload) => - removeLock(payload.plan, cloneDeep(state), 'planning'), - - [EVENTS.ACTIONS.UNLOCK_EVENT]: (state, payload) => - removeLock(payload.event, cloneDeep(state), 'event'), - - [EVENTS.ACTIONS.LOCK_EVENT]: (state, payload) => ( - addLock(payload.event, cloneDeep(state), 'event') - ), + [RESET_STORE]: () => null, - [PLANNING.ACTIONS.LOCK_PLANNING]: (state, payload) => ( - addLock(payload.plan, cloneDeep(state), 'planning') - ), + [INIT_STORE]: () => initialLockState, - [ASSIGNMENTS.ACTIONS.LOCK_ASSIGNMENT]: (state, payload) => ( - addLock(payload.assignment, cloneDeep(state), 'assignment') + [LOCKS.ACTIONS.RECEIVE]: (state: ILockedItems, payload: ILockedItems) => ( + { + event: payload.event || {}, + planning: payload.planning || {}, + recurring: payload.recurring || {}, + assignment: payload.assignment || {}, + } ), - [ASSIGNMENTS.ACTIONS.UNLOCK_ASSIGNMENT]: (state, payload) => ( - removeLock(payload.assignment, cloneDeep(state), 'assignment') + [LOCKS.ACTIONS.SET_ITEM_AS_LOCKED]: (state: ILockedItems, payload: IWebsocketMessageData['ITEM_LOCKED']) => ( + addLock(cloneDeep(state), payload) ), - [LOCKS.ACTIONS.RECEIVE]: (state, payload) => { - const locks = { - event: {}, - planning: {}, - recurring: {}, - assignment: {}, - }; - - if (payload.events) { - payload.events.forEach((event) => addLock(event, locks, 'event')); - } - - if (payload.plans) { - payload.plans.forEach((plan) => addLock(plan, locks, 'planning')); - } - - if (payload.assignments) { - payload.assignments.forEach((assignment) => addLock(assignment, locks, 'assignment')); - } - - return locks; - }, - - [EVENTS.ACTIONS.MARK_EVENT_POSTPONED]: (state, payload) => ( - removeLock(payload.event, cloneDeep(state), 'event') + [LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED]: (state: ILockedItems, payload: IWebsocketMessageData['ITEM_UNLOCKED']) => ( + removeLock(cloneDeep(state), payload) ), }); diff --git a/client/reducers/planning.ts b/client/reducers/planning.ts index 7dba6656c..b68c1d6cc 100644 --- a/client/reducers/planning.ts +++ b/client/reducers/planning.ts @@ -166,15 +166,6 @@ const planningReducer = createReducer(initialState, { }; }, - [LOCKS.ACTIONS.RECEIVE]: (state, payload) => ( - get(payload, 'plans.length', 0) <= 0 ? - state : - planningReducer(state, { - type: PLANNING.ACTIONS.RECEIVE_PLANNINGS, - payload: payload.plans, - }) - ), - [PLANNING.ACTIONS.SPIKE_PLANNING]: (state, payload) => { // If the planning is not loaded, disregard this action if (!(payload.id in state.plannings)) return state; diff --git a/client/reducers/tests/locks_test.ts b/client/reducers/tests/locks_test.ts index a3bf04bc6..1cd5c2bc4 100644 --- a/client/reducers/tests/locks_test.ts +++ b/client/reducers/tests/locks_test.ts @@ -1,4 +1,15 @@ -import locks, {convertItemToLock} from '../locks'; +import {IWebsocketMessageData} from '../../interfaces'; +import locks from '../locks'; +import {LOCKS} from '../../constants'; +import {lockUtils} from '../../utils'; + +function getLockMessageData(item): IWebsocketMessageData['ITEM_LOCKED'] { + return { + ...item, + item: item._id, + user: item.lock_user, + }; +} describe('lock reducers', () => { let initialState; @@ -7,6 +18,7 @@ describe('lock reducers', () => { events: { event: { _id: 'e1', + type: 'event', lock_action: 'edit', lock_session: 'sess123', lock_user: 'user123', @@ -14,6 +26,7 @@ describe('lock reducers', () => { }, recurring: { _id: 'e2', + type: 'event', recurrence_id: 'r1', lock_action: 'postpone', lock_session: 'sess123', @@ -24,6 +37,7 @@ describe('lock reducers', () => { planning: { planning: { _id: 'p1', + type: 'planning', lock_action: 'update_time', lock_session: 'sess123', lock_user: 'user123', @@ -31,6 +45,7 @@ describe('lock reducers', () => { }, event: { _id: 'p2', + type: 'planning', event_item: 'e3', lock_action: 'reschedule', lock_session: 'sess123', @@ -39,6 +54,7 @@ describe('lock reducers', () => { }, recurring: { _id: 'p3', + type: 'planning', event_item: 'e7', recurrence_id: 'r2', lock_action: 'edit', @@ -49,6 +65,7 @@ describe('lock reducers', () => { }, assignment: { _id: 'a1', + type: 'assignment', lock_action: 'edit', lock_session: 'sess123', lock_user: 'user123', @@ -58,15 +75,15 @@ describe('lock reducers', () => { const lockItems = { event: { - e1: convertItemToLock(lockTypes.events.event, 'event'), - e3: convertItemToLock(lockTypes.planning.event, 'planning'), + e1: lockUtils.getLockFromItem(lockTypes.events.event), + e3: lockUtils.getLockFromItem(lockTypes.planning.event), }, - planning: {p1: convertItemToLock(lockTypes.planning.planning, 'planning')}, + planning: {p1: lockUtils.getLockFromItem(lockTypes.planning.planning)}, recurring: { - r1: convertItemToLock(lockTypes.events.recurring, 'event'), - r2: convertItemToLock(lockTypes.planning.recurring, 'planning'), + r1: lockUtils.getLockFromItem(lockTypes.events.recurring), + r2: lockUtils.getLockFromItem(lockTypes.planning.recurring), }, - assignment: {a1: convertItemToLock(lockTypes.assignment, 'assignment')}, + assignment: {a1: lockUtils.getLockFromItem(lockTypes.assignment)}, }; const initialLocks = { @@ -86,7 +103,7 @@ describe('lock reducers', () => { initialState, { type: 'RECEIVE_LOCKS', - payload: initialLocks, + payload: lockItems, } )); @@ -118,30 +135,17 @@ describe('lock reducers', () => { expect(result).toEqual(initialState); }); - it('convertItemToLock', () => { - expect(convertItemToLock(lockTypes.events.event, 'event')).toEqual({ - action: 'edit', - session: 'sess123', - time: '2099-10-15T14:30+0000', - user: 'user123', - item_type: 'event', - item_id: 'e1', - }); - }); - it('LOCKS.ACTIONS.RECEIVE', () => { - const result = getInitialLocks(); - - expect(result).toEqual(lockItems); + expect(getInitialLocks()).toEqual(lockItems); }); - it('LOCK_PLANNING', () => { + it('LOCKS.ACTIONS.SET_ITEM_AS_LOCKED - Planning', () => { // Planning item with direct Planning lock let result = locks( initialState, { - type: 'LOCK_PLANNING', - payload: {plan: lockTypes.planning.planning}, + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: getLockMessageData(lockTypes.planning.planning), } ); @@ -152,12 +156,11 @@ describe('lock reducers', () => { assignment: {}, }); - // Planning item with associated Event lock result = locks( initialState, { - type: 'LOCK_PLANNING', - payload: {plan: lockTypes.planning.event}, + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: getLockMessageData(lockTypes.planning.event), } ); expect(result).toEqual({ @@ -167,13 +170,12 @@ describe('lock reducers', () => { assignment: {}, }); - // Planning item with associated series of Recurring Events lock result = locks( initialState, { - type: 'LOCK_PLANNING', - payload: {plan: lockTypes.planning.recurring}, - } + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: getLockMessageData(lockTypes.planning.recurring), + }, ); expect(result).toEqual({ event: {}, @@ -183,13 +185,13 @@ describe('lock reducers', () => { }); }); - it('UNLOCK_PLANNING', () => { + it('LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED - Planning', () => { // Planning item with direct Planning lock let result = locks( getInitialLocks(), { - type: 'UNLOCK_PLANNING', - payload: {plan: lockTypes.planning.planning}, + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: getLockMessageData(lockTypes.planning.planning), } ); @@ -204,8 +206,8 @@ describe('lock reducers', () => { result = locks( getInitialLocks(), { - type: 'UNLOCK_PLANNING', - payload: {plan: lockTypes.planning.event}, + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: getLockMessageData(lockTypes.planning.event), } ); expect(result).toEqual({ @@ -219,8 +221,8 @@ describe('lock reducers', () => { result = locks( getInitialLocks(), { - type: 'UNLOCK_PLANNING', - payload: {plan: lockTypes.planning.recurring}, + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: getLockMessageData(lockTypes.planning.recurring), } ); expect(result).toEqual({ @@ -231,13 +233,13 @@ describe('lock reducers', () => { }); }); - it('LOCK_EVENT', () => { + it('LOCKS.ACTIONS.SET_ITEM_AS_LOCKED - Events', () => { // Event item with direct Event lock let result = locks( initialState, { - type: 'LOCK_EVENT', - payload: {event: lockTypes.events.event}, + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: getLockMessageData(lockTypes.events.event), } ); @@ -252,8 +254,8 @@ describe('lock reducers', () => { result = locks( initialState, { - type: 'LOCK_EVENT', - payload: {event: lockTypes.events.recurring}, + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: getLockMessageData(lockTypes.events.recurring), } ); expect(result).toEqual({ @@ -264,46 +266,13 @@ describe('lock reducers', () => { }); }); - it('UNLOCK_EVENT', () => { - // Event item with direct Event lock - let result = locks( - getInitialLocks(), - { - type: 'UNLOCK_EVENT', - payload: {event: lockTypes.events.event}, - } - ); - - expect(result).toEqual({ - event: {e3: lockItems.event.e3}, - planning: lockItems.planning, - recurring: lockItems.recurring, - assignment: lockItems.assignment, - }); - - // Event item with series of Recurring Events lock - result = locks( - getInitialLocks(), - { - type: 'UNLOCK_EVENT', - payload: {event: lockTypes.events.recurring}, - } - ); - expect(result).toEqual({ - event: lockItems.event, - planning: lockItems.planning, - recurring: {r2: lockItems.recurring.r2}, - assignment: lockItems.assignment, - }); - }); - - it('MARK_EVENT_POSTPONED', () => { + it('LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED - Events', () => { // Event item with direct Event lock let result = locks( getInitialLocks(), { - type: 'MARK_EVENT_POSTPONED', - payload: {event: lockTypes.events.event}, + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: getLockMessageData(lockTypes.events.event), } ); @@ -318,8 +287,8 @@ describe('lock reducers', () => { result = locks( getInitialLocks(), { - type: 'MARK_EVENT_POSTPONED', - payload: {event: lockTypes.events.recurring}, + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: getLockMessageData(lockTypes.events.recurring), } ); expect(result).toEqual({ @@ -330,13 +299,13 @@ describe('lock reducers', () => { }); }); - it('LOCK_ASSIGNMENT', () => { + it('LOCKS.ACTIONS.SET_ITEM_AS_LOCKED - Assignment', () => { // Planning item with direct Planning lock let result = locks( initialState, { - type: 'LOCK_ASSIGNMENT', - payload: {assignment: lockTypes.assignment}, + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: getLockMessageData(lockTypes.assignment), } ); @@ -348,13 +317,13 @@ describe('lock reducers', () => { }); }); - it('UNLOCK_ASSIGNMENT', () => { + it('LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED - Assignment', () => { // Planning item with direct Planning lock let result = locks( getInitialLocks(), { - type: 'UNLOCK_ASSIGNMENT', - payload: {assignment: lockTypes.assignment}, + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: getLockMessageData(lockTypes.assignment), } ); diff --git a/client/selectors/locks.ts b/client/selectors/locks.ts index 3374aab53..18974aa78 100644 --- a/client/selectors/locks.ts +++ b/client/selectors/locks.ts @@ -1,20 +1,33 @@ import {createSelector} from 'reselect'; -import {get, sortBy, filter} from 'lodash'; +import {get, sortBy} from 'lodash'; +import { + IPlanningAppState, + ILockedItems, + ISession, + IEventOrPlanningItem, + IPlanningItem, + IEventItem +} from '../interfaces'; import {storedEvents} from './events'; import {storedPlannings} from './planning'; import {currentUserId} from './general'; import {newItemAutosaves} from './forms'; -import {lockUtils} from '../utils'; - const EMPTY_LOCKS = {}; -const eventLocks = (state) => get(state, 'locks.event', EMPTY_LOCKS); -const planningLocks = (state) => get(state, 'locks.planning', EMPTY_LOCKS); -const recurringLocks = (state) => get(state, 'locks.recurring', EMPTY_LOCKS); -const assignmentLocks = (state) => get(state, 'locks.assignment', EMPTY_LOCKS); +const eventLocks = (state: IPlanningAppState) => state.locks?.event ?? EMPTY_LOCKS; +const planningLocks = (state: IPlanningAppState) => state.locks?.planning ?? EMPTY_LOCKS; +const recurringLocks = (state: IPlanningAppState) => state.locks?.recurring ?? EMPTY_LOCKS; +const assignmentLocks = (state: IPlanningAppState) => state.locks?.assignment ?? EMPTY_LOCKS; -export const getLockedItems = createSelector( +export const getLockedItems = createSelector< + IPlanningAppState, + ILockedItems['event'], + ILockedItems['planning'], + ILockedItems['recurring'], + ILockedItems['assignment'], + ILockedItems +>( [eventLocks, planningLocks, recurringLocks, assignmentLocks], (event, planning, recurring, assignment) => ({ event, @@ -24,19 +37,75 @@ export const getLockedItems = createSelector( }) ); -/** Returns the list of currently locked planning items */ -export const getLockedPlannings = createSelector( - [storedPlannings, currentUserId], - (plannings, userId) => filter(plannings, (item) => lockUtils.isLockedByUser(item, userId, 'edit')) +export const getItemIdsEditLockedByCurrentUser = createSelector< + IPlanningAppState, + ILockedItems, + ISession['identity']['_id'], + Array +>( + [getLockedItems, currentUserId], + (lockedItems, userId) => ( + [ + ...Object.keys(lockedItems.event) + .filter((lockId) => ( + lockedItems.event[lockId].user === userId && + lockedItems.event[lockId].action === 'edit' + )) + .map((lockId) => lockedItems.event[lockId].item_id), + ...Object.keys(lockedItems.recurring) + .filter((lockId) => ( + lockedItems.recurring[lockId].user === userId && + lockedItems.recurring[lockId].action === 'edit' + )) + .map((lockId) => lockedItems.recurring[lockId].item_id), + ...Object.keys(lockedItems.planning) + .filter((lockId) => ( + lockedItems.planning[lockId].user === userId && + lockedItems.planning[lockId].action === 'edit' + )) + .map((lockId) => lockedItems.planning[lockId].item_id), + ] + ) +); + +export const getLockedPlannings = createSelector< + IPlanningAppState, + {[key: string]: IPlanningItem}, + Array, + Array +>( + [storedPlannings, getItemIdsEditLockedByCurrentUser], + (plannings, lockedItemIds) => ( + Object.keys(plannings) + .filter((planningId) => lockedItemIds.includes(planningId)) + .map((planningId) => plannings[planningId]) + ) ); -/** Returns the list of currently locked events */ -export const getLockedEvents = createSelector( - [storedEvents, currentUserId], - (events, userId) => filter(events, (item) => lockUtils.isLockedByUser(item, userId, 'edit')) +export const getLockedEvents = createSelector< + IPlanningAppState, + {[key: string]: IEventItem}, + Array, + Array +>( + [storedEvents, getItemIdsEditLockedByCurrentUser], + (events, lockedItemIds) => ( + Object.keys(events) + .filter((eventId) => lockedItemIds.includes(eventId)) + .map((eventId) => events[eventId]) + ) ); -export const workqueueItems = createSelector( +export const workqueueItems = createSelector< + IPlanningAppState, + Array, + Array, + { + event: Array, + planning: Array + }, + Array +>( [getLockedEvents, getLockedPlannings, newItemAutosaves], (lockedEvents, lockedPlanning, newItems) => ( sortBy( diff --git a/client/selectors/tests/locks_test.ts b/client/selectors/tests/locks_test.ts index e48cb3d87..14428bf06 100644 --- a/client/selectors/tests/locks_test.ts +++ b/client/selectors/tests/locks_test.ts @@ -1,6 +1,7 @@ import {cloneDeep} from 'lodash'; import {locks} from '../'; +import {lockUtils} from '../../utils'; import * as testData from '../../utils/testData'; describe('selectors.locks', () => { @@ -53,6 +54,8 @@ describe('selectors.locks', () => { _id: 'e6', lock_user: users[0]._id, lock_action: 'edit', + lock_session: sessions[1].sessionId, + lock_time: '2029-01-16T02:45:00+0000', }, e7: { _id: 'e7', @@ -104,6 +107,8 @@ describe('selectors.locks', () => { _id: 'p6', lock_user: users[0]._id, lock_action: 'edit', + lock_session: sessions[1].sessionId, + lock_time: '2029-01-16T02:47:00+0000', }, p7: { _id: 'p7', @@ -120,12 +125,37 @@ describe('selectors.locks', () => { state.planning.plannings = lockedPlannings; state.events.events = lockedEvents; + state.locks = { + event: { + e1: lockUtils.getLockFromItem(lockedEvents.e1), + e2: lockUtils.getLockFromItem(lockedEvents.e2), + e3: lockUtils.getLockFromItem(lockedEvents.e3), + e4: lockUtils.getLockFromItem(lockedEvents.e4), + e5: lockUtils.getLockFromItem(lockedEvents.e5), + e6: lockUtils.getLockFromItem(lockedEvents.e6), + e7: lockUtils.getLockFromItem(lockedEvents.e7), + e8: lockUtils.getLockFromItem(lockedEvents.e8), + }, + planning: { + p1: lockUtils.getLockFromItem(lockedPlannings.p1), + p2: lockUtils.getLockFromItem(lockedPlannings.p2), + p3: lockUtils.getLockFromItem(lockedPlannings.p3), + p4: lockUtils.getLockFromItem(lockedPlannings.p4), + p5: lockUtils.getLockFromItem(lockedPlannings.p5), + p6: lockUtils.getLockFromItem(lockedPlannings.p6), + p7: lockUtils.getLockFromItem(lockedPlannings.p7), + p8: lockUtils.getLockFromItem(lockedPlannings.p8), + }, + recurring: {}, + assignment: {}, + }; }); it('getLockedPlannings returns only the locked planning items for the current user', () => { expect(locks.getLockedPlannings(state)).toEqual([ lockedPlannings.p1, lockedPlannings.p2, + lockedPlannings.p6, ]); }); @@ -133,6 +163,7 @@ describe('selectors.locks', () => { expect(locks.getLockedEvents(state)).toEqual([ lockedEvents.e1, lockedEvents.e2, + lockedEvents.e6, ]); }); @@ -142,6 +173,8 @@ describe('selectors.locks', () => { lockedEvents.e1, // 2029-01-16T02:40:00 lockedPlannings.p2, // 2029-01-16T02:40:44 lockedPlannings.p1, // 2029-01-16T02:41:00 + lockedEvents.e6, // 2029-01-16T02:45:00 + lockedPlannings.p6, // 2029-01-16T02:47:00 lockedEvents.e2, // 2029-01-16T02:52:00 ]); }); diff --git a/client/utils/assignments.ts b/client/utils/assignments.ts index 0f4c8a15a..6355b1403 100644 --- a/client/utils/assignments.ts +++ b/client/utils/assignments.ts @@ -2,7 +2,15 @@ import {get, includes, isNil, find} from 'lodash'; import moment from 'moment'; import {IVocabularyItem} from 'superdesk-api'; -import {IAssignmentItem, ISession, IPrivileges, ASSIGNMENT_STATE} from '../interfaces'; +import { + IAssignmentItem, + ISession, + IPrivileges, + ASSIGNMENT_STATE, + ILockedItems, + IG2ContentType, + IItemAction, +} from '../interfaces'; import {ASSIGNMENTS, PRIVILEGES} from '../constants'; import * as selectors from '../selectors'; @@ -11,9 +19,9 @@ import {gettext, planningUtils, lockUtils, getCreator, getItemInArrayById, isExi import {getUserInterfaceLanguageFromCV} from './users'; import {getVocabularyItemFieldTranslated} from './vocabularies'; -const isNotLockRestricted = (assignment, session) => ( +const isNotLockRestricted = (assignment, session, lockedItems) => ( !get(assignment, 'lock_user') || - lockUtils.isItemLockedInThisSession(assignment, session) + lockUtils.isItemLockedInThisSession(assignment, session, lockedItems) ); const isTextAssignment = (assignment, contentTypes = []) => { @@ -33,17 +41,24 @@ function canEditPriorityOrReassignAssignment( session: ISession, privileges: IPrivileges, privilege: string, + lockedItems: ILockedItems, ) { return !!privileges[privilege] && - self.isNotLockRestricted(assignment, session) && + self.isNotLockRestricted(assignment, session, lockedItems) && self.isAssignmentInEditableState(assignment); } -const canRemoveAssignment = (assignment, session, privileges, privilege) => ( - !!privileges[privilege] && - self.isNotLockRestricted(assignment, session) && - self.isAssignmentInEditableState(assignment) -); +function canRemoveAssignment( + assignment: IAssignmentItem, + session: ISession, + privileges: IPrivileges, + privilege: string, + lockedItems: ILockedItems, +) { + return !!privileges[privilege] && + self.isNotLockRestricted(assignment, session, lockedItems) && + self.isAssignmentInEditableState(assignment); +} const canStartWorking = (assignment, session, privileges, contentTypes) => ( !!privileges[PRIVILEGES.ARCHIVE] && @@ -57,11 +72,16 @@ const canStartWorking = (assignment, session, privileges, contentTypes) => ( !isAssignedToProvider(assignment) ); -const canFulfilAssignment = (assignment, session, privileges) => ( - !!privileges[PRIVILEGES.ARCHIVE] && - isNotLockRestricted(assignment, session) && - get(assignment, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.ASSIGNED -); +function canFulfilAssignment( + assignment: IAssignmentItem, + session: ISession, + privileges: IPrivileges, + lockedItems: ILockedItems +) { + return !!privileges[PRIVILEGES.ARCHIVE] && + isNotLockRestricted(assignment, session, lockedItems) && + get(assignment, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.ASSIGNED; +} const isAssignmentInEditableState = (assignment) => ( (includes([ASSIGNMENTS.WORKFLOW_STATE.SUBMITTED, ASSIGNMENTS.WORKFLOW_STATE.ASSIGNED, @@ -72,10 +92,11 @@ const isAssignmentInEditableState = (assignment) => ( function canCompleteAssignment( assignment: IAssignmentItem, session: ISession, - privileges: IPrivileges + privileges: IPrivileges, + lockedItems: ILockedItems, ): boolean { return !!privileges[PRIVILEGES.ARCHIVE] && - self.isNotLockRestricted(assignment, session) && + self.isNotLockRestricted(assignment, session, lockedItems) && ( assignment.assigned_to?.state === ASSIGNMENT_STATE.IN_PROGRESS || ( @@ -89,21 +110,32 @@ function canCompleteAssignment( ); } -const canConfirmAvailability = (assignment, session, privileges, contentTypes) => ( - !!privileges[PRIVILEGES.ARCHIVE] && - self.isNotLockRestricted(assignment, session) && +function canConfirmAvailability( + assignment: IAssignmentItem, + session: ISession, + privileges: IPrivileges, + contentTypes: Array, + lockedItems: ILockedItems, +) { + return !!privileges[PRIVILEGES.ARCHIVE] && + self.isNotLockRestricted(assignment, session, lockedItems) && !self.isTextAssignment(assignment, contentTypes) && ( get(assignment, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.ASSIGNED || get(assignment, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.SUBMITTED - ) -); + ); +} -const canRevertAssignment = (assignment, session, privileges) => ( - !!privileges[PRIVILEGES.ARCHIVE] && - self.isNotLockRestricted(assignment, session) && - get(assignment, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.COMPLETED -); +function canRevertAssignment( + assignment: IAssignmentItem, + session: ISession, + privileges: IPrivileges, + lockedItems: ILockedItems, +) { + return !!privileges[PRIVILEGES.ARCHIVE] && + self.isNotLockRestricted(assignment, session, lockedItems) && + get(assignment, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.COMPLETED; +} const assignmentHasContent = (assignment) => ( get(assignment, 'item_ids.length', 0) > 0 @@ -128,7 +160,14 @@ const getContactLabel = (assignment) => ( gettext('Coverage Contact') ); -const getAssignmentActions = (assignment, session, privileges, lockedItems, contentTypes, callBacks) => { +function getAssignmentActions( + assignment: IAssignmentItem, + session: ISession, + privileges: IPrivileges, + lockedItems: ILockedItems, + contentTypes: Array, + callBacks: {[key: string]: (...args: Array) => any}, +) { if (!isExistingItem(assignment) || lockUtils.isLockRestricted(assignment, session, lockedItems)) { return []; } @@ -207,30 +246,37 @@ const getAssignmentActions = (assignment, session, privileges, lockedItems, cont } }); - return getAssignmentItemActions(assignment, session, privileges, contentTypes, actions); -}; + return getAssignmentItemActions(assignment, session, privileges, contentTypes, actions, lockedItems); +} -const getAssignmentItemActions = (assignment, session, privileges, contentTypes, actions) => { +function getAssignmentItemActions( + assignment: IAssignmentItem, + session: ISession, + privileges: IPrivileges, + contentTypes: Array, + actions: Array, + lockedItems: ILockedItems, +) { let itemActions = []; let key = 1; const actionsValidator = { [ASSIGNMENTS.ITEM_ACTIONS.REASSIGN.actionName]: () => - self.canEditPriorityOrReassignAssignment(assignment, session, privileges, PRIVILEGES.ARCHIVE), + self.canEditPriorityOrReassignAssignment(assignment, session, privileges, PRIVILEGES.ARCHIVE, lockedItems), [ASSIGNMENTS.ITEM_ACTIONS.COMPLETE.actionName]: () => - self.canCompleteAssignment(assignment, session, privileges), + self.canCompleteAssignment(assignment, session, privileges, lockedItems), [ASSIGNMENTS.ITEM_ACTIONS.EDIT_PRIORITY.actionName]: () => - self.canEditPriorityOrReassignAssignment(assignment, session, privileges, PRIVILEGES.ARCHIVE), + self.canEditPriorityOrReassignAssignment(assignment, session, privileges, PRIVILEGES.ARCHIVE, lockedItems), [ASSIGNMENTS.ITEM_ACTIONS.START_WORKING.actionName]: () => self.canStartWorking(assignment, session, privileges, contentTypes), [ASSIGNMENTS.ITEM_ACTIONS.REMOVE.actionName]: () => - self.canRemoveAssignment(assignment, session, privileges, PRIVILEGES.PLANNING_MANAGEMENT), + self.canRemoveAssignment(assignment, session, privileges, PRIVILEGES.PLANNING_MANAGEMENT, lockedItems), [ASSIGNMENTS.ITEM_ACTIONS.PREVIEW_ARCHIVE.actionName]: () => self.assignmentHasContent(assignment), [ASSIGNMENTS.ITEM_ACTIONS.CONFIRM_AVAILABILITY.actionName]: () => - self.canConfirmAvailability(assignment, session, privileges, contentTypes), + self.canConfirmAvailability(assignment, session, privileges, contentTypes, lockedItems), [ASSIGNMENTS.ITEM_ACTIONS.REVERT_AVAILABILITY.actionName]: () => - self.canRevertAssignment(assignment, session, privileges), + self.canRevertAssignment(assignment, session, privileges, lockedItems), }; actions.forEach((action) => { @@ -248,7 +294,7 @@ const getAssignmentItemActions = (assignment, session, privileges, contentTypes, }); return itemActions; -}; +} const getAssignmentGroupsByStates = (groups, states) => { if (get(states, 'length', 0) < 1) { diff --git a/client/utils/events.ts b/client/utils/events.ts index 1d3d48d97..12f09d726 100644 --- a/client/utils/events.ts +++ b/client/utils/events.ts @@ -3,9 +3,23 @@ import RRule from 'rrule'; import {get, map, isNil, sortBy, cloneDeep, omitBy, find, isEqual, pickBy, flatten} from 'lodash'; import {IMenuItem} from 'superdesk-ui-framework/react/components/Menu'; -import {appConfig} from 'appConfig'; -import {IEventItem, ISession, ILockedItems} from '../interfaces'; +import {IVocabularyItem} from 'superdesk-api'; +import { + IEventItem, + ISession, + ILockedItems, + IDateTime, + IPlanningItem, + IPrivileges, + IItemAction, + IPlanningConfig, + IItemSubActions, + IEventOccurStatus, +} from '../interfaces'; import {planningApi} from '../superdeskApi'; +import {appConfig as config} from 'appConfig'; + +const appConfig = config as IPlanningConfig; import { PRIVILEGES, @@ -53,33 +67,26 @@ import {toUIFrameworkInterface} from './planning'; * @param {boolean} checkMultiDay - If true include multi-day in the check, otherwise must be single day only * @return {boolean} If the date/times occupy entire day(s) */ -const isEventAllDay = (startingDate, endingDate, checkMultiDay = false) => { +function isEventAllDay(startingDate: IDateTime, endingDate: IDateTime, checkMultiDay: boolean = false): boolean { const start = moment(startingDate).clone(); const end = moment(endingDate).clone(); return (checkMultiDay || start.isSame(end, 'day')) && start.isSame(start.clone().startOf('day'), 'minute') && end.isSame(end.clone().endOf('day'), 'minute'); -}; - -const isEventSameDay = (startingDate, endingDate) => ( - moment(startingDate).format('DD/MM/YYYY') === moment(endingDate).format('DD/MM/YYYY') -); - -const eventHasPlanning = (event) => get(event, 'planning_ids', []).length > 0; +} -function isEventLocked(event: IEventItem, locks: ILockedItems): boolean { - return lockUtils.getLock(event, locks) != null; +function isEventSameDay(startingDate: IDateTime, endingDate: IDateTime): boolean { + return moment(startingDate).format('DD/MM/YYYY') === moment(endingDate).format('DD/MM/YYYY'); } -function isEventLockRestricted(event: IEventItem, session: ISession, locks: ILockedItems): boolean { - return ( - isEventLocked(event, locks) && - !lockUtils.isItemLockedInThisSession(event, session, locks) - ); +function eventHasPlanning(event: IEventItem): boolean { + return get(event, 'planning_ids', []).length > 0; } -const isEventCompleted = (event) => (get(event, 'completed')); +function isEventCompleted(event: IEventItem): boolean { + return get(event, 'completed'); +} /** * Helper function to determine if a recurring event instances overlap @@ -90,7 +97,11 @@ const isEventCompleted = (event) => (get(event, 'completed')); * @param {object} recurringRule - The list of recurring rules * @returns {boolean} True if the instances overlap, false otherwise */ -const doesRecurringEventsOverlap = (startingDate, endingDate, recurringRule) => { +function doesRecurringEventsOverlap( + startingDate: IDateTime, + endingDate: IDateTime, + recurringRule: IEventItem['dates']['recurring_rule'] +): boolean { if (!recurringRule || !startingDate || !endingDate || !('frequency' in recurringRule) || !('interval' in recurringRule)) return false; @@ -127,9 +138,13 @@ const doesRecurringEventsOverlap = (startingDate, endingDate, recurringRule) => let nextEvent = moment(rule.after(startingDate.toDate())); return nextEvent.isBetween(startingDate, endingDate) || nextEvent.isSame(endingDate); -}; +} -const getRelatedEventsForRecurringEvent = (recurringEvent, filter, postedPlanningOnly) => { +function getRelatedEventsForRecurringEvent( + recurringEvent: IEventItem, + filter: {name: string, value: string}, + postedPlanningOnly: boolean +): IEventItem & {_events: Array, _relatedPlannings: Array} { let eventsInSeries = get(recurringEvent, '_recurring', []); let events = []; let plannings = get(recurringEvent, '_plannings', []); @@ -163,14 +178,15 @@ const getRelatedEventsForRecurringEvent = (recurringEvent, filter, postedPlannin _events: events, _relatedPlannings: plannings, }; -}; +} -const isEventIngested = (event) => ( - get(event, 'state', WORKFLOW_STATE.DRAFT) === WORKFLOW_STATE.INGESTED -); +function isEventIngested(event: IEventItem): boolean { + return get(event, 'state', WORKFLOW_STATE.DRAFT) === WORKFLOW_STATE.INGESTED; +} -const canSpikeEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canSpikeEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + !isNil(event) && !isItemPosted(event) && ( getItemWorkflowState(event) === WORKFLOW_STATE.DRAFT || @@ -179,161 +195,242 @@ const canSpikeEvent = (event, session, privileges, locks) => ( ) && !!privileges[PRIVILEGES.SPIKE_EVENT] && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !get(event, 'reschedule_from') && - (!isItemExpired(event) || privileges[PRIVILEGES.EDIT_EXPIRED]) -); + ( + !isItemExpired(event) || + !!privileges[PRIVILEGES.EDIT_EXPIRED] + ) + ); +} -const canUnspikeEvent = (event, privileges) => ( - !isNil(event) && +function canUnspikeEvent(event: IEventItem, privileges: IPrivileges): boolean { + return ( + !isNil(event) && isItemSpiked(event) && !!privileges[PRIVILEGES.UNSPIKE_EVENT] && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && - (!isItemExpired(event) || privileges[PRIVILEGES.EDIT_EXPIRED]) -); + ( + !isItemExpired(event) || + !!privileges[PRIVILEGES.EDIT_EXPIRED] + ) + ); +} -const canDuplicateEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canDuplicateEvent( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] -); + ); +} -const canCreatePlanningFromEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canCreatePlanningFromEvent( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !isItemCancelled(event) && !isItemRescheduled(event) && !isItemPostponed(event) && !isItemExpired(event) && !isItemKilled(event) -); - -const canCreateAndOpenPlanningFromEvent = (event, session, privileges, locks) => ( - canCreatePlanningFromEvent(event, session, privileges, locks) -); + ); +} -const canPostEvent = (event, session, privileges, locks) => ( - isExistingItem(event) && +function canPostEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + isExistingItem(event) && !isItemSpiked(event) && getPostedState(event) !== POST_STATE.USABLE && !!privileges[PRIVILEGES.POST_EVENT] && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && (!isItemCancelled(event) || getItemWorkflowState(event) === WORKFLOW_STATE.KILLED) && !isItemRescheduled(event) -); + ); +} -const canUnpostEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canUnpostEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && getPostedState(event) === POST_STATE.USABLE && !!privileges[PRIVILEGES.UNPOST_EVENT] && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && !isItemRescheduled(event) -); + ); +} -const canCancelEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canCancelEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && !isItemCancelled(event) && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && !(getPostedState(event) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_EVENT]) && !isItemRescheduled(event) && !isEventCompleted(event) && !isItemExpired(event) -); - -const isEventInUse = (event) => ( - !isNil(event) && - (eventHasPlanning(event) || isItemPublic(event)) -); + ); +} -const isEventLockedForMetadataEdit = (event) => ( - get(event, 'lock_action', null) === 'edit' -); +function isEventInUse(event: IEventItem): boolean { + return !isNil(event) && ( + eventHasPlanning(event) || + isItemPublic(event) + ); +} -const canConvertToRecurringEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canConvertToRecurringEvent( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isNil(event) && !event.recurrence_id && canEditEvent(event, session, privileges, locks) && !isItemPostponed(event) && - !isEventLockedForMetadataEdit(event) && !isItemCancelled(event) && !isEventCompleted(event) && + lockUtils.getLockAction(event, locks) !== 'edit' && + !isItemCancelled(event) && !isEventCompleted(event) && !isItemExpired(event) -); + ); +} -const canEditEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canEditEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && !(getPostedState(event) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_EVENT]) && !isItemRescheduled(event) && - (!isItemExpired(event) || privileges[PRIVILEGES.EDIT_EXPIRED]) -); + ( + !isItemExpired(event) || + !!privileges[PRIVILEGES.EDIT_EXPIRED] + ) + ); +} -const canUpdateEvent = (event, session, privileges, locks) => ( - canEditEvent(event, session, privileges, locks) && +function canUpdateEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + canEditEvent(event, session, privileges, locks) && isItemPublic(event) && !isItemKilled(event) && !!privileges[PRIVILEGES.POST_EVENT] -); + ); +} -const canUpdateEventTime = (event, session, privileges, locks) => ( - !isNil(event) && +function canUpdateEventTime( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isNil(event) && canEditEvent(event, session, privileges, locks) && !isItemPostponed(event) && !isItemCancelled(event) && !isEventCompleted(event) && !isItemExpired(event) -); + ); +} -const canRescheduleEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canRescheduleEvent( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && !isItemCancelled(event) && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && !isItemRescheduled(event) && !(getPostedState(event) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_EVENT]) && !isEventCompleted(event) && !isItemExpired(event) -); + ); +} -const canPostponeEvent = (event, session, privileges, locks) => ( - !isNil(event) && +function canPostponeEvent(event: IEventItem, session: ISession, privileges: IPrivileges, locks: ILockedItems): boolean { + return ( + !isNil(event) && !isItemSpiked(event) && !isItemCancelled(event) && - !isEventLockRestricted(event, session, locks) && + !lockUtils.isLockRestricted(event, session, locks) && !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && !isItemPostponed(event) && !isItemRescheduled(event) && !(getPostedState(event) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_EVENT]) && !isEventCompleted(event) && !isItemExpired(event) -); + ); +} -const canUpdateEventRepetitions = (event, session, privileges, locks) => ( - !isNil(event) && +function canUpdateEventRepetitions( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isNil(event) && isEventRecurring(event) && canRescheduleEvent(event, session, privileges, locks) && !isEventCompleted(event) && !isItemExpired(event) -); + ); +} -const canAssignEventToCalendar = (event, session, privileges, locks) => ( - canEditEvent(event, session, privileges, locks) && - !isEventLocked(event, locks) -); +function canAssignEventToCalendar( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + canEditEvent(event, session, privileges, locks) && + !lockUtils.isItemLocked(event, locks) + ); +} -const canSaveEventAsTemplate = (event, session, privileges, locks) => ( - !isEventLockRestricted(event, session, locks) && privileges[PRIVILEGES.EVENT_TEMPLATES] -); +function canSaveEventAsTemplate( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !lockUtils.isLockRestricted(event, session, locks) && + !!privileges[PRIVILEGES.EVENT_TEMPLATES] + ); +} -const canMarkEventAsComplete = (event, session, privileges, locks) => { +function canMarkEventAsComplete( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { const currentDate = moment(); const precondition = [ WORKFLOW_STATE.DRAFT, @@ -344,15 +441,27 @@ const canMarkEventAsComplete = (event, session, privileges, locks) => { if (get(event, 'recurrence_id')) { // can action on any recurring event from present to future - return precondition && event.dates.end.isSameOrAfter(currentDate, 'date'); + return ( + precondition && + event.dates.end.isSameOrAfter(currentDate, 'date') + ); } else { // can action only if event is of current day or current day passes through a multi day event - return precondition && event.dates.start.isSameOrBefore(currentDate, 'date') && - event.dates.end.isSameOrAfter(currentDate, 'date'); + return ( + precondition && + event.dates.start.isSameOrBefore(currentDate, 'date') && + event.dates.end.isSameOrAfter(currentDate, 'date') + ); } -}; +} -const getEventItemActions = (event, session, privileges, actions, locks) => { +function getEventItemActions( + event: IEventItem, + session: ISession, + privileges: IPrivileges, + actions: Array, + locks: ILockedItems +): Array { let itemActions = []; let key = 1; @@ -360,7 +469,7 @@ const getEventItemActions = (event, session, privileges, actions, locks) => { [EVENTS.ITEM_ACTIONS.SPIKE.actionName]: () => canSpikeEvent(event, session, privileges, locks), [EVENTS.ITEM_ACTIONS.UNSPIKE.actionName]: () => - canUnspikeEvent(event, privileges, locks), + canUnspikeEvent(event, privileges), [EVENTS.ITEM_ACTIONS.DUPLICATE.actionName]: () => canDuplicateEvent(event, session, privileges, locks), [EVENTS.ITEM_ACTIONS.CANCEL_EVENT.actionName]: () => @@ -368,7 +477,7 @@ const getEventItemActions = (event, session, privileges, actions, locks) => { [EVENTS.ITEM_ACTIONS.CREATE_PLANNING.actionName]: () => canCreatePlanningFromEvent(event, session, privileges, locks), [EVENTS.ITEM_ACTIONS.CREATE_AND_OPEN_PLANNING.actionName]: () => - canCreateAndOpenPlanningFromEvent(event, session, privileges, locks), + canCreatePlanningFromEvent(event, session, privileges, locks), [EVENTS.ITEM_ACTIONS.UPDATE_TIME.actionName]: () => canUpdateEventTime(event, session, privileges, locks), [EVENTS.ITEM_ACTIONS.RESCHEDULE_EVENT.actionName]: () => @@ -393,7 +502,7 @@ const getEventItemActions = (event, session, privileges, actions, locks) => { actions.forEach((action) => { if (actionsValidator[action.actionName] && - !actionsValidator[action.actionName](event, session, privileges)) { + !actionsValidator[action.actionName]()) { return; } @@ -410,13 +519,18 @@ const getEventItemActions = (event, session, privileges, actions, locks) => { } return itemActions; -}; +} -const isEventRecurring = (item) => ( - get(item, 'recurrence_id', null) !== null -); +function isEventRecurring(item: IEventItem): boolean { + return item.recurrence_id != null; +} -const getDateStringForEvent = (event, dateOnly = false, useLocal = true, withTimezone = true) => { +function getDateStringForEvent( + event: IEventItem, + dateOnly: boolean = false, + useLocal: boolean = true, + withTimezone: boolean = true +): string { // !! Note - expects event dates as instance of moment() !! // const dateFormat = appConfig.planning.dateformat; const timeFormat = appConfig.planning.timeformat; @@ -497,9 +611,14 @@ const getDateStringForEvent = (event, dateOnly = false, useLocal = true, withTim } else { return `${timezoneString}${dateString}`; } -}; +} -const getSingleDayPlanningActions = (item, actions, createPlanning, createAndOpenPlanning) => { +function getSingleDayPlanningActions( + item: IEventItem, + actions: Array, + createPlanning: (event: IEventItem, a2: null, a3: false) => void, + createAndOpenPlanning: (event: IEventItem, a2: null, a3: true) => void +) { if (createPlanning || createAndOpenPlanning) { actions.push(GENERIC_ITEM_ACTIONS.DIVIDER); @@ -517,9 +636,14 @@ const getSingleDayPlanningActions = (item, actions, createPlanning, createAndOpe }); } } -}; +} -const generateMultiDayPlanningActions = (item, subActions, createPlanning, createAndOpenPlanning) => { +function generateMultiDayPlanningActions( + item: IEventItem, + subActions: IItemSubActions, + createPlanning: (event: IEventItem, date: moment.Moment, a3: boolean) => void, + createAndOpenPlanning: (event: IEventItem, date: moment.Moment, a3: boolean) => void +) { let eventDate = moment(item.dates.start); const currentDate = moment(); @@ -580,11 +704,16 @@ const generateMultiDayPlanningActions = (item, subActions, createPlanning, creat ...subActions.createAndOpen.past, ]; } -}; +} -const getMultiDayPlanningActions = (item, actions, createPlanning, createAndOpenPlanning) => { +function getMultiDayPlanningActions( + item: IEventItem, + actions: Array, + createPlanning: (event: IEventItem, date: moment.Moment, a3: boolean) => void, + createAndOpenPlanning: (event: IEventItem, date: moment.Moment, a3: boolean) => void +) { // Multi-day event with a requirement of a submenu - let subActions = { + let subActions: IItemSubActions = { create: { current: [], past: [], @@ -632,17 +761,29 @@ const getMultiDayPlanningActions = (item, actions, createPlanning, createAndOpen ); } } -}; +} + +interface IGetEventActionArgs { + item: IEventItem; + session: ISession; + privileges: IPrivileges; + lockedItems: ILockedItems; + callBacks: {[key: string]: (...args: Array) => any}; + withMultiPlanningDate: boolean; + calendars: Array; +} -const getEventActions = ({ - item, - session, - privileges, - lockedItems, - callBacks, - withMultiPlanningDate, - calendars, -}) => { +function getEventActions( + { + item, + session, + privileges, + lockedItems, + callBacks, + withMultiPlanningDate, + calendars, + }: IGetEventActionArgs +): Array { if (!isExistingItem(item)) { return []; } @@ -726,25 +867,25 @@ const getEventActions = ({ actions, lockedItems ); -}; +} -function getEventActionsForUiFrameworkMenu(data): Array { +function getEventActionsForUiFrameworkMenu(data: IGetEventActionArgs): Array { return toUIFrameworkInterface(getEventActions(data)); } /* * Groups the events by date */ -const getFlattenedEventsByDate = (events, startDate, endDate) => { +function getFlattenedEventsByDate(events: Array, startDate: moment.Moment, endDate: moment.Moment) { const eventsList = getEventsByDate(events, startDate, endDate); return flatten(sortBy(eventsList, [(e) => (e.date)]).map((e) => e.events.map((k) => [e.date, k._id]))); -}; +} /* * Groups the events by date */ -const getEventsByDate = (events, startDate, endDate) => { +function getEventsByDate(events: Array, startDate: moment.Moment, endDate: moment.Moment) { if (!get(events, 'length', 0)) return []; // check if search exists // order by date @@ -757,7 +898,7 @@ const getEventsByDate = (events, startDate, endDate) => { const days = {}; - function addEventToDate(event, date) { + function addEventToDate(event: IEventItem, date?: moment.Moment) { let eventDate = date || event.dates.start; let eventStart = event.dates.start; let eventEnd = event.dates.end; @@ -818,9 +959,9 @@ const getEventsByDate = (events, startDate, endDate) => { }); return sortBasedOnTBC(days); -}; +} -const modifyForClient = (event) => { +function modifyForClient(event: Partial): Partial { sanitizeItemFields(event); // The `_status` field is available when the item comes from a POST/PATCH request @@ -858,14 +999,14 @@ const modifyForClient = (event) => { } return event; -}; +} function modifyEventsForClient(events: Array): Array { events.forEach(modifyForClient); return events; } -const modifyLocationForServer = (event) => { +function modifyLocationForServer(event: IEventItem) { if (!('location' in event) || Array.isArray(event.location)) { return; } @@ -873,9 +1014,9 @@ const modifyLocationForServer = (event) => { event.location = event.location ? [event.location] : null; -}; +} -const removeFieldsStartingWith = (updates: {[key: string]: Array | any}, prefix: string) => { +function removeFieldsStartingWith(updates: {[key: string]: Array | any}, prefix: string) { Object.keys(updates).forEach((field) => { if (!Array.isArray(updates[field])) { if (field.startsWith(prefix)) { @@ -891,9 +1032,9 @@ const removeFieldsStartingWith = (updates: {[key: string]: Array | any}, pr }); } }); -}; +} -const modifyForServer = (event, removeNullLinks = false) => { +function modifyForServer(event: IEventItem, removeNullLinks: boolean = false) { modifyLocationForServer(event); // remove links if it contains only null values @@ -924,9 +1065,9 @@ const modifyForServer = (event, removeNullLinks = false) => { } return event; -}; +} -const duplicateEvent = (event, occurStatus) => { +function duplicateEvent(event: IEventItem, occurStatus: IEventOccurStatus) { let duplicatedEvent = cloneDeep(omitBy(event, (v, k) => ( k.startsWith('_')) || ['guid', 'unique_name', 'unique_id', 'lock_user', 'lock_time', 'lock_session', 'lock_action', @@ -954,14 +1095,23 @@ const duplicateEvent = (event, occurStatus) => { duplicatedEvent.dates.tz = moment.tz.guess(); return duplicatedEvent; -}; +} -export const shouldLockEventForEdit = (item, privileges) => ( - !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && - (!isItemPublic(item) || !!privileges[PRIVILEGES.POST_EVENT]) -); +export function shouldLockEventForEdit(item: IEventItem, privileges: IPrivileges): boolean { + return ( + !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && + ( + !isItemPublic(item) || + !!privileges[PRIVILEGES.POST_EVENT] + ) + ); +} -const defaultEventValues = (occurStatuses, defaultCalendars, defaultPlaceList) => { +function defaultEventValues( + occurStatuses: IEventOccurStatus, + defaultCalendars: IEventItem['calendars'], + defaultPlaceList: IEventItem['place'] +): Partial { const occurStatus = getItemInArrayById(occurStatuses, 'eocstat:eos5', 'qcode') || { label: 'Confirmed', qcode: 'eocstat:eos5', @@ -969,8 +1119,8 @@ const defaultEventValues = (occurStatuses, defaultCalendars, defaultPlaceList) = }; const language = planningApi.contentProfiles.getDefaultLanguage(planningApi.contentProfiles.get('event')); - let newEvent = { - type: ITEM_TYPE.EVENT, + let newEvent: Partial = { + type: 'event', occur_status: occurStatus, dates: { start: null, @@ -989,7 +1139,7 @@ const defaultEventValues = (occurStatuses, defaultCalendars, defaultPlaceList) = newEvent.place = defaultPlaceList; } return newEvent; -}; +} function shouldFetchFilesForEvent(event?: IEventItem) { return (event?.files || []) @@ -997,7 +1147,7 @@ function shouldFetchFilesForEvent(event?: IEventItem) { .length > 0; } -const getRepeatSummaryForEvent = (schedule) => { +function getRepeatSummaryForEvent(schedule: IEventItem['dates']): string { const frequency = get(schedule, 'recurring_rule.frequency'); const endRepeatMode = get(schedule, 'recurring_rule.endRepeatMode'); const until = get(schedule, 'recurring_rule.until'); @@ -1097,9 +1247,13 @@ const getRepeatSummaryForEvent = (schedule) => { }; return getFrequency() + getEnds() + getDays(); -}; +} -const eventsDatesSame = (event1, event2, granularity = TIME_COMPARISON_GRANULARITY.MILLISECOND) => { +function eventsDatesSame( + event1: IEventItem, + event2: IEventItem, + granularity: string = TIME_COMPARISON_GRANULARITY.MILLISECOND +): boolean { const pickField = (value, key) => (key !== 'until'); const nonMomentFieldsEqual = isEqual( pickBy(get(event1, 'dates.recurring_rule'), pickField), @@ -1128,9 +1282,9 @@ const eventsDatesSame = (event1, event2, granularity = TIME_COMPARISON_GRANULARI } return nonMomentFieldsEqual; -}; +} -const eventHasPostedPlannings = (event) => { +function eventHasPostedPlannings(event: IEventItem): boolean { let hasPosteditem = false; get(event, '_relatedPlannings', []).forEach((p) => { @@ -1140,9 +1294,9 @@ const eventHasPostedPlannings = (event) => { }); return hasPosteditem; -}; +} -const fillEventTime = (event) => { +function fillEventTime(event: IEventItem) { if (!get(event, TO_BE_CONFIRMED_FIELD) && get(event, 'dates')) { event._startTime = event.dates.start; event._endTime = event.dates.end; @@ -1150,7 +1304,7 @@ const fillEventTime = (event) => { event._startTime = null; event._endTime = null; } -}; +} // eslint-disable-next-line consistent-this @@ -1161,7 +1315,6 @@ const self = { canSpikeEvent, canUnspikeEvent, canCreatePlanningFromEvent, - canCreateAndOpenPlanningFromEvent, canPostEvent, canUnpostEvent, canEditEvent, @@ -1175,8 +1328,6 @@ const self = { canUpdateEventTime, canConvertToRecurringEvent, canUpdateEventRepetitions, - isEventLocked, - isEventLockRestricted, isEventSameDay, isEventRecurring, getDateStringForEvent, diff --git a/client/utils/index.ts b/client/utils/index.ts index 6750410de..241e64a2b 100644 --- a/client/utils/index.ts +++ b/client/utils/index.ts @@ -505,7 +505,7 @@ export const shouldUnLockItem = ( ignoreSession = false ) => isExistingItem(item) && - ((currentWorkspace === WORKSPACE.AUTHORING && planningUtils.isLockedForAddToPlanning(item)) || + ((currentWorkspace === WORKSPACE.AUTHORING && lockUtils.isLockedForAddToPlanning(item, lockedItems)) || (currentWorkspace !== WORKSPACE.AUTHORING && lockUtils.isItemLockedInThisSession(item, session, lockedItems, ignoreSession)) ); diff --git a/client/utils/locks.ts b/client/utils/locks.ts index 96d47e514..046b44949 100644 --- a/client/utils/locks.ts +++ b/client/utils/locks.ts @@ -1,5 +1,3 @@ -import {isNil, get} from 'lodash'; - import { IEventItem, IEventOrPlanningItem, @@ -7,23 +5,22 @@ import { ILockedItems, IPlanningItem, IAssignmentItem, + ISession, } from '../interfaces'; -import {ITEM_TYPE} from '../constants'; -import {getItemType, eventUtils, planningUtils, assignmentUtils, timeUtils} from './index'; - -const isLockedByUser = (item, userId, action) => ( - !isNil(get(item, 'lock_session')) && - get(item, 'lock_user') === userId && - (!action || get(item, 'lock_action') === action) -); - -const getLockedUser = (item, lockedItems, users) => { +import {IUser} from 'superdesk-api'; +import {PLANNING} from '../constants'; + +function getLockedUser( + item: IEventOrPlanningItem | IAssignmentItem, + lockedItems: ILockedItems, + users: Array +): IUser | null { const lock = self.getLock(item, lockedItems); return (lock !== null && Array.isArray(users) && users.length > 0) ? users.find((u) => (u._id === lock.user)) || null : null; -}; +} function getLock(item: IEventOrPlanningItem | IAssignmentItem | null, lockedItems: ILockedItems): ILock | null { if (item?._id == null) { @@ -35,15 +32,6 @@ function getLock(item: IEventOrPlanningItem | IAssignmentItem | null, lockedItem } else if (item.type === 'assignment') { if (lockedItems.assignment[item._id] != null) { return lockedItems.assignment[item._id]; - } else if (item.lock_session != null) { - return { - action: item.lock_action, - item_id: item._id, - item_type: item.type, - session: item.lock_session, - time: item.lock_time == null ? undefined : timeUtils.getDateAsString(item.lock_time), - user: item.lock_user, - }; } } @@ -57,15 +45,6 @@ function getEventLock(item: IEventItem | null, lockedItems: ILockedItems): ILock return lockedItems.recurring[item.recurrence_id]; } else if (lockedItems.event[item._id] != null) { return lockedItems.event[item._id]; - } else if (item.lock_session != null) { - return { - action: item.lock_action, - item_id: item._id, - item_type: item.type, - session: item.lock_session, - time: item.lock_time == null ? undefined : timeUtils.getDateAsString(item.lock_time), - user: item.lock_user, - }; } return null; @@ -80,69 +59,73 @@ function getPlanningLock(item: IPlanningItem | null, lockedItems: ILockedItems): return lockedItems.recurring[item.recurrence_id]; } else if (item.event_item != null && lockedItems.event[item.event_item] != null) { return lockedItems.event[item.event_item]; - } else if (item.lock_session != null) { - return { - action: item.lock_action, - item_id: item._id, - item_type: item.type, - session: item.lock_session, - time: item.lock_time == null ? undefined : timeUtils.getDateAsString(item.lock_time), - user: item.lock_user, - }; } return null; } -const getLockAction = (item, lockedItems) => ( - get(self.getLock(item, lockedItems), 'action') -); - -const isItemLockedInThisSession = (item, session, lockedItems = null, ignoreSession = false) => { - const userId = get(session, 'identity._id'); - const sessionId = get(session, 'sessionId'); - - if (get(item, 'lock_user') === userId && - (ignoreSession || get(item, 'lock_session') === sessionId) - ) { - return true; - } else if (lockedItems === null) { - return false; - } +function getLockAction(item: IEventOrPlanningItem | IAssignmentItem, lockedItems: ILockedItems): string | null { + return self.getLock(item, lockedItems)?.action; +} +function isItemLockedInThisSession( + item: IEventOrPlanningItem | IAssignmentItem, + session: ISession, + lockedItems: ILockedItems, + ignoreSession?: boolean +): boolean { + const userId = session.identity?._id; + const sessionId = session.sessionId; const lock = self.getLock(item, lockedItems); - return !!lock && + return lock != null && lock.user === userId && (ignoreSession || lock.session === sessionId) && lock.item_id === item._id; -}; +} -const isLockRestricted = (item, session, lockedItems) => { - switch (getItemType(item)) { - case ITEM_TYPE.EVENT: - return eventUtils.isEventLockRestricted(item, session, lockedItems); - case ITEM_TYPE.PLANNING: - return planningUtils.isPlanningLockRestricted(item, session, lockedItems); - case ITEM_TYPE.ASSIGNMENT: - return assignmentUtils.isAssignmentLockRestricted(item, session, lockedItems); - } +function isLockRestricted( + item: IEventOrPlanningItem | IAssignmentItem, + session: ISession, + lockedItems: ILockedItems +): boolean { + const lock = self.getLock(item, lockedItems); + const userId = session.identity?._id; + const sessionId = session.sessionId; - return false; -}; + return lock != null && !( + lock.user === userId && + lock.session === sessionId && + lock.item_id === item._id + ); +} -const isItemLocked = (item, lockedItems) => { - switch (getItemType(item)) { - case ITEM_TYPE.EVENT: - return eventUtils.isEventLocked(item, lockedItems); - case ITEM_TYPE.PLANNING: - return planningUtils.isPlanningLocked(item, lockedItems); - case ITEM_TYPE.ASSIGNMENT: - return assignmentUtils.isAssignmentLocked(item, lockedItems); - } +function isItemLocked(item: IEventOrPlanningItem | IAssignmentItem, lockedItems: ILockedItems): boolean { + return self.getLock(item, lockedItems) != null; +} - return false; -}; +function isLockedForAddToPlanning(item: IEventOrPlanningItem, lockedItems: ILockedItems): boolean { + return self.getLockAction(item, lockedItems) === PLANNING.ITEM_ACTIONS.ADD_TO_PLANNING.lock_action; +} + +function getLockedItemIds(lockedItems: ILockedItems): Array { + return [ + ...Object.keys(lockedItems.event).map((lockId) => lockedItems.event[lockId].item_id), + ...Object.keys(lockedItems.recurring).map((lockId) => lockedItems.recurring[lockId].item_id), + ...Object.keys(lockedItems.planning).map((lockId) => lockedItems.planning[lockId].item_id), + ]; +} + +export function getLockFromItem(item: IEventOrPlanningItem): ILock { + return { + item_id: item._id, + item_type: item.type, + action: item.lock_action, + user: item.lock_user, + session: item.lock_session, + time: item.lock_time, + }; +} // eslint-disable-next-line consistent-this const self = { @@ -153,8 +136,10 @@ const self = { getLockAction, isLockRestricted, isItemLockedInThisSession, - isLockedByUser, isItemLocked, + isLockedForAddToPlanning, + getLockedItemIds, + getLockFromItem, }; export default self; diff --git a/client/utils/planning.ts b/client/utils/planning.ts index 112324e71..8a75ffbf7 100644 --- a/client/utils/planning.ts +++ b/client/utils/planning.ts @@ -1,7 +1,7 @@ import moment from 'moment-timezone'; import {get, set, isNil, uniq, sortBy, isEmpty, cloneDeep, isArray, find, flatten} from 'lodash'; -import {appConfig} from 'appConfig'; +import {appConfig as config} from 'appConfig'; import {IDesk, IArticle, IUser} from 'superdesk-api'; import {superdeskApi, planningApi} from '../superdeskApi'; import { @@ -12,7 +12,18 @@ import { IG2ContentType, ISession, ILockedItems, + IPrivileges, + IPlanningConfig, + IAgenda, + IPlace, + IPlanningAppState, + IFeaturedPlanningItem, + ICoverageScheduledUpdate, + IDateTime, + IItemAction, } from '../interfaces'; +const appConfig = config as IPlanningConfig; + import {stripHtmlRaw} from 'superdesk-core/scripts/apps/authoring/authoring/helpers'; import { @@ -24,7 +35,6 @@ import { ASSIGNMENTS, POST_STATE, COVERAGES, - ITEM_TYPE, TIME_COMPARISON_GRANULARITY, } from '../constants'; import { @@ -57,11 +67,18 @@ import {IMenuItem} from 'superdesk-ui-framework/react/components/Menu'; const isCoverageAssigned = (coverage) => !!get(coverage, 'assigned_to.desk'); -const canPostPlanning = (planning, event, session, privileges, locks) => ( - isExistingItem(planning) && +function canPostPlanning( + planning: IPlanningItem, + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + isExistingItem(planning) && !!privileges[PRIVILEGES.POST_PLANNING] && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isPlanningLockRestricted(planning, session, locks) && + !lockUtils.isLockRestricted(planning, session, locks) && getPostedState(planning) !== POST_STATE.USABLE && (isNil(event) || getItemWorkflowState(event) !== WORKFLOW_STATE.KILLED) && !isItemSpiked(planning) && @@ -71,148 +88,278 @@ const canPostPlanning = (planning, event, session, privileges, locks) => ( !isItemRescheduled(planning) && !isItemRescheduled(event) && !isNotForPublication(planning) -); + ); +} -const canUnpostPlanning = (planning, event, session, privileges, locks) => ( - !!privileges[PRIVILEGES.UNPOST_PLANNING] && +function canUnpostPlanning( + planning: IPlanningItem, + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !!privileges[PRIVILEGES.UNPOST_PLANNING] && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && !isItemSpiked(planning) && - !isPlanningLockRestricted(planning, session, locks) && + !lockUtils.isLockRestricted(planning, session, locks) && getPostedState(planning) === POST_STATE.USABLE -); + ); +} -const canEditPlanning = (planning, event, session, privileges, locks) => ( - !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isPlanningLockRestricted(planning, session, locks) && +function canEditPlanning( + planning: IPlanningItem, + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && + !lockUtils.isLockRestricted(planning, session, locks) && !isItemSpiked(planning) && !isItemSpiked(event) && !(getPostedState(planning) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_PLANNING]) && !isItemRescheduled(planning) && (!isItemExpired(planning) || privileges[PRIVILEGES.EDIT_EXPIRED]) && (isNil(event) || getItemWorkflowState(event) !== WORKFLOW_STATE.KILLED) -); + ); +} -const canModifyPlanning = (planning, event, privileges, locks) => ( - !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isPlanningLocked(planning, locks) && +function canModifyPlanning( + planning: IPlanningItem, + event: IEventItem, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && + !lockUtils.isItemLocked(planning, locks) && !isItemSpiked(planning) && !isItemSpiked(event) && !isItemCancelled(planning) && !isItemRescheduled(planning) -); + ); +} -const canAddFeatured = (planning, event, session, privileges, locks) => ( - !get(planning, 'featured', false) && +function canAddFeatured( + planning: IPlanningItem, + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !get(planning, 'featured', false) && canEditPlanning(planning, event, session, privileges, locks) && !!privileges[PRIVILEGES.FEATURED_STORIES] && !isItemKilled(planning) && !isItemCancelled(planning) -); + ); +} -const canRemovedFeatured = (planning, event, session, privileges, locks) => ( - get(planning, 'featured', false) === true && +function canRemovedFeatured( + planning: IPlanningItem, + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + get(planning, 'featured', false) === true && canEditPlanning(planning, event, session, privileges, locks) && !!privileges[PRIVILEGES.FEATURED_STORIES] -); + ); +} -const canUpdatePlanning = (planning, event, session, privileges, locks) => ( - canEditPlanning(planning, event, session, privileges, locks) && +function canUpdatePlanning( + planning: IPlanningItem, + event: IEventItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + canEditPlanning(planning, event, session, privileges, locks) && isItemPublic(planning) && !isItemKilled(planning) && !!privileges[PRIVILEGES.POST_PLANNING] -); + ); +} -const canSpikePlanning = (plan, session, privileges, locks) => ( - !isItemPosted(plan) && +function canSpikePlanning( + plan: IPlanningItem, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isItemPosted(plan) && getItemWorkflowState(plan) === WORKFLOW_STATE.DRAFT && !!privileges[PRIVILEGES.SPIKE_PLANNING] && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isPlanningLockRestricted(plan, session, locks) && - (!isItemExpired(plan) || privileges[PRIVILEGES.EDIT_EXPIRED]) -); + !lockUtils.isLockRestricted(plan, session, locks) && + ( + !isItemExpired(plan) || + !!privileges[PRIVILEGES.EDIT_EXPIRED] + ) + ); +} -const canUnspikePlanning = (plan, event = null, privileges) => ( - isItemSpiked(plan) && +function canUnspikePlanning( + plan: IPlanningItem, + event: IEventItem | null, + privileges: IPrivileges +): boolean { + return ( + isItemSpiked(plan) && !!privileges[PRIVILEGES.UNSPIKE_PLANNING] && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && !isItemSpiked(event) && - (!isItemExpired(plan) || privileges[PRIVILEGES.EDIT_EXPIRED]) -); + ( + !isItemExpired(plan) || + !!privileges[PRIVILEGES.EDIT_EXPIRED] + ) + ); +} -const canDuplicatePlanning = (plan, event = null, session, privileges, locks) => ( - !isItemSpiked(plan) && +function canDuplicatePlanning( + plan: IPlanningItem, + event: IEventItem | null, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !isItemSpiked(plan) && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !self.isPlanningLockRestricted(plan, session, locks) && + !lockUtils.isLockRestricted(plan, session, locks) && !isItemSpiked(event) -); + ); +} -const canCancelPlanning = (planning, event = null, session, privileges, locks) => ( - !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isPlanningLockRestricted(planning, session, locks) && +function canCancelPlanning( + planning: IPlanningItem, + event: IEventItem | null, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && + !lockUtils.isLockRestricted(planning, session, locks) && getItemWorkflowState(planning) === WORKFLOW_STATE.SCHEDULED && getItemWorkflowState(event) !== WORKFLOW_STATE.SPIKED && - !(getPostedState(planning) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_PLANNING]) && + !( + getPostedState(planning) === POST_STATE.USABLE && + !privileges[PRIVILEGES.POST_PLANNING] + ) && !isItemExpired(planning) -); + ); +} -const canCancelAllCoverage = (planning, event = null, session, privileges, locks) => ( - !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - !isItemSpiked(planning) && !isPlanningLockRestricted(planning, session, locks) && +function canCancelAllCoverage( + planning: IPlanningItem, + event: IEventItem | null, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && + !isItemSpiked(planning) && + !lockUtils.isLockRestricted(planning, session, locks) && getItemWorkflowState(event) !== WORKFLOW_STATE.SPIKED && canCancelAllCoverageForPlanning(planning) && - !(getPostedState(planning) === POST_STATE.USABLE && !privileges[PRIVILEGES.POST_PLANNING]) && + !( + getPostedState(planning) === POST_STATE.USABLE && + !privileges[PRIVILEGES.POST_PLANNING] + ) && !isItemExpired(planning) -); + ); +} -const canAddAsEvent = (planning, event = null, session, privileges, locks) => ( - !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && +function canAddAsEvent( + planning: IPlanningItem, + event: IEventItem | null, + session: ISession, + privileges: IPrivileges, + locks: ILockedItems +): boolean { + return ( + !!privileges[PRIVILEGES.EVENT_MANAGEMENT] && !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && isPlanAdHoc(planning) && - !isPlanningLocked(planning, locks) && + !lockUtils.isItemLocked(planning, locks) && !isItemSpiked(planning) && getItemWorkflowState(planning) !== WORKFLOW_STATE.KILLED && !isItemExpired(planning) -); - -const isCoverageCancelled = (coverage) => - (get(coverage, 'workflow_status') === WORKFLOW_STATE.CANCELLED); - -const canCancelCoverage = (coverage, planning, field = 'coverage_id') => - (!isCoverageCancelled(coverage) && isExistingItem(coverage, field) && (!get(coverage, 'assigned_to.state') - || get(coverage, 'assigned_to.state') !== ASSIGNMENTS.WORKFLOW_STATE.COMPLETED)) && !isItemExpired(planning); - -const canAddCoverageToWorkflow = (coverage, planning) => - isExistingItem(coverage, 'coverage_id') && - isCoverageDraft(coverage) && - isCoverageAssigned(coverage) && - !appConfig.planning_auto_assign_to_workflow && - !isItemExpired(planning); - -const canRemoveCoverage = (coverage, planning) => !isItemCancelled(planning) && - ([WORKFLOW_STATE.DRAFT, WORKFLOW_STATE.CANCELLED].includes(get(coverage, 'workflow_status')) || - get(coverage, 'previous_status') === WORKFLOW_STATE.DRAFT) && !isItemExpired(planning); - -const canCancelAllCoverageForPlanning = (planning) => ( - get(planning, 'coverages.length') > 0 && get(planning, 'coverages') - .filter((c) => canCancelCoverage(c)).length > 0 -); - -const canAddCoverages = (planning, event, privileges, session, locks) => ( - !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - isPlanningLocked(planning, locks) && - lockUtils.isItemLockedInThisSession(planning, session, locks) && - (isNil(event) || !isItemCancelled(event)) && - (!isItemCancelled(planning) || isItemKilled(planning)) && !isItemRescheduled(planning) && + ); +} + +function isCoverageCancelled(coverage: IPlanningCoverageItem): boolean { + return get(coverage, 'workflow_status') === WORKFLOW_STATE.CANCELLED; +} + +function canCancelCoverage( + coverage: IPlanningCoverageItem, + planning: IPlanningItem, + field: string = 'coverage_id' +): boolean { + return ( + !isItemExpired(planning) && + ( + !isCoverageCancelled(coverage) && + isExistingItem(coverage, field) && + ( + !get(coverage, 'assigned_to.state') + || get(coverage, 'assigned_to.state') !== ASSIGNMENTS.WORKFLOW_STATE.COMPLETED + ) + ) + ); +} + +function canAddCoverageToWorkflow(coverage: IPlanningCoverageItem, planning: IPlanningItem): boolean { + return ( + isExistingItem(coverage, 'coverage_id') && + isCoverageDraft(coverage) && + isCoverageAssigned(coverage) && + !appConfig.planning_auto_assign_to_workflow && !isItemExpired(planning) -); + ); +} + +function canRemoveCoverage(coverage: IPlanningCoverageItem, planning: IPlanningItem): boolean { + return ( + !isItemCancelled(planning) && + !isItemExpired(planning) && + ( + [WORKFLOW_STATE.DRAFT, WORKFLOW_STATE.CANCELLED].includes(get(coverage, 'workflow_status')) || + get(coverage, 'previous_status') === WORKFLOW_STATE.DRAFT + ) + ); +} -function isPlanningLocked(plan: IPlanningItem, locks: ILockedItems): boolean { - return lockUtils.getLock(plan, locks) != null; +function canCancelAllCoverageForPlanning(planning: IPlanningItem): boolean { + return ( + get(planning, 'coverages.length') > 0 && + get(planning, 'coverages').filter((c) => canCancelCoverage(c, planning)).length > 0 + ); } -function isPlanningLockRestricted(plan: IPlanningItem, session: ISession, locks: ILockedItems): boolean { +function canAddCoverages( + planning: IPlanningItem, + event: IEventItem, + privileges: IPrivileges, + session: ISession, + locks: ILockedItems +): boolean { return ( - isPlanningLocked(plan, locks) && - !lockUtils.isItemLockedInThisSession(plan, session, locks) + !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && + lockUtils.isItemLocked(planning, locks) && + lockUtils.isItemLockedInThisSession(planning, session, locks) && + (isNil(event) || !isItemCancelled(event)) && + (!isItemCancelled(planning) || isItemKilled(planning)) && !isItemRescheduled(planning) && + !isItemExpired(planning) ); } @@ -221,18 +368,20 @@ function isPlanningLockRestricted(plan: IPlanningItem, session: ISession, locks: * @param {Array} coverages * @returns {Array} */ -export const mapCoverageByDate = (coverages = []) => ( - coverages.map((c) => ({ +export function mapCoverageByDate(coverages: Array = []): Array { + return coverages.map((c) => ({ ...c, g2_content_type: c.planning.g2_content_type || '', assigned_to: get(c, 'assigned_to'), - })) -); + })); +} // ad hoc plan created directly from planning list and not from an event -const isPlanAdHoc = (plan) => !get(plan, 'event_item'); +function isPlanAdHoc(plan: IPlanningItem): boolean { + return plan.event_item == null; +} -const isPlanMultiDay = (plan) => { +function isPlanMultiDay(plan: IPlanningItem): boolean { const coverages = get(plan, 'coverages', []); if (coverages.length > 0) { @@ -245,11 +394,20 @@ const isPlanMultiDay = (plan) => { } return false; -}; +} -export const isNotForPublication = (plan) => get(plan, 'flags.marked_for_not_publication', false); +export function isNotForPublication(plan: IPlanningItem): boolean { + return plan.flags?.marked_for_not_publication === true; +} -export const getPlanningItemActions = (plan, event = null, session, privileges, actions, locks) => { +export function getPlanningItemActions( + plan: IPlanningItem, + event: IEventItem | null, + session: ISession, + privileges: IPrivileges, + actions: Array, + locks: ILockedItems +): Array { let itemActions = []; let key = 1; @@ -332,17 +490,30 @@ export const getPlanningItemActions = (plan, event = null, session, privileges, } return itemActions; -}; +} -const getPlanningActions = ({ - item, - event, - session, - privileges, - lockedItems, - agendas, - callBacks, - contentTypes}) => { +interface IGetPlanningActionArgs { + item: IPlanningItem; + event: IEventItem | null; + session: ISession; + privileges: IPrivileges; + lockedItems: ILockedItems; + agendas: Array + callBacks: {[key: string]: (...args: Array) => any}; + contentTypes: Array; +} +function getPlanningActions( + { + item, + event, + session, + privileges, + lockedItems, + agendas, + callBacks, + contentTypes, + }: IGetPlanningActionArgs +): Array { if (!isExistingItem(item)) { return []; } @@ -475,12 +646,12 @@ const getPlanningActions = ({ actions, lockedItems ); -}; +} /** * Converts output from `getPlanningActions` to `Array` */ -export function toUIFrameworkInterface(actions: any): Array { +export function toUIFrameworkInterface(actions: Array): Array { return actions .filter((item, index) => { // Trim dividers. Menu should not start or end with a divider. @@ -525,11 +696,11 @@ export function toUIFrameworkInterface(actions: any): Array { }); } -function getPlanningActionsForUiFrameworkMenu(data): Array { +function getPlanningActionsForUiFrameworkMenu(data: IGetPlanningActionArgs): Array { return toUIFrameworkInterface(getPlanningActions(data)); } -export const modifyForClient = (plan) => { +export function modifyForClient(plan: Partial): Partial { sanitizeItemFields(plan); // The `_status` field is available when the item comes from a POST/PATCH request @@ -559,7 +730,7 @@ export const modifyForClient = (plan) => { plan.coverages.forEach((coverage) => self.modifyCoverageForClient(coverage)); return plan; -}; +} function modifyForServer(plan: Partial): Partial { const modifyGenre = (coverage) => { @@ -592,7 +763,7 @@ function modifyForServer(plan: Partial): Partial { * @param {object} coverage - The coverage to modify * @return {object} coverage item provided */ -const modifyCoverageForClient = (coverage) => { +function modifyCoverageForClient(coverage: IPlanningCoverageItem): IPlanningCoverageItem { const modifyGenre = (coverage) => { // Convert genre from an Array to an Object if (get(coverage, 'planning.genre[0]')) { @@ -626,15 +797,15 @@ const modifyCoverageForClient = (coverage) => { }); return coverage; -}; +} -const createNewPlanningFromNewsItem = ( +function createNewPlanningFromNewsItem( addNewsItemToPlanning: IArticle, newsCoverageStatus: Array, desk: IDesk['_id'], user: IUser['_id'], contentTypes: Array -) => { +) { const newCoverage = self.createCoverageFromNewsItem( addNewsItemToPlanning, newsCoverageStatus, @@ -644,7 +815,7 @@ const createNewPlanningFromNewsItem = ( ); let newPlanning: Partial = { - type: ITEM_TYPE.PLANNING, + type: 'planning', slugline: addNewsItemToPlanning.slugline, headline: get(addNewsItemToPlanning, 'headline'), planning_date: moment(), @@ -666,15 +837,15 @@ const createNewPlanningFromNewsItem = ( } return newPlanning; -}; +} -const createCoverageFromNewsItem = ( +function createCoverageFromNewsItem( addNewsItemToPlanning: IArticle, newsCoverageStatus: Array, desk: IDesk['_id'], user: IUser['_id'], contentTypes: Array -): Partial => { +): Partial { let newCoverage = self.defaultCoverageValues(newsCoverageStatus); newCoverage.workflow_status = COVERAGES.WORKFLOW_STATE.ACTIVE; @@ -722,14 +893,14 @@ const createCoverageFromNewsItem = ( newCoverage.assigned_to.priority = ASSIGNMENTS.DEFAULT_PRIORITY; return newCoverage; -}; +} -const getCoverageReadOnlyFields = ( +function getCoverageReadOnlyFields( coverage, readOnly, newsCoverageStatus, addNewsItemToPlanning -) => { +): {[key: string]: boolean} { const scheduledUpdatesExist = get(coverage, 'scheduled_updates.length', 0) > 0; if (addNewsItemToPlanning) { @@ -838,21 +1009,31 @@ const getCoverageReadOnlyFields = ( xmp_file: readOnly, }; } -}; +} -const getFlattenedPlanningByDate = (plansInList, events, startDate, endDate, timezone = null) => { +function getFlattenedPlanningByDate( + plansInList: Array, + events: {[key: string]: IEventItem}, + startDate: IDateTime, + endDate: IDateTime, + timezone?: string +) { const planning = getPlanningByDate(plansInList, events, startDate, endDate, timezone); - return flatten(sortBy(planning, [(e) => (e.date)]).map((e) => e.events.map((k) => [e.date, k._id]))); -}; + return flatten( + sortBy(planning, [(e) => (e.date)]) + .map((e) => e.events.map((k) => [e.date, k._id])) + ); +} -const getPlanningByDate = ( - plansInList, - events, - startDate, - endDate, - timezone = null, - includeScheduledUpdates = false) => { +function getPlanningByDate( + plansInList: Array, + events: {[key: string]: IEventItem}, + startDate: IDateTime, + endDate: IDateTime, + timezone?: string, + includeScheduledUpdates?: boolean +): Array<{[date: string]: Array}> { if (!plansInList) return []; const days = {}; @@ -911,7 +1092,7 @@ const getPlanningByDate = ( }); return sortBasedOnTBC(days); -}; +} function getFeaturedPlanningItemsForDate(items: Array, date: moment.Moment): Array { const startDate = moment.tz(moment(date.format('YYYY-MM-DD')), appConfig.default_timezone); @@ -939,15 +1120,22 @@ function getFeaturedPlanningItemsForDate(items: Array, date: mome return []; } -const isLockedForAddToPlanning = (item) => get(item, 'lock_action') === - PLANNING.ITEM_ACTIONS.ADD_TO_PLANNING.lock_action; - -const isCoverageDraft = (coverage) => get(coverage, 'workflow_status') === WORKFLOW_STATE.DRAFT; -const isCoverageInWorkflow = (coverage) => !isEmpty(coverage.assigned_to) && - get(coverage, 'assigned_to.state') !== WORKFLOW_STATE.DRAFT; -const formatAgendaName = (agenda) => agenda.is_enabled ? agenda.name : agenda.name + ` - [${gettext('Disabled')}]`; +function isCoverageDraft(coverage: IPlanningCoverageItem | ICoverageScheduledUpdate): boolean { + return get(coverage, 'workflow_status') === WORKFLOW_STATE.DRAFT; +} +function isCoverageInWorkflow(coverage: IPlanningCoverageItem): boolean { + return ( + !isEmpty(coverage.assigned_to) && + get(coverage, 'assigned_to.state') !== WORKFLOW_STATE.DRAFT + ); +} +function formatAgendaName(agenda: IAgenda): string { + return agenda.is_enabled ? + agenda.name : + agenda.name + ` - [${gettext('Disabled')}]`; +} -function getCoverageDateTimeText(coverage: IPlanningCoverageItem) { +function getCoverageDateTimeText(coverage: IPlanningCoverageItem): string { const {gettext} = superdeskApi.localization; const coverage_date = moment.isMoment(coverage.planning?.scheduled) ? coverage.planning.scheduled : @@ -995,7 +1183,7 @@ function getCoverageIcon( return get(coverageIcons, type, 'icon-file'); } -const getCoverageIconColor = (coverage) => { +function getCoverageIconColor(coverage: IPlanningCoverageItem): 'icon--green' | 'icon--red' | 'icon--yellow' { if (get(coverage, 'assigned_to.state') === ASSIGNMENTS.WORKFLOW_STATE.COMPLETED) { return 'icon--green'; } else if (isCoverageDraft(coverage) || get(coverage, 'workflow_status') === COVERAGES.WORKFLOW_STATE.ACTIVE) { @@ -1004,9 +1192,9 @@ const getCoverageIconColor = (coverage) => { // Cancelled return 'icon--yellow'; } -}; +} -const getCoverageWorkflowIcon = (coverage) => { +function getCoverageWorkflowIcon(coverage: IPlanningCoverageItem): string | null { if (!get(coverage, 'assigned_to.desk')) { return; } @@ -1025,20 +1213,32 @@ const getCoverageWorkflowIcon = (coverage) => { case COVERAGES.WORKFLOW_STATE.ACTIVE: return 'icon-user'; } -}; +} -const getCoverageContentType = (coverage, contentTypes = []) => get(contentTypes.find( - (c) => get(c, 'qcode') === get(coverage, 'planning.g2_content_type')), 'content item type'); +function getCoverageContentType( + coverage: IPlanningCoverageItem, + contentTypes: Array = [] +): IG2ContentType['content item type'] { + return get( + contentTypes.find((c) => get(c, 'qcode') === get(coverage, 'planning.g2_content_type')), + 'content item type' + ); +} -const shouldLockPlanningForEdit = (item, privileges) => ( - !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && - (!isItemPublic(item) || !!privileges[PRIVILEGES.POST_PLANNING]) -); +function shouldLockPlanningForEdit(item: IPlanningItem, privileges: IPrivileges): boolean { + return ( + !!privileges[PRIVILEGES.PLANNING_MANAGEMENT] && + ( + !isItemPublic(item) || + !!privileges[PRIVILEGES.POST_PLANNING] + ) + ); +} -const defaultPlanningValues = (currentAgenda, defaultPlaceList) => { +function defaultPlanningValues(currentAgenda: IAgenda, defaultPlaceList: Array): Partial { const language = planningApi.contentProfiles.getDefaultLanguage(planningApi.contentProfiles.get('planning')); - const newPlanning = { - type: ITEM_TYPE.PLANNING, + const newPlanning: Partial = { + type: 'planning', planning_date: moment(), agendas: get(currentAgenda, 'is_enabled') ? [getItemId(currentAgenda)] : [], @@ -1053,18 +1253,20 @@ const defaultPlanningValues = (currentAgenda, defaultPlaceList) => { } return self.modifyForClient(newPlanning); -}; +} -const getDefaultCoverageStatus = (newsCoverageStatus) => newsCoverageStatus[0]; +function getDefaultCoverageStatus(newsCoverageStatus: Array): IPlanningNewsCoverageStatus { + return newsCoverageStatus[0]; +} -const defaultCoverageValues = ( +function defaultCoverageValues( newsCoverageStatus: Array, planningItem?: DeepPartial, eventItem?: IEventItem, g2contentType?: IG2ContentType['qcode'], defaultDesk?: IDesk, preferredCoverageDesks?: {[key: string]: IDesk['_id']}, -): DeepPartial => { +): DeepPartial { let newCoverage: DeepPartial = { coverage_id: generateTempId(), planning: { @@ -1153,9 +1355,14 @@ const defaultCoverageValues = ( self.setDefaultAssignment(newCoverage, preferredCoverageDesks, g2contentType, defaultDesk); return newCoverage; -}; +} -const setDefaultAssignment = (coverage, preferredCoverageDesks, g2contentType, defaultDesk) => { +function setDefaultAssignment( + coverage: DeepPartial, + preferredCoverageDesks: {[key: string]: IDesk['_id']}, + g2contentType: IG2ContentType['qcode'], + defaultDesk: IDesk +) { if (get(preferredCoverageDesks, g2contentType)) { coverage.assigned_to = {desk: preferredCoverageDesks[g2contentType]}; } else if (g2contentType === 'text' && defaultDesk) { @@ -1163,9 +1370,12 @@ const setDefaultAssignment = (coverage, preferredCoverageDesks, g2contentType, d } else { delete coverage.assigned_to; } -}; +} -const modifyPlanningsBeingAdded = (state, payload) => { +function modifyPlanningsBeingAdded( + state: IPlanningAppState['planning'], + payload: IPlanningItem | Array +): IPlanningAppState['planning']['plannings'] { // payload must be an array. If not, we transform const plans = Array.isArray(payload) ? payload : [payload]; @@ -1178,9 +1388,9 @@ const modifyPlanningsBeingAdded = (state, payload) => { }); return plannings; -}; +} -const isFeaturedPlanningUpdatedAfterPosting = (item) => { +function isFeaturedPlanningUpdatedAfterPosting(item: IFeaturedPlanningItem): boolean { if (!item || !get(item, '_updated')) { return; } @@ -1189,18 +1399,26 @@ const isFeaturedPlanningUpdatedAfterPosting = (item) => { const postedDate = moment(get(item, 'last_posted_time')); return updatedDate.isAfter(postedDate); -}; +} -const shouldFetchFilesForPlanning = (planning) => ( - self.getPlanningFiles(planning).filter((f) => typeof (f) === 'string' - || f instanceof String).length > 0 -); +function shouldFetchFilesForPlanning(planning: IPlanningItem): boolean { + return ( + self.getPlanningFiles(planning) + .filter((f) => typeof (f) === 'string' || f instanceof String) + .length > 0 + ); +} -const getAgendaNames = (item = {}, agendas = [], onlyEnabled = false, field = 'agendas') => ( - get(item, field, []) +function getAgendaNames( + item: DeepPartial = {}, + agendas: Array = [], + onlyEnabled: boolean = false, + field: string = 'agendas' +): Array { + return get(item, field, []) .map((agendaId) => agendas.find((agenda) => agenda._id === get(agendaId, '_id', agendaId))) - .filter((agenda) => agenda && (!onlyEnabled || agenda.is_enabled)) -); + .filter((agenda) => agenda && (!onlyEnabled || agenda.is_enabled)); +} function getDateStringForPlanning(planning: IPlanningItem): string { const {gettext} = superdeskApi.localization; @@ -1222,7 +1440,7 @@ function getDateStringForPlanning(planning: IPlanningItem): string { ); } -const getCoverageDateText = (coverage) => { +function getCoverageDateText(coverage: IPlanningCoverageItem): string { const coverageDate = get(coverage, 'planning.scheduled'); return !coverageDate ? @@ -1234,20 +1452,37 @@ const getCoverageDateText = (coverage) => { ' @ ', false ); -}; +} -const canAddScheduledUpdateToWorkflow = (scheduledUpdate, autoAssignToWorkflow, planning, coverage) => - isExistingItem(scheduledUpdate, 'scheduled_update_id') && isCoverageInWorkflow(coverage) && - isCoverageDraft(scheduledUpdate) && isCoverageAssigned(scheduledUpdate) && !autoAssignToWorkflow && - !isItemExpired(planning); +function canAddScheduledUpdateToWorkflow( + scheduledUpdate: ICoverageScheduledUpdate, + autoAssignToWorkflow: boolean, + planning: IPlanningItem, + coverage: IPlanningCoverageItem +): boolean { + return ( + isExistingItem(scheduledUpdate, 'scheduled_update_id') && + isCoverageInWorkflow(coverage) && + isCoverageDraft(scheduledUpdate) && + isCoverageAssigned(scheduledUpdate) && + !autoAssignToWorkflow && + !isItemExpired(planning) + ); +} -const setCoverageActiveValues = (coverage, newsCoverageStatus) => { +function setCoverageActiveValues( + coverage: IPlanningCoverageItem | ICoverageScheduledUpdate, + newsCoverageStatus: Array +) { set(coverage, 'news_coverage_status', newsCoverageStatus.find((s) => s.qcode === 'ncostat:int')); set(coverage, 'workflow_status', COVERAGES.WORKFLOW_STATE.ACTIVE); set(coverage, 'assigned_to.state', ASSIGNMENTS.WORKFLOW_STATE.ASSIGNED); -}; +} -const getActiveCoverage = (updatedCoverage, newsCoverageStatus) => { +function getActiveCoverage( + updatedCoverage: IPlanningCoverageItem, + newsCoverageStatus: Array +): IPlanningCoverageItem { const coverage = cloneDeep(updatedCoverage); setCoverageActiveValues(coverage, newsCoverageStatus); @@ -1259,9 +1494,9 @@ const getActiveCoverage = (updatedCoverage, newsCoverageStatus) => { }); return coverage; -}; +} -const getPlanningFiles = (planning) => { +function getPlanningFiles(planning: IPlanningItem): IPlanningItem['files'] { let filesToFetch = get(planning, 'files') || []; (get(planning, 'coverages') || []).forEach((c) => { @@ -1278,14 +1513,14 @@ const getPlanningFiles = (planning) => { }); return filesToFetch; -}; +} -const showXMPFileUIControl = (coverage) => ( - get(coverage, 'planning.g2_content_type') === 'picture' && ( +function showXMPFileUIControl(coverage: IPlanningCoverageItem): boolean { + return get(coverage, 'planning.g2_content_type') === 'picture' && ( appConfig.planning_use_xmp_for_pic_assignments || appConfig.planning_use_xmp_for_pic_slugline - ) -); + ); +} function duplicateCoverage( item: DeepPartial, @@ -1344,8 +1579,6 @@ const self = { canUpdatePlanning, mapCoverageByDate, getPlanningItemActions, - isPlanningLocked, - isPlanningLockRestricted, isPlanAdHoc, modifyCoverageForClient, isCoverageCancelled, @@ -1360,7 +1593,6 @@ const self = { getFeaturedPlanningItemsForDate, createNewPlanningFromNewsItem, createCoverageFromNewsItem, - isLockedForAddToPlanning, isCoverageAssigned, isCoverageDraft, isCoverageInWorkflow, diff --git a/client/utils/testApi.ts b/client/utils/testApi.ts index 3972509cd..c604f364e 100644 --- a/client/utils/testApi.ts +++ b/client/utils/testApi.ts @@ -33,6 +33,11 @@ Object.assign(superdeskApi, { }, privileges: { hasPrivilege: (privilege: string) => privileges[privilege] === 1 + }, + ui: { + notify: { + error: sinon.stub().returns(undefined), + } } }); diff --git a/client/utils/testData.ts b/client/utils/testData.ts index 62456c841..dc1c42807 100644 --- a/client/utils/testData.ts +++ b/client/utils/testData.ts @@ -1,3 +1,5 @@ +import {ILockedItems, IEventItem, IPlanningItem, ILock} from '../interfaces'; + export const config = { server: {url: 'http://server.com'}, model: {dateformat: 'DD/MM/YYYY'}, @@ -483,7 +485,7 @@ export const urgency = { ], }; -export const locks = { +export const locks: ILockedItems = { event: {}, planning: {}, recurring: {}, @@ -590,7 +592,7 @@ export const templates = {templates: []}; export const form = {}; -export const events = [ +export const events: Array = [ { _id: 'e1', type: 'event', @@ -806,55 +808,82 @@ export const eventsHistory = [ }, ]; -export const lockedEvents = [ +export const lockedEvents: Array = [ { - _id: 'e1', - name: 'Event 1', - dates: { - start: '2016-10-15T13:01:11+0000', - end: '2016-10-15T14:01:11+0000', - }, + ...events[0], lock_action: 'edit', lock_user: 'ident1', lock_session: 'session1', + lock_time: '2023-04-28T12:01:11+0000', }, { - _id: 'e2', - name: 'Event 2', - dates: { - start: '2014-10-15T14:01:11+0000', - end: '2014-10-15T15:01:11+0000', - }, + ...events[1], lock_action: 'edit', lock_user: 'ident1', lock_session: 'session1', + lock_time: '2023-04-28T13:01:11+0000', }, ]; +export const eventLocks: {[key: string]: ILock} = { + [lockedEvents[0]._id]: { + item_id: lockedEvents[0]._id, + item_type: lockedEvents[0].type, + user: lockedEvents[0].lock_user, + session: lockedEvents[0].lock_session, + action: lockedEvents[0].lock_action, + time: lockedEvents[0].lock_time, + }, +}; export const lockedPlannings = [ { - _id: 'p1', - slugline: 'Planning1', - headline: 'Some Plan 1', - coverages: [], - agendas: [], + ...plannings[0], lock_action: 'edit', lock_user: 'ident1', lock_session: 'session1', + lock_time: '2023-04-28T12:01:11+0000', }, { - _id: 'p2', - slugline: 'Planning2', - headline: 'Some Plan 2', - event_item: 'e1', - coverages: [], - agendas: ['a2'], + ...plannings[1], lock_action: 'edit', lock_user: 'ident1', lock_session: 'session1', + lock_time: '2023-04-28T13:01:11+0000', + }, +]; + +export const planningLocks: {[key: string]: ILock} = { + [lockedPlannings[0]._id]: { + item_id: lockedPlannings[0]._id, + item_type: lockedPlannings[0].type, + user: lockedPlannings[0].lock_user, + session: lockedPlannings[0].lock_session, + action: lockedPlannings[0].lock_action, + time: lockedPlannings[0].lock_time, + }, +}; + +export const lockedAssignments = [ + { + ...assignments[0], + lock_action: 'reassign', + lock_user: 'ident1', + lock_session: 'session1', + lock_time: '2023-04-28T12:01:11+0000' }, ]; +export const assignmentLocks: {[key: string]: ILock} = { + [lockedAssignments[0]._id]: { + item_id: lockedAssignments[0]._id, + item_type: lockedAssignments[0].type, + user: lockedAssignments[0].lock_user, + session: lockedAssignments[0].lock_session, + action: lockedAssignments[0].lock_action, + time: lockedAssignments[0].lock_time, + }, +}; + export const archive = [ { _id: 'item1', @@ -991,6 +1020,7 @@ export const items = { events_history: eventsHistory, locked_events: lockedEvents, locked_plannings: lockedPlannings, + lockedAssignments: lockedAssignments, archive: archive, planning_search: events.concat(plannings), contacts: contacts, diff --git a/client/utils/tests/events_test.ts b/client/utils/tests/events_test.ts index dfafe4fd1..09c929a82 100644 --- a/client/utils/tests/events_test.ts +++ b/client/utils/tests/events_test.ts @@ -1,7 +1,8 @@ import sinon from 'sinon'; -import eventUtils from '../events'; import moment from 'moment'; import {cloneDeep, get} from 'lodash'; + +import {eventUtils, lockUtils} from '../'; import lockReducer from '../../reducers/locks'; import {EVENTS, WORKFLOW_STATE, POST_STATE} from '../../constants'; import {expectActions, restoreSinonStub} from '../testUtils'; @@ -146,19 +147,38 @@ describe('EventUtils', () => { lockedItems = lockReducer({}, { type: 'RECEIVE_LOCKS', payload: { - events: [ - locks.events.standalone.currentUser.currentSession, - locks.events.standalone.currentUser.otherSession, - locks.events.standalone.otherUser, - locks.events.recurring.currentUser.currentSession, - locks.events.recurring.currentUser.otherSession, - locks.events.recurring.otherUser, - ], - plans: [ - locks.plans.standalone, - locks.plans.recurring.direct, - locks.plans.recurring.indirect, - ], + event: { + [locks.events.standalone.currentUser.currentSession._id]: lockUtils.getLockFromItem( + locks.events.standalone.currentUser.currentSession + ), + [locks.events.standalone.currentUser.otherSession._id]: lockUtils.getLockFromItem( + locks.events.standalone.currentUser.otherSession + ), + [locks.events.standalone.otherUser._id]: lockUtils.getLockFromItem( + locks.events.standalone.otherUser + ), + [locks.plans.standalone.event_item]: lockUtils.getLockFromItem(locks.plans.standalone), + }, + recurring: { + [locks.events.recurring.currentUser.currentSession.recurrence_id]: lockUtils.getLockFromItem( + locks.events.recurring.currentUser.currentSession + ), + [locks.events.recurring.currentUser.otherSession.recurrence_id]: lockUtils.getLockFromItem( + locks.events.recurring.currentUser.otherSession + ), + [locks.events.recurring.otherUser.recurrence_id]: lockUtils.getLockFromItem( + locks.events.recurring.otherUser + ), + + [locks.plans.recurring.direct.recurrence_id]: lockUtils.getLockFromItem( + locks.plans.recurring.direct + ), + [locks.plans.recurring.indirect.recurrence_id]: lockUtils.getLockFromItem( + locks.plans.recurring.indirect + ), + }, + planning: {}, + assignment: {}, }, }); }); @@ -227,10 +247,10 @@ describe('EventUtils', () => { }); const isEventLocked = (event, result) => ( - expect(eventUtils.isEventLocked(event, lockedItems)).toBe(result) + expect(lockUtils.isItemLocked(event, lockedItems)).toBe(result) ); const isEventLockRestricted = (event, result) => ( - expect(eventUtils.isEventLockRestricted(event, session, lockedItems)).toBe(result) + expect(lockUtils.isLockRestricted(event, session, lockedItems)).toBe(result) ); it('isEventLocked', () => { diff --git a/client/utils/tests/locks_test.ts b/client/utils/tests/locks_test.ts index cb6342796..55e9c46f4 100644 --- a/client/utils/tests/locks_test.ts +++ b/client/utils/tests/locks_test.ts @@ -1,8 +1,6 @@ import {cloneDeep} from 'lodash'; -import sinon from 'sinon'; -import {restoreSinonStub} from '../testUtils'; -import {eventUtils, planningUtils, lockUtils} from '../index'; +import {lockUtils} from '../index'; import * as testData from '../testData'; describe('utils.locks', () => { @@ -94,12 +92,16 @@ describe('utils.locks', () => { it('isItemLockedInThisSession', () => { const expectItemLock = (result, lockedItem, currentSession) => ( - expect(lockUtils.isItemLockedInThisSession(lockedItem, currentSession)).toBe(result) + expect(lockUtils.isItemLockedInThisSession(lockedItem, currentSession, lockedItems)).toBe(result) ); let item = { + _id: 'e1', + type: 'event', lock_user: 'ident1', lock_session: 'session1', + lock_action: 'edit', + lock_time: '2099-10-15T14:30+0000', }; let session = { @@ -107,16 +109,30 @@ describe('utils.locks', () => { sessionId: 'session1', }; + lockedItems.event.e1 = { + item_id: 'e1', + item_type: 'event', + user: 'ident1', + session: 'session1', + action: 'edit', + time: '2099-10-15T14:30+0000', + }; + // Test item locked in this session expectItemLock(true, item, session); // Test item not locked in this session + lockedItems.event.e1.session = 'session2'; expectItemLock(false, {...item, lock_session: 'session2'}, session); + lockedItems.event.e1.user = 'ident2'; + lockedItems.event.e1.session = 'session1'; expectItemLock(false, {...item, lock_user: 'ident2'}, session); + lockedItems.event.e1.user = 'ident1'; expectItemLock(false, item, {...session, identity: {_id: 'ident2'}}); expectItemLock(false, item, {...session, sessionId: 'session2'}); // Test values not defined + delete lockedItems.event.e1; expectItemLock(false, {...item, lock_user: null}, session); expectItemLock(false, {...item, lock_session: null}, session); expectItemLock(false, item, {...session, identity: {_id: null}}); @@ -124,36 +140,4 @@ describe('utils.locks', () => { expectItemLock(false, {}, session); expectItemLock(false, item, {}); }); - - describe('isLockRestricted', () => { - beforeEach(() => { - sinon.stub(eventUtils, 'isEventLockRestricted').returns(true); - sinon.stub(planningUtils, 'isPlanningLockRestricted').returns(true); - }); - - afterEach(() => { - restoreSinonStub(eventUtils.isEventLockRestricted); - restoreSinonStub(planningUtils.isPlanningLockRestricted); - }); - - it('tests event and planning lock restriction', () => { - expect(lockUtils.isLockRestricted({type: 'event'}, testData.sessions[0], testData.locks)).toBe(true); - expect(eventUtils.isEventLockRestricted.callCount).toBe(1); - expect(eventUtils.isEventLockRestricted.args[0]).toEqual([ - {type: 'event'}, - testData.sessions[0], - testData.locks, - ]); - - expect(lockUtils.isLockRestricted({type: 'planning'}, testData.sessions[0], testData.locks)).toBe(true); - expect(planningUtils.isPlanningLockRestricted.callCount).toBe(1); - expect(planningUtils.isPlanningLockRestricted.args[0]).toEqual([ - {type: 'planning'}, - testData.sessions[0], - testData.locks, - ]); - - expect(lockUtils.isLockRestricted({}, testData.sessions[0], testData.locks)).toBe(false); - }); - }); }); diff --git a/client/utils/tests/planning_test.ts b/client/utils/tests/planning_test.ts index 05818c5f3..01c9358bc 100644 --- a/client/utils/tests/planning_test.ts +++ b/client/utils/tests/planning_test.ts @@ -2,11 +2,13 @@ import moment from 'moment'; import {get, omit} from 'lodash'; import {appConfig} from 'appConfig'; +import {ILockedItems} from '../../interfaces'; -import planUtils from '../planning'; +import {lockUtils, planningUtils} from '../index'; import lockReducer from '../../reducers/locks'; import {EVENTS, PLANNING, ASSIGNMENTS} from '../../constants'; import {expectActions} from '../testUtils'; +import {sessions} from '../testData'; describe('PlanningUtils', () => { let session; @@ -14,11 +16,7 @@ describe('PlanningUtils', () => { let lockedItems; beforeEach(() => { - session = { - identity: {_id: 'ident1'}, - sessionId: 'session1', - }; - + session = sessions[0]; locks = { plans: { adhoc: { @@ -173,30 +171,55 @@ describe('PlanningUtils', () => { lockedItems = lockReducer({}, { type: 'RECEIVE_LOCKS', payload: { - events: [ - locks.events.standalone, - locks.events.recurring, - ], - plans: [ - locks.plans.adhoc.currentUser.currentSession, - locks.plans.adhoc.currentUser.otherSession, - locks.plans.adhoc.otherUser, - locks.plans.event.currentUser.currentSession, - locks.plans.event.currentUser.otherSession, - locks.plans.event.otherUser, - locks.plans.recurring.currentUser.currentSession, - locks.plans.recurring.currentUser.otherSession, - locks.plans.recurring.otherUser, - ], + event: { + [locks.events.standalone._id]: lockUtils.getLockFromItem( + locks.events.standalone + ), + [locks.plans.event.currentUser.currentSession.event_item]: lockUtils.getLockFromItem( + locks.plans.event.currentUser.currentSession + ), + [locks.plans.event.currentUser.otherSession.event_item]: lockUtils.getLockFromItem( + locks.plans.event.currentUser.otherSession + ), + [locks.plans.event.otherUser.event_item]: lockUtils.getLockFromItem( + locks.plans.event.otherUser + ), + }, + recurring: { + [locks.events.recurring.recurrence_id]: lockUtils.getLockFromItem( + locks.events.recurring + ), + [locks.plans.recurring.currentUser.currentSession.recurrence_id]: lockUtils.getLockFromItem( + locks.plans.recurring.currentUser.currentSession + ), + [locks.plans.recurring.currentUser.otherSession.recurrence_id]: lockUtils.getLockFromItem( + locks.plans.recurring.currentUser.otherSession + ), + [locks.plans.recurring.otherUser.recurrence_id]: lockUtils.getLockFromItem( + locks.plans.recurring.otherUser + ), + }, + planning: { + [locks.plans.adhoc.currentUser.currentSession._id]: lockUtils.getLockFromItem( + locks.plans.adhoc.currentUser.currentSession + ), + [locks.plans.adhoc.currentUser.otherSession._id]: lockUtils.getLockFromItem( + locks.plans.adhoc.currentUser.otherSession + ), + [locks.plans.adhoc.otherUser._id]: lockUtils.getLockFromItem( + locks.plans.adhoc.otherUser + ), + }, + assignment: {}, }, }); }); const isPlanningLocked = (plan, result) => ( - expect(planUtils.isPlanningLocked(plan, lockedItems)).toBe(result) + expect(lockUtils.isItemLocked(plan, lockedItems)).toBe(result) ); const isPlanningLockRestricted = (plan, result) => ( - expect(planUtils.isPlanningLockRestricted(plan, session, lockedItems)).toBe(result) + expect(lockUtils.isLockRestricted(plan, session, lockedItems)).toBe(result) ); it('isPlanningLocked', () => { @@ -275,7 +298,7 @@ describe('PlanningUtils', () => { version_creator: 'ident2', }; - const coverage = planUtils.createCoverageFromNewsItem( + const coverage = planningUtils.createCoverageFromNewsItem( newsItem, newsCoverageStatus, desk, user, contentTypes); expect(omit(coverage, ['coverage_id', 'planning._scheduledTime'])).toEqual({ @@ -311,7 +334,7 @@ describe('PlanningUtils', () => { firstpublished: '2017-10-15T16:00:00', }; - const coverage = planUtils.createCoverageFromNewsItem( + const coverage = planningUtils.createCoverageFromNewsItem( newsItem, newsCoverageStatus, desk, user, contentTypes); expect(omit(coverage, ['coverage_id', 'planning._scheduledTime'])).toEqual({ @@ -347,7 +370,7 @@ describe('PlanningUtils', () => { firstpublished: '2017-10-15T16:00:00', }; - const coverage = planUtils.createCoverageFromNewsItem( + const coverage = planningUtils.createCoverageFromNewsItem( newsItem, newsCoverageStatus, desk, user, contentTypes); expect(omit(coverage, ['coverage_id', 'planning._scheduledTime'])).toEqual({ @@ -384,7 +407,7 @@ describe('PlanningUtils', () => { schedule_settings: {utc_publish_schedule: '2017-10-15T20:00:00'}, }; - const coverage = planUtils.createCoverageFromNewsItem( + const coverage = planningUtils.createCoverageFromNewsItem( newsItem, newsCoverageStatus, desk, user, contentTypes); expect(omit(coverage, ['coverage_id', 'planning._scheduledTime'])).toEqual({ @@ -443,7 +466,7 @@ describe('PlanningUtils', () => { place: [{name: 'Australia'}], }; - const plan = planUtils.createNewPlanningFromNewsItem( + const plan = planningUtils.createNewPlanningFromNewsItem( newsItem, newsCoverageStatus, desk, user, contentTypes); expect(plan).toEqual(jasmine.objectContaining({ @@ -498,7 +521,7 @@ describe('PlanningUtils', () => { flags: {marked_for_not_publication: true}, }; - const plan = planUtils.createNewPlanningFromNewsItem( + const plan = planningUtils.createNewPlanningFromNewsItem( newsItem, newsCoverageStatus, desk, user, contentTypes); expect(plan).toEqual(jasmine.objectContaining({ @@ -551,18 +574,19 @@ describe('PlanningUtils', () => { EVENTS.ITEM_ACTIONS.CONVERT_TO_RECURRING, ]; - let locks; + let locks: ILockedItems; let session; let planning; let event; let privileges; beforeEach(() => { - session = {}; + session = sessions[0]; locks = { event: {}, planning: {}, recurring: {}, + assignment: {}, }; event = null; planning = { @@ -580,7 +604,7 @@ describe('PlanningUtils', () => { }); it('draft event and planning', () => { - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -595,7 +619,7 @@ describe('PlanningUtils', () => { state: 'draft', planning_ids: ['1'], }; - itemActions = planUtils.getPlanningItemActions( + itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -613,7 +637,7 @@ describe('PlanningUtils', () => { it('postponed event and planning', () => { planning.state = 'postponed'; - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -627,7 +651,7 @@ describe('PlanningUtils', () => { state: 'postponed', planning_ids: ['1'], }; - itemActions = planUtils.getPlanningItemActions( + itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -641,7 +665,7 @@ describe('PlanningUtils', () => { it('canceled event and planning', () => { planning.state = 'cancelled'; - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -652,7 +676,7 @@ describe('PlanningUtils', () => { state: 'cancelled', planning_ids: ['1'], }; - itemActions = planUtils.getPlanningItemActions( + itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -661,7 +685,7 @@ describe('PlanningUtils', () => { it('rescheduled event and planning', () => { planning.state = 'rescheduled'; - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -674,7 +698,7 @@ describe('PlanningUtils', () => { state: 'rescheduled', planning_ids: ['1'], }; - itemActions = planUtils.getPlanningItemActions( + itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -691,7 +715,7 @@ describe('PlanningUtils', () => { planning_ids: ['1'], }; - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -713,7 +737,7 @@ describe('PlanningUtils', () => { planning_ids: ['1'], }; - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -729,18 +753,19 @@ describe('PlanningUtils', () => { }); it('add coverage', () => { - session = { - identity: {_id: 'ident1'}, - sessionId: 'session1', - }; + session = sessions[0]; planning.lock_user = 'ident1'; planning.lock_session = 'session1'; locks.planning.plan1 = { + item_id: 'plan1', + item_type: 'planning', user: 'ident1', session: 'session1', + action: 'edit', + time: '2023-04-20T13:01:11+0000', }; - let itemActions = planUtils.getPlanningItemActions( + let itemActions = planningUtils.getPlanningItemActions( planning, event, session, privileges, actions, locks ); @@ -767,7 +792,7 @@ describe('PlanningUtils', () => { ], }; - planUtils.modifyForClient(planning); + planningUtils.modifyForClient(planning); expect(get(planning, 'coverages[0].planning.genre.name')).toEqual('foo'); expect(get(planning, 'coverages[0].planning.genre.qcode')).toEqual('bar'); }); @@ -785,7 +810,7 @@ describe('PlanningUtils', () => { ], }; - planUtils.modifyForClient(planning); + planningUtils.modifyForClient(planning); expect(get(planning, 'coverages[0].planning.genre.name')).toEqual('foo'); expect(get(planning, 'coverages[0].planning.genre.qcode')).toEqual('bar'); }); @@ -801,7 +826,7 @@ describe('PlanningUtils', () => { ], }; - planUtils.modifyForClient(planning); + planningUtils.modifyForClient(planning); expect(get(planning, 'coverages[0].planning')).toEqual({}); }); }); @@ -820,7 +845,7 @@ describe('PlanningUtils', () => { ], }; - planUtils.modifyForServer(planning); + planningUtils.modifyForServer(planning); expect(get(planning, 'coverages[0].planning.genre[0].name')).toEqual('foo'); expect(get(planning, 'coverages[0].planning.genre[0].qcode')).toEqual('bar'); }); @@ -838,7 +863,7 @@ describe('PlanningUtils', () => { ], }; - planUtils.modifyForServer(planning); + planningUtils.modifyForServer(planning); expect(get(planning, 'coverages[0].planning.genre')).toEqual([1, 2]); }); @@ -853,7 +878,7 @@ describe('PlanningUtils', () => { ], }; - planUtils.modifyForServer(planning); + planningUtils.modifyForServer(planning); expect(get(planning, 'coverages[0].planning.genre')).toBeNull(null); }); }); @@ -873,7 +898,7 @@ describe('PlanningUtils', () => { workflow_status: 'draft', }; - expect(planUtils.canRemoveCoverage(coverage, planning)).toBe(true); + expect(planningUtils.canRemoveCoverage(coverage, planning)).toBe(true); }); it('cancelled coverages can be removed only if planning item is not cancelled', () => { @@ -886,7 +911,7 @@ describe('PlanningUtils', () => { workflow_status: 'cancelled', }; - expect(planUtils.canRemoveCoverage(coverage, planning)).toBe(true); + expect(planningUtils.canRemoveCoverage(coverage, planning)).toBe(true); }); it('no coverages can be removed if planning item is cancelled', () => { @@ -899,7 +924,7 @@ describe('PlanningUtils', () => { workflow_status: 'draft', }; - expect(planUtils.canRemoveCoverage(coverage, planning)).toBe(false); + expect(planningUtils.canRemoveCoverage(coverage, planning)).toBe(false); }); }); @@ -912,13 +937,13 @@ describe('PlanningUtils', () => { ednote: 'Ed note', planning_date: planned}; - let coverage = planUtils.defaultCoverageValues(newsCoverageStatus, plan, null); + let coverage = planningUtils.defaultCoverageValues(newsCoverageStatus, plan, null); expect(get(coverage, 'planning.scheduled').format()).toBe(planned.add(1, 'hour').format()); planned = moment('2119-03-15T09:33:00+11:00'); plan.planning_date = planned; - coverage = planUtils.defaultCoverageValues( + coverage = planningUtils.defaultCoverageValues( newsCoverageStatus, plan, null @@ -941,14 +966,14 @@ describe('PlanningUtils', () => { event_item: 'xxx'}; const event = {dates: {end: eventEnd}}; - let coverage = planUtils.defaultCoverageValues(newsCoverageStatus, plan, event); + let coverage = planningUtils.defaultCoverageValues(newsCoverageStatus, plan, event); expect(get(coverage, 'planning.scheduled').format()).toBe(eventEnd.add(1, 'hour').format()); eventEnd = moment('2119-03-17T09:33:00+11:00'); event.dates.end = eventEnd; - coverage = planUtils.defaultCoverageValues( + coverage = planningUtils.defaultCoverageValues( newsCoverageStatus, plan, event @@ -970,7 +995,7 @@ describe('PlanningUtils', () => { const event = {dates: {end: eventEnd, start: eventStart}}; appConfig.long_event_duration_threshold = 4; - let coverage = planUtils.defaultCoverageValues(newsCoverageStatus, plan, event); + let coverage = planningUtils.defaultCoverageValues(newsCoverageStatus, plan, event); expect(get(coverage, 'planning.scheduled', null)).toBeNull(null); }); diff --git a/server/planning/__init__.py b/server/planning/__init__.py index ef2e7c703..9ce3f618e 100644 --- a/server/planning/__init__.py +++ b/server/planning/__init__.py @@ -73,6 +73,7 @@ import planning.output_formatters # noqa import planning.io # noqa from planning.planning_download import init_app as init_planning_download_app +from planning.planning_locks import init_app as init_planning_locks_app __version__ = "2.7.0-dev" @@ -110,6 +111,7 @@ def init_app(app): init_search_app(app) init_validator_app(app) init_planning_download_app(app) + init_planning_locks_app(app) superdesk.register_resource( "planning_article_export", diff --git a/server/planning/assignments/assignments.py b/server/planning/assignments/assignments.py index dcc902f20..6e209f89f 100644 --- a/server/planning/assignments/assignments.py +++ b/server/planning/assignments/assignments.py @@ -254,6 +254,7 @@ def notify(self, event_name, updates, original): assigned_to = doc.get("assigned_to") or {} kwargs = { "item": doc.get(config.ID_FIELD), + "etag": doc.get("_etag"), "coverage": doc.get("coverage_item"), "planning": doc.get("planning_item"), "assigned_user": assigned_to.get("user"), diff --git a/server/planning/item_lock.py b/server/planning/item_lock.py index 0c4fcc2fa..9b9b245ad 100644 --- a/server/planning/item_lock.py +++ b/server/planning/item_lock.py @@ -41,6 +41,10 @@ def name(cls): def lock(self, item, user_id, session_id, action, resource): if not item: raise SuperdeskApiError.notFoundError() + elif self.existing_lock_is_unchanged(item, user_id, session_id, action): + # No need to lock the item for this user, session and action + # as it is already locked for such a purpose + return item item_service = get_resource_service(resource) item_id = item.get(config.ID_FIELD) @@ -88,6 +92,9 @@ def lock(self, item, user_id, session_id, action, resource): lock_session=str(session_id), lock_action=updates.get(LOCK_ACTION), etag=updates["_etag"], + event_item=item.get("event_item"), + recurrence_id=item.get("recurrence_id") or None, + type=item.get("type"), ) else: raise SuperdeskApiError.forbiddenError(message=error_message) @@ -102,9 +109,17 @@ def lock(self, item, user_id, session_id, action, resource): # unlock the lock :) unlock(lock_id, remove=True) + def existing_lock_is_unchanged(self, item, user_id, session_id, action): + return ( + item.get(LOCK_USER) == user_id and item.get(LOCK_SESSION) == session_id and item.get(LOCK_ACTION) == action + ) + def unlock(self, item, user_id, session_id, resource): if not item: raise SuperdeskApiError.notFoundError() + if item.get(LOCK_USER) is None and item.get(LOCK_SESSION) is None and item.get(LOCK_ACTION) is None: + # No need to unlock the item, as it is already unlocked + return item item_service = get_resource_service(resource) item_id = item.get(config.ID_FIELD) @@ -133,14 +148,16 @@ def unlock(self, item, user_id, session_id, resource): item=str(item.get(config.ID_FIELD)), user=str(user_id), lock_session=lock_session, - etag=item.get("_etag"), + etag=updates.get("_etag") or item.get("_etag"), event_item=item.get("event_item") or None, recurrence_id=item.get("recurrence_id") or None, + type=item.get("type"), ) return item def unlock_session(self, user_id, session_id, is_last_session): + logger.info(f"planning:item_lock: Unlocking session {session_id}") self.unlock_session_for_resource(user_id, session_id, is_last_session, "planning") self.unlock_session_for_resource(user_id, session_id, is_last_session, "events") self.unlock_session_for_resource(user_id, session_id, is_last_session, "assignments") @@ -155,6 +172,7 @@ def unlock_featured_planning(self, user_id, session_id, is_last_session): item_service.delete_action(lookup={}) def unlock_session_for_resource(self, user_id, session_id, is_last_session, resource): + logger.info(f"planning:item_lock: Unlocking {resource} resources") item_service = get_resource_service(resource) term_filter = {LOCK_USER: str(user_id)} if is_last_session else {LOCK_SESSION: str(session_id)} for item in item_service.search({"query": {"bool": {"filter": {"term": term_filter}}}}): @@ -195,6 +213,7 @@ def can_unlock(self, item, user_id, resource): return True, "" def on_session_end(self, user_id, session_id, is_last_session): + logger.info("planning:item_lock: On session end") self.unlock_session(user_id, session_id, is_last_session) def validate_relationship_locks(self, item, resource_name): diff --git a/server/planning/planning/planning_featured.py b/server/planning/planning/planning_featured.py index 7e4735d61..7c9ab524c 100644 --- a/server/planning/planning/planning_featured.py +++ b/server/planning/planning/planning_featured.py @@ -55,7 +55,9 @@ def on_created(self, docs): def on_update(self, updates, original): # Find all planning items in the list - added_featured = [id for id in updates.get("items") if id not in original.get("items")] + added_featured = [ + item_id for item_id in updates.get("items") or [] if item_id not in original.get("items") or [] + ] self.validate_featured_attrribute(added_featured) updates["version_creator"] = str(get_user_id()) self.post_featured_planning(updates, original) diff --git a/server/planning/planning_locks.py b/server/planning/planning_locks.py new file mode 100644 index 000000000..2a0652d17 --- /dev/null +++ b/server/planning/planning_locks.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8; -*- +# +# This file is part of Superdesk. +# +# Copyright 2023 Sourcefabric z.u. and contributors. +# +# For the full copyright and license information, please see the +# AUTHORS and LICENSE files distributed with this source code, or +# at https://www.sourcefabric.org/superdesk/license + + +from enum import Enum +from flask import request, json +from eve.utils import ParsedRequest +from eve.render import send_response + +from superdesk import Resource, get_resource_service, Blueprint, blueprint +from superdesk.auth.decorator import blueprint_auth + +from planning.search.queries.elastic import ElasticQuery, field_exists + + +class PlanningLocksResource(Resource): + resource_methods = ["GET"] + item_methods = [] + endpoint_name = "planning_locks" + allow_unknown = True + + +class PlanningLockRepos(Enum): + EVENTS_AND_PLANNING = "events_and_planning" + FEATURED_PLANNING = "featured_planning" + ASSIGNMENTS = "assignments" + + +DEFAULT_REPOS = ( + f"{PlanningLockRepos.EVENTS_AND_PLANNING.value}," + f"{PlanningLockRepos.FEATURED_PLANNING.value}," + f"{PlanningLockRepos.ASSIGNMENTS.value}" +) + +PROJECTED_FIELDS = [ + "_id", + "type", + "recurrence_id", + "event_item", + "lock_time", + "lock_action", + "lock_user", + "lock_session", +] + + +bp = Blueprint("planning_locks", __name__) + + +@bp.route("/planning_locks", methods=["GET", "OPTIONS"]) +@blueprint_auth() +def get_planning_locks(): + resp = _get_planning_module_locks() if request.method == "GET" else None + return send_response(None, (resp, None, None, 200)) + + +def _get_planning_module_locks(): + repos = (request.args.get("repos") or DEFAULT_REPOS).split(",") + + item_locks = [] + locks = {} + for repo in repos: + if repo == PlanningLockRepos.EVENTS_AND_PLANNING.value: + locks.update({"event": {}, "planning": {}, "recurring": {}}) + item_locks.extend(list(_get_event_locks())) + item_locks.extend(list(_get_planning_locks())) + elif repo == PlanningLockRepos.FEATURED_PLANNING.value: + locks["featured"] = None + item_locks.extend(list(_get_planning_featured_lock())) + elif repo == PlanningLockRepos.ASSIGNMENTS.value: + locks["assignment"] = {} + item_locks.extend(list(_get_assignment_locks())) + + for item in item_locks: + if item.get("_type") == "planning_featured_lock": + locks["featured"] = { + "item_id": item.get("_id"), + "item_type": item.get("_type"), + "user": item.get("lock_user"), + "session": item.get("lock_session"), + "action": "featured", + "time": item.get("lock_time"), + } + continue + + lock = { + "item_id": item.get("_id") if not item.get("recurrence_id") else item["recurrence_id"], + "item_type": item.get("type"), + "user": item.get("lock_user"), + "session": item.get("lock_session"), + "action": item.get("lock_action"), + "time": item.get("lock_time"), + } + if item.get("recurrence_id"): + locks["recurring"][lock["item_id"]] = lock + elif item.get("event_item"): + locks["event"][item["event_item"]] = lock + else: + locks[item["type"]][lock["item_id"]] = lock + + return locks + + +def _get_query(): + query = ElasticQuery() + query.must.append(field_exists("lock_session")) + req = ParsedRequest() + req.args = { + "source": json.dumps( + { + "query": query.build(), + "size": 1000, + "from": 0, + }, + ), + "projections": json.dumps(PROJECTED_FIELDS), + } + req.page = 1 + req.max_results = 1000 + + return req + + +def _get_event_locks(): + return get_resource_service("events").get(req=_get_query(), lookup=None) + + +def _get_planning_locks(): + return get_resource_service("planning").get(req=_get_query(), lookup=None) + + +def _get_planning_featured_lock(): + return get_resource_service("planning_featured_lock").get(req=_get_query(), lookup=None) + + +def _get_assignment_locks(): + return get_resource_service("assignments").get(req=_get_query(), lookup=None) + + +def init_app(app): + blueprint(bp, app) From ef44cb303a85349ac583357abba88ba99daae0b4 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Tue, 23 May 2023 10:23:35 +0530 Subject: [PATCH 032/261] Update the Desk validation on the coverage assignment form and the Coverage assignment filter based on the key 'planning_auto_assign_to_workflow' [SDESK-6743] (#1807) * update desk validation based on key planning_auto_assign_to_workflow * hide filter if planning_auto_assign_to_workflow is true * update elastic queries to disregard the cancelled coverages * Revert "update elastic queries to disregard the cancelled coverages" This reverts commit 0fb9aca41dc0bd536696bc9b66871f5c7a9d8c79. * Update assignment validation logic * address the comment * update tests config * refactore condition * update validation for desk --- client/components/fields/editor/AssignedCoverage.tsx | 8 +++++++- client/tests.ts | 1 + client/validators/assignments.tsx | 5 ++++- server/planning/assignments/assignments.py | 6 ++++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/client/components/fields/editor/AssignedCoverage.tsx b/client/components/fields/editor/AssignedCoverage.tsx index 1adae4ec2..8196628b6 100644 --- a/client/components/fields/editor/AssignedCoverage.tsx +++ b/client/components/fields/editor/AssignedCoverage.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; import {superdeskApi} from '../../../superdeskApi'; -import {ICoverageAssigned, IEditorFieldProps} from '../../../interfaces'; +import {ICoverageAssigned, IEditorFieldProps, IPlanningConfig} from '../../../interfaces'; import {EditorFieldSelect} from './base/select'; +import * as config from 'appConfig'; +const appConfig = config.appConfig as IPlanningConfig; interface IProps extends IEditorFieldProps { contentTypes: Array; @@ -14,6 +16,10 @@ export class EditorFieldAssignedCoverageComponent extends React.PureComponent { - if (isEmpty(get(value, 'deskId'))) { + if (isEmpty(get(value, 'deskId')) && appConfig.planning_auto_assign_to_workflow) { errors.desk = gettext('This field is required'); messages.push(gettext('{{ name }} is a required field', {name: field.toUpperCase()})); } else { diff --git a/server/planning/assignments/assignments.py b/server/planning/assignments/assignments.py index 6e209f89f..978b8869d 100644 --- a/server/planning/assignments/assignments.py +++ b/server/planning/assignments/assignments.py @@ -58,6 +58,7 @@ TO_BE_CONFIRMED_FIELD_SCHEMA, update_assignment_on_link_unlink, get_notify_self_on_assignment, + planning_auto_assign_to_workflow, ) from icalendar import Calendar, Event from flask import request, json, current_app as app @@ -182,8 +183,9 @@ def set_assignment(self, updates, original=None): updates["assigned_to"] = {} assigned_to = updates.get("assigned_to") or {} - if (assigned_to.get("user") or assigned_to.get("contact")) and not assigned_to.get("desk"): - raise SuperdeskApiError.badRequestError(message="Assignment should have a desk.") + if (assigned_to.get("user") or assigned_to.get("contact")) and planning_auto_assign_to_workflow(app): + if not assigned_to.get("desk"): + raise SuperdeskApiError.badRequestError(message="Assignment should have a desk.") # set the assignment information user = get_user() From 8dc3e9bffe4d98c82c467f5a23404ac1fcbbada5 Mon Sep 17 00:00:00 2001 From: devketanpro <73937490+devketanpro@users.noreply.github.com> Date: Tue, 23 May 2023 15:50:37 +0530 Subject: [PATCH 033/261] update coverage desk validation [SDESK-6743] (#1809) --- .../editor/EventRelatedPlannings/AddNewCoverages.tsx | 7 +++++-- .../editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx b/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx index 94d942d5e..85195c5f9 100644 --- a/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx @@ -7,7 +7,8 @@ import { IG2ContentType, IPlanningCoverageItem, IPlanningItem, - IPlanningNewsCoverageStatus + IPlanningNewsCoverageStatus, + IPlanningConfig } from '../../../../interfaces'; import {IDesk, IUser} from 'superdesk-api'; @@ -17,6 +18,8 @@ import {planningUtils, generateTempId} from '../../../../utils'; import {ButtonGroup, Button, IconLabel} from 'superdesk-ui-framework/react'; import {ICoverageDetails, CoverageRowForm} from './CoverageRowForm'; import {Group} from '../../../UI/List'; +import * as config from 'appConfig'; +const appConfig = config.appConfig as IPlanningConfig; interface IProps { event: IEventItem; @@ -100,7 +103,7 @@ class AddNewCoveragesComponent extends React.Component { Object.assign({}, coverage, updates) : coverage; - if (updatedCoverage.enabled && updatedCoverage.desk == null) { + if (updatedCoverage.enabled && updatedCoverage.desk == null && appConfig.planning_auto_assign_to_workflow) { errors[updatedCoverage.id] = {desk: gettext('Desk is required')}; } diff --git a/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx b/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx index 857363ce6..b7ab1fbf1 100644 --- a/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import {IDesk, IUser} from 'superdesk-api'; -import {IPlanningNewsCoverageStatus} from '../../../../interfaces'; +import {IPlanningNewsCoverageStatus, IPlanningConfig} from '../../../../interfaces'; import {ICoverageDetails} from './CoverageRowForm'; import {superdeskApi} from '../../../../superdeskApi'; @@ -11,6 +11,8 @@ import {Select, Option} from 'superdesk-ui-framework/react'; import * as List from '../../../UI/List'; import {Row, SelectUserInput} from '../../../UI/Form'; import {EditorFieldNewsCoverageStatus} from '../NewsCoverageStatus'; +import * as config from 'appConfig'; +const appConfig = config.appConfig as IPlanningConfig; interface IProps { coverage: ICoverageDetails; @@ -76,7 +78,7 @@ export class EmbeddedCoverageForm extends React.PureComponent { label={gettext('Desk:')} value={coverage.desk?._id} onChange={this.onDeskChange} - required={true} + required={appConfig.planning_auto_assign_to_workflow} invalid={this.props.errors?.desk != null} error={this.props.errors?.desk} > From 75f00981851f1899c5ff2a84ad0a1afa3a1d964d Mon Sep 17 00:00:00 2001 From: Nikola Stojanovic <68916411+dzonidoo@users.noreply.github.com> Date: Wed, 24 May 2023 15:03:28 +0200 Subject: [PATCH 034/261] [SDESK-6744] Improve displaying of coverage icons & avatars (#1800) * use new avatar instead of CoverageIcon componet * finished improvements on popup * rename PlanningDateTime.scss to coverage-icons.scss * remove unused code * remove CoverageIcon * use single avatars inside popover row * use spacer for layout * review and correct displayed data to match existing code only design should change, not data itself * update peer dependency * forgotten icon in avatar placeholder ; zindex; spacer usage update * fix lint * show type icon also for unassigned coverages; include workflow state label to icon tooltip, don't show avatar icon in popover since there is already an icon there * add overflow * fix lint * update ui-framework; fix tests * status/state should only be displayed if there's a desk as in the old implementation * fix lint * update usage of switch component * cancelled coverage icons * use gettext from superdeskApi * address review comments * use rem for line height * update icon colors * update ui-framework and usages of avatar group --------- Co-authored-by: Tomas Kikutis --- .../components/Coverages/CoverageHistory.tsx | 6 +- client/components/Coverages/CoverageIcon.tsx | 161 ---- client/components/Coverages/CoverageIcons.tsx | 275 ++++++ client/components/Coverages/CoverageItem.tsx | 6 +- .../components/Coverages/coverage-icons.scss | 20 + client/components/Coverages/index.ts | 1 - .../Editor/bookmarks/CoveragesBookmark.tsx | 71 +- .../components/Planning/PlanningDateTime.tsx | 25 +- client/components/Planning/PlanningItem.tsx | 2 +- .../RelatedPlanningListItem.tsx | 17 +- client/components/UI/List/Row.tsx | 4 +- client/components/UserAvatar/index.tsx | 6 +- client/utils/planning.ts | 59 +- package-lock.json | 863 +++++++++++------- package.json | 4 +- 15 files changed, 937 insertions(+), 583 deletions(-) delete mode 100644 client/components/Coverages/CoverageIcon.tsx create mode 100644 client/components/Coverages/CoverageIcons.tsx create mode 100644 client/components/Coverages/coverage-icons.scss diff --git a/client/components/Coverages/CoverageHistory.tsx b/client/components/Coverages/CoverageHistory.tsx index ad33987c5..0504b0b62 100644 --- a/client/components/Coverages/CoverageHistory.tsx +++ b/client/components/Coverages/CoverageHistory.tsx @@ -9,7 +9,7 @@ import {stringUtils, historyUtils, getDateTimeString, getItemInArrayById, gettex import {Item, Column, Row, Border} from '../UI/List'; import {ContentBlock} from '../UI/SidePanel'; import {CollapseBox} from '../UI'; -import {CoverageIcon} from './index'; +import {CoverageIcons} from './CoverageIcons'; export class CoverageHistory extends React.Component { getHistoryActionElement(historyItem) { @@ -143,8 +143,8 @@ export class CoverageHistory extends React.Component { - ; - users: Array; - desks: Array; - contentTypes: Array; - contacts: Dictionary; - tooltipDirection?: 'top' | 'right' | 'bottom' | 'left'; // defaults to 'right' - iconWrapper?(children: React.ReactNode): React.ReactNode; -} - -export class CoverageIcon extends React.PureComponent { - render() { - const {gettext} = superdeskApi.localization; - const language = this.props.coverage.planning?.language ?? getUserInterfaceLanguageFromCV(); - const user = this.props.users.find( - (u) => u._id === this.props.coverage.assigned_to?.user, - ); - const desk = this.props.desks.find( - (d) => d._id === this.props.coverage.assigned_to?.desk, - ); - const dateFormat = appConfig.planning.dateformat; - const timeFormat = appConfig.planning.timeformat; - let provider = this.props.coverage.assigned_to?.coverage_provider?.name; - const contactId = this.props.coverage.assigned_to?.contact; - - if (contactId != null && this.props.contacts?.[contactId] != null) { - const contact = this.props.contacts[contactId]; - - provider = contact.first_name ? - `${contact.last_name}, ${contact.first_name}` : - contact.organisation; - } - - const assignmentStr = desk ? - gettext('Desk: {{ desk }}', {desk: desk.name}) : - gettext('Status: Unassigned'); - let scheduledStr = this.props.coverage.planning?.scheduled != null && dateFormat && timeFormat ? - moment(this.props.coverage.planning.scheduled).format(dateFormat + ' ' + timeFormat) : - null; - - if (this.props.coverage._time_to_be_confirmed) { - scheduledStr = moment(this.props.coverage.planning.scheduled) - .format(dateFormat + ` @ ${gettext('TBC')}`); - } - const state = getItemWorkflowStateLabel(this.props.coverage.assigned_to); - const genre = getVocabularyItemFieldTranslated( - this.props.coverage.planning?.genre, - 'name', - language - ); - const slugline = this.props.coverage.planning?.slugline ?? ''; - const contentType = getVocabularyItemFieldTranslated( - this.props.contentTypes.find( - (type) => type.qcode === this.props.coverage.planning?.g2_content_type - ), - 'name', - language - ); - const icons = ( - - - - - ); - const ContentWrapper = this.props.iconWrapper != null ? - this.props.iconWrapper : - () => icons; - - return ( - - {!contentType?.length ? null : ( - - {gettext('Type: {{ type }}', {type: contentType})}
-
- )} - {!desk ? null : ( - - {gettext('Status: {{ state }}', {state: state.label})}
-
- )} - {assignmentStr} - {!user ? null : ( - -
{gettext('User: {{ user }}', {user: user.display_name})} -
- )} - {!provider ? null : ( - -
{gettext('Provider: {{ provider }}', {provider: provider})} -
- )} - {!genre ? null : ( - -
{gettext('Genre: {{ genre }}', {genre: genre})} -
- )} - {!slugline ? null : ( - -
{gettext('Slugline: {{ slugline }}', {slugline: slugline})} -
- )} - {!scheduledStr ? null : ( - -
{gettext('Due: {{ date }}', {date: scheduledStr})} -
- )} - {(this.props.coverage.scheduled_updates ?? []).map((s) => { - if (s.planning?.scheduled != null) { - scheduledStr = dateFormat && timeFormat ? - moment(s.planning.scheduled).format(dateFormat + ' ' + timeFormat) : - null; - return ( - -
{gettext('Update Due: {{ date }}', {date: scheduledStr})} -
- ); - } - - return null; - })} - - )} - > - {ContentWrapper(icons)} -
- ); - } -} diff --git a/client/components/Coverages/CoverageIcons.tsx b/client/components/Coverages/CoverageIcons.tsx new file mode 100644 index 000000000..4662d6325 --- /dev/null +++ b/client/components/Coverages/CoverageIcons.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; +import moment from 'moment-timezone'; +import {getUserInitials} from './../../components/UserAvatar'; +import * as config from 'appConfig'; +import {IPlanningCoverageItem, IG2ContentType, IContactItem, IPlanningConfig} from '../../interfaces'; +import {IUser, IDesk} from 'superdesk-api'; +import {superdeskApi} from '../../superdeskApi'; +import { + AvatarGroup, + ContentDivider, + Icon, + WithPopover, + Avatar, + AvatarPlaceholder, + Spacer, +} from 'superdesk-ui-framework/react'; +import {IPropsAvatarPlaceholder} from 'superdesk-ui-framework/react/components/avatar/avatar-placeholder'; +import {IPropsAvatar} from 'superdesk-ui-framework/react/components/avatar/avatar'; +import {trimStartExact} from 'superdesk-core/scripts/core/helpers/utils'; +import {getItemWorkflowStateLabel, planningUtils} from '../../utils'; +import {getVocabularyItemFieldTranslated} from '../../utils/vocabularies'; +import {getUserInterfaceLanguageFromCV} from '../../utils/users'; +import './coverage-icons.scss'; +import classNames from 'classnames'; +import {noop} from 'lodash'; + +interface IProps { + coverages: Array>; + users: Array; + desks: Array; + contentTypes: Array; + contacts?: Dictionary; + tooltipDirection?: 'top' | 'right' | 'bottom' | 'left'; // defaults to 'right' + iconWrapper?(children: React.ReactNode): React.ReactNode; +} + +const appConfig = config.appConfig as IPlanningConfig; + +export function isAvatarPlaceholder( + item: Omit | Omit +): item is Omit { + return (item as any)['kind'] != null; +} + +export function getAvatarForCoverage( + coverage: DeepPartial, + users: Array, + contentTypes: Array, + noIcon: boolean = false, +): Omit | Omit { + const user = users.find((u) => u._id === coverage.assigned_to?.user); + + const icon: {name: string; color: string} | undefined = + noIcon === true || coverage.planning?.g2_content_type == null ? undefined : { + name: trimStartExact( + planningUtils.getCoverageIcon( + planningUtils.getCoverageContentType( + coverage, + contentTypes, + ) || coverage.planning?.g2_content_type, + coverage, + ), + 'icon-', + ), + color: planningUtils.getCoverageIconColor(coverage), + }; + + if (user == null) { + const placeholder: Omit = { + kind: 'plus-button', + icon: icon, + }; + + return placeholder; + } else { + const avatar: Omit = { + initials: getUserInitials(user.display_name), + imageUrl: user.picture_url, + displayName: user.display_name, + icon: icon, + }; + + return avatar; + } +} + +export class CoverageIcons extends React.PureComponent { + render() { + const {coverages, users} = this.props; + const {gettext} = superdeskApi.localization; + + return ( + ( +
+ + {this.props.coverages.map((coverage, i) => { + const language = coverage.planning?.language ?? getUserInterfaceLanguageFromCV(); + const desk = this.props.desks.find( + (d) => d._id === coverage.assigned_to?.desk, + ); + const dateFormat = appConfig.planning.dateformat; + const timeFormat = appConfig.planning.timeformat; + + const assignmentStr = desk ? + gettext('Desk: {{ desk }}', {desk: desk.name}) : + gettext('Status: Unassigned'); + let scheduledStr = coverage.planning?.scheduled != null && dateFormat && timeFormat ? + moment(coverage.planning.scheduled).format(dateFormat + ' ' + timeFormat) : + null; + + if (coverage._time_to_be_confirmed) { + scheduledStr = moment(coverage.planning.scheduled) + .format(dateFormat + ` @ ${gettext('TBC')}`); + } + const slugline = coverage.planning?.slugline ?? ''; + const contentType = getVocabularyItemFieldTranslated( + this.props.contentTypes.find( + (type) => type.qcode === coverage.planning?.g2_content_type + ), + 'name', + language + ); + + const maybeAvatar = getAvatarForCoverage( + coverage, + users, + this.props.contentTypes, + true, + ); + const state = getItemWorkflowStateLabel(coverage.assigned_to); + + const iconTooltipInfo: Array = [ + gettext('Type: {{ type }}', {type: contentType}), + ]; + + if (desk != null) { + iconTooltipInfo.push(gettext('Status: {{ state }}', {state: state.label})); + } + + return ( + + +
+ + + +
+ +
+
+ + {gettext('Due:')} + + {scheduledStr} + + +
+ + {(coverage.scheduled_updates ?? []).map((s) => { + if (s.planning?.scheduled != null) { + const scheduledStr2 = dateFormat && timeFormat ? + moment(s.planning.scheduled) + .format(dateFormat + ' ' + timeFormat) : + null; + + return ( +
+ + {gettext('Update Due:')} + + {scheduledStr2} + + +
+ ); + } + + return null; + })} + + +
{assignmentStr}
+ +
+ +
+ + {!slugline ? null : ( +
+ + {slugline} + +
+ )} +
+
+
+ +
+ { + isAvatarPlaceholder(maybeAvatar) + ? ( + + ) + : ( + + ) + } +
+
+ ); + })} +
+
+ )} + > + {(onToggle) => ( +
{ + event.stopPropagation(); + onToggle(event.target as HTMLElement); + }} + > + getAvatarForCoverage(coverage, users, this.props.contentTypes), + )} + onClick={noop} // can move code from onClick, because event is not available + /> +
+ )} +
+ ); + } +} diff --git a/client/components/Coverages/CoverageItem.tsx b/client/components/Coverages/CoverageItem.tsx index f8aace117..dfd12729d 100644 --- a/client/components/Coverages/CoverageItem.tsx +++ b/client/components/Coverages/CoverageItem.tsx @@ -19,7 +19,7 @@ import {getUserInterfaceLanguageFromCV} from '../../utils/users'; import {Item, Column, Row, Border, ActionMenu} from '../UI/List'; import {StateLabel, InternalNoteLabel} from '../../components'; -import {CoverageIcon} from './CoverageIcon'; +import {CoverageIcons} from './CoverageIcons'; import {UserAvatar} from '../UserAvatar'; interface IProps { @@ -187,8 +187,8 @@ export class CoverageItemComponent extends React.Component { renderFirstRow() { return ( - ; @@ -66,34 +68,37 @@ class CoveragesBookmarkComponent extends React.Component { } renderForPanel() { - return (this.props.item?.coverages ?? []).map((coverage) => ( - ( - - )} - /> - )); + return (this.props.item?.coverages ?? []).map((coverage) => { + const {users} = this.props; + const maybeAvatar = getAvatarForCoverage(coverage, users, this.props.contentTypes); + + return ( + + ); + }); } renderForPopup() { @@ -150,9 +155,9 @@ class CoveragesBookmarkComponent extends React.Component { return null; } - return this.props.editorType === EDITOR_TYPE.POPUP ? - this.renderForPopup() : - this.renderForPanel(); + return this.props.editorType === EDITOR_TYPE.POPUP + ? this.renderForPopup() + : this.renderForPanel(); } } diff --git a/client/components/Planning/PlanningDateTime.tsx b/client/components/Planning/PlanningDateTime.tsx index 2985c8576..204514943 100644 --- a/client/components/Planning/PlanningDateTime.tsx +++ b/client/components/Planning/PlanningDateTime.tsx @@ -2,10 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import {get} from 'lodash'; import moment from 'moment'; - import {planningUtils} from '../../utils/index'; import {MAIN} from '../../constants'; -import {CoverageIcon} from '../Coverages/'; +import {CoverageIcons} from '../Coverages/CoverageIcons'; export const PlanningDateTime = ({ item, @@ -46,22 +45,12 @@ export const PlanningDateTime = ({ }); return ( - - {coverageToDisplay.map((coverage, i) => ( - - ) - )} - + ); }; diff --git a/client/components/Planning/PlanningItem.tsx b/client/components/Planning/PlanningItem.tsx index fac214669..1f78be4be 100644 --- a/client/components/Planning/PlanningItem.tsx +++ b/client/components/Planning/PlanningItem.tsx @@ -245,7 +245,7 @@ class PlanningItemComponent extends React.Component {
)} - + {/** overflow is needed for coverage icons */} {isExpired && (