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..05fa27f4 --- /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",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..ae2effcd --- /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 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 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","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,UAAW,sDAKvBC,kBACSC,iBACDJ,KAAKK,WAAWL,KAAKH,UAAUC,SAC/B,UACA,oBAAUQ,OAAUN,KAAKO,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,GACWO,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..a9cd9441 --- /dev/null +++ b/amd/src/package_search/components/tag_bar.js @@ -0,0 +1,115 @@ +/* + * 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', + 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]; + 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..246452d6 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 = []; @@ -232,8 +234,8 @@ private static function create_tag_filter_sql(array $tags): array { $jointagsparam = "tag$i"; $params[$jointagsparam] = $tag; $joinsql .= " - JOIN {qtype_questionpy_tags} t$i - ON t$i.packageid = p.id AND t$i.id = :$jointagsparam + JOIN {qtype_questionpy_pkgtag} pt$i + ON pt$i.packageid = p.id AND pt$i.id = :$jointagsparam "; } return [$joinsql, $params]; diff --git a/classes/package/package.php b/classes/package/package.php index 4a67dfed..a894bcc7 100644 --- a/classes/package/package.php +++ b/classes/package/package.php @@ -170,10 +170,11 @@ 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]); + $tagdata = $DB->get_records('qtype_questionpy_pkgtag', ['packageid' => $packageid]); $tags = []; foreach ($tagdata as $record) { - $tags[] = $record->tag; + // TODO + $tags[] = $record->id; } return $tags; } @@ -189,7 +190,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 @@ - +
- + + + +
+ + + + + + + +
diff --git a/db/services.php b/db/services.php index ba2d4d84..e38dbabf 100644 --- a/db/services.php +++ b/db/services.php @@ -53,4 +53,11 @@ 'ajax' => true, 'loginrequired' => true, ], + 'qtype_questionpy_get_tags' => [ + 'classname' => 'qtype_questionpy\external\get_tags', + 'description' => 'Get available package tags.', + 'type' => 'read', + 'ajax' => true, + 'loginrequired' => true, + ], ]; diff --git a/lang/en/qtype_questionpy.php b/lang/en/qtype_questionpy.php index e57368b9..af84194c 100644 --- a/lang/en/qtype_questionpy.php +++ b/lang/en/qtype_questionpy.php @@ -74,6 +74,9 @@ // Package search. $string['search_bar'] = 'Search...'; $string['search_bar_label_aria'] = 'Search Bar'; +$string['tag_bar'] = 'Tags...'; +$string['tag_bar_label_aria'] = 'Tags'; +$string['tag_bar_no_selection'] = ''; $string['search_all_header'] = 'All ({$a})'; $string['search_recentlyused_header'] = 'Recently Used ({$a})'; $string['search_favourites_header'] = 'Favourites ({$a})'; diff --git a/styles.css b/styles.css index a8e23230..aa4640b3 100644 --- a/styles.css +++ b/styles.css @@ -26,10 +26,6 @@ } /* Style package selection container */ -.qpy-package-search-container { - width: var(--qpy-card-width); - margin: 2px; -} .qpy-tab-content { display: flex; @@ -100,10 +96,36 @@ display: none; } +.qpy-package-search-area { + width: var(--qpy-card-width); +} + .qpy-package-search-area:not(.qpy-loading) .qpy-loading-indicator { visibility: hidden; } +.qpy-package-search-bars { + display: flex; + justify-content: flex-start; + margin-bottom: 5px; +} + +.qpy-tag-bar-container { + overflow-x: hidden; +} + +.qpy-tag-bar-top { + display: flex; + align-items: center; +} + +/* TAG BAR */ +.qpy-tag-bar-selection { + white-space: nowrap; + overflow-x: auto; + max-width: 100%; +} + .qpy-repetition { /* Visually groups the elements of each repetition. */ background: #eee; diff --git a/templates/package_search/area.mustache b/templates/package_search/area.mustache index f725c9f5..80029e9a 100644 --- a/templates/package_search/area.mustache +++ b/templates/package_search/area.mustache @@ -37,9 +37,16 @@ } }}
- -
- +
+ +
+ +
+ + +
+ +
@@ -81,4 +88,4 @@ }; area.init(areaElement, options); }); -{{/js}} \ No newline at end of file +{{/js}} diff --git a/templates/package_search/tag_bar/layout.mustache b/templates/package_search/tag_bar/layout.mustache new file mode 100644 index 00000000..e41fd7fd --- /dev/null +++ b/templates/package_search/tag_bar/layout.mustache @@ -0,0 +1,44 @@ +{{! + 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 . +}} +{{! + @template qtype_questionpy/package_search/tag_bar/layout + + Template for the layout of autocomplete elements. + + Classes required for JS: + * none + + Data attributes required for JS: + * data-region="form_autocomplete-input" + * data-region="form_autocomplete-suggestions" + * data-region="form_autocomplete-selection" + + Context variables required for this template: + * none + + Example context (json): + {} +}} +
+
+
+
+
+
+
+
+
diff --git a/templates/package_search/tag_bar/selection.mustache b/templates/package_search/tag_bar/selection.mustache new file mode 100644 index 00000000..8767d953 --- /dev/null +++ b/templates/package_search/tag_bar/selection.mustache @@ -0,0 +1,50 @@ +{{! + 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 . +}} +{{! + @template qtype_questionpy/package_search/tag_bar/layout + + Template for the wrapper of currently selected items in an autocomplate form element. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * multiple True if this field allows multiple selections + * selectionId The dom id of the current selection list. + * items List of items with label and value fields (used by the partial). + * noSelectionString String to use when no items are selected (used by the partial). + + Example context (json): + { "multiple": true, "selectionId": 1, "items": [ + { "label": "Item label with tags", "value": "5" }, + { "label": "Another item label with tags", "value": "4" } + ], "noSelectionString": "No selection" } +}} +{{#str}}selecteditems, form{{/str}} + + {{> core/form_autocomplete_selection_items }} +
diff --git a/tests/external/get_tags_test.php b/tests/external/get_tags_test.php new file mode 100644 index 00000000..bcc865a6 --- /dev/null +++ b/tests/external/get_tags_test.php @@ -0,0 +1,142 @@ +. + +/** + * Unit tests for the get_tags function. + * + * @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 */ + +namespace qtype_questionpy\external; + +use external_api; +use moodle_exception; +use function qtype_questionpy\package_provider; + +defined('MOODLE_INTERNAL') || die(); + +require_once(dirname(__DIR__) . '/data_provider.php'); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +/** + * Tests for {@see get_tags}. + * + * @runTestsInSeparateProcesses + * + * @package qtype_questionpy + * @author Jan Britz + * @copyright 2024 TU Berlin, innoCampus {@link https://www.questionpy.org} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class get_tags_test extends \externallib_advanced_testcase { + /** + * Test that the user needs to be logged in. + * + * @covers \qtype_questionpy\external\get_tags::execute + * @throws moodle_exception + */ + public function test_get_tags_needs_user_to_be_logged_in(): void { + $this->expectException(\require_login_exception::class); + get_tags::execute(''); + } + + /** + * Provider for {@see test} + * @return array[] + */ + public static function query_provider(): array { + return [ + 'No query and no tags' => [ + '', + [], + [], + ], + 'No query and tags' => [ + '', + [['a'], ['b'], ['c']], + ['a', 'b', 'c'], + ], + 'No query and duplicate tags' => [ + '', + [['a'], ['a', 'b']], + ['a', 'b'], + ], + 'No query and sorting by usage count' => [ + '', + [['a'], ['b'], ['b']], + ['b', 'a'], + ], + 'No query, sorting by usage count and then alphabetically' => [ + '', + [['a'], ['c'], ['b', 'z'], ['z']], + ['z', 'a', 'b', 'c'], + ], + 'Tags which start with query at the front' => [ + 'tag', + [['a_tag'], ['tag', 'a_tag']], + ['tag', 'a_tag'], + ], + 'Tags which start with query at the front and sorted alphabetically' => [ + 'tag', + [['a_tag'], ['tag_z', 'tag_a'], ['tag_a', 'tag_z']], + ['tag_a', 'tag_z', 'a_tag'], + ], + 'Tags which start with query at the front and sorted by usage count then alphabetically' => [ + 'tag', + [['a_tag'], ['tag_z', 'tag_a'], ['tag_a', 'tag_z'], ['tag_z']], + ['tag_z', 'tag_a', 'a_tag'], + ], + 'No matching tag' => [ + 'a', + [['b'], ['c']], + [], + ], + 'Some matching tags' => [ + 'a', + [['b'], ['a'], ['c']], + ['a'], + ], + // TODO: test for sql injection. + ]; + } + + /** + * Test that service orders results by usage count. + * + * @dataProvider query_provider + * @covers \qtype_questionpy\external\get_tags::execute + * @throws moodle_exception + */ + public function test_get_tags(string $query, array $packagetags, array $expected): void { + $this->resetAfterTest(); + $this->setGuestUser(); + + foreach ($packagetags as $i => $tags) { + package_provider(['namespace' => "ns$i", 'tags' => $tags])->store(); + } + $tags = get_tags::execute($query); + $tags = external_api::clean_returnvalue(get_tags::execute_returns(), $tags); + + $this->assertEquals($expected, array_column($tags, 'tag')); + $counts = array_count_values(array_merge(...$packagetags)); + foreach ($tags as $tag) { + $this->assertEquals($counts[$tag['tag']], $tag['usage_count'], 'Usage count does not match.'); + } + } +} diff --git a/tests/external/search_packages_test.php b/tests/external/search_packages_test.php index 48e020c5..02d7352d 100644 --- a/tests/external/search_packages_test.php +++ b/tests/external/search_packages_test.php @@ -832,5 +832,48 @@ public function test_favourites_are_not_shared_across_users(): void { } } - // TODO: add tests for filtering by tags when localized tags are supported. + /** + * Tests that only packages with the chosen tags are returned. + * + * @covers \qtype_questionpy\external\search_packages::execute + * @return void + * @throws moodle_exception + */ + public function test_search_with_tags() { + global $DB; + + package_provider(['namespace' => 'ns1', 'tags' => ['a']])->store(); + package_provider(['namespace' => 'ns2', 'tags' => ['a', 'b']])->store(); + + $tagida = $DB->get_field('qtype_questionpy_tag', 'id', ['tag' => 'a']); + $tagidb = $DB->get_field('qtype_questionpy_tag', 'id', ['tag' => 'b']); + + $tagidb = $DB->get_field('qtype_questionpy_pkgtag', 'tagid', ['tagid' => $tagidb]); + + // Searching with tag 'a' should return both packages. + $res = search_packages::execute('', [$tagidb], 'all', 'alpha', 'asc', 2, 0, null); + $res = external_api::clean_returnvalue(search_packages::execute_returns(), $res); + throw new \Exception(json_encode($res)); + //$this->assert_count_and_total($res, 2, 2); + + /* + // Searching with tag 'b' should only return the second package. + $res = search_packages::execute('', [$tagidb], 'all', 'alpha', 'asc', 1, 0, null); + $res = external_api::clean_returnvalue(search_packages::execute_returns(), $res); + $this->assert_count_and_total($res, 1, 1); + $this->assertEquals('ns2', $res['packages'][0]['namespace']); + + // Searching with tag 'a' and 'b' should only return the second package. + $res = search_packages::execute('', [$tagida, $tagidb], 'all', 'alpha', 'asc', 1, 0, null); + $res = external_api::clean_returnvalue(search_packages::execute_returns(), $res); + $this->assert_count_and_total($res, 1, 1); + $this->assertEquals('ns2', $res['packages'][0]['namespace']); + + // Searching with a non-existing tag should return nothing. + /* + $res = search_packages::execute('', [-1], 'all', 'alpha', 'asc', 1, 0, null); + $res = external_api::clean_returnvalue(search_packages::execute_returns(), $res); + $this->assert_count_and_total($res, 0, 0); + */ + } } diff --git a/tests/package/package_raw_test.php b/tests/package/package_raw_test.php index 1aa7279b..f56f577a 100644 --- a/tests/package/package_raw_test.php +++ b/tests/package/package_raw_test.php @@ -198,23 +198,28 @@ public function test_store_package($packagedata) { $this->assertEquals($packagedata['description'][$language], $record->description); } - // Check qtype_questionpy_tags table. + // Check qtype_questionpy_tag and qtype_questionpy_pkgtag table. $tags = $packagedata['tags'] ?? []; - $this->assertEquals(count($tags), $DB->count_records('qtype_questionpy_tags')); + $tagscount = count($tags); + $this->assertEquals($tagscount, $DB->count_records('qtype_questionpy_tag')); + $this->assertEquals($tagscount, $DB->count_records('qtype_questionpy_pkgtag')); foreach ($tags as $tag) { - $record = $DB->get_record('qtype_questionpy_tags', ['packageid' => $packageid, 'tag' => $tag]); + $tagid = $DB->get_field('qtype_questionpy_tag', 'id', ['tag' => $tag]); + $this->assertNotFalse($tagid); + + $record = $DB->get_record('qtype_questionpy_pkgtag', ['packageid' => $packageid, 'tagid' => $tagid]); $this->assertNotFalse($record); } } /** - * Tests that storing same package without user should not throw an exception and only store the source once. + * Tests that storing same package should not throw an exception and only store it once. * * @covers \package::store * @return void * @throws moodle_exception */ - public function test_store_package_twice_without_user() { + public function test_store_package_twice() { global $DB; $this->resetAfterTest(); @@ -225,7 +230,8 @@ public function test_store_package_twice_without_user() { $this->assertEquals(1, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(1, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(2, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(2, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(2, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(2, $DB->count_records('qtype_questionpy_tag')); } /** @@ -268,6 +274,7 @@ public function test_store_different_versions_of_a_package() { $this->assertEquals(2, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(1, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(1, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(1, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(1, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(1, $DB->count_records('qtype_questionpy_tag')); } } diff --git a/tests/package/package_test.php b/tests/package/package_test.php index 76e2b1f5..3548f1be 100644 --- a/tests/package/package_test.php +++ b/tests/package/package_test.php @@ -71,7 +71,8 @@ public function test_delete() { $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(0, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_tag')); } /** @@ -97,7 +98,8 @@ public function test_delete_with_multiple_versions() { $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(0, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_tag')); } diff --git a/tests/package/package_version_test.php b/tests/package/package_version_test.php index f385c06f..e59cfdcc 100644 --- a/tests/package/package_version_test.php +++ b/tests/package/package_version_test.php @@ -89,12 +89,13 @@ public function test_delete() { $package = package_version::get_by_id($pkgversionid); // Delete the package. - $package->delete(true); + $package->delete(); $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(0, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_tag')); } /** @@ -116,18 +117,20 @@ public function test_delete_where_multiple_versions_exist() { $package2 = package_version::get_by_id($pkgversionid2); // Delete the first package. - $package1->delete(true); + $package1->delete(); $this->assertEquals(1, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(1, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(1, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(1, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(1, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(1, $DB->count_records('qtype_questionpy_tag')); // Delete the second package. - $package2->delete(true); + $package2->delete(); $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgversion')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_package')); $this->assertEquals(0, $DB->count_records('qtype_questionpy_language')); - $this->assertEquals(0, $DB->count_records('qtype_questionpy_tags')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_pkgtag')); + $this->assertEquals(0, $DB->count_records('qtype_questionpy_tag')); } } diff --git a/version.php b/version.php index 95522a08..a11db501 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_questionpy'; -$plugin->version = 2025041804; +$plugin->version = 2025041805; $plugin->requires = 2022041901; $plugin->maturity = MATURITY_ALPHA; $plugin->release = '0.1';