diff --git a/amd/build/package_search/components/area.min.js b/amd/build/package_search/components/area.min.js
index 99cd28f7..d5736c5e 100644
--- a/amd/build/package_search/components/area.min.js
+++ b/amd/build/package_search/components/area.min.js
@@ -1,3 +1,3 @@
-define("qtype_questionpy/package_search/components/area",["exports","qtype_questionpy/package_search/component","qtype_questionpy/package_search/components/container","qtype_questionpy/package_search/components/search_bar"],(function(_exports,_component,_container,_search_bar){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=_interopRequireDefault(_component),_container=_interopRequireDefault(_container),_search_bar=_interopRequireDefault(_search_bar);class _default extends _component.default{getWatchers(){return[{watch:"general.loading:updated",handler:this.updateStatus}]}create(descriptor){new _search_bar.default({element:this.getElement('[data-for="search-bar-container"'),name:"search_bar",reactive:descriptor.reactive}),new _container.default({element:this.getElement('[data-for="package-container"'),name:"container",reactive:descriptor.reactive})}stateReady(){this.reactive.dispatch("searchPackages")}updateStatus(){const loading=this.getState().general.loading;this.element.classList.toggle("qpy-loading",loading)}}return _exports.default=_default,_exports.default}));
+define("qtype_questionpy/package_search/components/area",["exports","qtype_questionpy/package_search/component","qtype_questionpy/package_search/components/container","qtype_questionpy/package_search/components/search_bar","qtype_questionpy/package_search/components/tag_bar"],(function(_exports,_component,_container,_search_bar,_tag_bar){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_component=_interopRequireDefault(_component),_container=_interopRequireDefault(_container),_search_bar=_interopRequireDefault(_search_bar);class _default extends _component.default{getWatchers(){return[{watch:"general.loading:updated",handler:this.updateStatus}]}create(descriptor){new _search_bar.default({element:this.getElement('[data-for="search-bar-container"'),name:"search_bar",reactive:descriptor.reactive}),new _tag_bar.TagBar({element:this.getElement('[data-for="tag-bar"]'),name:"tag_bar",reactive:descriptor.reactive}),new _container.default({element:this.getElement('[data-for="package-container"'),name:"container",reactive:descriptor.reactive})}stateReady(){this.reactive.dispatch("searchPackages")}updateStatus(){const loading=this.getState().general.loading;this.element.classList.toggle("qpy-loading",loading)}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=area.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/components/area.min.js.map b/amd/build/package_search/components/area.min.js.map
index 30e543d1..67ff2370 100644
--- a/amd/build/package_search/components/area.min.js.map
+++ b/amd/build/package_search/components/area.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"area.min.js","sources":["../../../src/package_search/components/area.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/components/area\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\nimport Container from 'qtype_questionpy/package_search/components/container';\nimport SearchBar from 'qtype_questionpy/package_search/components/search_bar';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `general.loading:updated`, handler: this.updateStatus},\n ];\n }\n\n create(descriptor) {\n // Register search bar.\n // TODO: register component inside mustache template.\n new SearchBar({\n element: this.getElement('[data-for=\"search-bar-container\"'),\n name: \"search_bar\",\n reactive: descriptor.reactive,\n });\n // Register package container.\n // TODO: register component inside mustache template.\n new Container({\n element: this.getElement('[data-for=\"package-container\"'),\n name: \"container\",\n reactive: descriptor.reactive,\n });\n }\n\n stateReady() {\n // Initial loading of the packages.\n this.reactive.dispatch(\"searchPackages\");\n }\n\n /**\n * Adds or removes the `qpy-loading` class from the search area.\n */\n updateStatus() {\n const loading = this.getState().general.loading;\n this.element.classList.toggle(\"qpy-loading\", loading);\n }\n}\n"],"names":["Component","getWatchers","watch","handler","this","updateStatus","create","descriptor","SearchBar","element","getElement","name","reactive","Container","stateReady","dispatch","loading","getState","general","classList","toggle"],"mappings":"2lBAyB6BA,mBACzBC,oBACW,CACH,CAACC,gCAAkCC,QAASC,KAAKC,eAIzDC,OAAOC,gBAGCC,oBAAU,CACVC,QAASL,KAAKM,WAAW,oCACzBC,KAAM,aACNC,SAAUL,WAAWK,eAIrBC,mBAAU,CACVJ,QAASL,KAAKM,WAAW,iCACzBC,KAAM,YACNC,SAAUL,WAAWK,WAI7BE,kBAESF,SAASG,SAAS,kBAM3BV,qBACUW,QAAUZ,KAAKa,WAAWC,QAAQF,aACnCP,QAAQU,UAAUC,OAAO,cAAeJ"}
\ No newline at end of file
+{"version":3,"file":"area.min.js","sources":["../../../src/package_search/components/area.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/components/area\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\nimport Container from 'qtype_questionpy/package_search/components/container';\nimport SearchBar from 'qtype_questionpy/package_search/components/search_bar';\nimport {TagBar} from 'qtype_questionpy/package_search/components/tag_bar';\n\nexport default class extends Component {\n getWatchers() {\n return [\n {watch: `general.loading:updated`, handler: this.updateStatus},\n ];\n }\n\n create(descriptor) {\n // Register search bar.\n // TODO: register component inside mustache template.\n new SearchBar({\n element: this.getElement('[data-for=\"search-bar-container\"'),\n name: \"search_bar\",\n reactive: descriptor.reactive,\n });\n // Register tag bar.\n // TODO: register component inside mustache template.\n new TagBar({\n element: this.getElement('[data-for=\"tag-bar\"]'),\n name: \"tag_bar\",\n reactive: descriptor.reactive,\n });\n // Register package container.\n // TODO: register component inside mustache template.\n new Container({\n element: this.getElement('[data-for=\"package-container\"'),\n name: \"container\",\n reactive: descriptor.reactive,\n });\n }\n\n stateReady() {\n // Initial loading of the packages.\n this.reactive.dispatch(\"searchPackages\");\n }\n\n /**\n * Adds or removes the `qpy-loading` class from the search area.\n */\n updateStatus() {\n const loading = this.getState().general.loading;\n this.element.classList.toggle(\"qpy-loading\", loading);\n }\n}\n"],"names":["Component","getWatchers","watch","handler","this","updateStatus","create","descriptor","SearchBar","element","getElement","name","reactive","TagBar","Container","stateReady","dispatch","loading","getState","general","classList","toggle"],"mappings":"ypBA0B6BA,mBACzBC,oBACW,CACH,CAACC,gCAAkCC,QAASC,KAAKC,eAIzDC,OAAOC,gBAGCC,oBAAU,CACVC,QAASL,KAAKM,WAAW,oCACzBC,KAAM,aACNC,SAAUL,WAAWK,eAIrBC,gBAAO,CACPJ,QAASL,KAAKM,WAAW,wBACzBC,KAAM,UACNC,SAAUL,WAAWK,eAIrBE,mBAAU,CACVL,QAASL,KAAKM,WAAW,iCACzBC,KAAM,YACNC,SAAUL,WAAWK,WAI7BG,kBAESH,SAASI,SAAS,kBAM3BX,qBACUY,QAAUb,KAAKc,WAAWC,QAAQF,aACnCR,QAAQW,UAAUC,OAAO,cAAeJ"}
\ No newline at end of file
diff --git a/amd/build/package_search/components/tag_bar.min.js b/amd/build/package_search/components/tag_bar.min.js
new file mode 100644
index 00000000..038a5d99
--- /dev/null
+++ b/amd/build/package_search/components/tag_bar.min.js
@@ -0,0 +1,3 @@
+define("qtype_questionpy/package_search/components/tag_bar",["exports","qtype_questionpy/package_search/component","core/form-autocomplete","core/ajax","core/utils","core/str"],(function(_exports,_component,_formAutocomplete,_ajax,_utils,_str){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.transport=_exports.processResults=_exports.TagBar=void 0,_component=_interopRequireDefault(_component),_formAutocomplete=_interopRequireDefault(_formAutocomplete),_ajax=_interopRequireDefault(_ajax);const TagBar=class extends _component.default{async create(){this.selectors={TAG_BAR:'[data-for="tag-bar"] > select'},_formAutocomplete.default.enhance(this.selectors.TAG_BAR,!1,"qtype_questionpy/package_search/components/tag_bar",await(0,_str.getString)("tag_bar","qtype_questionpy"),!1,!0,await(0,_str.getString)("tag_bar_no_selection","qtype_questionpy"),!0,{layout:"qtype_questionpy/package_search/tag_bar/layout",input:"qtype_questionpy/package_search/tag_bar/input",selection:"qtype_questionpy/package_search/tag_bar/selection"})}stateReady(){this.addEventListener(this.getElement(this.selectors.TAG_BAR),"change",(0,_utils.debounce)((event=>this.filterPackages(event.target.selectedOptions)),300))}filterPackages(selectedOptions){const tags=[];for(let i=0;i_ajax.default.call([{methodname:"qtype_questionpy_get_tags",args:{query:query}}])[0].then(callback).catch(failure);_exports.processResults=(selector,results)=>{const tags=[];for(const result of results)tags.push({value:result.id,label:result.tag});return tags}}));
+
+//# sourceMappingURL=tag_bar.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/components/tag_bar.min.js.map b/amd/build/package_search/components/tag_bar.min.js.map
new file mode 100644
index 00000000..1137fc6c
--- /dev/null
+++ b/amd/build/package_search/components/tag_bar.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"tag_bar.min.js","sources":["../../../src/package_search/components/tag_bar.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/components/tag_bar\n */\n\nimport Component from 'qtype_questionpy/package_search/component';\nimport Autocomplete from 'core/form-autocomplete';\nimport Ajax from 'core/ajax';\nimport {debounce} from 'core/utils';\nimport {getString} from 'core/str';\n\nexport const TagBar = class extends Component {\n async create() {\n this.selectors = {\n TAG_BAR: '[data-for=\"tag-bar\"] > select',\n };\n\n Autocomplete.enhance(\n // The selector to the select element.\n this.selectors.TAG_BAR,\n // No custom words allowed.\n false,\n // We want to use ajax.\n 'qtype_questionpy/package_search/components/tag_bar',\n // The placeholder.\n await getString('tag_bar', 'qtype_questionpy'),\n // We do not want to be case-sensitive.\n false,\n // We want to show suggestions.\n true,\n // If no selection is made, show this string.\n await getString('tag_bar_no_selection', 'qtype_questionpy'),\n // Close suggestion.\n true,\n // We want to overwrite the layout.\n {\n layout: 'qtype_questionpy/package_search/tag_bar/layout',\n input: 'qtype_questionpy/package_search/tag_bar/input',\n selection: 'qtype_questionpy/package_search/tag_bar/selection',\n },\n );\n }\n\n stateReady() {\n this.addEventListener(\n this.getElement(this.selectors.TAG_BAR),\n 'change',\n debounce((event) => this.filterPackages(event.target.selectedOptions), 300)\n );\n }\n\n /**\n * Dispatches package filter by tag mutation.\n *\n * @param {HTMLCollectionOf} selectedOptions\n */\n filterPackages(selectedOptions) {\n const tags = [];\n for (let i = 0; i < selectedOptions.length; i++) {\n tags.push(selectedOptions[i].value);\n }\n this.reactive.dispatch('filterPackagesByTags', tags);\n }\n};\n\n/**\n * Source of data for Ajax element.\n *\n * @param {String} selector The selector of the auto complete element.\n * @param {String} query The query string.\n * @param {Function} callback A callback function receiving an array of results.\n * @param {Function} failure A callback function to be called in case of failure, receiving the error message.\n */\nexport const transport = (selector, query, callback, failure) => {\n const promise = Ajax.call([{\n methodname: 'qtype_questionpy_get_tags',\n args: {\n query: query\n }\n }])[0];\n\n // eslint-disable-next-line promise/no-callback-in-promise\n return promise.then(callback).catch(failure);\n};\n\n/**\n * Process the results for auto complete elements.\n *\n * @param {string} selector The selector of the auto complete element.\n * @param {array} results An array or results.\n * @return {array} New array of results.\n */\nexport const processResults = (selector, results) => {\n const tags = [];\n for (const result of results) {\n tags.push({\n value: result.id,\n label: result.tag,\n });\n }\n return tags;\n};\n"],"names":["TagBar","Component","selectors","TAG_BAR","enhance","this","layout","input","selection","stateReady","addEventListener","getElement","event","filterPackages","target","selectedOptions","tags","i","length","push","value","reactive","dispatch","selector","query","callback","failure","Ajax","call","methodname","args","then","catch","results","result","id","label","tag"],"mappings":"olBA2BaA,OAAS,cAAcC,uCAEvBC,UAAY,CACbC,QAAS,2DAGAC,QAETC,KAAKH,UAAUC,SAEf,EAEA,2DAEM,kBAAU,UAAW,qBAE3B,GAEA,QAEM,kBAAU,uBAAwB,qBAExC,EAEA,CACIG,OAAQ,iDACRC,MAAO,gDACPC,UAAW,sDAKvBC,kBACSC,iBACDL,KAAKM,WAAWN,KAAKH,UAAUC,SAC/B,UACA,oBAAUS,OAAUP,KAAKQ,eAAeD,MAAME,OAAOC,kBAAkB,MAS/EF,eAAeE,uBACLC,KAAO,OACR,IAAIC,EAAI,EAAGA,EAAIF,gBAAgBG,OAAQD,IACxCD,KAAKG,KAAKJ,gBAAgBE,GAAGG,YAE5BC,SAASC,SAAS,uBAAwBN,kDAY9B,CAACO,SAAUC,MAAOC,SAAUC,UACjCC,cAAKC,KAAK,CAAC,CACvBC,WAAY,4BACZC,KAAM,CACFN,MAAOA,UAEX,GAGWO,KAAKN,UAAUO,MAAMN,iCAUV,CAACH,SAAUU,iBAC/BjB,KAAO,OACR,MAAMkB,UAAUD,QACjBjB,KAAKG,KAAK,CACNC,MAAOc,OAAOC,GACdC,MAAOF,OAAOG,aAGfrB"}
\ No newline at end of file
diff --git a/amd/build/package_search/mutations.min.js b/amd/build/package_search/mutations.min.js
index 97b2c3d0..786c3c41 100644
--- a/amd/build/package_search/mutations.min.js
+++ b/amd/build/package_search/mutations.min.js
@@ -1,3 +1,3 @@
-define("qtype_questionpy/package_search/mutations",["exports","core/ajax","core/notification","qtype_questionpy/utils"],(function(_exports,_ajax,_notification,_utils){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(options){this.options=options}_getSearchPackagesInCategoriesPromise(state,page,categories){const methods=[];for(const category of categories){const method={methodname:"qtype_questionpy_search_packages",args:{query:state.general.query,tags:state.general.tags,category:category,sort:state.general.sorting.sort,order:state.general.sorting.order,limit:this.options.limit,page:"number"==typeof page?page:state[category].page,contextid:this.options.contextid}};methods.push(method)}return _ajax.default.call(methods)}_setLoading(stateManager,loading){const state=stateManager.state;if(state.loading===loading)return;const isReadonly=stateManager.readonly;isReadonly&&stateManager.setReadOnly(!1),state.general.loading=loading,isReadonly&&stateManager.setReadOnly(!0)}async searchPackages(stateManager){let args=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,categories=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const state=stateManager.state;args=args||{},categories=categories||["all","recentlyused","favourites"],stateManager.setReadOnly(!1),this._setLoading(stateManager,!0),state.general.query="string"==typeof args.query?args.query:state.general.query,state.general.tags=[],state.general.sorting={sort:args.sort||state.general.sorting.sort,order:args.order||state.general.sorting.order},stateManager.setReadOnly(!0);try{let results=await this._getSearchPackagesInCategoriesPromise(state,args.page,categories);stateManager.setReadOnly(!1);for(const[index,category]of categories.entries()){const result=await results[index];state["".concat(category,"Packages")]=result.packages,state[category].count=result.count,state[category].total=result.total,"number"==typeof args.page&&(state[category].page=args.page)}stateManager.setReadOnly(!0)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}async searchPackagesByQuery(stateManager,query){await this.searchPackages(stateManager,{page:0,query:query})}async changePage(stateManager,category,page){await this.searchPackages(stateManager,{page:page},[category])}async changeSort(stateManager,sort,order){await this.searchPackages(stateManager,{sort:sort,order:order},["all","favourites"])}async load(stateManager,categories){await this.searchPackages(stateManager,{},categories)}async favourite(stateManager,packageid,favourite){const state=stateManager.state;try{this._setLoading(stateManager,!0);if(!await(0,_utils.favouritePackage)(packageid,favourite))return;stateManager.setReadOnly(!1);for(const category of["all","recentlyused"]){const pkg=state["".concat(category,"Packages")].get(packageid);pkg&&(pkg.isfavourite=favourite)}stateManager.setReadOnly(!0);let page=state.favourites.page;if(!favourite){const isFirstPage=0===page,isLastPage=page===Math.floor((state.favourites.total-1)/this.options.limit),existsOnePackage=1===state.favourites.count;!isFirstPage&&isLastPage&&existsOnePackage&&(page-=1)}await this.changePage(stateManager,"favourites",page)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}},_exports.default}));
+define("qtype_questionpy/package_search/mutations",["exports","core/ajax","core/notification","qtype_questionpy/utils"],(function(_exports,_ajax,_notification,_utils){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification);return _exports.default=class{constructor(options){this.options=options}_getSearchPackagesInCategoriesPromise(state,page,categories){const methods=[];for(const category of categories){const method={methodname:"qtype_questionpy_search_packages",args:{query:state.general.query,tags:state.general.tags,category:category,sort:state.general.sorting.sort,order:state.general.sorting.order,limit:this.options.limit,page:"number"==typeof page?page:state[category].page,contextid:this.options.contextid}};methods.push(method)}return _ajax.default.call(methods)}_setLoading(stateManager,loading){const state=stateManager.state;if(state.loading===loading)return;const isReadonly=stateManager.readonly;isReadonly&&stateManager.setReadOnly(!1),state.general.loading=loading,isReadonly&&stateManager.setReadOnly(!0)}async searchPackages(stateManager){let args=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,categories=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null;const state=stateManager.state;args=args||{},categories=categories||["all","recentlyused","favourites"],stateManager.setReadOnly(!1),this._setLoading(stateManager,!0),state.general.query="string"==typeof args.query?args.query:state.general.query,state.general.tags=args.tags||state.general.tags,state.general.sorting={sort:args.sort||state.general.sorting.sort,order:args.order||state.general.sorting.order},stateManager.setReadOnly(!0);try{let results=await this._getSearchPackagesInCategoriesPromise(state,args.page,categories);stateManager.setReadOnly(!1);for(const[index,category]of categories.entries()){const result=await results[index];state["".concat(category,"Packages")]=result.packages,state[category].count=result.count,state[category].total=result.total,"number"==typeof args.page&&(state[category].page=args.page)}stateManager.setReadOnly(!0)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}async searchPackagesByQuery(stateManager,query){await this.searchPackages(stateManager,{page:0,query:query})}async filterPackagesByTags(stateManager,tags){await this.searchPackages(stateManager,{tags:tags})}async changePage(stateManager,category,page){await this.searchPackages(stateManager,{page:page},[category])}async changeSort(stateManager,sort,order){await this.searchPackages(stateManager,{sort:sort,order:order},["all","favourites"])}async load(stateManager,categories){await this.searchPackages(stateManager,{},categories)}async favourite(stateManager,packageid,favourite){const state=stateManager.state;try{this._setLoading(stateManager,!0);if(!await(0,_utils.favouritePackage)(packageid,favourite))return;stateManager.setReadOnly(!1);for(const category of["all","recentlyused"]){const pkg=state["".concat(category,"Packages")].get(packageid);pkg&&(pkg.isfavourite=favourite)}stateManager.setReadOnly(!0);let page=state.favourites.page;if(!favourite){const isFirstPage=0===page,isLastPage=page===Math.floor((state.favourites.total-1)/this.options.limit),existsOnePackage=1===state.favourites.count;!isFirstPage&&isLastPage&&existsOnePackage&&(page-=1)}await this.changePage(stateManager,"favourites",page)}catch(exception){await _notification.default.exception(exception)}finally{this._setLoading(stateManager,!1)}}},_exports.default}));
//# sourceMappingURL=mutations.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/mutations.min.js.map b/amd/build/package_search/mutations.min.js.map
index e7536717..5a54ea3c 100644
--- a/amd/build/package_search/mutations.min.js.map
+++ b/amd/build/package_search/mutations.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"mutations.min.js","sources":["../../src/package_search/mutations.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/mutations\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\nimport {favouritePackage} from 'qtype_questionpy/utils';\n\nexport default class {\n /**\n * @param {{contextid: number, limit: number}} options\n */\n constructor(options) {\n this.options = options;\n }\n\n /**\n * Search through given categories.\n *\n * If no page is provided the current page will be used.\n *\n * @param {any} state\n * @param {number|null} page\n * @param {string[]} categories\n * @returns {any}\n * @private\n */\n _getSearchPackagesInCategoriesPromise(state, page, categories) {\n const methods = [];\n for (const category of categories) {\n const method = {\n methodname: \"qtype_questionpy_search_packages\",\n args: {\n query: state.general.query,\n tags: state.general.tags,\n category: category,\n sort: state.general.sorting.sort,\n order: state.general.sorting.order,\n limit: this.options.limit,\n page: (typeof page === \"number\") ? page : state[category].page,\n contextid: this.options.contextid,\n },\n };\n methods.push(method);\n }\n return Ajax.call(methods);\n }\n\n /**\n * Sets the `loading` property.\n *\n * It only communicates changes to the watchers if the `StateManager` is currently readonly.\n *\n * @param {StateManager} stateManager\n * @param {boolean} loading\n * @private\n */\n _setLoading(stateManager, loading) {\n const state = stateManager.state;\n if (state.loading === loading) {\n return;\n }\n const isReadonly = stateManager.readonly;\n if (isReadonly) {\n stateManager.setReadOnly(false);\n }\n state.general.loading = loading;\n if (isReadonly) {\n stateManager.setReadOnly(true);\n }\n }\n\n /**\n * Used to search packages.\n *\n * Missing arguments are taken from the current state.\n *\n * @param {StateManager} stateManager\n * @param {Object|null} args\n * @param {string[]|null} categories\n */\n async searchPackages(stateManager, args = null, categories = null) {\n const state = stateManager.state;\n\n // Missing arguments are taken from the current state.\n args = args || {};\n\n // Search through every category if no categories are provided.\n categories = categories || [\"all\", \"recentlyused\", \"favourites\"];\n\n // Update general data.\n stateManager.setReadOnly(false);\n this._setLoading(stateManager, true);\n state.general.query = (typeof args.query === \"string\") ? args.query : state.general.query;\n state.general.tags = [];\n state.general.sorting = {\n sort: args.sort || state.general.sorting.sort,\n order: args.order || state.general.sorting.order,\n };\n stateManager.setReadOnly(true);\n\n try {\n // Get search results for each category.\n let results = await this._getSearchPackagesInCategoriesPromise(state, args.page, categories);\n\n stateManager.setReadOnly(false);\n // Update category specific data.\n for (const [index, category] of categories.entries()) {\n const result = await results[index];\n state[`${category}Packages`] = result.packages;\n state[category].count = result.count;\n state[category].total = result.total;\n if (typeof args.page === \"number\") {\n state[category].page = args.page;\n }\n }\n stateManager.setReadOnly(true);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n\n /**\n * Used to search for packages only by providing a query.\n *\n * @param {StateManager} stateManager\n * @param {string} query\n */\n async searchPackagesByQuery(stateManager, query) {\n await this.searchPackages(stateManager, {page: 0, query: query});\n }\n\n /**\n * Used to change the current page of a tab.\n *\n * @param {StateManager} stateManager\n * @param {string} category\n * @param {number} page\n */\n async changePage(stateManager, category, page) {\n await this.searchPackages(stateManager, {page: page}, [category]);\n }\n\n /**\n * Used to change the current sorting.\n *\n * @param {StateManager} stateManager\n * @param {string} sort\n * @param {string} order\n */\n async changeSort(stateManager, sort, order) {\n await this.searchPackages(stateManager, {sort: sort, order: order}, [\"all\", \"favourites\"]);\n }\n\n /**\n * Used to re-/load data of given categories.\n *\n * @param {StateManager} stateManager\n * @param {string[]} categories\n */\n async load(stateManager, categories) {\n await this.searchPackages(stateManager, {}, categories);\n }\n\n /**\n * Used to un-/favourite a package.\n *\n * @param {StateManager} stateManager\n * @param {int} packageid\n * @param {boolean} favourite\n */\n async favourite(stateManager, packageid, favourite) {\n const state = stateManager.state;\n try {\n this._setLoading(stateManager, true);\n const successful = await favouritePackage(packageid, favourite);\n if (!successful) {\n return;\n }\n stateManager.setReadOnly(false);\n for (const category of [\"all\", \"recentlyused\"]) {\n const pkg = state[`${category}Packages`].get(packageid);\n if (pkg) {\n pkg.isfavourite = favourite;\n }\n }\n stateManager.setReadOnly(true);\n let page = state.favourites.page;\n if (!favourite) {\n // Turn back a page in 'favourites' if the unmarked package was the last one on the page.\n const isFirstPage = page === 0;\n const isLastPage = page === Math.floor((state.favourites.total - 1) / this.options.limit);\n const existsOnePackage = state.favourites.count === 1;\n if (!isFirstPage && isLastPage && existsOnePackage) {\n page -= 1;\n }\n }\n await this.changePage(stateManager, 'favourites', page);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n}\n"],"names":["constructor","options","_getSearchPackagesInCategoriesPromise","state","page","categories","methods","category","method","methodname","args","query","general","tags","sort","sorting","order","limit","this","contextid","push","Ajax","call","_setLoading","stateManager","loading","isReadonly","readonly","setReadOnly","results","index","entries","result","packages","count","total","exception","Notification","searchPackages","packageid","favourite","pkg","get","isfavourite","favourites","isFirstPage","isLastPage","Math","floor","existsOnePackage","changePage"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"mutations.min.js","sources":["../../src/package_search/mutations.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/mutations\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\nimport {favouritePackage} from 'qtype_questionpy/utils';\n\nexport default class {\n /**\n * @param {{contextid: number, limit: number}} options\n */\n constructor(options) {\n this.options = options;\n }\n\n /**\n * Search through given categories.\n *\n * If no page is provided the current page will be used.\n *\n * @param {any} state\n * @param {number|null} page\n * @param {string[]} categories\n * @returns {any}\n * @private\n */\n _getSearchPackagesInCategoriesPromise(state, page, categories) {\n const methods = [];\n for (const category of categories) {\n const method = {\n methodname: \"qtype_questionpy_search_packages\",\n args: {\n query: state.general.query,\n tags: state.general.tags,\n category: category,\n sort: state.general.sorting.sort,\n order: state.general.sorting.order,\n limit: this.options.limit,\n page: (typeof page === \"number\") ? page : state[category].page,\n contextid: this.options.contextid,\n },\n };\n methods.push(method);\n }\n return Ajax.call(methods);\n }\n\n /**\n * Sets the `loading` property.\n *\n * It only communicates changes to the watchers if the `StateManager` is currently readonly.\n *\n * @param {StateManager} stateManager\n * @param {boolean} loading\n * @private\n */\n _setLoading(stateManager, loading) {\n const state = stateManager.state;\n if (state.loading === loading) {\n return;\n }\n const isReadonly = stateManager.readonly;\n if (isReadonly) {\n stateManager.setReadOnly(false);\n }\n state.general.loading = loading;\n if (isReadonly) {\n stateManager.setReadOnly(true);\n }\n }\n\n /**\n * Used to search packages.\n *\n * Missing arguments are taken from the current state.\n *\n * @param {StateManager} stateManager\n * @param {Object|null} args\n * @param {string[]|null} categories\n */\n async searchPackages(stateManager, args = null, categories = null) {\n const state = stateManager.state;\n\n // Missing arguments are taken from the current state.\n args = args || {};\n\n // Search through every category if no categories are provided.\n categories = categories || [\"all\", \"recentlyused\", \"favourites\"];\n\n // Update general data.\n stateManager.setReadOnly(false);\n this._setLoading(stateManager, true);\n state.general.query = (typeof args.query === \"string\") ? args.query : state.general.query;\n state.general.tags = args.tags || state.general.tags;\n state.general.sorting = {\n sort: args.sort || state.general.sorting.sort,\n order: args.order || state.general.sorting.order,\n };\n stateManager.setReadOnly(true);\n\n try {\n // Get search results for each category.\n let results = await this._getSearchPackagesInCategoriesPromise(state, args.page, categories);\n\n stateManager.setReadOnly(false);\n // Update category specific data.\n for (const [index, category] of categories.entries()) {\n const result = await results[index];\n state[`${category}Packages`] = result.packages;\n state[category].count = result.count;\n state[category].total = result.total;\n if (typeof args.page === \"number\") {\n state[category].page = args.page;\n }\n }\n stateManager.setReadOnly(true);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n\n /**\n * Used to search for packages only by providing a query.\n *\n * @param {StateManager} stateManager\n * @param {string} query\n */\n async searchPackagesByQuery(stateManager, query) {\n await this.searchPackages(stateManager, {page: 0, query: query});\n }\n\n /**\n * Used to filter packages only by providing tags.\n *\n * @param {StateManager} stateManager\n * @param {int[]} tags\n * @returns {Promise}\n */\n async filterPackagesByTags(stateManager, tags) {\n await this.searchPackages(stateManager, {tags: tags});\n }\n\n /**\n * Used to change the current page of a tab.\n *\n * @param {StateManager} stateManager\n * @param {string} category\n * @param {number} page\n */\n async changePage(stateManager, category, page) {\n await this.searchPackages(stateManager, {page: page}, [category]);\n }\n\n /**\n * Used to change the current sorting.\n *\n * @param {StateManager} stateManager\n * @param {string} sort\n * @param {string} order\n */\n async changeSort(stateManager, sort, order) {\n await this.searchPackages(stateManager, {sort: sort, order: order}, [\"all\", \"favourites\"]);\n }\n\n /**\n * Used to re-/load data of given categories.\n *\n * @param {StateManager} stateManager\n * @param {string[]} categories\n */\n async load(stateManager, categories) {\n await this.searchPackages(stateManager, {}, categories);\n }\n\n /**\n * Used to un-/favourite a package.\n *\n * @param {StateManager} stateManager\n * @param {int} packageid\n * @param {boolean} favourite\n */\n async favourite(stateManager, packageid, favourite) {\n const state = stateManager.state;\n try {\n this._setLoading(stateManager, true);\n const successful = await favouritePackage(packageid, favourite);\n if (!successful) {\n return;\n }\n stateManager.setReadOnly(false);\n for (const category of [\"all\", \"recentlyused\"]) {\n const pkg = state[`${category}Packages`].get(packageid);\n if (pkg) {\n pkg.isfavourite = favourite;\n }\n }\n stateManager.setReadOnly(true);\n let page = state.favourites.page;\n if (!favourite) {\n // Turn back a page in 'favourites' if the unmarked package was the last one on the page.\n const isFirstPage = page === 0;\n const isLastPage = page === Math.floor((state.favourites.total - 1) / this.options.limit);\n const existsOnePackage = state.favourites.count === 1;\n if (!isFirstPage && isLastPage && existsOnePackage) {\n page -= 1;\n }\n }\n await this.changePage(stateManager, 'favourites', page);\n } catch (exception) {\n await Notification.exception(exception);\n } finally {\n this._setLoading(stateManager, false);\n }\n }\n}\n"],"names":["constructor","options","_getSearchPackagesInCategoriesPromise","state","page","categories","methods","category","method","methodname","args","query","general","tags","sort","sorting","order","limit","this","contextid","push","Ajax","call","_setLoading","stateManager","loading","isReadonly","readonly","setReadOnly","results","index","entries","result","packages","count","total","exception","Notification","searchPackages","packageid","favourite","pkg","get","isfavourite","favourites","isFirstPage","isLastPage","Math","floor","existsOnePackage","changePage"],"mappings":"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"}
\ No newline at end of file
diff --git a/amd/build/package_search/reactive.min.js b/amd/build/package_search/reactive.min.js
index 2e83cec9..82943b9a 100644
--- a/amd/build/package_search/reactive.min.js
+++ b/amd/build/package_search/reactive.min.js
@@ -1,3 +1,3 @@
-define("qtype_questionpy/package_search/reactive",["exports","core/reactive","qtype_questionpy/package_search/mutations","qtype_questionpy/package_search/events","qtype_questionpy/package_search/components/area"],(function(_exports,_reactive,_mutations,_events,_area){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_mutations=_interopRequireDefault(_mutations),_area=_interopRequireDefault(_area);let counter=0;class _default extends _reactive.Reactive{constructor(target,options){super({name:"PackageSearch".concat(counter++),eventName:_events.eventNames.stateChanged,eventDispatch:_events.notifyStateChanged,target:target,mutations:new _mutations.default(options),state:{general:{loading:!0,sorting:{sort:"alpha",order:"asc"},query:""},all:{count:0,total:0,page:0},allPackages:[],recentlyused:{count:0,total:0,page:0},recentlyusedPackages:[],favourites:{count:0,total:0,page:0},favouritesPackages:[]}}),this.options=options}load(){new _area.default({element:this.target,name:"search_area",reactive:this})}}return _exports.default=_default,_exports.default}));
+define("qtype_questionpy/package_search/reactive",["exports","core/reactive","qtype_questionpy/package_search/mutations","qtype_questionpy/package_search/events","qtype_questionpy/package_search/components/area"],(function(_exports,_reactive,_mutations,_events,_area){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_mutations=_interopRequireDefault(_mutations),_area=_interopRequireDefault(_area);let counter=0;class _default extends _reactive.Reactive{constructor(target,options){super({name:"PackageSearch".concat(counter++),eventName:_events.eventNames.stateChanged,eventDispatch:_events.notifyStateChanged,target:target,mutations:new _mutations.default(options),state:{general:{loading:!0,sorting:{sort:"alpha",order:"asc"},query:"",tags:[]},all:{count:0,total:0,page:0},allPackages:[],recentlyused:{count:0,total:0,page:0},recentlyusedPackages:[],favourites:{count:0,total:0,page:0},favouritesPackages:[]}}),this.options=options}load(){new _area.default({element:this.target,name:"search_area",reactive:this})}}return _exports.default=_default,_exports.default}));
//# sourceMappingURL=reactive.min.js.map
\ No newline at end of file
diff --git a/amd/build/package_search/reactive.min.js.map b/amd/build/package_search/reactive.min.js.map
index 28a4daa1..8a165a02 100644
--- a/amd/build/package_search/reactive.min.js.map
+++ b/amd/build/package_search/reactive.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"reactive.min.js","sources":["../../src/package_search/reactive.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/reactive\n */\n\nimport {Reactive} from 'core/reactive';\n\nimport SearchMutations from 'qtype_questionpy/package_search/mutations';\nimport {eventNames, notifyStateChanged} from 'qtype_questionpy/package_search/events';\nimport Area from 'qtype_questionpy/package_search/components/area';\n\nlet counter = 0;\n\nexport default class extends Reactive {\n /**\n * Reactive element used for package search.\n *\n * @param {HTMLDivElement} target\n * @param {{contextid: number, limit: number}} options\n */\n constructor(target, options) {\n super({\n name: `PackageSearch${counter++}`,\n eventName: eventNames.stateChanged,\n eventDispatch: notifyStateChanged,\n target: target,\n mutations: new SearchMutations(options),\n state: {\n general: {\n loading: true,\n sorting: {\n sort: \"alpha\",\n order: \"asc\",\n },\n query: \"\",\n },\n all: {\n count: 0,\n total: 0,\n page: 0,\n },\n allPackages: [],\n recentlyused: {\n count: 0,\n total: 0,\n page: 0,\n },\n recentlyusedPackages: [],\n favourites: {\n count: 0,\n total: 0,\n page: 0,\n },\n favouritesPackages: [],\n },\n });\n this.options = options;\n }\n\n /**\n * Loads every component of the package search area.\n */\n load() {\n new Area({\n element: this.target,\n name: \"search_area\",\n reactive: this\n });\n }\n}\n"],"names":["counter","Reactive","constructor","target","options","name","eventName","eventNames","stateChanged","eventDispatch","notifyStateChanged","mutations","SearchMutations","state","general","loading","sorting","sort","order","query","all","count","total","page","allPackages","recentlyused","recentlyusedPackages","favourites","favouritesPackages","load","Area","element","this","reactive"],"mappings":"ogBA2BIA,QAAU,yBAEeC,mBAOzBC,YAAYC,OAAQC,eACV,CACFC,4BAAsBL,WACtBM,UAAWC,mBAAWC,aACtBC,cAAeC,2BACfP,OAAQA,OACRQ,UAAW,IAAIC,mBAAgBR,SAC/BS,MAAO,CACHC,QAAS,CACLC,SAAS,EACTC,QAAS,CACPC,KAAM,QACNC,MAAO,OAETC,MAAO,IAEXC,IAAK,CACDC,MAAO,EACPC,MAAO,EACPC,KAAM,GAEVC,YAAa,GACbC,aAAc,CACVJ,MAAO,EACPC,MAAO,EACPC,KAAM,GAEVG,qBAAsB,GACtBC,WAAY,CACRN,MAAO,EACPC,MAAO,EACPC,KAAM,GAEVK,mBAAoB,WAGvBxB,QAAUA,QAMnByB,WACQC,cAAK,CACLC,QAASC,KAAK7B,OACdE,KAAM,cACN4B,SAAUD"}
\ No newline at end of file
+{"version":3,"file":"reactive.min.js","sources":["../../src/package_search/reactive.js"],"sourcesContent":["/*\n * This file is part of the QuestionPy Moodle plugin - https://questionpy.org\n *\n * Moodle is free software: you can redistribute it and/or modify\n * it under the terms of the GNU General Public License as published by\n * the Free Software Foundation, either version 3 of the License, or\n * (at your option) any later version.\n *\n * Moodle is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU General Public License for more details.\n *\n * You should have received a copy of the GNU General Public License\n * along with Moodle. If not, see .\n */\n\n/**\n * @module qtype_questionpy/package_search/reactive\n */\n\nimport {Reactive} from 'core/reactive';\n\nimport SearchMutations from 'qtype_questionpy/package_search/mutations';\nimport {eventNames, notifyStateChanged} from 'qtype_questionpy/package_search/events';\nimport Area from 'qtype_questionpy/package_search/components/area';\n\nlet counter = 0;\n\nexport default class extends Reactive {\n /**\n * Reactive element used for package search.\n *\n * @param {HTMLDivElement} target\n * @param {{contextid: number, limit: number}} options\n */\n constructor(target, options) {\n super({\n name: `PackageSearch${counter++}`,\n eventName: eventNames.stateChanged,\n eventDispatch: notifyStateChanged,\n target: target,\n mutations: new SearchMutations(options),\n state: {\n general: {\n loading: true,\n sorting: {\n sort: \"alpha\",\n order: \"asc\",\n },\n query: \"\",\n tags: [],\n },\n all: {\n count: 0,\n total: 0,\n page: 0,\n },\n allPackages: [],\n recentlyused: {\n count: 0,\n total: 0,\n page: 0,\n },\n recentlyusedPackages: [],\n favourites: {\n count: 0,\n total: 0,\n page: 0,\n },\n favouritesPackages: [],\n },\n });\n this.options = options;\n }\n\n /**\n * Loads every component of the package search area.\n */\n load() {\n new Area({\n element: this.target,\n name: \"search_area\",\n reactive: this\n });\n }\n}\n"],"names":["counter","Reactive","constructor","target","options","name","eventName","eventNames","stateChanged","eventDispatch","notifyStateChanged","mutations","SearchMutations","state","general","loading","sorting","sort","order","query","tags","all","count","total","page","allPackages","recentlyused","recentlyusedPackages","favourites","favouritesPackages","load","Area","element","this","reactive"],"mappings":"ogBA2BIA,QAAU,yBAEeC,mBAOzBC,YAAYC,OAAQC,eACV,CACFC,4BAAsBL,WACtBM,UAAWC,mBAAWC,aACtBC,cAAeC,2BACfP,OAAQA,OACRQ,UAAW,IAAIC,mBAAgBR,SAC/BS,MAAO,CACHC,QAAS,CACLC,SAAS,EACTC,QAAS,CACPC,KAAM,QACNC,MAAO,OAETC,MAAO,GACPC,KAAM,IAEVC,IAAK,CACDC,MAAO,EACPC,MAAO,EACPC,KAAM,GAEVC,YAAa,GACbC,aAAc,CACVJ,MAAO,EACPC,MAAO,EACPC,KAAM,GAEVG,qBAAsB,GACtBC,WAAY,CACRN,MAAO,EACPC,MAAO,EACPC,KAAM,GAEVK,mBAAoB,WAGvBzB,QAAUA,QAMnB0B,WACQC,cAAK,CACLC,QAASC,KAAK9B,OACdE,KAAM,cACN6B,SAAUD"}
\ No newline at end of file
diff --git a/amd/src/package_search/components/area.js b/amd/src/package_search/components/area.js
index 6e604993..65d01be4 100644
--- a/amd/src/package_search/components/area.js
+++ b/amd/src/package_search/components/area.js
@@ -22,6 +22,7 @@
import Component from 'qtype_questionpy/package_search/component';
import Container from 'qtype_questionpy/package_search/components/container';
import SearchBar from 'qtype_questionpy/package_search/components/search_bar';
+import {TagBar} from 'qtype_questionpy/package_search/components/tag_bar';
export default class extends Component {
getWatchers() {
@@ -38,6 +39,13 @@ export default class extends Component {
name: "search_bar",
reactive: descriptor.reactive,
});
+ // Register tag bar.
+ // TODO: register component inside mustache template.
+ new TagBar({
+ element: this.getElement('[data-for="tag-bar"]'),
+ name: "tag_bar",
+ reactive: descriptor.reactive,
+ });
// Register package container.
// TODO: register component inside mustache template.
new Container({
diff --git a/amd/src/package_search/components/tag_bar.js b/amd/src/package_search/components/tag_bar.js
new file mode 100644
index 00000000..e6a2a060
--- /dev/null
+++ b/amd/src/package_search/components/tag_bar.js
@@ -0,0 +1,118 @@
+/*
+ * This file is part of the QuestionPy Moodle plugin - https://questionpy.org
+ *
+ * Moodle is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * Moodle is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with Moodle. If not, see .
+ */
+
+/**
+ * @module qtype_questionpy/package_search/components/tag_bar
+ */
+
+import Component from 'qtype_questionpy/package_search/component';
+import Autocomplete from 'core/form-autocomplete';
+import Ajax from 'core/ajax';
+import {debounce} from 'core/utils';
+import {getString} from 'core/str';
+
+export const TagBar = class extends Component {
+ async create() {
+ this.selectors = {
+ TAG_BAR: '[data-for="tag-bar"] > select',
+ };
+
+ Autocomplete.enhance(
+ // The selector to the select element.
+ this.selectors.TAG_BAR,
+ // No custom words allowed.
+ false,
+ // We want to use ajax.
+ 'qtype_questionpy/package_search/components/tag_bar',
+ // The placeholder.
+ await getString('tag_bar', 'qtype_questionpy'),
+ // We do not want to be case-sensitive.
+ false,
+ // We want to show suggestions.
+ true,
+ // If no selection is made, show this string.
+ await getString('tag_bar_no_selection', 'qtype_questionpy'),
+ // Close suggestion.
+ true,
+ // We want to overwrite the layout.
+ {
+ layout: 'qtype_questionpy/package_search/tag_bar/layout',
+ input: 'qtype_questionpy/package_search/tag_bar/input',
+ selection: 'qtype_questionpy/package_search/tag_bar/selection',
+ },
+ );
+ }
+
+ stateReady() {
+ this.addEventListener(
+ this.getElement(this.selectors.TAG_BAR),
+ 'change',
+ debounce((event) => this.filterPackages(event.target.selectedOptions), 300)
+ );
+ }
+
+ /**
+ * Dispatches package filter by tag mutation.
+ *
+ * @param {HTMLCollectionOf} selectedOptions
+ */
+ filterPackages(selectedOptions) {
+ const tags = [];
+ for (let i = 0; i < selectedOptions.length; i++) {
+ tags.push(selectedOptions[i].value);
+ }
+ this.reactive.dispatch('filterPackagesByTags', tags);
+ }
+};
+
+/**
+ * Source of data for Ajax element.
+ *
+ * @param {String} selector The selector of the auto complete element.
+ * @param {String} query The query string.
+ * @param {Function} callback A callback function receiving an array of results.
+ * @param {Function} failure A callback function to be called in case of failure, receiving the error message.
+ */
+export const transport = (selector, query, callback, failure) => {
+ const promise = Ajax.call([{
+ methodname: 'qtype_questionpy_get_tags',
+ args: {
+ query: query
+ }
+ }])[0];
+
+ // eslint-disable-next-line promise/no-callback-in-promise
+ return promise.then(callback).catch(failure);
+};
+
+/**
+ * Process the results for auto complete elements.
+ *
+ * @param {string} selector The selector of the auto complete element.
+ * @param {array} results An array or results.
+ * @return {array} New array of results.
+ */
+export const processResults = (selector, results) => {
+ const tags = [];
+ for (const result of results) {
+ tags.push({
+ value: result.id,
+ label: result.tag,
+ });
+ }
+ return tags;
+};
diff --git a/amd/src/package_search/mutations.js b/amd/src/package_search/mutations.js
index 473196ea..8ff6f1d7 100644
--- a/amd/src/package_search/mutations.js
+++ b/amd/src/package_search/mutations.js
@@ -109,7 +109,7 @@ export default class {
stateManager.setReadOnly(false);
this._setLoading(stateManager, true);
state.general.query = (typeof args.query === "string") ? args.query : state.general.query;
- state.general.tags = [];
+ state.general.tags = args.tags || state.general.tags;
state.general.sorting = {
sort: args.sort || state.general.sorting.sort,
order: args.order || state.general.sorting.order,
@@ -149,6 +149,17 @@ export default class {
await this.searchPackages(stateManager, {page: 0, query: query});
}
+ /**
+ * Used to filter packages only by providing tags.
+ *
+ * @param {StateManager} stateManager
+ * @param {int[]} tags
+ * @returns {Promise}
+ */
+ async filterPackagesByTags(stateManager, tags) {
+ await this.searchPackages(stateManager, {tags: tags});
+ }
+
/**
* Used to change the current page of a tab.
*
diff --git a/amd/src/package_search/reactive.js b/amd/src/package_search/reactive.js
index 177a9636..34d4cb44 100644
--- a/amd/src/package_search/reactive.js
+++ b/amd/src/package_search/reactive.js
@@ -49,6 +49,7 @@ export default class extends Reactive {
order: "asc",
},
query: "",
+ tags: [],
},
all: {
count: 0,
diff --git a/classes/external/get_tags.php b/classes/external/get_tags.php
new file mode 100644
index 00000000..463f0b3c
--- /dev/null
+++ b/classes/external/get_tags.php
@@ -0,0 +1,119 @@
+.
+
+namespace qtype_questionpy\external;
+
+defined('MOODLE_INTERNAL') || die;
+
+global $CFG;
+require_once($CFG->libdir . "/externallib.php");
+
+use context_system;
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use moodle_exception;
+
+/**
+ * This service localised package tags.
+ *
+ * @package qtype_questionpy
+ * @copyright 2024 Jan Britz, TU Berlin, innoCampus - www.questionpy.org
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_tags extends external_api {
+ /**
+ * Used to verify the parameters passed to the service.
+ *
+ * @return external_function_parameters
+ */
+ public static function execute_parameters(): external_function_parameters {
+ return new external_function_parameters([
+ 'query' => new external_value(PARAM_TEXT, 'The search query', VALUE_REQUIRED),
+ ]);
+ }
+
+ /**
+ * Returns package tags.
+ *
+ * @param string $query
+ * @throws moodle_exception
+ */
+ public static function execute($query) {
+ global $DB;
+
+ $params = external_api::validate_parameters(self::execute_parameters(), [
+ 'query' => $query,
+ ]);
+
+ // Validate context.
+ $context = context_system::instance();
+ self::validate_context($context);
+
+ $wheresql = '';
+ $casesql = '';
+ $sqlparams = [];
+
+ $query = trim($params['query']);
+ if ($query !== '') {
+ $query = $DB->sql_like_escape($query);
+
+ $likesql = $DB->sql_like('t.tag', ':tag', false);
+ $sqlparams['tag'] = "%{$query}%";
+ $wheresql = "WHERE {$likesql}";
+
+ // Place tags starting with the query before the other results.
+ $likestartsql = $DB->sql_like('t.tag', ':starttag', false);
+ $sqlparams['starttag'] = "{$query}%";
+ $casesql = "
+ CASE
+ WHEN {$likestartsql} THEN 0
+ ELSE 1
+ END,
+ ";
+ }
+
+ // TODO: localize tags.
+ return $DB->get_records_sql("
+ SELECT t.*, COUNT(pt.tagid) AS usage_count
+ FROM {qtype_questionpy_tag} t
+ LEFT JOIN {qtype_questionpy_pkgtag} pt ON t.id = pt.tagid
+ $wheresql
+ GROUP BY t.id
+ ORDER BY
+ $casesql
+ usage_count DESC,
+ t.tag;
+ ", $sqlparams);
+ }
+
+ /**
+ * Parameter description.
+ *
+ * @return external_multiple_structure
+ */
+ public static function execute_returns(): external_multiple_structure {
+ return new external_multiple_structure(new external_single_structure(
+ [
+ 'id' => new external_value(PARAM_INT, 'ID of the tag'),
+ 'tag' => new external_value(PARAM_TEXT, 'The tag'),
+ 'usage_count' => new external_value(PARAM_INT, 'Number of usages'),
+ ]
+ ));
+ }
+}
diff --git a/classes/external/search_packages.php b/classes/external/search_packages.php
index 393b4c35..70d0bea0 100644
--- a/classes/external/search_packages.php
+++ b/classes/external/search_packages.php
@@ -167,9 +167,11 @@ private static function get_tags_and_versions(array $packageids): array {
// Get tags.
$tagsraw = $DB->get_records_sql("
- SELECT id, packageid, tag
- FROM {qtype_questionpy_tags}
- WHERE packageid {$inpackagesql}
+ SELECT pt.id as ptid, t.id, t.tag, pt.packageid
+ FROM {qtype_questionpy_pkgtag} pt
+ JOIN {qtype_questionpy_tag} t
+ ON pt.tagid = t.id
+ WHERE pt.packageid {$inpackagesql}
", $inpackageparams);
$tags = [];
@@ -224,19 +226,26 @@ private static function create_order_by_sql(string $sort, string $order, bool $t
*
* @param array $tags
* @return array A list containing the constructed sql fragment and an array of parameters.
+ * @throws moodle_exception
*/
private static function create_tag_filter_sql(array $tags): array {
- $joinsql = '';
- $params = [];
- foreach ($tags as $i => $tag) {
- $jointagsparam = "tag$i";
- $params[$jointagsparam] = $tag;
- $joinsql .= "
- JOIN {qtype_questionpy_tags} t$i
- ON t$i.packageid = p.id AND t$i.id = :$jointagsparam
- ";
+ global $DB;
+
+ if (empty($tags)) {
+ return ['', []];
}
- return [$joinsql, $params];
+
+ [$insql, $inparams] = $DB->get_in_or_equal($tags, SQL_PARAMS_NAMED, 'tag');
+ $count = count($tags);
+ $wheresql = "
+ (
+ SELECT COUNT(DISTINCT pt.tagid)
+ FROM {qtype_questionpy_pkgtag} pt
+ WHERE pt.packageid = p.id
+ AND pt.tagid $insql
+ ) = $count
+ ";
+ return [$wheresql, $inparams];
}
/**
@@ -284,11 +293,36 @@ private static function create_recently_used_sql(int $contextid): array {
return [$joinlastusedsql, ['contextid' => $contextid]];
}
+ /**
+ * Construct a conjunctive where clause.
+ *
+ * @param string ...$clauses
+ * @return string
+ */
+ private static function sql_where(string ...$clauses): string {
+ $where = '';
+
+ foreach ($clauses as $clause) {
+ if ($clause === '') {
+ continue;
+ }
+ $where .= "({$clause}) AND ";
+ }
+ if ($where !== '') {
+ // Remove last ' AND '.
+ $where = substr($where, 0, -5);
+ $where = "WHERE $where";
+ }
+
+ return $where;
+ }
+
/**
* Constructs the sql query used for searching through packages.
*
* @param mixed $params The parameters.
* @return array
+ * @throws moodle_exception
*/
private static function create_sql($params): array {
global $USER;
@@ -301,7 +335,7 @@ private static function create_sql($params): array {
[$joinlangssql, $joinlangsparams, $coalescenamesql, $coalescedescsql] = self::create_best_language_sql();
// Get only packages with specified tags.
- [$jointagssql, $jointagsparams] = self::create_tag_filter_sql($params['tags']);
+ [$wheretagssql, $wheretagsparams] = self::create_tag_filter_sql($params['tags']);
// Prepare query.
[$wherelikesql, $wherelikeparams] = self::create_text_search_sql(['name', 'description'], $params['query']);
@@ -312,7 +346,7 @@ private static function create_sql($params): array {
[$joinfavsql, $joinfavparams] = $ufservice->get_join_sql_by_type('qtype_questionpy', 'package', 'f', 'p.id');
// Merge existing parameters.
- $finalparams = array_merge($joinlangsparams, $jointagsparams, $wherelikeparams, $joinfavparams);
+ $finalparams = array_merge($joinlangsparams, $wheretagsparams, $wherelikeparams, $joinfavparams);
// Search through recently used packages if the category is set.
$selecttimeusedsql = '';
@@ -325,9 +359,11 @@ private static function create_sql($params): array {
$selecttimeusedsql = ', lu.timeused';
} else if ($params['category'] === 'favourites') {
// We only want to include packages which were marked as favourite.
- $wherefavsql = 'WHERE f.id IS NOT NULL';
+ $wherefavsql = 'f.id IS NOT NULL';
}
+ $wheresql = self::sql_where($wherefavsql, $wheretagssql);
+
// Assemble final sql query.
$finalsql = "
SELECT id, short_name, namespace, author, url, icon, license, name, description, isfavourite
@@ -340,8 +376,7 @@ private static function create_sql($params): array {
$joinfavsql
$joinrecentlyusedsql
$joinlangssql
- $jointagssql
- $wherefavsql
+ $wheresql
) subq
$wherelikesql
$orderbysql
diff --git a/classes/package/package.php b/classes/package/package.php
index 4a67dfed..b3027bbd 100644
--- a/classes/package/package.php
+++ b/classes/package/package.php
@@ -162,7 +162,7 @@ private static function get_language_data(int $packageid): array {
}
/**
- * Get the records from the qtype_questionpy_tags table given the foreign key packageid.
+ * Get the package tags.
*
* @param int $packageid
* @return array
@@ -170,12 +170,12 @@ private static function get_language_data(int $packageid): array {
*/
private static function get_tag_data(int $packageid): array {
global $DB;
- $tagdata = $DB->get_records('qtype_questionpy_tags', ['packageid' => $packageid]);
- $tags = [];
- foreach ($tagdata as $record) {
- $tags[] = $record->tag;
- }
- return $tags;
+ return $DB->get_fieldset_sql("
+ SELECT DISTINCT t.tag
+ FROM {qtype_questionpy_tag} t
+ JOIN {qtype_questionpy_pkgtag} pt
+ ON pt.id = :packageid AND pt.tagid = t.id
+ ", ['packageid' => $packageid]);
}
/**
@@ -189,7 +189,21 @@ public function delete(): void {
$transaction = $DB->start_delegated_transaction();
$DB->delete_records('qtype_questionpy_pkgversion', ['packageid' => $this->id]);
$DB->delete_records('qtype_questionpy_language', ['packageid' => $this->id]);
- $DB->delete_records('qtype_questionpy_tags', ['packageid' => $this->id]);
+ $sql = "
+ DELETE
+ FROM {qtype_questionpy_tag}
+ WHERE id IN (
+ SELECT t.id
+ FROM {qtype_questionpy_tag} t
+ JOIN {qtype_questionpy_pkgtag} pt
+ ON t.id = pt.tagid
+ WHERE pt.packageid = :packageid
+ GROUP BY t.id
+ HAVING COUNT(t.id) = 1
+ )
+ ";
+ $DB->execute($sql, ['packageid' => $this->id]);
+ $DB->delete_records('qtype_questionpy_pkgtag', ['packageid' => $this->id]);
$DB->delete_records('qtype_questionpy_package', ['id' => $this->id]);
last_used_service::remove_by_package($this->id);
$transaction->allow_commit();
diff --git a/classes/package/package_raw.php b/classes/package/package_raw.php
index 26c2b51c..475863ad 100644
--- a/classes/package/package_raw.php
+++ b/classes/package/package_raw.php
@@ -100,6 +100,26 @@ private function store_pkgversion(int $packageid, int $timecreated): int {
]);
}
+ /**
+ * Persists a package tag in the database.
+ *
+ * @param string $tag
+ * @return int
+ * @throws moodle_exception
+ */
+ private function store_tag(string $tag): int {
+ global $DB;
+
+ $record = ['tag' => $tag];
+
+ $id = $DB->get_field('qtype_questionpy_tag', 'id', $record);
+ if ($id === false) {
+ // TODO: store them in upper- or lowercase?
+ return $DB->insert_record('qtype_questionpy_tag', $record);
+ }
+ return $id;
+ }
+
/**
* Persists a package in the database.
*
@@ -142,10 +162,10 @@ private function store_package(int $timestamp): int {
foreach ($this->tags as $tag) {
$tagsdata[] = [
'packageid' => $packageid,
- 'tag' => $tag,
+ 'tagid' => $this->store_tag($tag),
];
}
- $DB->insert_records('qtype_questionpy_tags', $tagsdata);
+ $DB->insert_records('qtype_questionpy_pkgtag', $tagsdata);
}
return $packageid;
}
diff --git a/classes/package/package_version.php b/classes/package/package_version.php
index 1a4b4b81..ea28724d 100644
--- a/classes/package/package_version.php
+++ b/classes/package/package_version.php
@@ -160,7 +160,21 @@ public function delete(): void {
// Delete package related data.
$DB->delete_records('qtype_questionpy_language', ['packageid' => $this->packageid]);
- $DB->delete_records('qtype_questionpy_tags', ['packageid' => $this->packageid]);
+ $sql = "
+ DELETE
+ FROM {qtype_questionpy_tag}
+ WHERE id IN (
+ SELECT t.id
+ FROM {qtype_questionpy_tag} t
+ JOIN {qtype_questionpy_pkgtag} pt
+ ON t.id = pt.tagid
+ WHERE pt.packageid = :packageid
+ GROUP BY t.id
+ HAVING COUNT(t.id) = 1
+ )
+ ";
+ $DB->execute($sql, ['packageid' => $this->packageid]);
+ $DB->delete_records('qtype_questionpy_pkgtag', ['packageid' => $this->packageid]);
$DB->delete_records('qtype_questionpy_package', ['id' => $this->packageid]);
// Remove the package from the last used table.
diff --git a/db/install.xml b/db/install.xml
index 665cc6b9..a7f726e1 100644
--- a/db/install.xml
+++ b/db/install.xml
@@ -84,15 +84,26 @@
-