diff --git a/.storybook/main.js b/.storybook/main.js index 3443047d7..6d3a8b83b 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,5 +1,11 @@ module.exports = { framework: '@storybook/angular', - addons: ['@storybook/addon-controls'], + addons: [ + '@storybook/addon-controls', + '@storybook/addon-viewport', + 'storybook-addon-designs', + 'storybook-addon-rtl-direction', + 'storybook-dark-mode', + ], stories: ['../src/**/*.stories.ts'], } diff --git a/.storybook/preview.js b/.storybook/preview.js index 4db753bc8..c3b705822 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -3,4 +3,9 @@ import '!style-loader!css-loader!sass-loader!./styles.scss'; export const parameters = { layout: 'centered', + darkMode: { + darkClass: 'dark', + classTarget: 'html', + stylePreview: true, + }, }; diff --git a/.storybook/styles.scss b/.storybook/styles.scss index 88ff7d26e..8be75bade 100644 --- a/.storybook/styles.scss +++ b/.storybook/styles.scss @@ -1,3 +1,7 @@ +storybook-dynamic-app-root { + color: var(--ion-text-color); +} + .core-error-info { max-width: 300px; } diff --git a/.vscode/moodle.code-snippets b/.vscode/moodle.code-snippets index d68e02b60..8d5b7c30d 100644 --- a/.vscode/moodle.code-snippets +++ b/.vscode/moodle.code-snippets @@ -7,7 +7,7 @@ "", "@Component({", " selector: '$2${TM_FILENAME_BASE}',", - " templateUrl: '$2${TM_FILENAME_BASE}.html',", + " templateUrl: '${TM_FILENAME_BASE}.html',", "})", "export class ${1:${TM_FILENAME_BASE}}Component {", "", @@ -110,6 +110,24 @@ ], "description": "[Moodle] Create a Pure Singleton" }, + "[Moodle] Events": { + "prefix": "maeventsdeclaration", + "body": [ + "declare module '@singletons/events' {", + "", + " /**", + " * Augment CoreEventsData interface with events specific to this service.", + " *", + " * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation", + " */", + " export interface CoreEventsData {", + " [$1]: $2;", + " }", + "", + "}" + ], + "description": "" + }, "Innherit doc": { "prefix": "inheritdoc", "body": [ diff --git a/package-lock.json b/package-lock.json index 3fa03dfcd..c1753405c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,12 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true + }, "@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -3385,6 +3391,12 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "dev": true }, + "@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "dev": true + }, "@emotion/utils": { "version": "0.11.3", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-0.11.3.tgz", @@ -3469,6 +3481,26 @@ } } }, + "@figspec/components": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/@figspec/components/-/components-0.1.10.tgz", + "integrity": "sha512-1Iy87RbTwwoxpXYLNkYRWpIYc/Ao02+56WVzp7TC9k52h3dv9+TJDvzqFpTmA9v1uaCQqnH4hq4LrJjciYpUoA==", + "dev": true, + "requires": { + "copy-to-clipboard": "^3.0.0", + "lit-element": "^2.4.0", + "lit-html": "^1.1.1" + } + }, + "@figspec/react": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@figspec/react/-/react-0.1.6.tgz", + "integrity": "sha512-oi0JL8uIXgJ+PWRl4LDxJ7WWa80E3jdYmi6wsHAFDq1vT0rKuyhqimEJzCezIrHHz4fXKpNRO98TO7ccma6hjw==", + "dev": true, + "requires": { + "@figspec/components": "^0.1.1" + } + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -4762,6 +4794,12 @@ } } }, + "@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "dev": true + }, "@moodlehq/cordova-plugin-camera": { "version": "6.0.0-moodle.2", "resolved": "https://registry.npmjs.org/@moodlehq/cordova-plugin-camera/-/cordova-plugin-camera-6.0.0-moodle.2.tgz", @@ -5611,6 +5649,25 @@ "ts-dedent": "^2.0.0" } }, + "@storybook/addon-viewport": { + "version": "6.1.21", + "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-6.1.21.tgz", + "integrity": "sha512-FrQk0BXCI4HdbBn9+8b+Cp2HvsweZkgW/joKfcF2vVLoasUBB4bl+9uU3HV/3a08glgjPl24caDMPgoRKS90WQ==", + "dev": true, + "requires": { + "@storybook/addons": "6.1.21", + "@storybook/api": "6.1.21", + "@storybook/client-logger": "6.1.21", + "@storybook/components": "6.1.21", + "@storybook/core-events": "6.1.21", + "@storybook/theming": "6.1.21", + "core-js": "^3.0.1", + "global": "^4.3.2", + "memoizerific": "^1.11.3", + "prop-types": "^15.7.2", + "regenerator-runtime": "^0.13.7" + } + }, "@storybook/addons": { "version": "6.1.21", "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.21.tgz", @@ -7151,6 +7208,114 @@ "lodash": "^4.17.15" } }, + "@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true + }, + "@storybook/manager-api": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-7.0.24.tgz", + "integrity": "sha512-cBpgDWq8reFgyrv4fBZlZJQyWYb9cDW0LDe476rWn/29uXNvYMNsHRwveLNgSA8Oy1NdyQCgf4ZgcYvY3wpvMA==", + "dev": true, + "requires": { + "@storybook/channels": "7.0.24", + "@storybook/client-logger": "7.0.24", + "@storybook/core-events": "7.0.24", + "@storybook/csf": "^0.1.0", + "@storybook/global": "^5.0.0", + "@storybook/router": "7.0.24", + "@storybook/theming": "7.0.24", + "@storybook/types": "7.0.24", + "dequal": "^2.0.2", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "semver": "^7.3.7", + "store2": "^2.14.2", + "telejson": "^7.0.3", + "ts-dedent": "^2.0.0" + }, + "dependencies": { + "@storybook/channels": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.0.24.tgz", + "integrity": "sha512-NZVLwMhtzy6cZrNRjshFvMAD9mQTmJDNwhohodSkM/YFCDVFhmxQk9tgizVGh9MwY3CYGJ1SI96RUejGosb49Q==", + "dev": true + }, + "@storybook/client-logger": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.0.24.tgz", + "integrity": "sha512-4zRTb+QQ1hWaRqad/UufZNRfi2d/cf5a40My72Ct97VwjhJFE6aQ3K+hl1Xt6hh8dncDL2JK3cgziw6ElqjT0w==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0" + } + }, + "@storybook/core-events": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.0.24.tgz", + "integrity": "sha512-xkf/rihCkhqMeh5EA8lVp90/mzbb2gcg6I3oeFWw2hognVcTnPXg6llhWdU4Spqd0cals7GEFmQugIILCmH8GA==", + "dev": true + }, + "@storybook/csf": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.1.tgz", + "integrity": "sha512-4hE3AlNVxR60Wc5KSC68ASYzUobjPqtSKyhV6G+ge0FIXU55N5nTY7dXGRZHQGDBPq+XqchMkIdlkHPRs8nTHg==", + "dev": true, + "requires": { + "type-fest": "^2.19.0" + } + }, + "@storybook/router": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/router/-/router-7.0.24.tgz", + "integrity": "sha512-SRCV+srCZUbko/V0phVN8jY8ilrxQWWAY/gegwNlIYaNqLJSyYqIj739VDmX+deXl6rOEpFLZreClVXWiDU9+w==", + "dev": true, + "requires": { + "@storybook/client-logger": "7.0.24", + "memoizerific": "^1.11.3", + "qs": "^6.10.0" + } + }, + "@storybook/theming": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.0.24.tgz", + "integrity": "sha512-CMeCCfqffJ/D5rBl1HpAM/e5Vw0h7ucT+CLzP0ALtLrguz9ZzOiIZYgMj17KpfvWqje7HT+DwEtNkSrnJ01FNQ==", + "dev": true, + "requires": { + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", + "@storybook/client-logger": "7.0.24", + "@storybook/global": "^5.0.0", + "memoizerific": "^1.11.3" + } + }, + "qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "telejson": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/telejson/-/telejson-7.1.0.tgz", + "integrity": "sha512-jFJO4P5gPebZAERPkJsqMAQ0IMA1Hi0AoSfxpnUaV6j6R2SZqlpkbS20U6dEUtA3RUYt2Ak/mTlkQzHH9Rv/hA==", + "dev": true, + "requires": { + "memoizerific": "^1.11.3" + } + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true + } + } + }, "@storybook/node-logger": { "version": "6.1.21", "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.1.21.tgz", @@ -7176,6 +7341,99 @@ } } }, + "@storybook/preview-api": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-7.0.24.tgz", + "integrity": "sha512-psycU07tuB5nyJvfAJiDN/9e8cjOdJ+5lrCSYC3vPzH86LxADDIN0/8xFb1CaQWcXZsADEFJGpHKWbRhjym5ew==", + "dev": true, + "requires": { + "@storybook/channel-postmessage": "7.0.24", + "@storybook/channels": "7.0.24", + "@storybook/client-logger": "7.0.24", + "@storybook/core-events": "7.0.24", + "@storybook/csf": "^0.1.0", + "@storybook/global": "^5.0.0", + "@storybook/types": "7.0.24", + "@types/qs": "^6.9.5", + "dequal": "^2.0.2", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "qs": "^6.10.0", + "synchronous-promise": "^2.0.15", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + }, + "dependencies": { + "@storybook/channel-postmessage": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-7.0.24.tgz", + "integrity": "sha512-QLtLXjEeTEwBN/7pB888mBaykmRU9Jy2BitvZuLJWyHHygTYm3vYZOaGR37DT+q/6Ob5GaZ0tURZmCSNDe8IIA==", + "dev": true, + "requires": { + "@storybook/channels": "7.0.24", + "@storybook/client-logger": "7.0.24", + "@storybook/core-events": "7.0.24", + "@storybook/global": "^5.0.0", + "qs": "^6.10.0", + "telejson": "^7.0.3" + } + }, + "@storybook/channels": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.0.24.tgz", + "integrity": "sha512-NZVLwMhtzy6cZrNRjshFvMAD9mQTmJDNwhohodSkM/YFCDVFhmxQk9tgizVGh9MwY3CYGJ1SI96RUejGosb49Q==", + "dev": true + }, + "@storybook/client-logger": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.0.24.tgz", + "integrity": "sha512-4zRTb+QQ1hWaRqad/UufZNRfi2d/cf5a40My72Ct97VwjhJFE6aQ3K+hl1Xt6hh8dncDL2JK3cgziw6ElqjT0w==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0" + } + }, + "@storybook/core-events": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.0.24.tgz", + "integrity": "sha512-xkf/rihCkhqMeh5EA8lVp90/mzbb2gcg6I3oeFWw2hognVcTnPXg6llhWdU4Spqd0cals7GEFmQugIILCmH8GA==", + "dev": true + }, + "@storybook/csf": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.1.tgz", + "integrity": "sha512-4hE3AlNVxR60Wc5KSC68ASYzUobjPqtSKyhV6G+ge0FIXU55N5nTY7dXGRZHQGDBPq+XqchMkIdlkHPRs8nTHg==", + "dev": true, + "requires": { + "type-fest": "^2.19.0" + } + }, + "qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "telejson": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/telejson/-/telejson-7.1.0.tgz", + "integrity": "sha512-jFJO4P5gPebZAERPkJsqMAQ0IMA1Hi0AoSfxpnUaV6j6R2SZqlpkbS20U6dEUtA3RUYt2Ak/mTlkQzHH9Rv/hA==", + "dev": true, + "requires": { + "memoizerific": "^1.11.3" + } + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true + } + } + }, "@storybook/router": { "version": "6.1.21", "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.1.21.tgz", @@ -7229,6 +7487,53 @@ } } }, + "@storybook/types": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.0.24.tgz", + "integrity": "sha512-SZh/XBHP1TT5bmEk0W52nT0v6fUnYwmZVls3da5noutdgOAiwL7TANtl41XrNjG+UDr8x0OE3PVVJi+LhwUaNA==", + "dev": true, + "requires": { + "@storybook/channels": "7.0.24", + "@types/babel__core": "^7.0.0", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" + }, + "dependencies": { + "@storybook/channels": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.0.24.tgz", + "integrity": "sha512-NZVLwMhtzy6cZrNRjshFvMAD9mQTmJDNwhohodSkM/YFCDVFhmxQk9tgizVGh9MwY3CYGJ1SI96RUejGosb49Q==", + "dev": true + }, + "file-system-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz", + "integrity": "sha512-l4DMNdsIPsVnKrgEXbJwDJsA5mB8rGwHYERMgqQx/xAUtChPJMre1bXBzDEqqVbWv9AIbFezXMxeEkZDSrXUOQ==", + "dev": true, + "requires": { + "fs-extra": "11.1.1", + "ramda": "0.29.0" + } + }, + "fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "ramda": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.29.0.tgz", + "integrity": "sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==", + "dev": true + } + } + }, "@storybook/ui": { "version": "6.1.21", "resolved": "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.21.tgz", @@ -7382,6 +7687,16 @@ "@babel/types": "^7.20.7" } }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/braces": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.1.tgz", @@ -7396,6 +7711,15 @@ "moment": "^2.10.2" } }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cordova": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", @@ -7406,6 +7730,30 @@ "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.7.tgz", "integrity": "sha512-ddDIRTO1ajtbxaNo2o7fPJggpN54PZf1ZUJKOjto2ENMJE/9GKUvaw3ZRuQzlS/p0E+PnIcssxfoqYJ4yiXSBw==" }, + "@types/express": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", + "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.35", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", + "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "@types/faker": { "version": "5.5.9", "resolved": "https://registry.npmjs.org/@types/faker/-/faker-5.5.9.tgz", @@ -7461,6 +7809,12 @@ "integrity": "sha512-h4lTMgMJctJybDp8CQrxTUiiYmedihHWkjnF/8Pxseu2S6Nlfcy8kwboQ8yejh456rP2yWoEVm1sS/FVsfM48w==", "dev": true }, + "@types/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", + "dev": true + }, "@types/is-function": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.1.tgz", @@ -7522,6 +7876,12 @@ "@types/react": "*" } }, + "@types/marked": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.1.tgz", + "integrity": "sha512-vSSbKZFbNktrQ15v7o1EaH78EbWV+sPQbPjHG+Cp8CaNcPFUEfjZ0Iml/V0bFDwsTlYe8o6XC5Hfdp91cqPV2g==", + "dev": true + }, "@types/micromatch": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.2.tgz", @@ -7531,6 +7891,12 @@ "@types/braces": "*" } }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, "@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -7614,6 +7980,12 @@ "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", "dev": true }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "@types/reach__router": { "version": "1.3.11", "resolved": "https://registry.npmjs.org/@types/reach__router/-/reach__router-1.3.11.tgz", @@ -7624,9 +7996,9 @@ } }, "@types/react": { - "version": "18.2.11", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.11.tgz", - "integrity": "sha512-+hsJr9hmwyDecSMQAmX7drgbDpyE+EgSF6t7+5QEBAn1tQK7kl1vWZ4iRf6SjQ8lk7dyEULxUmZOIpN0W5baZA==", + "version": "18.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.14.tgz", + "integrity": "sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==", "dev": true, "requires": { "@types/prop-types": "*", @@ -7674,6 +8046,27 @@ "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==", "dev": true }, + "@types/send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", + "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz", + "integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, "@types/slice-ansi": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", @@ -8474,18 +8867,11 @@ "dev": true }, "android-versions": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/android-versions/-/android-versions-1.8.1.tgz", - "integrity": "sha512-5a0YyylAk6pPM2Ezi0vWaPPNbS6tSNRs+micbgk5NpHEN5YW1ez+T94G5orysfwBEBDMHoxm5GNc5ZDUPgRrhw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/android-versions/-/android-versions-1.8.2.tgz", + "integrity": "sha512-2MT/Y/mR3BLSbR9E3ugwvE/aA4k84XtjG2Iusu4pRKt4FwfpEvIEAHzm7ZBhL3/aTVNdx3PzZ+sAiK+Dbc4r9A==", "requires": { - "semver": "^5.7.1" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - } + "semver": "^7.5.2" } }, "ansi": { @@ -9804,25 +10190,6 @@ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true }, - "bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "optional": true, - "requires": { - "file-uri-to-path": "1.0.0" - }, - "dependencies": { - "file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "optional": true - } - } - }, "bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -10134,13 +10501,13 @@ } }, "browserslist": { - "version": "4.21.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.7.tgz", - "integrity": "sha512-BauCXrQ7I2ftSqd2mvKHGo85XR0u7Ru3C/Hxsy/0TkfCtjrmAbPdzLGasmoiBxplpDXlPvdjX9u7srIMfgasNA==", + "version": "4.21.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.9.tgz", + "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", "dev": true, "requires": { - "caniuse-lite": "^1.0.30001489", - "electron-to-chromium": "^1.4.411", + "caniuse-lite": "^1.0.30001503", + "electron-to-chromium": "^1.4.431", "node-releases": "^2.0.12", "update-browserslist-db": "^1.0.11" } @@ -10372,9 +10739,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001502", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001502.tgz", - "integrity": "sha512-AZ+9tFXw1sS0o0jcpJQIXvFTOB/xGiQ4OQ2t98QX3NDn2EZTSRBC801gxrsGgViuq2ak/NLkNgSNEPtCr5lfKg==", + "version": "1.0.30001509", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001509.tgz", + "integrity": "sha512-2uDDk+TRiTX5hMcUYT/7CSyzMZxjfGu0vAUjS2g0LSD8UoXOv0LtpH4LxGMemsiPq6LCVIUjNwVM0erkOkGCDA==", "dev": true }, "canonical-path": { @@ -13991,6 +14358,12 @@ "integrity": "sha512-KqtH4/EZdtdfWX0p6MGP9jljvxSY6msy/pRUD4jgNwVpv3v1QmNLlsB3LDSSUg79BRVSn7jI1QPRtArGABovAQ==", "dev": true }, + "dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true + }, "des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -14355,9 +14728,9 @@ } }, "electron-to-chromium": { - "version": "1.4.427", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.427.tgz", - "integrity": "sha512-HK3r9l+Jm8dYAm1ctXEWIC+hV60zfcjS9UA5BDlYvnI5S7PU/yytjpvSrTNrSSRRkuu3tDyZhdkwIczh+0DWaw==", + "version": "1.4.442", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.442.tgz", + "integrity": "sha512-RkrZF//Ya+0aJq2NM3OdisNh5ZodZq1rdXOS96G8DdDgpDKqKE81yTbbQ3F/4CKm1JBPsGu1Lp/akkna2xO06Q==", "dev": true }, "element-resize-detector": { @@ -14930,17 +15303,17 @@ } }, "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, "requires": { + "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "type-check": "^0.4.0" } }, "prelude-ls": { @@ -16895,11 +17268,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true, - "requires": { - "bindings": "^1.5.0", - "nan": "^2.12.1" - } + "optional": true }, "glob-parent": { "version": "3.1.0", @@ -20339,9 +20708,9 @@ }, "dependencies": { "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", + "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", "dev": true }, "escodegen": { @@ -20814,6 +21183,21 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "lit-element": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.1.tgz", + "integrity": "sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==", + "dev": true, + "requires": { + "lit-html": "^1.1.1" + } + }, + "lit-html": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz", + "integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA==", + "dev": true + }, "load-json-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", @@ -21229,6 +21613,12 @@ "unquote": "^1.1.0" } }, + "marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true + }, "matchdep": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/matchdep/-/matchdep-2.0.0.tgz", @@ -21889,13 +22279,6 @@ "global": "^4.4.0" } }, - "nan": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz", - "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==", - "dev": true, - "optional": true - }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -23330,9 +23713,9 @@ } }, "pirates": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", - "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", "dev": true }, "pkcs7": { @@ -24988,9 +25371,9 @@ } }, "react-textarea-autosize": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.4.1.tgz", - "integrity": "sha512-aD2C+qK6QypknC+lCMzteOdIjoMbNlgSFmJjCV+DrfTPwp59i/it9mMNf2HDzvRjQgKAyBDPyLJhcrzElf2U4Q==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.0.tgz", + "integrity": "sha512-cp488su3U9RygmHmGpJp0KEt0i/+57KCK33XVPH+50swVRBhIZYh0fGduz2YLKXwl9vSKBZ9HUXcg9PQXUXqIw==", "dev": true, "requires": { "@babel/runtime": "^7.20.13", @@ -26382,9 +26765,9 @@ } }, "semver": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", - "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", + "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", "requires": { "lru-cache": "^6.0.0" } @@ -27377,6 +27760,118 @@ "integrity": "sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==", "dev": true }, + "storybook-addon-designs": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/storybook-addon-designs/-/storybook-addon-designs-6.1.0.tgz", + "integrity": "sha512-S7Av7MACRP3XtUeD4gHF/g3Ov+3WQ0KyJPqsjv4KFAZlP7CQXu2DkC/w9532OqTJV2C7ksCbi5XBrm5tbwMx3g==", + "dev": true, + "requires": { + "@figspec/react": "^0.1.6" + } + }, + "storybook-addon-rtl-direction": { + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/storybook-addon-rtl-direction/-/storybook-addon-rtl-direction-0.0.19.tgz", + "integrity": "sha512-0WPyRF0p+zlSqUj9f3HrTzTprSdmNvAmIaKeuH41B/lXP1zezMtxPa23pDepKe0y3+gV4PfsVo4wy07z0odGug==", + "dev": true + }, + "storybook-dark-mode": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/storybook-dark-mode/-/storybook-dark-mode-3.0.0.tgz", + "integrity": "sha512-aeAvqP/mmdccEiCsvx6aw3M0i7mZSiXROsrAsEQN8vl1lAg3FZN+y3Xu/f+ye59wLMRuKJC/JBp7E3/H7vLBRQ==", + "dev": true, + "requires": { + "@storybook/addons": "^7.0.0", + "@storybook/api": "^7.0.0", + "@storybook/components": "^7.0.0", + "@storybook/core-events": "^7.0.0", + "@storybook/global": "^5.0.0", + "@storybook/theming": "^7.0.0", + "fast-deep-equal": "^3.1.3", + "memoizerific": "^1.11.3" + }, + "dependencies": { + "@storybook/addons": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-7.0.24.tgz", + "integrity": "sha512-e15hORnOD0ugvOVOTyZyLJhbDTWa4G1OHVUlboazy8O4TSvAXNBdLV1wOdY5RGoGD6Z5A4iR/gZXM0qc6Fh9xg==", + "dev": true, + "requires": { + "@storybook/manager-api": "7.0.24", + "@storybook/preview-api": "7.0.24", + "@storybook/types": "7.0.24" + } + }, + "@storybook/api": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/api/-/api-7.0.24.tgz", + "integrity": "sha512-rjWZgBbt43Ma5Vg2RwK9FtiF9ZkLRT+vOfDFtRL1PQkOIUlYlm33dOdPTh+HrW5QMO9cj/cchqmzU2AtgEZCyw==", + "dev": true, + "requires": { + "@storybook/client-logger": "7.0.24", + "@storybook/manager-api": "7.0.24" + } + }, + "@storybook/client-logger": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.0.24.tgz", + "integrity": "sha512-4zRTb+QQ1hWaRqad/UufZNRfi2d/cf5a40My72Ct97VwjhJFE6aQ3K+hl1Xt6hh8dncDL2JK3cgziw6ElqjT0w==", + "dev": true, + "requires": { + "@storybook/global": "^5.0.0" + } + }, + "@storybook/components": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/components/-/components-7.0.24.tgz", + "integrity": "sha512-Pu7zGurCyWyiuFl2Pb5gybHA0f4blmHuVqccbMqnUw4Ew80BRu8AqfhNqN2hNdxFCx0mmy0baRGVftx76rNZ0w==", + "dev": true, + "requires": { + "@storybook/client-logger": "7.0.24", + "@storybook/csf": "^0.1.0", + "@storybook/global": "^5.0.0", + "@storybook/theming": "7.0.24", + "@storybook/types": "7.0.24", + "memoizerific": "^1.11.3", + "use-resize-observer": "^9.1.0", + "util-deprecate": "^1.0.2" + } + }, + "@storybook/core-events": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.0.24.tgz", + "integrity": "sha512-xkf/rihCkhqMeh5EA8lVp90/mzbb2gcg6I3oeFWw2hognVcTnPXg6llhWdU4Spqd0cals7GEFmQugIILCmH8GA==", + "dev": true + }, + "@storybook/csf": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.1.1.tgz", + "integrity": "sha512-4hE3AlNVxR60Wc5KSC68ASYzUobjPqtSKyhV6G+ge0FIXU55N5nTY7dXGRZHQGDBPq+XqchMkIdlkHPRs8nTHg==", + "dev": true, + "requires": { + "type-fest": "^2.19.0" + } + }, + "@storybook/theming": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-7.0.24.tgz", + "integrity": "sha512-CMeCCfqffJ/D5rBl1HpAM/e5Vw0h7ucT+CLzP0ALtLrguz9ZzOiIZYgMj17KpfvWqje7HT+DwEtNkSrnJ01FNQ==", + "dev": true, + "requires": { + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", + "@storybook/client-logger": "7.0.24", + "@storybook/global": "^5.0.0", + "memoizerific": "^1.11.3" + } + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true + } + } + }, "stream-browserify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", @@ -27917,10 +28412,16 @@ "object.getownpropertydescriptors": "^2.1.2" } }, + "synchronous-promise": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.17.tgz", + "integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==", + "dev": true + }, "systeminformation": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.18.3.tgz", - "integrity": "sha512-k+gk7zSi0hI/m3Mgu1OzR8j9BfXMDYa2HUMBdEQZUVCVAO326kDrzrvtVMljiSoDs6T6ojI0AHneDn8AMa0Y6A==" + "version": "5.18.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.18.5.tgz", + "integrity": "sha512-es2jgMdpjNv9B/sQc0afGlnS2ip7/Nzlo9hAgkaq22NZftQuqHTnf/CuBadzOwl/DZB2aCTLDtzw83nAnT2owg==" }, "table": { "version": "5.4.6", @@ -28163,9 +28664,9 @@ }, "dependencies": { "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", + "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", "dev": true }, "jest-worker": { @@ -28189,9 +28690,9 @@ } }, "schema-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", - "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, "requires": { "@types/json-schema": "^7.0.8", @@ -28225,9 +28726,9 @@ } }, "terser": { - "version": "5.17.7", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.7.tgz", - "integrity": "sha512-/bi0Zm2C6VAexlGgLlVxA0P2lru/sdLyfCVaRMfKVo9nWxbmz7f/sD8VPybPeSUJaJcwmCJis9pBIhcVcG1QcQ==", + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.18.2.tgz", + "integrity": "sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w==", "dev": true, "requires": { "@jridgewell/source-map": "^0.3.3", @@ -28644,9 +29145,9 @@ } }, "enhanced-resolve": { - "version": "5.14.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.1.tgz", - "integrity": "sha512-Vklwq2vDKtl0y/vtwjSesgJ5MYS7Etuk5txS8VdKL4AOS1aUlD96zqIfsOSLQsdv3xgMRbtkWM8eG9XDfKUPow==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", "dev": true, "requires": { "graceful-fs": "^4.2.4", @@ -28662,9 +29163,9 @@ } }, "tslib": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.3.tgz", - "integrity": "sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==" + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz", + "integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==" }, "tsutils": { "version": "3.21.0", @@ -29140,9 +29641,9 @@ }, "dependencies": { "schema-utils": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.2.0.tgz", - "integrity": "sha512-0zTyLGyDJYd/MBxG1AhJkKa6fpEBds4OQO2ut0w7OYG+ZGhGea09lijvzsqegYSik88zc7cUtIlnnO+/BvD6gQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, "requires": { "@types/json-schema": "^7.0.8", @@ -29201,6 +29702,15 @@ "use-isomorphic-layout-effect": "^1.1.1" } }, + "use-resize-observer": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", + "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==", + "dev": true, + "requires": { + "@juggle/resize-observer": "^3.3.1" + } + }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", @@ -29457,9 +29967,9 @@ }, "dependencies": { "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", + "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", "dev": true } } @@ -29639,11 +30149,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true, - "requires": { - "bindings": "^1.5.0", - "nan": "^2.12.1" - } + "optional": true }, "glob-parent": { "version": "3.1.0", @@ -30273,11 +30779,7 @@ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, - "optional": true, - "requires": { - "bindings": "^1.5.0", - "nan": "^2.12.1" - } + "optional": true }, "glob-parent": { "version": "3.1.0", @@ -30430,9 +30932,9 @@ "dev": true }, "webpack-hot-middleware": { - "version": "2.25.3", - "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.3.tgz", - "integrity": "sha512-IK/0WAHs7MTu1tzLTjio73LjS3Ov+VvBKQmE8WPlJutgG5zT6Urgq/BbAdRrHTRpyzK0dvAvFh1Qg98akxgZpA==", + "version": "2.25.4", + "resolved": "https://registry.npmjs.org/webpack-hot-middleware/-/webpack-hot-middleware-2.25.4.tgz", + "integrity": "sha512-IRmTspuHM06aZh98OhBJtqLpeWFM8FXJS5UYpKYxCJzyFoyWj1w6VGFfomZU7OPA55dMLrQK0pRT1eQ3PACr4w==", "dev": true, "requires": { "ansi-html-community": "0.0.8", @@ -30453,9 +30955,9 @@ "dev": true }, "html-entities": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.5.tgz", - "integrity": "sha512-72TJlcMkYsEJASa/3HnX7VT59htM7iSHbH59NSZbtc+22Ap0Txnlx91sfeB+/A7wNZg7UxtZdhAW4y+/jimrdg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", + "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", "dev": true }, "strip-ansi": { diff --git a/package.json b/package.json index 0b62e4e83..643db22fe 100644 --- a/package.json +++ b/package.json @@ -149,9 +149,11 @@ "@ionic/angular-toolkit": "^2.3.3", "@ionic/cli": "^6.19.0", "@storybook/addon-controls": "~6.1.21", + "@storybook/addon-viewport": "~6.1.21", "@storybook/angular": "~6.1.21", "@types/faker": "^5.1.3", "@types/jest": "^26.0.24", + "@types/marked": "^4.3.1", "@types/node": "^12.12.64", "@types/resize-observer-browser": "^0.1.5", "@types/webpack-env": "^1.16.0", @@ -183,9 +185,13 @@ "jest-preset-angular": "^8.3.1", "jest-raw-loader": "^1.0.1", "jsonc-parser": "^2.3.1", + "marked": "^4.3.0", "minimatch": "^5.1.0", "native-run": "^1.4.0", "patch-package": "^6.5.0", + "storybook-addon-designs": "~6.1.0", + "storybook-addon-rtl-direction": "0.0.19", + "storybook-dark-mode": "^3.0.0", "terser-webpack-plugin": "^4.2.3", "ts-jest": "^26.4.1", "ts-node": "~8.3.0", diff --git a/scripts/langindex.json b/scripts/langindex.json index 2141ce9ff..332b659a6 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -36,6 +36,7 @@ "addon.block_calendarupcoming.pluginname": "block_calendar_upcoming", "addon.block_comments.pluginname": "block_comments", "addon.block_completionstatus.pluginname": "block_completionstatus", + "addon.block_globalsearch.pluginname": "block_globalsearch", "addon.block_glossaryrandom.pluginname": "block_glossary_random", "addon.block_learningplans.pluginname": "block_lp", "addon.block_myoverview.all": "block_myoverview", @@ -2310,6 +2311,16 @@ "core.scanqr": "local_moodlemobileapp", "core.scrollbackward": "local_moodlemobileapp", "core.scrollforward": "local_moodlemobileapp", + "core.search.allcourses": "search", + "core.search.allcategories": "local_moodlemobileapp", + "core.search.empty": "local_moodlemobileapp", + "core.search.filtercategories": "local_moodlemobileapp", + "core.search.filtercourses": "local_moodlemobileapp", + "core.search.filterheader": "search", + "core.search.globalsearch": "search", + "core.search.noresults": "local_moodlemobileapp", + "core.search.noresultshelp": "local_moodlemobileapp", + "core.search.resultby": "local_moodlemobileapp", "core.search": "moodle", "core.searching": "local_moodlemobileapp", "core.searchresults": "moodle", diff --git a/src/addons/block/block.module.ts b/src/addons/block/block.module.ts index f62aa4481..0989bedf9 100644 --- a/src/addons/block/block.module.ts +++ b/src/addons/block/block.module.ts @@ -41,6 +41,7 @@ import { AddonBlockSiteMainMenuModule } from './sitemainmenu/sitemainmenu.module import { AddonBlockStarredCoursesModule } from './starredcourses/starredcourses.module'; import { AddonBlockTagsModule } from './tags/tags.module'; import { AddonBlockTimelineModule } from './timeline/timeline.module'; +import { AddonBlockGlobalSearchModule } from '@addons/block/globalsearch/globalsearch.module'; @NgModule({ imports: [ @@ -55,6 +56,7 @@ import { AddonBlockTimelineModule } from './timeline/timeline.module'; AddonBlockCommentsModule, AddonBlockCompletionStatusModule, AddonBlockCourseListModule, + AddonBlockGlobalSearchModule, AddonBlockGlossaryRandomModule, AddonBlockHtmlModule, AddonBlockLearningPlansModule, diff --git a/src/addons/block/globalsearch/globalsearch.module.ts b/src/addons/block/globalsearch/globalsearch.module.ts new file mode 100644 index 000000000..3861622ec --- /dev/null +++ b/src/addons/block/globalsearch/globalsearch.module.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreBlockDelegate } from '@features/block/services/block-delegate'; +import { AddonBlockGlobalSearchHandler } from './services/block-handler'; +import { CoreBlockComponentsModule } from '@features/block/components/components.module'; + +@NgModule({ + imports: [ + IonicModule, + CoreBlockComponentsModule, + TranslateModule.forChild(), + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreBlockDelegate.registerHandler(AddonBlockGlobalSearchHandler.instance); + }, + }, + ], +}) +export class AddonBlockGlobalSearchModule {} diff --git a/src/addons/block/globalsearch/lang.json b/src/addons/block/globalsearch/lang.json new file mode 100644 index 000000000..abf5ca286 --- /dev/null +++ b/src/addons/block/globalsearch/lang.json @@ -0,0 +1,3 @@ +{ + "pluginname": "Global search" +} diff --git a/src/addons/block/globalsearch/services/block-handler.ts b/src/addons/block/globalsearch/services/block-handler.ts new file mode 100644 index 000000000..609befb5f --- /dev/null +++ b/src/addons/block/globalsearch/services/block-handler.ts @@ -0,0 +1,57 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreBlockHandlerData } from '@features/block/services/block-delegate'; +import { CoreBlockOnlyTitleComponent } from '@features/block/components/only-title-block/only-title-block'; +import { CoreBlockBaseHandler } from '@features/block/classes/base-block-handler'; +import { makeSingleton } from '@singletons'; +import { CoreCourseBlock } from '@features/course/services/course'; +import { CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu'; +import { CoreSearchGlobalSearch } from '@features/search/services/global-search'; + +/** + * Block handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonBlockGlobalSearchHandlerService extends CoreBlockBaseHandler { + + name = 'AddonBlockGlobalSearch'; + blockName = 'globalsearch'; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return CoreSearchGlobalSearch.isEnabled(); + } + + /** + * @inheritdoc + */ + getDisplayData(block: CoreCourseBlock, contextLevel: string, instanceId: number): CoreBlockHandlerData | undefined { + return { + title: 'addon.block_globalsearch.pluginname', + class: 'addon-block-globalsearch', + component: CoreBlockOnlyTitleComponent, + link: CORE_SEARCH_PAGE_NAME, + linkParams: contextLevel === 'course' + ? { courseId: instanceId } + : {}, + }; + } + +} + +export const AddonBlockGlobalSearchHandler = makeSingleton(AddonBlockGlobalSearchHandlerService); diff --git a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts index 3992ed377..1194ca25e 100644 --- a/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts +++ b/src/addons/block/recentlyaccesseditems/components/recentlyaccesseditems/recentlyaccesseditems.ts @@ -21,7 +21,6 @@ import { } from '../../services/recentlyaccesseditems'; import { CoreTextUtils } from '@services/utils/text'; import { CoreDomUtils } from '@services/utils/dom'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreUtils } from '@services/utils/utils'; /** @@ -88,10 +87,7 @@ export class AddonBlockRecentlyAccessedItemsComponent extends CoreBlockBaseCompo const modal = await CoreDomUtils.showModalLoading(); try { - const treated = await CoreContentLinksHelper.handleLink(url); - if (!treated) { - return CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(url); - } + await CoreSites.visitLink(url); } finally { modal.dismiss(); } diff --git a/src/addons/block/timeline/components/events/events.ts b/src/addons/block/timeline/components/events/events.ts index 7b5cbbdac..2b855bd02 100644 --- a/src/addons/block/timeline/components/events/events.ts +++ b/src/addons/block/timeline/components/events/events.ts @@ -16,7 +16,6 @@ import { Component, Input, Output, EventEmitter } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreEnrolledCourseDataWithOptions } from '@features/courses/services/courses-helper'; import { AddonBlockTimelineDayEvents } from '@addons/block/timeline/classes/section'; @@ -54,10 +53,7 @@ export class AddonBlockTimelineEventsComponent { const modal = await CoreDomUtils.showModalLoading(); try { - const treated = await CoreContentLinksHelper.handleLink(url); - if (!treated) { - return CoreSites.getRequiredCurrentSite().openInBrowserWithAutoLogin(url); - } + await CoreSites.visitLink(url); } finally { modal.dismiss(); } diff --git a/src/addons/mod/feedback/pages/form/form.ts b/src/addons/mod/feedback/pages/form/form.ts index 0c45198a1..65f34f18c 100644 --- a/src/addons/mod/feedback/pages/form/form.ts +++ b/src/addons/mod/feedback/pages/form/form.ts @@ -14,7 +14,6 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { CoreSite } from '@classes/site'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreCourse, CoreCourseCommonModWSOptions } from '@features/course/services/course'; import { CoreCourseModuleData } from '@features/course/services/course-helper'; import { CanLeave } from '@guards/can-leave'; @@ -428,11 +427,7 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave { const modal = await CoreDomUtils.showModalLoading(); try { - const treated = await CoreContentLinksHelper.handleLink(this.siteAfterSubmit); - - if (!treated) { - await this.currentSite.openInBrowserWithAutoLogin(this.siteAfterSubmit); - } + await CoreSites.visitLink(this.siteAfterSubmit, { siteId: this.currentSite.id }); } finally { modal.dismiss(); } diff --git a/src/addons/mod/forum/services/handlers/module.ts b/src/addons/mod/forum/services/handlers/module.ts index e55e46453..7917e8df8 100644 --- a/src/addons/mod/forum/services/handlers/module.ts +++ b/src/addons/mod/forum/services/handlers/module.ts @@ -19,7 +19,6 @@ import { CoreEvents } from '@singletons/events'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; import { CoreConstants, ModPurpose } from '@/core/constants'; -import { AddonModForumIndexComponent } from '../../components/index'; import { CoreModuleHandlerBase } from '@features/course/classes/module-base-handler'; import { CoreCourseModuleData } from '@features/course/services/course-helper'; import { CoreIonicColorNames } from '@singletons/colors'; @@ -86,6 +85,8 @@ export class AddonModForumModuleHandlerService extends CoreModuleHandlerBase imp * @inheritdoc */ async getMainComponent(): Promise | undefined> { + const { AddonModForumIndexComponent } = await import('../../components/index'); + return AddonModForumIndexComponent; } diff --git a/src/addons/mod/url/services/url-helper.ts b/src/addons/mod/url/services/url-helper.ts index c59e77076..da2d65d3b 100644 --- a/src/addons/mod/url/services/url-helper.ts +++ b/src/addons/mod/url/services/url-helper.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { makeSingleton } from '@singletons'; @@ -33,11 +32,10 @@ export class AddonModUrlHelperProvider { const modal = await CoreDomUtils.showModalLoading(); try { - const treated = await CoreContentLinksHelper.handleLink(url, undefined, true, true); - - if (!treated) { - await CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(url); - } + await CoreSites.visitLink(url, { + checkRoot: true, + openBrowserRoot: true, + }); } finally { modal.dismiss(); } diff --git a/src/addons/report/insights/services/handlers/action-link.ts b/src/addons/report/insights/services/handlers/action-link.ts index 1c25b13db..e91ce2386 100644 --- a/src/addons/report/insights/services/handlers/action-link.ts +++ b/src/addons/report/insights/services/handlers/action-link.ts @@ -16,7 +16,6 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { makeSingleton, Translate } from '@singletons'; @@ -75,13 +74,7 @@ export class AddonReportInsightsActionLinkHandlerService extends CoreContentLink // Try to open the link in the app. const forwardUrl = decodeURIComponent(params.forwardurl); - const treated = await CoreContentLinksHelper.handleLink(forwardUrl); - if (!treated) { - // Cannot be opened in the app, open in browser. - const site = await CoreSites.getSite(siteId); - - await site.openInBrowserWithAutoLogin(forwardUrl); - } + await CoreSites.visitLink(forwardUrl, { siteId }); } }, }]; diff --git a/src/assets/storybook/courses.json b/src/assets/storybook/courses.json new file mode 100644 index 000000000..81b9390e0 --- /dev/null +++ b/src/assets/storybook/courses.json @@ -0,0 +1 @@ +[{"id":1,"courseimage":"https://picsum.photos/500/500","shortname":"Moodle and Mountaineering","summary":"This course will introduce you to the basics of Alpine Mountaineering, while at the same time highlighting some of the great features of Moodle."},{"id":2,"courseimage":"assets/storybook/geopattern.svg","shortname":"Digital Literacy","summary":"This course explores Digital Literacy and its importance for teachers and students. The course is optimised for the Moodle App. Please try it out!"},{"id":3,"shortname":"Class and Conflict in World Cinema","summary":"In this module we will analyse two very significant films - City of God and La Haine, both of which depict violent lives in poor conditions, the former in the favelas of Brazil and the latter in a Parisian banlieue. We will look at how conflict and class are portrayed, focusing particularly on the use of mise en scène."}] diff --git a/src/assets/storybook/geopattern.svg b/src/assets/storybook/geopattern.svg new file mode 100644 index 000000000..60e7e8a9d --- /dev/null +++ b/src/assets/storybook/geopattern.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/assets/storybook/sites/school.json b/src/assets/storybook/sites/school.json new file mode 100644 index 000000000..1260527ba --- /dev/null +++ b/src/assets/storybook/sites/school.json @@ -0,0 +1 @@ +{"id":"123456","info":{"version":"2022041900","sitename":"School","username":"barbara","firstname":"Barbara","lastname":"Gardner","fullname":"Barbara Gardner","lang":"en","userid":1,"siteurl":"https://campus.example.edu","userpictureurl":"","functions":[]}} diff --git a/src/core/classes/items-management/items-manager-source.ts b/src/core/classes/items-management/items-manager-source.ts index c2d6fe0fd..06059e5be 100644 --- a/src/core/classes/items-management/items-manager-source.ts +++ b/src/core/classes/items-management/items-manager-source.ts @@ -36,6 +36,15 @@ export abstract class CoreItemsManagerSource { this.loadedPromise = new Promise(resolve => this.resolveLoaded = resolve); } + /** + * Check whether the source is dirty. + * + * @returns Whether the source is dirty. + */ + isDirty(): boolean { + return this.dirty; + } + /** * Check whether data is loaded. * @@ -88,6 +97,7 @@ export abstract class CoreItemsManagerSource { reset(): void { this.items = null; this.dirty = false; + this.loaded = false; this.listeners.forEach(listener => listener.onReset?.call(listener)); } diff --git a/src/core/classes/items-management/paginated-items-manager-source.ts b/src/core/classes/items-management/paginated-items-manager-source.ts new file mode 100644 index 000000000..0e4e02d07 --- /dev/null +++ b/src/core/classes/items-management/paginated-items-manager-source.ts @@ -0,0 +1,129 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreItemsManagerSource } from './items-manager-source'; + +/** + * Paginated items collection source data. + */ +export abstract class CorePaginatedItemsManagerSource extends CoreItemsManagerSource { + + protected hasMoreItems = true; + + /** + * Check whether there are more pages to be loaded. + * + * @returns Whether there are more pages to be loaded. + */ + isCompleted(): boolean { + return !this.hasMoreItems; + } + + /** + * Check whether the source is empty or not. + * + * @returns Whether the source is empty. + */ + isEmpty(): boolean { + return !this.isLoaded() || (this.getItems() ?? []).length === 0; + } + + /** + * Get the count of pages that have been loaded. + * + * @returns Pages loaded. + */ + getPagesLoaded(): number { + if (this.items === null) { + return 0; + } + + const pageLength = this.getPageLength(); + if (pageLength === null) { + return 1; + } + + return Math.ceil(this.items.length / pageLength); + } + + /** + * Reset collection data. + */ + reset(): void { + this.hasMoreItems = true; + + super.reset(); + } + + /** + * Reload the collection, this resets the data to the first page. + */ + async reload(): Promise { + this.dirty = true; + + await this.load(); + } + + /** + * Load more items, if any. + */ + async load(): Promise { + if (this.dirty) { + const { items, hasMoreItems } = await this.loadPageItems(0); + + this.dirty = false; + this.setItems(items, hasMoreItems ?? false); + + return; + } + + if (!this.hasMoreItems) { + return; + } + + const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); + + this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false); + } + + /** + * Load page items. + * + * @param page Page number (starting at 0). + * @returns Page items data. + */ + protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; + + /** + * Get the length of each page in the collection. + * + * @returns Page length; null for collections that don't support pagination. + */ + protected getPageLength(): number | null { + return null; + } + + /** + * Update the collection items. + * + * @param items Items. + * @param hasMoreItems Whether there are more pages to be loaded. + */ + protected setItems(items: Item[], hasMoreItems = false): void { + this.hasMoreItems = hasMoreItems; + + super.setItems(items); + } + +} diff --git a/src/core/classes/items-management/routed-items-manager-source.ts b/src/core/classes/items-management/routed-items-manager-source.ts index 294d65cc6..4a02c4624 100644 --- a/src/core/classes/items-management/routed-items-manager-source.ts +++ b/src/core/classes/items-management/routed-items-manager-source.ts @@ -13,14 +13,12 @@ // limitations under the License. import { Params } from '@angular/router'; -import { CoreItemsManagerSource } from './items-manager-source'; +import { CorePaginatedItemsManagerSource } from './paginated-items-manager-source'; /** * Routed items collection source data. */ -export abstract class CoreRoutedItemsManagerSource extends CoreItemsManagerSource { - - protected hasMoreItems = true; +export abstract class CoreRoutedItemsManagerSource extends CorePaginatedItemsManagerSource { /** * Get a string to identify instances constructed with the given arguments as being reusable. @@ -32,102 +30,6 @@ export abstract class CoreRoutedItemsManagerSource extends CoreI return args.map(argument => String(argument)).join('-'); } - /** - * Check whether there are more pages to be loaded. - * - * @returns Whether there are more pages to be loaded. - */ - isCompleted(): boolean { - return !this.hasMoreItems; - } - - /** - * Get the count of pages that have been loaded. - * - * @returns Pages loaded. - */ - getPagesLoaded(): number { - if (this.items === null) { - return 0; - } - - const pageLength = this.getPageLength(); - if (pageLength === null) { - return 1; - } - - return Math.ceil(this.items.length / pageLength); - } - - /** - * Reset collection data. - */ - reset(): void { - this.hasMoreItems = true; - - super.reset(); - } - - /** - * Reload the collection, this resets the data to the first page. - */ - async reload(): Promise { - this.dirty = true; - - await this.load(); - } - - /** - * Load more items, if any. - */ - async load(): Promise { - if (this.dirty) { - const { items, hasMoreItems } = await this.loadPageItems(0); - - this.dirty = false; - this.setItems(items, hasMoreItems ?? false); - - return; - } - - if (!this.hasMoreItems) { - return; - } - - const { items, hasMoreItems } = await this.loadPageItems(this.getPagesLoaded()); - - this.setItems((this.items ?? []).concat(items), hasMoreItems ?? false); - } - - /** - * Load page items. - * - * @param page Page number (starting at 0). - * @returns Page items data. - */ - protected abstract loadPageItems(page: number): Promise<{ items: Item[]; hasMoreItems?: boolean }>; - - /** - * Get the length of each page in the collection. - * - * @returns Page length; null for collections that don't support pagination. - */ - protected getPageLength(): number | null { - return null; - } - - /** - * Update the collection items. - * - * @param items Items. - * @param hasMoreItems Whether there are more pages to be loaded. - */ - protected setItems(items: Item[], hasMoreItems = false): void { - this.hasMoreItems = hasMoreItems; - - super.setItems(items); - } - /** * Get the query parameters to use when navigating to an item page. * diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 5531cdf9b..c39a09f1e 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -2747,6 +2747,8 @@ export const enum CoreSiteConfigSupportAvailability { */ export type CoreSiteConfig = Record & { supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability. + searchbanner?: string; // Search banner text. + searchbannerenable?: string; // Whether search banner is enabled. }; /** diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 4609c0f04..fc64175b1 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -64,6 +64,7 @@ import { CoreMessageComponent } from './message/message'; import { CoreGroupSelectorComponent } from './group-selector/group-selector'; import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal'; import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; +import { CoreCourseImageComponent } from '@components/course-image/course-image'; @NgModule({ declarations: [ @@ -75,6 +76,7 @@ import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; CoreContextMenuComponent, CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, + CoreCourseImageComponent, CoreDownloadRefreshComponent, CoreDynamicComponent, CoreEmptyBoxComponent, @@ -128,6 +130,7 @@ import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; CoreContextMenuComponent, CoreContextMenuItemComponent, CoreContextMenuPopoverComponent, + CoreCourseImageComponent, CoreDownloadRefreshComponent, CoreDynamicComponent, CoreEmptyBoxComponent, diff --git a/src/core/components/course-image/course-image.html b/src/core/components/course-image/course-image.html new file mode 100644 index 000000000..6251e1eea --- /dev/null +++ b/src/core/components/course-image/course-image.html @@ -0,0 +1,5 @@ + + + + diff --git a/src/core/components/course-image/course-image.scss b/src/core/components/course-image/course-image.scss new file mode 100644 index 000000000..5bd16990d --- /dev/null +++ b/src/core/components/course-image/course-image.scss @@ -0,0 +1,65 @@ +@import "~theme/globals"; + +:host { + --core-image-radius: var(--core-courseimage-radius); + --core-image-size: 60px; + + display: flex; + justify-content: center; + align-items: center; + background: var(--course-color, white); + border-radius: var(--core-image-radius); + + @for $i from 0 to length($core-course-image-background) { + &.course-color-#{$i} { + --course-color: var(--core-course-color-#{$i}); + --course-color-tint: var(--core-course-color-#{$i}-tint); + } + } + + ion-icon { + --padding: 12px; + + padding: var(--padding); + font-size: calc(var(--core-image-size) - var(--padding) * 2); + color: var(--course-color-tint); + } + + ion-avatar { + --border-radius: var(--core-image-radius); + width: var(--core-image-size); + height: var(--core-image-size); + + img { + background: transparent; + } + + } + + img[src$=".svg"] { + min-width: 100%; + } + + &.fill-container { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + + --core-image-radius: 0px; + --core-image-size: 100%; + + ion-icon { + opacity: 0.5; + width: 80px; + height: 80px; + } + + } + +} + +:host-context(ion-item) { + @include margin(6px, 8px, 6px, 0px); +} diff --git a/src/core/components/course-image/course-image.ts b/src/core/components/course-image/course-image.ts new file mode 100644 index 000000000..dc440b81a --- /dev/null +++ b/src/core/components/course-image/course-image.ts @@ -0,0 +1,81 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, ElementRef, OnInit, OnChanges, HostBinding } from '@angular/core'; +import { CoreCourseListItem } from '@features/courses/services/courses'; +import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; +import { CoreColors } from '@singletons/colors'; + +@Component({ + selector: 'core-course-image', + templateUrl: 'course-image.html', + styleUrls: ['./course-image.scss'], +}) +export class CoreCourseImageComponent implements OnInit, OnChanges { + + @Input() course!: CoreCourseListItem; + @Input() fill = false; + + protected element: HTMLElement; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + @HostBinding('class.fill-container') + get fillContainer(): boolean { + return this.fill; + } + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.setCourseColor(); + } + + /** + * @inheritdoc + */ + ngOnChanges(): void { + this.setCourseColor(); + } + + /** + * Removes the course image set because it cannot be loaded and set the fallback icon color. + */ + loadFallbackCourseIcon(): void { + this.course.courseimage = undefined; + + // Set the color because it won't be set at this point. + this.setCourseColor(); + } + + /** + * Set course color. + */ + protected async setCourseColor(): Promise { + await CoreCoursesHelper.loadCourseColorAndImage(this.course); + + if (this.course.color) { + this.element.style.setProperty('--course-color', this.course.color); + + const tint = CoreColors.lighter(this.course.color, 50); + this.element.style.setProperty('--course-color-tint', tint); + } else if(this.course.colorNumber !== undefined) { + this.element.classList.add('course-color-' + this.course.colorNumber); + } + } + +} diff --git a/src/core/components/empty-box/empty-box.scss b/src/core/components/empty-box/empty-box.scss index 5f333fb36..86fe96d48 100644 --- a/src/core/components/empty-box/empty-box.scss +++ b/src/core/components/empty-box/empty-box.scss @@ -1,22 +1,23 @@ @import "~theme/globals"; :host { + --image-size: 120px; + --icon-color: var(--text-color); + display: flex; flex-direction: column; align-items: center; justify-content: center; flex-grow: 1; - color: var(--text-color); margin: 0 auto; text-align: center; padding: 16px; - --image-size: 120px; - height: 100%; ion-icon { font-size: var(--image-size); + color: var(--icon-color); } img { height: var(--image-size); @@ -28,6 +29,20 @@ &.core-empty-box-clickable { z-index: 0; } + + &.dimmed { + --icon-color: var(--gray-400); + --text-color: var(--gray-700); + } + +} + +:host-context(html.dark) { + + &.dimmed { + --text-color: var(--gray-300); + } + } @include media-breakpoint-down(sm) { diff --git a/src/core/components/empty-box/empty-box.ts b/src/core/components/empty-box/empty-box.ts index efbec5b2d..255ebad47 100644 --- a/src/core/components/empty-box/empty-box.ts +++ b/src/core/components/empty-box/empty-box.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input } from '@angular/core'; +import { Component, HostBinding, Input } from '@angular/core'; /** * Component to show an empty box message. It will show an optional icon or image and a text centered on page. @@ -30,6 +30,7 @@ import { Component, Input } from '@angular/core'; export class CoreEmptyBoxComponent { @Input() message = ''; // Message to display. + @Input() dimmed = false; // Wether the box is dimmed or not. @Input() icon?: string; // Name of the icon to use. @Input() image?: string; // Image source. If an icon is provided, image won't be used. @Input() flipIconRtl = false; // Whether to flip the icon in RTL. Defaults to false. @@ -39,4 +40,9 @@ export class CoreEmptyBoxComponent { */ @Input() inline = false; + @HostBinding('class.dimmed') + get isDimmed(): boolean { + return this.dimmed; + } + } diff --git a/src/core/components/stories/components/components.module.ts b/src/core/components/stories/components/components.module.ts new file mode 100644 index 000000000..e70834d30 --- /dev/null +++ b/src/core/components/stories/components/components.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreEmptyBoxPageComponent } from './empty-box-page/empty-box-page'; +import { CoreEmptyBoxWrapperComponent } from './empty-box-wrapper/empty-box-wrapper'; +import { StorybookModule } from '@/storybook/storybook.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; +import { CoreComponentsModule } from '@components/components.module'; +import { CommonModule } from '@angular/common'; +import { CoreCourseImageCardsPageComponent } from '@components/stories/components/course-image-cards-page/course-image-cards-page'; +import { CoreCourseImageListPageComponent } from '@components/stories/components/course-image-list-page/course-image-list-page'; + +@NgModule({ + declarations: [ + CoreCourseImageCardsPageComponent, + CoreCourseImageListPageComponent, + CoreEmptyBoxPageComponent, + CoreEmptyBoxWrapperComponent, + ], + imports: [ + CommonModule, + StorybookModule, + CoreComponentsModule, + CoreSearchComponentsModule, + ], +}) +export class CoreComponentsStorybookModule {} diff --git a/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.html b/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.html new file mode 100644 index 000000000..90514a8e3 --- /dev/null +++ b/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.html @@ -0,0 +1,22 @@ + + + + +

Course Cards

+
+
+
+ + +
+ +
+ + {{ course.shortname }} + + + {{ course.summary }} + +
+
+
diff --git a/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.scss b/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.scss new file mode 100644 index 000000000..23f07c6fd --- /dev/null +++ b/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.scss @@ -0,0 +1,16 @@ +:host { + + ion-card { + max-width: 350px; + margin-right: auto; + margin-left: auto; + } + + .course-image-wrapper { + width: 100%; + height: 0; + padding-top: 40%; + position: relative; + } + +} diff --git a/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.ts b/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.ts new file mode 100644 index 000000000..f2b6b0323 --- /dev/null +++ b/src/core/components/stories/components/course-image-cards-page/course-image-cards-page.ts @@ -0,0 +1,28 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { CoreCourseListItem } from '@features/courses/services/courses'; +import courses from '@/assets/storybook/courses.json'; + +@Component({ + selector: 'core-course-image-cards-page', + templateUrl: 'course-image-cards-page.html', + styleUrls: ['./course-image-cards-page.scss'], +}) +export class CoreCourseImageCardsPageComponent { + + courses: Partial[] = courses; + +} diff --git a/src/core/components/stories/components/course-image-list-page/course-image-list-page.html b/src/core/components/stories/components/course-image-list-page/course-image-list-page.html new file mode 100644 index 000000000..ec47e63fb --- /dev/null +++ b/src/core/components/stories/components/course-image-list-page/course-image-list-page.html @@ -0,0 +1,19 @@ + + + + +

Courses List

+
+
+
+ + + + + + {{ course.shortname }} + + + + +
diff --git a/src/core/components/stories/components/course-image-list-page/course-image-list-page.ts b/src/core/components/stories/components/course-image-list-page/course-image-list-page.ts new file mode 100644 index 000000000..cf66b61f0 --- /dev/null +++ b/src/core/components/stories/components/course-image-list-page/course-image-list-page.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { CoreCourseListItem } from '@features/courses/services/courses'; +import courses from '@/assets/storybook/courses.json'; + +@Component({ + selector: 'core-course-image-list-page', + templateUrl: 'course-image-list-page.html', +}) +export class CoreCourseImageListPageComponent { + + courses: Partial[] = courses; + +} diff --git a/src/core/components/stories/components/empty-box-page/empty-box-page.html b/src/core/components/stories/components/empty-box-page/empty-box-page.html new file mode 100644 index 000000000..9246d8bf6 --- /dev/null +++ b/src/core/components/stories/components/empty-box-page/empty-box-page.html @@ -0,0 +1,16 @@ + + + + +

Search

+
+
+
+ +
+ + + +
+
+
diff --git a/src/core/components/stories/components/empty-box-page/empty-box-page.ts b/src/core/components/stories/components/empty-box-page/empty-box-page.ts new file mode 100644 index 000000000..a290850b8 --- /dev/null +++ b/src/core/components/stories/components/empty-box-page/empty-box-page.ts @@ -0,0 +1,27 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'core-empty-box-page', + templateUrl: 'empty-box-page.html', +}) +export class CoreEmptyBoxPageComponent { + + @Input() icon!: string; + @Input() content!: string; + @Input() dimmed!: boolean; + +} diff --git a/src/core/components/stories/components/empty-box-wrapper/empty-box-wrapper.html b/src/core/components/stories/components/empty-box-wrapper/empty-box-wrapper.html new file mode 100644 index 000000000..10dc9fa5f --- /dev/null +++ b/src/core/components/stories/components/empty-box-wrapper/empty-box-wrapper.html @@ -0,0 +1,3 @@ + +
+
diff --git a/src/core/components/stories/components/empty-box-wrapper/empty-box-wrapper.ts b/src/core/components/stories/components/empty-box-wrapper/empty-box-wrapper.ts new file mode 100644 index 000000000..6b99f2a47 --- /dev/null +++ b/src/core/components/stories/components/empty-box-wrapper/empty-box-wrapper.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnChanges } from '@angular/core'; +import { DomSanitizer } from '@singletons'; +import { SafeHtml } from '@angular/platform-browser'; + +@Component({ + selector: 'core-empty-box-wrapper', + templateUrl: 'empty-box-wrapper.html', +}) +export class CoreEmptyBoxWrapperComponent implements OnChanges { + + @Input() icon!: string; + @Input() content!: string; + @Input() dimmed!: boolean; + + html?: SafeHtml; + + /** + * @inheritdoc + */ + ngOnChanges(): void { + this.html = DomSanitizer.bypassSecurityTrustHtml(this.content); + } + +} diff --git a/src/core/components/stories/course-image.stories.ts b/src/core/components/stories/course-image.stories.ts new file mode 100644 index 000000000..cd919a6fe --- /dev/null +++ b/src/core/components/stories/course-image.stories.ts @@ -0,0 +1,104 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Meta, moduleMetadata } from '@storybook/angular'; + +import { story } from '@/storybook/utils/helpers'; + +import { CoreCourseImageComponent } from '@components/course-image/course-image'; +import { APP_INITIALIZER } from '@angular/core'; +import { CoreSitesStub } from '@/storybook/stubs/services/sites'; +import { CoreCourseImageListPageComponent } from '@components/stories/components/course-image-list-page/course-image-list-page'; +import { CoreComponentsStorybookModule } from '@components/stories/components/components.module'; +import { CoreCourseImageCardsPageComponent } from '@components/stories/components/course-image-cards-page/course-image-cards-page'; + +interface Args { + type: 'image' | 'geopattern' | 'color'; + fill: boolean; +} + +export default { + title: 'Core/Course Image', + component: CoreCourseImageComponent, + decorators: [ + moduleMetadata({ + imports: [CoreComponentsStorybookModule], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + const site = CoreSitesStub.getRequiredCurrentSite(); + + site.stubWSResponse('tool_mobile_get_config', { + settings: [ + { name: 'core_admin_coursecolor1', value: '#F9B000' }, + { name: 'core_admin_coursecolor2', value: '#EF4B00' }, + { name: 'core_admin_coursecolor3', value: '#4338FB' }, + { name: 'core_admin_coursecolor4', value: '#E142FB' }, + { name: 'core_admin_coursecolor5', value: '#FF0064' }, + { name: 'core_admin_coursecolor6', value: '#FF0F18' }, + { name: 'core_admin_coursecolor7', value: '#039B06' }, + { name: 'core_admin_coursecolor8', value: '#039B88' }, + { name: 'core_admin_coursecolor9', value: '#EF009B' }, + { name: 'core_admin_coursecolor10', value: '#020B6E' }, + ], + warnings: [], + }); + }, + }, + ], + }), + ], + argTypes: { + type: { + control: { + type: 'select', + options: ['image', 'geopattern', 'color'], + }, + }, + }, + args: { + type: 'image', + fill: false, + }, +}; + +const Template = story(({ type, ...args }) => { + const getImageSource = () => { + switch (type) { + case 'image': + return 'https://picsum.photos/500/500'; + case 'geopattern': + return 'assets/storybook/geopattern.svg'; + case 'color': + return undefined; + } + }; + + return { + component: CoreCourseImageComponent, + props: { + ...args, + course: { + id: 1, + courseimage: getImageSource(), + }, + }, + }; +}); + +export const Primary = story(Template); +export const ListPage = story(() => ({ component: CoreCourseImageListPageComponent })); +export const CardsPage = story(() => ({ component: CoreCourseImageCardsPageComponent })); diff --git a/src/core/components/stories/empty-box.stories.ts b/src/core/components/stories/empty-box.stories.ts new file mode 100644 index 000000000..8b2922fa6 --- /dev/null +++ b/src/core/components/stories/empty-box.stories.ts @@ -0,0 +1,79 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Meta, moduleMetadata } from '@storybook/angular'; +import { marked } from 'marked'; + +import { story } from '@/storybook/utils/helpers'; + +import { CoreEmptyBoxComponent } from '@components/empty-box/empty-box'; +import { CoreEmptyBoxWrapperComponent } from './components/empty-box-wrapper/empty-box-wrapper'; +import { CoreEmptyBoxPageComponent } from './components/empty-box-page/empty-box-page'; +import { CoreComponentsStorybookModule } from './components/components.module'; + +interface Args { + icon: string; + content: string; + dimmed: boolean; +} + +export default > { + title: 'Core/Empty Box', + component: CoreEmptyBoxComponent, + decorators: [ + moduleMetadata({ imports: [CoreComponentsStorybookModule] }), + ], + argTypes: { + icon: { + control: { + type: 'select', + options: ['fas-magnifying-glass', 'fas-user', 'fas-check'], + }, + }, + }, + args: { + icon: 'fas-user', + content: 'No users', + dimmed: false, + }, +}; + +const WrapperTemplate = story((args) => ({ + component: CoreEmptyBoxWrapperComponent, + props: { + ...args, + content: marked(args.content), + }, +})); + +const PageTemplate = story((args) => ({ + component: CoreEmptyBoxPageComponent, + props: { + ...args, + content: marked(args.content), + }, +})); + +export const Primary = story(WrapperTemplate); + +export const Example = story(PageTemplate, { + icon: 'fas-magnifying-glass', + content: '**No results for "Test Search"**\n\nCheck for typos or try using different keywords', +}); + +export const DimmedExample = story(PageTemplate, { + icon: 'fas-magnifying-glass', + content: 'What are you searching for?', + dimmed: true, +}); diff --git a/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts b/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts index ad4889a38..fda6ef0a7 100644 --- a/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts +++ b/src/core/features/block/components/pre-rendered-block/pre-rendered-block.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { OnInit, Component } from '@angular/core'; +import { OnInit, Component, HostBinding } from '@angular/core'; import { CoreBlockBaseComponent } from '../../classes/base-block-component'; /** @@ -26,6 +26,8 @@ export class CoreBlockPreRenderedComponent extends CoreBlockBaseComponent implem courseId?: number; + @HostBinding('attr.id') id?: string; + constructor() { super('CoreBlockPreRenderedComponent'); } @@ -39,6 +41,8 @@ export class CoreBlockPreRenderedComponent extends CoreBlockBaseComponent implem this.courseId = this.contextLevel == 'course' ? this.instanceId : undefined; this.fetchContentDefaultError = 'Error getting ' + this.block.contents?.title + ' data.'; + + this.id = `block-${this.block.instanceid}`; } } diff --git a/src/core/features/block/components/side-blocks/side-blocks.ts b/src/core/features/block/components/side-blocks/side-blocks.ts index 736ad9422..e3c75876c 100644 --- a/src/core/features/block/components/side-blocks/side-blocks.ts +++ b/src/core/features/block/components/side-blocks/side-blocks.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChildren, Input, OnInit, QueryList } from '@angular/core'; +import { Component, ViewChildren, Input, OnInit, QueryList, ElementRef } from '@angular/core'; import { ModalController } from '@singletons'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreCourse, CoreCourseBlock } from '@features/course/services/course'; @@ -22,6 +22,7 @@ import { CoreUtils } from '@services/utils/utils'; import { IonRefresher } from '@ionic/angular'; import { CoreCoursesDashboard } from '@features/courses/services/dashboard'; import { CoreTextUtils } from '@services/utils/text'; +import { CoreDom } from '@singletons/dom'; /** * Component that displays the list of side blocks. @@ -35,6 +36,7 @@ export class CoreBlockSideBlocksComponent implements OnInit { @Input() contextLevel!: string; @Input() instanceId!: number; + @Input() initialBlockInstanceId?: number; @Input() myDashboardPage?: string; @ViewChildren(CoreBlockComponent) blocksComponents?: QueryList; @@ -42,12 +44,16 @@ export class CoreBlockSideBlocksComponent implements OnInit { loaded = false; blocks: CoreCourseBlock[] = []; + constructor(protected elementRef: ElementRef) {} + /** * @inheritdoc */ async ngOnInit(): Promise { this.loadContent().finally(() => { this.loaded = true; + + this.focusInitialBlock(); }); } @@ -119,4 +125,20 @@ export class CoreBlockSideBlocksComponent implements OnInit { ModalController.dismiss(); } + /** + * Focus the initial block, if any. + */ + private async focusInitialBlock(): Promise { + if (!this.initialBlockInstanceId) { + return; + } + + const selector = '#block-' + this.initialBlockInstanceId; + + await CoreUtils.waitFor(() => !!this.elementRef.nativeElement.querySelector(selector)); + await CoreUtils.wait(200); + + CoreDom.scrollToElement(this.elementRef.nativeElement, selector, { addYAxis: -10 }); + } + } diff --git a/src/core/features/course/components/course-format/course-format.ts b/src/core/features/course/components/course-format/course-format.ts index 5e71561cf..d9e925eb9 100644 --- a/src/core/features/course/components/course-format/course-format.ts +++ b/src/core/features/course/components/course-format/course-format.ts @@ -52,6 +52,7 @@ import { CoreDom } from '@singletons/dom'; import { CoreUserTourDirectiveOptions } from '@directives/user-tour'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { CorePlatform } from '@services/platform'; +import { CoreBlockSideBlocksComponent } from '@features/block/components/side-blocks/side-blocks'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -76,6 +77,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { @Input() sections: CoreCourseSectionToDisplay[] = []; // List of course sections. @Input() initialSectionId?: number; // The section to load first (by ID). @Input() initialSectionNumber?: number; // The section to load first (by number). + @Input() initialBlockInstanceId?: number; // The instance to focus. @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. @Input() isGuest?: boolean; // If user is accessing using an ACCESS_GUEST enrolment method. @@ -298,16 +300,26 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { // Always load "All sections" to display the section title. If it isn't there just load the section. this.loaded = true; this.sectionChanged(sections[0]); - } else if (this.initialSectionId || this.initialSectionNumber) { + } else if (this.initialSectionId || this.initialSectionNumber !== undefined) { // We have an input indicating the section ID to load. Search the section. const section = sections.find((section) => - section.id == this.initialSectionId || (section.section && section.section == this.initialSectionNumber)); + section.id == this.initialSectionId || + (section.section !== undefined && section.section == this.initialSectionNumber)); // Don't load the section if it cannot be viewed by the user. if (section && this.canViewSection(section)) { this.loaded = true; this.sectionChanged(section); } + } else if (this.initialBlockInstanceId && this.displayBlocks && this.hasBlocks) { + CoreDomUtils.openSideModal({ + component: CoreBlockSideBlocksComponent, + componentProps: { + contextLevel: 'course', + instanceId: this.course.id, + initialBlockInstanceId: this.initialBlockInstanceId, + }, + }); } if (!this.loaded) { @@ -666,8 +678,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { CoreCourse.logView(this.course.id, sectionNumber), ); - let extraParams = sectionNumber ? `§ion=${sectionNumber}` : ''; - if (firstLoad && sectionNumber) { + let extraParams = sectionNumber !== undefined ? `§ion=${sectionNumber}` : ''; + if (firstLoad && sectionNumber !== undefined) { // If course is configured to show all sections in one page, don't include section in URL in first load. const courseDisplay = 'courseformatoptions' in this.course && this.course.courseformatoptions?.find(option => option.name === 'coursedisplay'); diff --git a/src/core/features/course/pages/contents/contents.html b/src/core/features/course/pages/contents/contents.html index 056ab073f..30246dd99 100644 --- a/src/core/features/course/pages/contents/contents.html +++ b/src/core/features/course/pages/contents/contents.html @@ -5,7 +5,8 @@ + [initialBlockInstanceId]="blockInstanceId" [moduleId]="moduleId" class="core-course-format-{{course.format}}" + *ngIf="dataLoaded && sections" [isGuest]="isGuest"> diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts index dac4a6212..82afe676d 100644 --- a/src/core/features/course/pages/contents/contents.ts +++ b/src/core/features/course/pages/contents/contents.ts @@ -58,6 +58,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon sections?: CoreCourseSection[]; sectionId?: number; sectionNumber?: number; + blockInstanceId?: number; dataLoaded = false; updatingData = false; downloadCourseEnabled = false; @@ -92,6 +93,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon this.sectionId = CoreNavigator.getRouteNumberParam('sectionId'); this.sectionNumber = CoreNavigator.getRouteNumberParam('sectionNumber'); + this.blockInstanceId = CoreNavigator.getRouteNumberParam('blockInstanceId'); this.moduleId = CoreNavigator.getRouteNumberParam('moduleId'); this.isGuest = CoreNavigator.getRouteBooleanParam('isGuest'); diff --git a/src/core/features/course/pages/index/index.ts b/src/core/features/course/pages/index/index.ts index b472cdaff..4176cee9d 100644 --- a/src/core/features/course/pages/index/index.ts +++ b/src/core/features/course/pages/index/index.ts @@ -74,7 +74,7 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { if (data.sectionId) { this.contentsTab.pageParams.sectionId = data.sectionId; } - if (data.sectionNumber) { + if (data.sectionNumber !== undefined) { this.contentsTab.pageParams.sectionNumber = data.sectionNumber; } @@ -162,12 +162,13 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy { course: this.course, sectionId: CoreNavigator.getRouteNumberParam('sectionId'), sectionNumber: CoreNavigator.getRouteNumberParam('sectionNumber'), + blockInstanceId: CoreNavigator.getRouteNumberParam('blockInstanceId'), isGuest: this.isGuest, }; if (this.module) { this.contentsTab.pageParams.moduleId = this.module.id; - if (!this.contentsTab.pageParams.sectionId && !this.contentsTab.pageParams.sectionNumber) { + if (!this.contentsTab.pageParams.sectionId && this.contentsTab.pageParams.sectionNumber === undefined) { // No section specified, use module section. this.contentsTab.pageParams.sectionId = this.module.section; } diff --git a/src/core/features/courses/services/handlers/course-link.ts b/src/core/features/courses/services/handlers/course-link.ts index d29db57e6..f1e2dad2a 100644 --- a/src/core/features/courses/services/handlers/course-link.ts +++ b/src/core/features/courses/services/handlers/course-link.ts @@ -69,6 +69,12 @@ export class CoreCoursesCourseLinkHandlerService extends CoreContentLinksHandler if (!isNaN(sectionNumber)) { pageParams.sectionNumber = sectionNumber; + } else { + const matches = url.match(/#inst(\d+)/); + + if (matches && matches[1]) { + pageParams.blockInstanceId = parseInt(matches[1], 10); + } } return [{ @@ -136,7 +142,7 @@ export class CoreCoursesCourseLinkHandlerService extends CoreContentLinksHandler // Direct access. const course = await CoreUtils.ignoreErrors(CoreCourses.getUserCourse(courseId), { id: courseId }); - CoreCourseHelper.openCourse(course, pageParams); + CoreCourseHelper.openCourse(course, { params: pageParams }); } else { this.navigateCourseSummary(courseId, pageParams); diff --git a/src/core/features/mainmenu/pages/more/more.ts b/src/core/features/mainmenu/pages/more/more.ts index dd3eaf5e8..af302e7ad 100644 --- a/src/core/features/mainmenu/pages/more/more.ts +++ b/src/core/features/mainmenu/pages/more/more.ts @@ -22,7 +22,6 @@ import { CoreMainMenu, CoreMainMenuCustomItem } from '../../services/mainmenu'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreNavigator } from '@services/navigator'; import { CoreCustomURLSchemes } from '@services/urlschemes'; -import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; import { CoreTextUtils } from '@services/utils/text'; import { Translate } from '@singletons'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; @@ -161,13 +160,10 @@ export class CoreMainMenuMorePage implements OnInit, OnDestroy { CoreCustomURLSchemes.treatHandleCustomURLError(error); }); } else if (/^[^:]{2,}:\/\/[^ ]+$/i.test(text)) { // Check if it's a URL. - // Check if the app can handle the URL. - const treated = await CoreContentLinksHelper.handleLink(text, undefined, true, true); - - if (!treated) { - // Can't handle it, open it in browser. - CoreSites.getCurrentSite()?.openInBrowserWithAutoLogin(text); - } + await CoreSites.visitLink(text, { + checkRoot: true, + openBrowserRoot: true, + }); } else { // It's not a URL, open it in a modal so the user can see it and copy it. CoreTextUtils.viewText(Translate.instant('core.qrscanner'), text, { diff --git a/src/core/features/search/classes/global-search-results-source.ts b/src/core/features/search/classes/global-search-results-source.ts new file mode 100644 index 000000000..183f0c4af --- /dev/null +++ b/src/core/features/search/classes/global-search-results-source.ts @@ -0,0 +1,145 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + CoreSearchGlobalSearchResult, + CoreSearchGlobalSearch, + CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, + CoreSearchGlobalSearchFilters, +} from '@features/search/services/global-search'; +import { CorePaginatedItemsManagerSource } from '@classes/items-management/paginated-items-manager-source'; + +/** + * Provides a collection of global search results. + */ +export class CoreSearchGlobalSearchResultsSource extends CorePaginatedItemsManagerSource { + + private query: string; + private filters: CoreSearchGlobalSearchFilters; + private pagesLoaded = 0; + private topResultsIds?: number[]; + + constructor(query: string, filters: CoreSearchGlobalSearchFilters) { + super(); + + this.query = query; + this.filters = filters; + } + + /** + * Check whether the source has an empty query. + * + * @returns Whether the source has an empty query. + */ + hasEmptyQuery(): boolean { + return !this.query || this.query.trim().length === 0; + } + + /** + * Get search query. + * + * @returns Search query. + */ + getQuery(): string { + return this.query; + } + + /** + * Get search filters. + * + * @returns Search filters. + */ + getFilters(): CoreSearchGlobalSearchFilters { + return this.filters; + } + + /** + * Set search query. + * + * @param query Search query. + */ + setQuery(query: string): void { + this.query = query; + + this.setDirty(true); + } + + /** + * Set search filters. + * + * @param filters Search filters. + */ + setFilters(filters: CoreSearchGlobalSearchFilters): void { + this.filters = filters; + + this.setDirty(true); + } + + /** + * @inheritdoc + */ + getPagesLoaded(): number { + return this.pagesLoaded; + } + + /** + * @inheritdoc + */ + async reload(): Promise { + this.pagesLoaded = 0; + + await super.reload(); + } + + /** + * Reset collection data. + */ + reset(): void { + this.pagesLoaded = 0; + delete this.topResultsIds; + + super.reset(); + } + + /** + * @inheritdoc + */ + protected async loadPageItems(page: number): Promise<{ items: CoreSearchGlobalSearchResult[]; hasMoreItems: boolean }> { + this.pagesLoaded++; + + const results: CoreSearchGlobalSearchResult[] = []; + + if (page === 0) { + const topResults = await CoreSearchGlobalSearch.getTopResults(this.query, this.filters); + + results.push(...topResults); + + this.topResultsIds = topResults.map(result => result.id); + } + + const pageResults = await CoreSearchGlobalSearch.getResults(this.query, this.filters, page); + + results.push(...pageResults.results.filter(result => !this.topResultsIds?.includes(result.id))); + + return { items: results, hasMoreItems: pageResults.canLoadMore }; + } + + /** + * @inheritdoc + */ + protected getPageLength(): number { + return CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH; + } + +} diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.component.ts b/src/core/features/search/components/global-search-filters/global-search-filters.component.ts new file mode 100644 index 000000000..54be5eea6 --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.component.ts @@ -0,0 +1,267 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, Input } from '@angular/core'; +import { CoreEnrolledCourseData, CoreCourses } from '@features/courses/services/courses'; +import { + CoreSearchGlobalSearchFilters, + CoreSearchGlobalSearch, + CoreSearchGlobalSearchSearchAreaCategory, + CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED, +} from '@features/search/services/global-search'; +import { CoreEvents } from '@singletons/events'; +import { ModalController } from '@singletons'; +import { IonRefresher } from '@ionic/angular'; +import { CoreUtils } from '@services/utils/utils'; + +type Filter = T & { checked: boolean }; + +@Component({ + selector: 'core-search-global-search-filters', + templateUrl: 'global-search-filters.html', + styleUrls: ['./global-search-filters.scss'], +}) +export class CoreSearchGlobalSearchFiltersComponent implements OnInit { + + allSearchAreaCategories: boolean | null = true; + searchAreaCategories: Filter[] = []; + allCourses: boolean | null = true; + courses: Filter[] = []; + + @Input() filters?: CoreSearchGlobalSearchFilters; + + private newFilters: CoreSearchGlobalSearchFilters = {}; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.newFilters = this.filters ?? {}; + + await this.updateSearchAreaCategories(); + await this.updateCourses(); + } + + /** + * Close popover. + */ + close(): void { + ModalController.dismiss(); + } + + /** + * Checkbox for all search area categories has been updated. + */ + allSearchAreaCategoriesUpdated(): void { + if (this.allSearchAreaCategories === null) { + return; + } + + const checked = this.allSearchAreaCategories; + + this.searchAreaCategories.forEach(searchAreaCategory => { + if (searchAreaCategory.checked === checked) { + return; + } + + searchAreaCategory.checked = checked; + }); + } + + /** + * Checkbox for one search area category has been updated. + * + * @param searchAreaCategory Filter status. + */ + onSearchAreaCategoryInputChanged(searchAreaCategory: Filter): void { + if ( + !searchAreaCategory.checked && + this.newFilters.searchAreaCategoryIds && + !this.newFilters.searchAreaCategoryIds.includes(searchAreaCategory.id) + ) { + return; + } + + if ( + searchAreaCategory.checked && + (!this.newFilters.searchAreaCategoryIds || this.newFilters.searchAreaCategoryIds.includes(searchAreaCategory.id)) + ) { + return; + } + + this.searchAreaCategoryUpdated(); + } + + /** + * Checkbox for all courses has been updated. + */ + allCoursesUpdated(): void { + if (this.allCourses === null) { + return; + } + + const checked = this.allCourses; + + this.courses.forEach(course => { + if (course.checked === checked) { + return; + } + + course.checked = checked; + }); + } + + /** + * Checkbox for one course has been updated. + * + * @param course Filter status. + */ + onCourseInputChanged(course: Filter): void { + if (!course.checked && this.newFilters.courseIds && !this.newFilters.courseIds.includes(course.id)) { + return; + } + + if (course.checked && (!this.newFilters.courseIds || this.newFilters.courseIds.includes(course.id))) { + return; + } + + this.courseUpdated(); + } + + /** + * Refresh filters. + * + * @param refresher Refresher. + */ + async refreshFilters(refresher?: IonRefresher): Promise { + await CoreUtils.ignoreErrors(Promise.all([ + CoreSearchGlobalSearch.invalidateSearchAreas(), + CoreCourses.invalidateUserCourses(), + ])); + + await this.updateSearchAreaCategories(); + await this.updateCourses(); + + refresher?.complete(); + } + + /** + * Update search area categories. + */ + private async updateSearchAreaCategories(): Promise { + const searchAreas = await CoreSearchGlobalSearch.getSearchAreas(); + const searchAreaCategoryIds = new Set(); + + this.searchAreaCategories = []; + + for (const searchArea of searchAreas) { + if (searchAreaCategoryIds.has(searchArea.category.id)) { + continue; + } + + searchAreaCategoryIds.add(searchArea.category.id); + this.searchAreaCategories.push({ + ...searchArea.category, + checked: this.filters?.searchAreaCategoryIds?.includes(searchArea.category.id) ?? true, + }); + } + + this.allSearchAreaCategories = this.getGroupFilterStatus(this.searchAreaCategories); + } + + /** + * Update courses. + */ + private async updateCourses(): Promise { + const courses = await CoreCourses.getUserCourses(); + + this.courses = courses + .sort((a, b) => (a.shortname?.toLowerCase() ?? '').localeCompare(b.shortname?.toLowerCase() ?? '')) + .map(course => ({ + ...course, + checked: this.filters?.courseIds?.includes(course.id) ?? true, + })); + + this.allCourses = this.getGroupFilterStatus(this.courses); + } + + /** + * Checkbox for one search area category has been updated. + */ + private searchAreaCategoryUpdated(): void { + const filterStatus = this.getGroupFilterStatus(this.searchAreaCategories); + + if (filterStatus !== this.allSearchAreaCategories) { + this.allSearchAreaCategories = filterStatus; + } + + this.emitFiltersUpdated(); + } + + /** + * Course filter status has been updated. + */ + private courseUpdated(): void { + const filterStatus = this.getGroupFilterStatus(this.courses); + + if (filterStatus !== this.allCourses) { + this.allCourses = filterStatus; + } + + this.emitFiltersUpdated(); + } + + /** + * Get the status for a filter representing a group of filters. + * + * @param filters Filters in the group. + * @returns Group filter status. This will be true if all filters are checked, false if all filters are unchecked, + * or null if filters have mixed states. + */ + private getGroupFilterStatus(filters: Filter[]): boolean | null { + if (filters.length === 0) { + return null; + } + + const firstChecked = filters[0].checked; + + for (const filter of filters) { + if (filter.checked === firstChecked) { + continue; + } + + return null; + } + + return firstChecked; + } + + /** + * Emit filters updated event. + */ + private emitFiltersUpdated(): void { + this.newFilters = {}; + + if (!this.allSearchAreaCategories) { + this.newFilters.searchAreaCategoryIds = this.searchAreaCategories.filter(({ checked }) => checked).map(({ id }) => id); + } + + if (!this.allCourses) { + this.newFilters.courseIds = this.courses.filter(({ checked }) => checked).map(({ id }) => id); + } + + CoreEvents.trigger(CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED, this.newFilters); + } + +} diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.html b/src/core/features/search/components/global-search-filters/global-search-filters.html new file mode 100644 index 000000000..5f38275b7 --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.html @@ -0,0 +1,58 @@ + + + +

{{ 'core.search.filterheader' | translate }}

+
+ + + + + +
+
+ + + + + + + + + + {{ 'core.search.filtercategories' | translate }} + + + + {{ 'core.search.allcategories' | translate }} + + + + + + + + + + + + + + {{ 'core.search.filtercourses' | translate }} + + + + {{ 'core.search.allcourses' | translate }} + + + + + + + + + + + + diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.module.ts b/src/core/features/search/components/global-search-filters/global-search-filters.module.ts new file mode 100644 index 000000000..0cd203468 --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.module.ts @@ -0,0 +1,30 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; + +import { CoreSearchGlobalSearchFiltersComponent } from './global-search-filters.component'; + +export { CoreSearchGlobalSearchFiltersComponent }; + +@NgModule({ + imports: [ + CoreSharedModule, + ], + declarations: [ + CoreSearchGlobalSearchFiltersComponent, + ], +}) +export class CoreSearchGlobalSearchFiltersComponentModule {} diff --git a/src/core/features/search/components/global-search-filters/global-search-filters.scss b/src/core/features/search/components/global-search-filters/global-search-filters.scss new file mode 100644 index 000000000..d9a9c2d1d --- /dev/null +++ b/src/core/features/search/components/global-search-filters/global-search-filters.scss @@ -0,0 +1,21 @@ +:host { + --help-text-color: var(--gray-700); + + ion-item.help { + color: var(--help-text-color); + + ion-label { + margin-bottom: 0; + } + + } + + ion-item:not(.help) { + font-size: 16px; + } + +} + +:host-context(html.dark) { + --help-text-color: var(--gray-400); +} diff --git a/src/core/features/search/components/global-search-result/global-search-result.html b/src/core/features/search/components/global-search-result/global-search-result.html new file mode 100644 index 000000000..2e6304296 --- /dev/null +++ b/src/core/features/search/components/global-search-result/global-search-result.html @@ -0,0 +1,25 @@ + + + + +

+ + + + +

+ +
+
+ + +
+
+ + {{ 'core.search.resultby' | translate: { $a: result.context.userName } }} +
+
+
+
diff --git a/src/core/features/search/components/global-search-result/global-search-result.scss b/src/core/features/search/components/global-search-result/global-search-result.scss new file mode 100644 index 000000000..7be59d0c0 --- /dev/null +++ b/src/core/features/search/components/global-search-result/global-search-result.scss @@ -0,0 +1,90 @@ +:host ion-item { + --core-global-search-result-image-size: 40px; + --core-global-search-result-title-color: var(--text); + --core-global-search-result-content-color: var(--gray-700); + --core-global-search-result-context-color: var(--gray-600); + --core-global-search-result-icon-size: 16px; + --mod-icon-filter: brightness(0); + + h3 { + font-size: 16px; + display: flex; + align-items: center; + color: var(--core-global-search-result-title-color); + + core-mod-icon { + --size: var(--core-global-search-result-icon-size); + --filter: var(--mod-icon-filter); + + margin-inline-end: var(--spacing-2); + margin-top: 0px; + margin-bottom: 0px; + padding: 0px; + background: transparent; + } + + ion-icon, .result-icon { + width: var(--core-global-search-result-icon-size); + height: var(--core-global-search-result-icon-size); + + margin-inline-end: var(--spacing-2); + } + + } + + core-user-avatar { + --core-avatar-size: var(--core-global-search-result-image-size); + + margin-top: var(--spacing-3); + margin-bottom: var(--spacing-3); + } + + core-course-image { + --core-image-size: var(--core-global-search-result-image-size); + + margin-top: var(--spacing-3); + margin-bottom: var(--spacing-3); + } + + ion-label { + + core-format-text { + color: var(--core-global-search-result-content-color); + + @supports (-webkit-line-clamp: 2) { + white-space: normal; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + } + + .result-context { + display: flex; + justify-items: center; + align-items: center; + color: var(--core-global-search-result-context-color); + margin-top: var(--spacing-2); + font-size: 12px; + + ion-icon { + margin-inline-end: var(--spacing-1); + } + + + .result-context { + margin-inline-start: var(--spacing-4); + } + + } + + } + +} + +:host-context(html.dark) ion-item { + --core-global-search-result-content-color: var(--gray-400); + --core-global-search-result-context-color: var(--gray-500); + --mod-icon-filter: brightness(0) invert(1); +} diff --git a/src/core/features/search/components/global-search-result/global-search-result.ts b/src/core/features/search/components/global-search-result/global-search-result.ts new file mode 100644 index 000000000..8b952b009 --- /dev/null +++ b/src/core/features/search/components/global-search-result/global-search-result.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter, OnChanges } from '@angular/core'; +import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search'; + +@Component({ + selector: 'core-search-global-search-result', + templateUrl: 'global-search-result.html', + styleUrls: ['./global-search-result.scss'], +}) +export class CoreSearchGlobalSearchResultComponent implements OnChanges { + + @Input() result!: CoreSearchGlobalSearchResult; + renderedIcon: string | null = null; + + @Output() onClick = new EventEmitter(); + + /** + * @inheritdoc + */ + ngOnChanges(): void { + this.renderedIcon = this.computeRenderedIcon(); + } + + /** + * Calculate the value of the icon to render. + * + * @returns Rendered icon. + */ + private computeRenderedIcon(): string | null { + return this.result.module?.name === 'forum' && this.result.module.area === 'post' + ? 'fa-message' + : null; + } + +} diff --git a/src/core/features/search/lang.json b/src/core/features/search/lang.json new file mode 100644 index 000000000..24824a183 --- /dev/null +++ b/src/core/features/search/lang.json @@ -0,0 +1,12 @@ +{ + "allcourses": "All courses", + "allcategories": "All categories", + "empty": "What are you searching for?", + "filtercategories": "Filter results by", + "filtercourses": "Search in", + "filterheader": "Filter", + "globalsearch": "Global search", + "noresults": "No results for \"{{$a}}\"", + "noresultshelp": "Check for typos or try using different keywords", + "resultby": "By {{$a}}" +} diff --git a/src/core/features/search/pages/global-search/global-search.html b/src/core/features/search/pages/global-search/global-search.html new file mode 100644 index 000000000..afbaf81af --- /dev/null +++ b/src/core/features/search/pages/global-search/global-search.html @@ -0,0 +1,52 @@ + + + + + + +

{{ 'core.search.globalsearch' | translate }}

+
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + +

{{ 'core.search.empty' | translate }}

+ +

{{ 'core.search.noresults' | translate: { $a: resultsSource.getQuery() } }}

+

{{ 'core.search.noresultshelp' | translate }}

+
+
+ + + + + + +
+
diff --git a/src/core/features/search/pages/global-search/global-search.ts b/src/core/features/search/pages/global-search/global-search.ts new file mode 100644 index 000000000..9f6b01f98 --- /dev/null +++ b/src/core/features/search/pages/global-search/global-search.ts @@ -0,0 +1,174 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, AfterViewInit, ViewChild } from '@angular/core'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreSearchGlobalSearchResultsSource } from '@features/search/classes/global-search-results-source'; +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; +import { Translate } from '@singletons'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreEvents, CoreEventObserver } from '@singletons/events'; +import { + CoreSearchGlobalSearchResult, + CoreSearchGlobalSearchFilters, + CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED, + CoreSearchGlobalSearch, +} from '@features/search/services/global-search'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSearchBoxComponent } from '@features/search/components/search-box/search-box'; + +@Component({ + selector: 'page-core-search-global-search', + templateUrl: 'global-search.html', +}) +export class CoreSearchGlobalSearchPage implements OnInit, OnDestroy, AfterViewInit { + + loadMoreError: string | null = null; + searchBanner: string | null = null; + resultsSource = new CoreSearchGlobalSearchResultsSource('', {}); + private filtersObserver?: CoreEventObserver; + + @ViewChild(CoreSearchBoxComponent) searchBox?: CoreSearchBoxComponent; + + /** + * @inheritdoc + */ + ngOnInit(): void { + const site = CoreSites.getRequiredCurrentSite(); + const searchBanner = site.config?.searchbanner?.trim() ?? ''; + const courseId = CoreNavigator.getRouteNumberParam('courseId'); + + if (CoreUtils.isTrueOrOne(site.config?.searchbannerenable) && searchBanner.length > 0) { + this.searchBanner = searchBanner; + } + + if (courseId) { + this.resultsSource.setFilters({ courseIds: [courseId] }); + } + + this.filtersObserver = CoreEvents.on( + CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED, + filters => this.resultsSource.setFilters(filters), + ); + } + + /** + * @inheritdoc + */ + ngAfterViewInit(): void { + const query = CoreNavigator.getRouteParam('query'); + + if (query) { + if (this.searchBox) { + this.searchBox.searchText = query; + } + + this.search(query); + } + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.filtersObserver?.off(); + } + + /** + * Perform a new search. + * + * @param query Search query. + */ + async search(query: string): Promise { + this.resultsSource.setQuery(query); + + if (this.resultsSource.hasEmptyQuery()) { + return; + } + + await CoreDomUtils.showOperationModals('core.searching', true, async () => { + await this.resultsSource.reload(); + await CoreUtils.ignoreErrors( + CoreSearchGlobalSearch.logViewResults(this.resultsSource.getQuery(), this.resultsSource.getFilters()), + ); + + CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.VIEW_ITEM_LIST, + ws: 'core_search_view_results', + name: Translate.instant('core.search.globalsearch'), + data: { + query, + filters: JSON.stringify(this.resultsSource.getFilters()), + }, + url: CoreUrlUtils.addParamsToUrl('/search/index.php', { + q: query, + }), + }); + }); + } + + /** + * Clear search results. + */ + clearSearch(): void { + this.loadMoreError = null; + + this.resultsSource.setQuery(''); + this.resultsSource.reset(); + } + + /** + * Open filters. + */ + async openFilters(): Promise { + const { CoreSearchGlobalSearchFiltersComponent } = + await import('@features/search/components/global-search-filters/global-search-filters.module'); + + await CoreDomUtils.openSideModal({ + component: CoreSearchGlobalSearchFiltersComponent, + componentProps: { filters: this.resultsSource.getFilters() }, + }); + + if (!this.resultsSource.hasEmptyQuery() && this.resultsSource.isDirty()) { + await CoreDomUtils.showOperationModals('core.searching', true, () => this.resultsSource.reload()); + } + } + + /** + * Visit a result's origin. + * + * @param result Result to visit. + */ + async visitResult(result: CoreSearchGlobalSearchResult): Promise { + await CoreSites.visitLink(result.url); + } + + /** + * Load more results. + * + * @param complete Notify completion. + */ + async loadMoreResults(complete: () => void ): Promise { + try { + await this.resultsSource?.load(); + } catch (error) { + this.loadMoreError = CoreDomUtils.getErrorMessage(error); + } finally { + complete(); + } + } + +} diff --git a/src/core/features/search/search-lazy.module.ts b/src/core/features/search/search-lazy.module.ts new file mode 100644 index 000000000..61c70cce0 --- /dev/null +++ b/src/core/features/search/search-lazy.module.ts @@ -0,0 +1,56 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSharedModule } from '@/core/shared.module'; +import { NgModule, Injector } from '@angular/core'; +import { RouterModule, Routes, ROUTES } from '@angular/router'; +import { CoreSearchGlobalSearchPage } from './pages/global-search/global-search'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; +import { CoreMainMenuComponentsModule } from '@features/mainmenu/components/components.module'; +import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result'; + +/** + * Build module routes. + * + * @param injector Injector. + * @returns Routes. + */ +function buildRoutes(injector: Injector): Routes { + return buildTabMainRoutes(injector, { + component: CoreSearchGlobalSearchPage, + }); +} + +@NgModule({ + imports: [ + CoreSharedModule, + CoreSearchComponentsModule, + CoreMainMenuComponentsModule, + ], + exports: [RouterModule], + declarations: [ + CoreSearchGlobalSearchPage, + CoreSearchGlobalSearchResultComponent, + ], + providers: [ + { + provide: ROUTES, + multi: true, + deps: [Injector], + useFactory: buildRoutes, + }, + ], +}) +export class CoreSearchLazyModule {} diff --git a/src/core/features/search/search.module.ts b/src/core/features/search/search.module.ts index b60effb9c..d5c7510cc 100644 --- a/src/core/features/search/search.module.ts +++ b/src/core/features/search/search.module.ts @@ -12,24 +12,50 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule, Type } from '@angular/core'; +import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreSearchGlobalSearchService } from '@features/search/services/global-search'; +import { CoreSearchMainMenuHandler, CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { CoreSearchComponentsModule } from './components/components.module'; import { SITE_SCHEMA } from './services/search-history-db'; import { CoreSearchHistoryProvider } from './services/search-history.service'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreSearchGlobalSearchLinkHandler } from '@features/search/services/handlers/global-search-link'; export const CORE_SEARCH_SERVICES: Type[] = [ CoreSearchHistoryProvider, + CoreSearchGlobalSearchService, +]; + +const mainMenuChildrenRoutes: Routes = [ + { + path: CORE_SEARCH_PAGE_NAME, + loadChildren: () => import('./search-lazy.module').then(m => m.CoreSearchLazyModule), + }, ]; @NgModule({ imports: [ CoreSearchComponentsModule, + CoreMainMenuTabRoutingModule.forChild(mainMenuChildrenRoutes), + CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }), ], providers: [ { provide: CORE_SITE_SCHEMAS, useValue: [SITE_SCHEMA], multi: true }, + { + provide: APP_INITIALIZER, + multi: true, + useValue() { + CoreMainMenuDelegate.registerHandler(CoreSearchMainMenuHandler.instance); + CoreContentLinksDelegate.registerHandler(CoreSearchGlobalSearchLinkHandler.instance); + }, + }, ], }) export class CoreSearchModule {} diff --git a/src/core/features/search/services/global-search.ts b/src/core/features/search/services/global-search.ts new file mode 100644 index 000000000..c4cbbe0e9 --- /dev/null +++ b/src/core/features/search/services/global-search.ts @@ -0,0 +1,420 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { makeSingleton } from '@singletons'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreCourseListItem, CoreCourses } from '@features/courses/services/courses'; +import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar'; +import { CoreUser } from '@features/user/services/user'; +import { CoreSite } from '@classes/site'; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED]: CoreSearchGlobalSearchFilters; + } + +} + +export const CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH = 10; +export const CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED = 'core-search-global-search-filters-updated'; + +export type CoreSearchGlobalSearchResult = { + id: number; + title: string; + url: string; + content?: string; + context?: CoreSearchGlobalSearchResultContext; + module?: CoreSearchGlobalSearchResultModule; + component?: CoreSearchGlobalSearchResultComponent; + course?: CoreCourseListItem; + user?: CoreUserWithAvatar; +}; + +export type CoreSearchGlobalSearchResultContext = { + userName?: string; + courseName?: string; +}; + +export type CoreSearchGlobalSearchResultModule = { + name: string; + iconurl: string; + area: string; +}; + +export type CoreSearchGlobalSearchResultComponent = { + name: string; + iconurl: string; +}; + +export type CoreSearchGlobalSearchSearchAreaCategory = { + id: string; + name: string; +}; + +export type CoreSearchGlobalSearchSearchArea = { + id: string; + name: string; + category: CoreSearchGlobalSearchSearchAreaCategory; +}; + +export interface CoreSearchGlobalSearchFilters { + searchAreaCategoryIds?: string[]; + courseIds?: number[]; +} + +/** + * Service to perform global searches. + */ +@Injectable({ providedIn: 'root' }) +export class CoreSearchGlobalSearchService { + + private static readonly SEARCH_AREAS_CACHE_KEY = 'CoreSearchGlobalSearch:SearchAreas'; + + /** + * Check whether global search is enabled or not. + * + * @returns Whether global search is enabled or not. + */ + async isEnabled(siteId?: string): Promise { + const site = siteId + ? await CoreSites.getSite(siteId) + : CoreSites.getRequiredCurrentSite(); + + return !site?.isFeatureDisabled('CoreNoDelegate_GlobalSearch') + && site?.wsAvailable('core_search_get_results') // @since 4.3 + && site?.canUseAdvancedFeature('enableglobalsearch'); + } + + /** + * Get results. + * + * @param query Search query. + * @param filters Search filters. + * @param page Page. + * @returns Search results. + */ + async getResults( + query: string, + filters: CoreSearchGlobalSearchFilters, + page: number, + ): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> { + if (this.filtersYieldEmptyResults(filters)) { + return { + results: [], + canLoadMore: false, + }; + } + + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchGetResultsWSParams = { + query, + page, + filters: await this.prepareWSFilters(filters), + }; + const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK); + + const { totalcount, results } = await site.read('core_search_get_results', params, preSets); + + return { + results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))), + canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, + }; + } + + /** + * Get top results. + * + * @param query Search query. + * @param filters Search filters. + * @returns Top search results. + */ + async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise { + if (this.filtersYieldEmptyResults(filters)) { + return []; + } + + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchGetTopResultsWSParams = { + query, + filters: await this.prepareWSFilters(filters), + }; + const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK); + + const { results } = await site.read('core_search_get_top_results', params, preSets); + + return await Promise.all((results ?? []).map(result => this.formatWSResult(result))); + } + + /** + * Get available search areas. + * + * @returns Search areas. + */ + async getSearchAreas(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchGetSearchAreasListWSParams = {}; + + const { areas } = await site.read('core_search_get_search_areas_list', params, { + updateFrequency: CoreSite.FREQUENCY_RARELY, + cacheKey: CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY, + }); + + return areas.map(area => ({ + id: area.id, + name: area.name, + category: { + id: area.categoryid, + name: area.categoryname, + }, + })); + } + + /** + * Invalidate search areas cache. + */ + async invalidateSearchAreas(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + + await site.invalidateWsCacheForKey(CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY); + } + + /** + * Log event for viewing results. + * + * @param query Search query. + * @param filters Search filters. + */ + async logViewResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const params: CoreSearchViewResultsWSParams = { + query, + filters: await this.prepareWSFilters(filters), + }; + + await site.write('core_search_view_results', params); + } + + /** + * Format a WS result to be used in the app. + * + * @param wsResult WS result. + * @returns App result. + */ + protected async formatWSResult(wsResult: CoreSearchWSResult): Promise { + const result: CoreSearchGlobalSearchResult = { + id: wsResult.itemid, + title: wsResult.title, + url: wsResult.docurl, + content: wsResult.content, + }; + + if (wsResult.componentname === 'core_user') { + const user = await CoreUser.getProfile(wsResult.itemid); + + result.user = user; + } else if (wsResult.componentname === 'core_course' && wsResult.areaname === 'course') { + const course = await CoreCourses.getCourseByField('id', wsResult.itemid); + + result.course = course; + } else { + if (wsResult.userfullname || wsResult.coursefullname) { + result.context = { + userName: wsResult.userfullname, + courseName: wsResult.coursefullname, + }; + } + + if (wsResult.iconurl) { + if (wsResult.componentname.startsWith('mod_')) { + result.module = { + name: wsResult.componentname.substring(4), + iconurl: wsResult.iconurl, + area: wsResult.areaname, + }; + } else { + result.component = { + name: wsResult.componentname, + iconurl: wsResult.iconurl, + }; + } + } + } + + return result; + } + + /** + * Check whether the given filter will necessarily yield an empty list of results. + * + * @param filters Filters. + * @returns Whether the given filters will return 0 results. + */ + protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean { + return filters.courseIds?.length === 0 || filters.searchAreaCategoryIds?.length === 0; + } + + /** + * Prepare search filters before sending to WS. + * + * @param filters App filters. + * @returns WS filters. + */ + protected async prepareWSFilters(filters: CoreSearchGlobalSearchFilters): Promise { + const wsFilters: CoreSearchBasicWSFilters = {}; + + if (filters.courseIds) { + wsFilters.courseids = filters.courseIds; + } + + if (filters.searchAreaCategoryIds) { + const searchAreas = await this.getSearchAreas(); + + wsFilters.areaids = searchAreas + .filter(({ category }) => filters.searchAreaCategoryIds?.includes(category.id)) + .map(({ id }) => id); + } + + return wsFilters; + } + +} + +export const CoreSearchGlobalSearch = makeSingleton(CoreSearchGlobalSearchService); + +/** + * Params of core_search_get_results WS. + */ +type CoreSearchGetResultsWSParams = { + query: string; // The search query. + filters?: CoreSearchAdvancedWSFilters; // Filters to apply. + page?: number; // Results page number starting from 0, defaults to the first page. +}; + +/** + * Params of core_search_get_search_areas_list WS. + */ +type CoreSearchGetSearchAreasListWSParams = { + cat?: string; // Category to filter areas. +}; + +/** + * Params of core_search_view_results WS. + */ +type CoreSearchViewResultsWSParams = { + query: string; // The search query. + filters?: CoreSearchBasicWSFilters; // Filters to apply. + page?: number; // Results page number starting from 0, defaults to the first page. +}; + +/** + * Params of core_search_get_top_results WS. + */ +type CoreSearchGetTopResultsWSParams = { + query: string; // The search query. + filters?: CoreSearchAdvancedWSFilters; // Filters to apply. +}; + +/** + * Search result returned in WS. + */ +type CoreSearchWSResult = { // Search results. + itemid: number; // Unique id in the search area scope. + componentname: string; // Component name. + areaname: string; // Search area name. + courseurl: string; // Result course url. + coursefullname: string; // Result course fullname. + timemodified: number; // Result modified time. + title: string; // Result title. + docurl: string; // Result url. + iconurl?: string; // Icon url. + content?: string; // Result contents. + contextid: number; // Result context id. + contexturl: string; // Result context url. + description1?: string; // Extra result contents, depends on the search area. + description2?: string; // Extra result contents, depends on the search area. + multiplefiles?: number; // Whether multiple files are returned or not. + filenames?: string[]; // Result file names if present. + filename?: string; // Result file name if present. + userid?: number; // User id. + userurl?: string; // User url. + userfullname?: string; // User fullname. + textformat: number; // Text fields format, it is the same for all of them. +}; + +/** + * Basic search filters used in WS. + */ +type CoreSearchBasicWSFilters = { + title?: string; // Result title. + areaids?: string[]; // Restrict results to these areas. + courseids?: number[]; // Restrict results to these courses. + timestart?: number; // Docs modified after this date. + timeend?: number; // Docs modified before this date. +}; + +/** + * Advanced search filters used in WS. + */ +type CoreSearchAdvancedWSFilters = CoreSearchBasicWSFilters & { + contextids?: number[]; // Restrict results to these contexts. + cat?: string; // Category to filter areas. + userids?: number[]; // Restrict results to these users. + groupids?: number[]; // Restrict results to these groups. + mycoursesonly?: boolean; // Only results from my courses. + order?: string; // How to order. +}; + +/** + * Data returned by core_search_get_results WS. + */ +type CoreSearchGetResultsWSResponse = { + totalcount: number; // Total number of results. + results?: CoreSearchWSResult[]; +}; + +/** + * Data returned by core_search_get_search_areas_list WS. + */ +type CoreSearchGetSearchAreasListWSResponse = { + areas: { // Search areas. + id: string; // Search area id. + categoryid: string; // Category id. + categoryname: string; // Category name. + name: string; // Search area name. + }[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by core_search_view_results WS. + */ +type CoreSearchViewResultsWSResponse = { + status: boolean; // Status: true if success. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data returned by core_search_get_top_results WS. + */ +type CoreSearchGetTopResultsWSResponse = { + results?: CoreSearchWSResult[]; +}; diff --git a/src/core/features/search/services/handlers/global-search-link.ts b/src/core/features/search/services/handlers/global-search-link.ts new file mode 100644 index 000000000..fb5d887e7 --- /dev/null +++ b/src/core/features/search/services/handlers/global-search-link.ts @@ -0,0 +1,56 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreSearchGlobalSearch } from '@features/search/services/global-search'; +import { CORE_SEARCH_PAGE_NAME } from '@features/search/services/handlers/mainmenu'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to search page. + */ +@Injectable( { providedIn: 'root' }) +export class CoreSearchGlobalSearchLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'CoreSearchSearchLinkHandler'; + pattern = /\/search\/index\.php.*/; + + /** + * @inheritdoc + */ + async isEnabled(siteId: string): Promise { + return CoreSearchGlobalSearch.isEnabled(siteId); + } + + /** + * @inheritdoc + */ + getActions(siteIds: string[], url: string, params: Record): CoreContentLinksAction[] { + return [{ + action: (siteId: string): void => { + CoreNavigator.navigateToSitePath(CORE_SEARCH_PAGE_NAME, { + siteId, + params: { + query: params.q, + }, + }); + }, + }]; + } + +} +export const CoreSearchGlobalSearchLinkHandler = makeSingleton(CoreSearchGlobalSearchLinkHandlerService); diff --git a/src/core/features/search/services/handlers/mainmenu.ts b/src/core/features/search/services/handlers/mainmenu.ts new file mode 100644 index 000000000..e97531ae8 --- /dev/null +++ b/src/core/features/search/services/handlers/mainmenu.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { makeSingleton } from '@singletons'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreSearchGlobalSearch } from '@features/search/services/global-search'; + +export const CORE_SEARCH_PAGE_NAME = 'search'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable({ providedIn: 'root' }) +export class CoreSearchMainMenuHandlerService implements CoreMainMenuHandler { + + name = 'CoreSearch'; + priority = 575; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return CoreSearchGlobalSearch.isEnabled(); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'fas-magnifying-glass', + title: 'core.search.globalsearch', + page: CORE_SEARCH_PAGE_NAME, + class: 'core-search-handler', + }; + } + +} + +export const CoreSearchMainMenuHandler = makeSingleton(CoreSearchMainMenuHandlerService); diff --git a/src/core/features/search/stories/components/components.module.ts b/src/core/features/search/stories/components/components.module.ts new file mode 100644 index 000000000..bfece68ca --- /dev/null +++ b/src/core/features/search/stories/components/components.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { StorybookModule } from '@/storybook/storybook.module'; +import { CoreSearchComponentsModule } from '@features/search/components/components.module'; +import { CoreComponentsModule } from '@components/components.module'; +import { CommonModule } from '@angular/common'; +import { + CoreSearchGlobalSearchResultsPageComponent, +} from '@features/search/stories/components/global-search-results-page/global-search-results-page'; +import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result'; +import { CoreSharedModule } from '@/core/shared.module'; + +@NgModule({ + declarations: [ + CoreSearchGlobalSearchResultsPageComponent, + CoreSearchGlobalSearchResultComponent, + ], + imports: [ + CoreSharedModule, + CommonModule, + StorybookModule, + CoreComponentsModule, + CoreSearchComponentsModule, + ], +}) +export class CoreSearchComponentsStorybookModule {} diff --git a/src/core/features/search/stories/components/global-search-results-page/global-search-results-page.html b/src/core/features/search/stories/components/global-search-results-page/global-search-results-page.html new file mode 100644 index 000000000..7625c8827 --- /dev/null +++ b/src/core/features/search/stories/components/global-search-results-page/global-search-results-page.html @@ -0,0 +1,18 @@ + + + + +

Search Results

+
+
+
+ +
+ + + + + +
+
+
diff --git a/src/core/features/search/stories/components/global-search-results-page/global-search-results-page.ts b/src/core/features/search/stories/components/global-search-results-page/global-search-results-page.ts new file mode 100644 index 000000000..8e4dd3dd3 --- /dev/null +++ b/src/core/features/search/stories/components/global-search-results-page/global-search-results-page.ts @@ -0,0 +1,121 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { CoreCourseListItem } from '@features/courses/services/courses'; +import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar'; +import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search'; +import courses from '@/assets/storybook/courses.json'; + +@Component({ + selector: 'core-search-global-search-results-page', + templateUrl: 'global-search-results-page.html', +}) +export class CoreSearchGlobalSearchResultsPageComponent { + + results: CoreSearchGlobalSearchResult[] = [ + { + id: 1, + url: '', + title: 'Activity forum test', + content: 'this is a content test for a forum to see in the search result.', + context: { + courseName: 'Course 102', + userName: 'Stephania Krovalenko', + }, + module: { + name: 'forum', + iconurl: 'assets/img/mod/forum.svg', + area: 'activity', + }, + }, + { + id: 2, + url: '', + title: 'Activity assignment test', + content: 'this is a content test for a forum to see in the search result.', + context: { + courseName: 'Course 102', + }, + module: { + name: 'assign', + iconurl: 'assets/img/mod/assign.svg', + area: '', + }, + }, + { + id: 3, + url: '', + title: 'Course 101', + course: courses[0] as CoreCourseListItem, + }, + { + id: 4, + url: '', + title: 'John the Tester', + user: { + fullname: 'John Doe', + profileimageurl: 'https://placekitten.com/300/300', + } as CoreUserWithAvatar, + }, + { + id: 5, + url: '', + title: 'Search result title', + content: 'this is a content test for a forum to see in the search result.', + context: { + userName: 'Stephania Krovalenko', + }, + module: { + name: 'forum', + iconurl: 'assets/img/mod/forum.svg', + area: 'post', + }, + }, + { + id: 6, + url: '', + title: 'Side block', + context: { + courseName: 'Moodle Site', + }, + component: { + name: 'block_html', + iconurl: 'https://master.mm.moodledemo.net/theme/image.php?theme=boost&component=core&image=e%2Fanchor', + }, + }, + { + id: 7, + url: '', + title: 'Course section', + context: { + courseName: 'Course 101', + }, + component: { + name: 'core_course', + iconurl: 'https://master.mm.moodledemo.net/theme/image.php?theme=boost&component=core&image=i%2Fsection', + }, + }, + ]; + + /** + * Result clicked. + * + * @param title Result title. + */ + resultClicked(title: string): void { + alert(`clicked on ${title}`); + } + +} diff --git a/src/core/features/search/stories/global-search-result.stories.ts b/src/core/features/search/stories/global-search-result.stories.ts new file mode 100644 index 000000000..125f4666b --- /dev/null +++ b/src/core/features/search/stories/global-search-result.stories.ts @@ -0,0 +1,134 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Meta, moduleMetadata } from '@storybook/angular'; + +import { story } from '@/storybook/utils/helpers'; + +import { CoreSearchGlobalSearchResultComponent } from '@features/search/components/global-search-result/global-search-result'; +import { CoreSearchComponentsStorybookModule } from '@features/search/stories/components/components.module'; +import { + CoreSearchGlobalSearchResultsPageComponent, +} from '@features/search/stories/components/global-search-results-page/global-search-results-page'; +import { APP_INITIALIZER } from '@angular/core'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { AddonModForumModuleHandler } from '@addons/mod/forum/services/handlers/module'; +import { AddonModAssignModuleHandler } from '@addons/mod/assign/services/handlers/module'; +import { CoreSearchGlobalSearchResult } from '@features/search/services/global-search'; +import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar'; +import { CoreCourseListItem } from '@features/courses/services/courses'; +import courses from '@/assets/storybook/courses.json'; + +interface Args { + title: string; + content: string; + image: 'course' | 'user' | 'none'; + module: 'forum-activity' | 'forum-post' | 'assign' | 'none'; + courseContext: boolean; + userContext: boolean; +} + +export default > { + title: 'Core/Search/Global Search Result', + component: CoreSearchGlobalSearchResultComponent, + decorators: [ + moduleMetadata({ + imports: [CoreSearchComponentsStorybookModule], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue() { + CoreCourseModuleDelegate.registerHandler(AddonModForumModuleHandler.instance); + CoreCourseModuleDelegate.registerHandler(AddonModAssignModuleHandler.instance); + CoreCourseModuleDelegate.updateHandlers(); + }, + }, + ], + }), + ], + argTypes: { + image: { + control: { + type: 'select', + options: ['course', 'user', 'none'], + }, + }, + module: { + control: { + type: 'select', + options: ['forum-activity', 'forum-post', 'assign', 'none'], + }, + }, + }, + args: { + title: 'Result #1', + content: 'This item seems really interesting, maybe you should click through', + image: 'none', + module: 'none', + courseContext: false, + userContext: false, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/h3E7pkfgyImJPaYmTfnwuF/Global-Search?node-id=118%3A4610', + }, + }, +}; + +const Template = story(({ image, courseContext, userContext, module, ...args }) => { + const result: CoreSearchGlobalSearchResult = { + ...args, + id: 1, + url: '', + }; + + if (courseContext || userContext) { + result.context = { + courseName: courseContext ? 'Course 101' : undefined, + userName: userContext ? 'John Doe' : undefined, + }; + } + + if (module !== 'none') { + const name = module.startsWith('forum') ? 'forum' : module; + + result.module = { + name, + iconurl: `assets/img/mod/${name}.svg`, + area: module.startsWith('forum') ? module.substring(6) : '', + }; + } + + switch (image) { + case 'course': + result.course = courses[0] as CoreCourseListItem; + break; + case 'user': + result.user = { + fullname: 'John Doe', + profileimageurl: 'https://placekitten.com/300/300', + } as CoreUserWithAvatar; + break; + } + + return { + component: CoreSearchGlobalSearchResultComponent, + props: { result }, + }; +}); + +export const Primary = story(Template); +export const ResultsPage = story(() => ({ component: CoreSearchGlobalSearchResultsPageComponent })); diff --git a/src/core/features/search/tests/behat/global-search.feature b/src/core/features/search/tests/behat/global-search.feature new file mode 100644 index 000000000..381dda737 --- /dev/null +++ b/src/core/features/search/tests/behat/global-search.feature @@ -0,0 +1,147 @@ +@core @core_search @app @javascript @lms_from4.3 +Feature: Test Global Search + + Background: + Given solr is installed + And the following config values are set as admin: + | enableglobalsearch | 1 | + | searchengine | solr | + And the following "courses" exist: + | fullname | shortname | + | Course 1 | C1 | + | Course 2 | C2 | + And the following "users" exist: + | username | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + | student1 | C2 | student | + And the following "activities" exist: + | activity | name | course | idnumber | + | page | Test page 01 | C1 | page01 | + | page | Test page 02 | C1 | page02 | + | page | Test page 03 | C1 | page03 | + | page | Test page 04 | C1 | page04 | + | page | Test page 05 | C1 | page05 | + | page | Test page 06 | C1 | page06 | + | page | Test page 07 | C1 | page07 | + | page | Test page 08 | C1 | page08 | + | page | Test page 09 | C1 | page09 | + | page | Test page 10 | C1 | page10 | + | page | Test page 11 | C1 | page11 | + | page | Test page 12 | C1 | page12 | + | page | Test page 13 | C1 | page13 | + | page | Test page 14 | C1 | page14 | + | page | Test page 15 | C1 | page15 | + | page | Test page 16 | C1 | page16 | + | page | Test page 17 | C1 | page17 | + | page | Test page 18 | C1 | page18 | + | page | Test page 19 | C1 | page19 | + | page | Test page 20 | C1 | page20 | + | page | Test page 21 | C1 | page21 | + | page | Test page C2 | C2 | pagec2 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | + | forum | Test forum | Test forum intro | C1 | forum | + + Scenario: Search in a site + Given global search expects the query "page" and will return: + | type | idnumber | + | activity | page01 | + | activity | page02 | + | activity | page03 | + | activity | page04 | + | activity | page05 | + | activity | page06 | + | activity | page07 | + | activity | page08 | + | activity | page09 | + | activity | page10 | + | activity | page11 | + | activity | page12 | + | activity | pagec2 | + And I entered the app as "student1" + When I press the more menu button in the app + And I press "Global search" in the app + And I set the field "Search" to "page" in the app + And I press "Search" "button" in the app + Then I should find "Test page 01" in the app + And I should find "Test page 10" in the app + + When I load more items in the app + Then I should find "Test page 11" in the app + + When I press "Test page 01" in the app + Then I should find "Test page content" in the app + + When I press the back button in the app + And global search expects the query "forum" and will return: + | type | idnumber | + | activity | forum | + And I set the field "Search" to "forum" in the app + And I press "Search" "button" in the app + Then I should find "Test forum" in the app + But I should not find "Test page" in the app + + When I press "Test forum" in the app + Then I should find "Test forum intro" in the app + + When I press the back button in the app + And I press "Clear search" in the app + Then I should find "What are you searching for?" in the app + But I should not find "Test forum" in the app + + Given global search expects the query "noresults" and will return: + | type | idnumber | + And I set the field "Search" to "noresults" in the app + And I press "Search" "button" in the app + Then I should find "No results for" in the app + + # TODO test other results like course, user, and messages (global search generator not supported) + + Scenario: Filter results + Given global search expects the query "page" and will return: + | type | idnumber | + | activity | page01 | + And I entered the app as "student1" + When I press the more menu button in the app + And I press "Global search" in the app + And I set the field "Search" to "page" in the app + And I press "Search" "button" in the app + Then I should find "Test page 01" in the app + + When I press "Filter" in the app + And I press "C1" in the app + And I press "Users" in the app + And global search expects the query "page" and will return: + | type | idnumber | + | activity | page02 | + And I press "Close" in the app + Then I should find "Test page 02" in the app + But I should not find "Test page 01" in the app + + Scenario: See search banner + Given the following config values are set as admin: + | searchbannerenable | 1 | + | searchbanner | Search indexing is under maintentance! | + And I entered the app as "student1" + When I press the more menu button in the app + And I press "Global search" in the app + Then I should find "Search indexing is under maintentance!" in the app + + Scenario: Open from side block + Given global search expects the query "message" and will return: + | type | idnumber | + | activity | page01 | + And the following "blocks" exist: + | blockname | contextlevel | reference | + | globalsearch | Course | C1 | + And I entered the course "Course 1" as "student1" in the app + When I press "Open block drawer" in the app + And I press "Global search" in the app + Then I should find "What are you searching for?" in the app + + When I press "Filter" in the app + Then "C1" should be selected in the app + But "C2" should not be selected in the app diff --git a/src/core/features/sitehome/pages/index/index.ts b/src/core/features/sitehome/pages/index/index.ts index a7f41db8a..dd12102d4 100644 --- a/src/core/features/sitehome/pages/index/index.ts +++ b/src/core/features/sitehome/pages/index/index.ts @@ -14,7 +14,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { IonRefresher } from '@ionic/angular'; -import { Params } from '@angular/router'; +import { ActivatedRoute, Params } from '@angular/router'; import { CoreSite, CoreSiteConfig } from '@classes/site'; import { CoreCourse, CoreCourseWSSection } from '@features/course/services/course'; @@ -31,6 +31,7 @@ import { CoreBlockHelper } from '@features/block/services/block-helper'; import { CoreUtils } from '@services/utils/utils'; import { CoreTime } from '@singletons/time'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; +import { CoreBlockSideBlocksComponent } from '@features/block/components/side-blocks/side-blocks'; /** * Page that displays site home index. @@ -58,7 +59,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { protected updateSiteObserver: CoreEventObserver; protected logView: () => void; - constructor() { + constructor(protected route: ActivatedRoute) { // Refresh the enabled flags if site is updated. this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { this.searchEnabled = !CoreCourses.isSearchCoursesDisabledInSite(); @@ -102,6 +103,10 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { this.loadContent().finally(() => { this.dataLoaded = true; }); + + this.openFocusedInstance(); + + this.route.queryParams.subscribe(() => this.openFocusedInstance()); } /** @@ -226,4 +231,22 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { this.updateSiteObserver.off(); } + /** + * Check whether there is a focused instance in the page parameters and open it. + */ + private openFocusedInstance() { + const blockInstanceId = CoreNavigator.getRouteNumberParam('blockInstanceId'); + + if (blockInstanceId) { + CoreDomUtils.openSideModal({ + component: CoreBlockSideBlocksComponent, + componentProps: { + contextLevel: 'course', + instanceId: this.siteHomeId, + initialBlockInstanceId: blockInstanceId, + }, + }); + } + } + } diff --git a/src/core/features/sitehome/services/handlers/index-link.ts b/src/core/features/sitehome/services/handlers/index-link.ts index ac0770424..755e14a53 100644 --- a/src/core/features/sitehome/services/handlers/index-link.ts +++ b/src/core/features/sitehome/services/handlers/index-link.ts @@ -22,6 +22,7 @@ import { makeSingleton } from '@singletons'; import { CoreNavigator } from '@services/navigator'; import { CoreSiteHomeHomeHandlerService } from './sitehome-home'; import { CoreMainMenuHomeHandlerService } from '@features/mainmenu/services/handlers/mainmenu'; +import { Params } from '@angular/router'; /** * Handler to treat links to site home index. @@ -36,7 +37,14 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler /** * @inheritdoc */ - getActions(): CoreContentLinksAction[] | Promise { + getActions(siteIds: string[], url: string): CoreContentLinksAction[] | Promise { + const pageParams: Params = {}; + const matches = url.match(/#inst(\d+)/); + + if (matches && matches[1]) { + pageParams.blockInstanceId = parseInt(matches[1], 10); + } + return [{ action: (siteId: string): void => { CoreNavigator.navigateToSitePath( @@ -44,6 +52,7 @@ export class CoreSiteHomeIndexLinkHandlerService extends CoreContentLinksHandler { preferCurrentTab: false, siteId, + params: pageParams, }, ); }, diff --git a/src/core/features/sitehome/tests/links.test.ts b/src/core/features/sitehome/tests/links.test.ts index 528e11b6e..fd25d399a 100644 --- a/src/core/features/sitehome/tests/links.test.ts +++ b/src/core/features/sitehome/tests/links.test.ts @@ -45,6 +45,7 @@ describe('Site Home link handlers', () => { expect(CoreNavigator.navigateToSitePath).toHaveBeenCalledWith('/home/site', { siteId, preferCurrentTab: false, + params: {}, }); }); diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 265232db3..29e383242 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -64,6 +64,7 @@ import { CoreNetwork } from '@services/network'; import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config'; import { CoreLang, CoreLangFormat } from '@services/lang'; import { CoreNative } from '@features/native/services/native'; +import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; export const CORE_SITE_SCHEMAS = new InjectionToken('CORE_SITE_SCHEMAS'); export const CORE_SITE_CURRENT_SITE_ID_CONFIG = 'current_site_id'; @@ -707,6 +708,39 @@ export class CoreSitesProvider { return CoreConstants.CONFIG.wsservice; } + /** + * Visit a site link. + * + * @param url URL to handle. + * @param options Behaviour options. + * @param options.siteId Site Id. + * @param options.username Username related with the URL. E.g. in 'http://myuser@m.com', url would be 'http://m.com' and + * the username 'myuser'. Don't use it if you don't want to filter by username. + * @param options.checkRoot Whether to check if the URL is the root URL of a site. + * @param options.openBrowserRoot Whether to open in browser if it's root URL and it belongs to current site. + */ + async visitLink( + url: string, + options: { + siteId?: string; + username?: string; + checkRoot?: boolean; + openBrowserRoot?: boolean; + } = {}, + ): Promise { + const treated = await CoreContentLinksHelper.handleLink(url, options.username, options.checkRoot, options.openBrowserRoot); + + if (treated) { + return; + } + + const site = options.siteId + ? await CoreSites.getSite(options.siteId) + : CoreSites.getCurrentSite(); + + await site?.openInBrowserWithAutoLogin(url); + } + /** * Check for the minimum required version. * diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index 9197a9b51..9483cdc3c 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -1506,6 +1506,28 @@ export class CoreDomUtilsProvider { return loading; } + /** + * Show a loading modal whilst an operation is running, and an error modal if it fails. + * + * @param text Loading dialog text. + * @param needsTranslate Whether the 'text' needs to be translated. + * @param operation Operation. + * @returns Operation result. + */ + async showOperationModals(text: string, needsTranslate: boolean, operation: () => Promise): Promise { + const modal = await this.showModalLoading(text, needsTranslate); + + try { + return await operation(); + } catch (error) { + CoreDomUtils.showErrorModal(error); + + return null; + } finally { + modal.dismiss(); + } + } + /** * Show a modal warning the user that he should use a different app. * diff --git a/src/storybook/storybook.module.ts b/src/storybook/storybook.module.ts index cd8679cd5..f4bf199c6 100644 --- a/src/storybook/storybook.module.ts +++ b/src/storybook/storybook.module.ts @@ -20,6 +20,14 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import englishTranslations from '@/assets/lang/en.json'; import { CoreApplicationInitStatus } from '@classes/application-init-status'; import { Translate } from '@singletons'; +import { CoreSitesProviderStub, CoreSitesStub } from '@/storybook/stubs/services/sites'; +import { CoreSitesProvider } from '@services/sites'; +import { CoreDbProviderStub } from '@/storybook/stubs/services/db'; +import { CoreDbProvider } from '@services/db'; +import { CoreFilepoolProviderStub } from '@/storybook/stubs/services/filepool'; +import { CoreFilepoolProvider } from '@services/filepool'; +import { HttpClientStub } from '@/storybook/stubs/services/http'; +import { HttpClient } from '@angular/common/http'; // For translate loader. AoT requires an exported function for factories. export class StaticTranslateLoader extends TranslateLoader { @@ -45,12 +53,17 @@ export class StaticTranslateLoader extends TranslateLoader { ], providers: [ { provide: ApplicationInitStatus, useClass: CoreApplicationInitStatus }, + { provide: CoreSitesProvider, useClass: CoreSitesProviderStub }, + { provide: CoreDbProvider, useClass: CoreDbProviderStub }, + { provide: CoreFilepoolProvider, useClass: CoreFilepoolProviderStub }, + { provide: HttpClient, useClass: HttpClientStub }, { provide: APP_INITIALIZER, multi: true, useValue: () => { Translate.setDefaultLang('en'); Translate.use('en'); + CoreSitesStub.stubCurrentSite(); }, }, ], diff --git a/src/storybook/stubs/classes/site.ts b/src/storybook/stubs/classes/site.ts new file mode 100644 index 000000000..12d8f89c2 --- /dev/null +++ b/src/storybook/stubs/classes/site.ts @@ -0,0 +1,57 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSite, CoreSiteConfigResponse, CoreSiteInfo, CoreSiteWSPreSets, WSObservable } from '@classes/site'; +import { of } from 'rxjs'; + +export interface CoreSiteFixture { + id: string; + info: CoreSiteInfo; +} + +export class CoreSiteStub extends CoreSite { + + protected wsStubs: Record = {}; + + constructor (fixture: CoreSiteFixture) { + super(fixture.id, fixture.info.siteurl, undefined, fixture.info); + + this.stubWSResponse('tool_mobile_get_config', { + settings: [], + warnings: [], + }); + } + + /** + * @inheritdoc + */ + readObservable(wsFunction: string, data: unknown, preSets?: CoreSiteWSPreSets): WSObservable { + if (wsFunction in this.wsStubs) { + return of(this.wsStubs[wsFunction] as T); + } + + return super.readObservable(wsFunction, data, preSets); + } + + /** + * Prepare as stubbed response for a given WS. + * + * @param wsFunction WS function. + * @param response Response. + */ + stubWSResponse(wsFunction: string, response: T): void { + this.wsStubs[wsFunction] = response; + } + +} diff --git a/src/storybook/stubs/classes/sqlitedb.ts b/src/storybook/stubs/classes/sqlitedb.ts new file mode 100644 index 000000000..94d81b5a1 --- /dev/null +++ b/src/storybook/stubs/classes/sqlitedb.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDB } from '@classes/sqlitedb'; +import { SQLiteObject } from '@ionic-native/sqlite/ngx'; + +/** + * SQlite database stub. + */ +export class SQLiteDBStub extends SQLiteDB { + + /** + * @inheritdoc + */ + async createDatabase(): Promise { + return new Proxy({ + executeSql: () => Promise.resolve({ insertId: Math.random().toString() }), + }, {}) as unknown as SQLiteObject; + } + +} diff --git a/src/storybook/stubs/services/db.ts b/src/storybook/stubs/services/db.ts new file mode 100644 index 000000000..e74c90bb2 --- /dev/null +++ b/src/storybook/stubs/services/db.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDBStub } from '@/storybook/stubs/classes/sqlitedb'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreDbProvider } from '@services/db'; + +/** + * Database provider stub. + */ +export class CoreDbProviderStub extends CoreDbProvider { + + /** + * @inheritdoc + */ + getDB(name: string, forceNew?: boolean): SQLiteDB { + if (this.dbInstances[name] === undefined || forceNew) { + this.dbInstances[name] = new SQLiteDBStub(name); + } + + return this.dbInstances[name]; + } + +} diff --git a/src/storybook/stubs/services/filepool.ts b/src/storybook/stubs/services/filepool.ts new file mode 100644 index 000000000..d5c604aad --- /dev/null +++ b/src/storybook/stubs/services/filepool.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { makeSingleton } from '@singletons'; +import { CoreFilepoolProvider } from '@services/filepool'; + +/** + * Filepool provider stub. + */ +export class CoreFilepoolProviderStub extends CoreFilepoolProvider { + + /** + * @inheritdoc + */ + async getSrcByUrl(siteId: string, fileUrl: string): Promise { + return fileUrl; + } + +} + +export const CoreFilepoolStub = makeSingleton(CoreFilepoolProvider); diff --git a/src/storybook/stubs/services/http.ts b/src/storybook/stubs/services/http.ts new file mode 100644 index 000000000..55e397d72 --- /dev/null +++ b/src/storybook/stubs/services/http.ts @@ -0,0 +1,38 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { makeSingleton } from '@singletons'; +import { HttpClient, HttpHandler } from '@angular/common/http'; +import { from, Observable } from 'rxjs'; + +/** + * Http service stub. + */ +export class HttpClientStub extends HttpClient { + + constructor() { + super(null as unknown as HttpHandler); + } + + /** + * @inheritdoc + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(url: string): Observable { + return from(fetch(url).then(response => response.text())); + } + +} + +export const HttpStub = makeSingleton(HttpClient); diff --git a/src/storybook/stubs/services/sites.ts b/src/storybook/stubs/services/sites.ts new file mode 100644 index 000000000..25f32541a --- /dev/null +++ b/src/storybook/stubs/services/sites.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import school from '@/assets/storybook/sites/school.json'; +import { CoreSiteFixture, CoreSiteStub } from '@/storybook/stubs/classes/site'; +import { CoreSitesProvider } from '@services/sites'; +import { makeSingleton } from '@singletons'; + +/** + * Sites provider stub. + */ +export class CoreSitesProviderStub extends CoreSitesProvider { + + /** + * @inheritdoc + */ + getRequiredCurrentSite!: () => CoreSiteStub; + + /** + * @inheritdoc + */ + stubCurrentSite(fixture?: CoreSiteFixture): CoreSiteStub { + if (!this.currentSite) { + this.currentSite = new CoreSiteStub(fixture ?? school); + } + + return this.getRequiredCurrentSite(); + } + +} + +export const CoreSitesStub = makeSingleton(CoreSitesProvider);