diff --git a/client/src/components/Tool/ToolCard.vue b/client/src/components/Tool/ToolCard.vue index ed43c9be240..fc98c870199 100644 --- a/client/src/components/Tool/ToolCard.vue +++ b/client/src/components/Tool/ToolCard.vue @@ -11,6 +11,7 @@ import Heading from "components/Common/Heading"; import ToolSelectPreferredObjectStore from "./ToolSelectPreferredObjectStore"; import ToolTargetPreferredObjectStorePopover from "./ToolTargetPreferredObjectStorePopover"; import { getAppRoot } from "onload/loadConfig"; +import ToolTutorialRecommendations from "./ToolTutorialRecommendations.vue"; import { computed, ref, watch } from "vue"; @@ -166,11 +167,17 @@ function onUpdatePreferredObjectStoreId(selectedToolPreferredObjectStoreId) {
-
+
Help
+ + +import Heading from "@/components/Common/Heading.vue"; +import { useToolTrainingMaterial } from "@/composables/toolTrainingMaterial"; +import ExternalLink from "@/components/ExternalLink.vue"; +import { BCollapse, BButton } from "bootstrap-vue"; +import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faCaretDown } from "@fortawesome/free-solid-svg-icons"; +import { useUid } from "@/composables/utils/uid"; +import slugify from "slugify"; +import { computed } from "vue"; + +const props = defineProps<{ + name: string; + id: string; + version: string; + owner?: string; +}>(); + +//@ts-ignore: bad library types +library.add(faCaretDown); + +const { trainingAvailable, trainingCategories, tutorialDetails, allTutorialsUrl, versionAvailable } = + useToolTrainingMaterial(props.id, props.name, props.version, props.owner); + +const collapseId = useUid("collapse-"); + +function idForCategory(category: string) { + return `${collapseId.value}-${slugify(category)}`; +} + +function tutorialsInCategory(category: string) { + return tutorialDetails.value.filter((tut) => tut.category === category); +} + +const tutorialText = computed(() => { + if (tutorialDetails.value.length > 1) { + return `There are ${tutorialDetails.value.length} tutorials available which use this tool.`; + } else { + return "There is 1 tutorial available which uses this tool."; + } +}); + + + diff --git a/client/src/composables/toolTrainingMaterial.ts b/client/src/composables/toolTrainingMaterial.ts new file mode 100644 index 00000000000..f437d96271e --- /dev/null +++ b/client/src/composables/toolTrainingMaterial.ts @@ -0,0 +1,192 @@ +import { useConfig } from "./config"; +import { computed, ref, watch, type Ref } from "vue"; +import { escapeRegExp } from "@/utils/regExp"; + +type TrainingDetails = { + tool_id: Array< + [ + string, // toolshed tool ID + string // Version + ] + >; + tutorials: Array< + [ + string, // tutorial ID (unused) + string, // Title + string, // Category + string // URL + ] + >; +}; + +type TrainingMaterialResponse = { + [id: string]: TrainingDetails; +}; + +type Config = { + tool_training_recommendations: boolean; + tool_training_recommendations_api_url: string; + tool_training_recommendations_link: string; +}; + +export type TutorialDetails = { + category: string; + title: string; + url: URL; +}; + +/** caches the response of the training material api */ +const cachedResponse: Ref = ref(null); + +/** maps toolshed tool ids to training tool ids */ +const toolIdMap: Map = new Map(); + +function mapToolIds() { + Object.entries(cachedResponse.value ?? {}).forEach(([trainingId, details]) => { + details.tool_id.forEach(([id, version]) => { + if (id === version) { + // built-in tool + toolIdMap.set(id, trainingId); + } else { + const regEx = new RegExp(`${escapeRegExp(version)}$`); + const trimmedId = id.replace(regEx, ""); + + toolIdMap.set(trimmedId, trainingId); + } + }); + }); +} + +/** Training information about given tool */ +export function useToolTrainingMaterial(id: string, name: string, version: string, owner?: string) { + const { config, isLoaded }: { config: Ref; isLoaded: Ref } = useConfig(); + const apiEnabled = computed(() => { + return Boolean( + isLoaded.value && + config.value.tool_training_recommendations && + config.value.tool_training_recommendations_api_url + ); + }); + + const cacheLoaded = ref(false); + + watch( + () => isLoaded.value, + async () => { + if (!isLoaded.value) { + return; + } + + if (apiEnabled.value && !cachedResponse.value) { + const res = await fetch(config.value.tool_training_recommendations_api_url); + + if (res.ok) { + cachedResponse.value = await res.json(); + mapToolIds(); + } + } + + cacheLoaded.value = true; + }, + { immediate: true } + ); + + const identifier = computed(() => { + const regEx = new RegExp(`${escapeRegExp(version)}$`); + const trimmedId = id.replace(regEx, ""); + + if (!cacheLoaded.value) { + return trimmedId; + } else { + return toolIdMap.get(trimmedId) ?? trimmedId; + } + }); + + const trainingAvailable = computed(() => { + if (!apiEnabled.value || !cachedResponse.value) { + return false; + } + + return Object.keys(cachedResponse.value).includes(identifier.value); + }); + + const trainingDetails = computed(() => { + if (!trainingAvailable.value) { + return null; + } + + return cachedResponse.value?.[identifier.value] ?? null; + }); + + const trainingCategories = computed(() => { + if (!trainingDetails.value) { + return []; + } + + const categories = new Set(); + + trainingDetails.value.tutorials.forEach((tutorial) => { + categories.add(tutorial[2]); + }); + + return Array.from(categories); + }); + + const tutorialDetails = computed(() => { + if (!trainingDetails.value) { + return []; + } + + const details: TutorialDetails[] = []; + + trainingDetails.value.tutorials.forEach((tutorial) => { + details.push({ + title: tutorial[1], + category: tutorial[2], + url: new URL(tutorial[3], config.value.tool_training_recommendations_api_url), + }); + }); + + return details; + }); + + const allTutorialsUrl = computed(() => { + if (!cacheLoaded.value || !config.value.tool_training_recommendations_link) { + return; + } + + let url = config.value.tool_training_recommendations_link; + + url = url.replace("{training_tool_identifier}", identifier.value); + url = url.replace("{tool_id}", id); + url = url.replace("{name}", name); + url = url.replace("{repository_owner}", owner ?? ""); + url = url.replace("{version}", version); + + return url; + }); + + const versionAvailable = computed(() => { + if (!trainingDetails.value) { + return false; + } + + for (let i = 0; i < trainingDetails.value.tool_id.length; i++) { + const element = trainingDetails.value.tool_id[i]!; + + if (element[1] === version) { + return true; + } + } + + return false; + }); + + return { + trainingAvailable, + trainingCategories, + tutorialDetails, + allTutorialsUrl, + versionAvailable, + }; +} diff --git a/client/src/style/scss/ui.scss b/client/src/style/scss/ui.scss index 0ce07043d1e..d8d3c5caabb 100644 --- a/client/src/style/scss/ui.scss +++ b/client/src/style/scss/ui.scss @@ -392,6 +392,10 @@ $ui-margin-horizontal-large: $margin-v * 2; column-gap: 0.25rem; } +.flex-gapy-1 { + row-gap: 0.25rem; +} + /* Heading Sizes */ .h-xl { font-size: $h1-font-size; @@ -422,6 +426,7 @@ $ui-margin-horizontal-large: $margin-v * 2; display: inline; line-height: unset; vertical-align: unset; + user-select: text; &:hover { text-decoration: underline; diff --git a/client/src/utils/regExp.ts b/client/src/utils/regExp.ts new file mode 100644 index 00000000000..a9f4d733295 --- /dev/null +++ b/client/src/utils/regExp.ts @@ -0,0 +1,10 @@ +/** + * Escapes all RegExp control characters from a string, so it can be matched literally + * @param string input string + * @returns string with all control characters escaped + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + */ +export function escapeRegExp(string: string) { + return string.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +} diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst index 75a57ef7f52..c49a2d07f30 100644 --- a/doc/source/admin/galaxy_options.rst +++ b/doc/source/admin/galaxy_options.rst @@ -5092,4 +5092,37 @@ :Type: bool +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``tool_training_recommendations`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +:Description: + Displays a link to training material, if any includes the current + tool. When activated the following options also need to be set: + tool_training_recommendations_link, + tool_training_recommendations_api_url +:Default: ``true`` +:Type: bool + + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``tool_training_recommendations_link`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:Description: + Template URL to display all tutorials containing current tool. + Valid template inputs are: {repository_owner} {name} + {tool_id} {training_tool_identifier} {version} +:Default: ``https://training.galaxyproject.org/training-material/by-tool/{training_tool_identifier}.html`` +:Type: str + + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``tool_training_recommendations_api_url`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:Description: + URL to API describing tutorials containing specific tools. When + CORS is used, make sure to add this host. +:Default: ``https://training.galaxyproject.org/training-material/api/top-tools.json`` +:Type: str diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample index 6531b58114c..336e4c2a784 100644 --- a/lib/galaxy/config/sample/galaxy.yml.sample +++ b/lib/galaxy/config/sample/galaxy.yml.sample @@ -1,21 +1,21 @@ # Galaxy is configured by default to be usable in a single-user development # environment. To tune the application for a multi-user production # environment, see the documentation at: -# +# # https://docs.galaxyproject.org/en/master/admin/production.html -# +# # Throughout this sample configuration file, except where stated otherwise, # uncommented values override the default if left unset, whereas commented # values are set to the default value. Relative paths are relative to the root # Galaxy directory. -# +# # Examples of many of these options are explained in more detail in the Galaxy # Community Hub. -# +# # https://galaxyproject.org/admin/config -# +# # Config hackers are encouraged to check there before asking for help. -# +# # Configuration for Gravity process manager. # ``uwsgi:`` section will be ignored if Galaxy is started via Gravity commands (e.g ``./run.sh``, ``galaxy`` or ``galaxyctl``). gravity: @@ -2715,3 +2715,17 @@ galaxy: # integration. #enable_beacon_integration: false + # Displays a link to training material, if any includes the current + # tool. When activated the following options also need to be set: + # tool_training_recommendations_link, + # tool_training_recommendations_api_url + #tool_training_recommendations: true + + # Template URL to display all tutorials containing current tool. Valid + # template inputs are: {repository_owner} {name} {tool_id} + # {training_tool_identifier} {version} + #tool_training_recommendations_link: https://training.galaxyproject.org/training-material/by-tool/{training_tool_identifier}.html + + # URL to API describing tutorials containing specific tools. When CORS + # is used, make sure to add this host. + #tool_training_recommendations_api_url: https://training.galaxyproject.org/training-material/api/top-tools.json diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index 7d7ccc181ef..5cc0837b0b0 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -3705,3 +3705,33 @@ mapping: desc: | Enables user preferences and api endpoint for the beacon integration. + tool_training_recommendations: + type: bool + default: true + required: false + desc: | + Displays a link to training material, if any includes the current tool. + When activated the following options also need to be set: + tool_training_recommendations_link, + tool_training_recommendations_api_url + + tool_training_recommendations_link: + type: str + default: https://training.galaxyproject.org/training-material/by-tool/{training_tool_identifier}.html + required: false + desc: | + Template URL to display all tutorials containing current tool. + Valid template inputs are: + {repository_owner} + {name} + {tool_id} + {training_tool_identifier} + {version} + + tool_training_recommendations_api_url: + type: str + default: https://training.galaxyproject.org/training-material/api/top-tools.json + required: false + desc: | + URL to API describing tutorials containing specific tools. + When CORS is used, make sure to add this host. diff --git a/lib/galaxy/managers/configuration.py b/lib/galaxy/managers/configuration.py index dd87c229486..55e3cc018e8 100644 --- a/lib/galaxy/managers/configuration.py +++ b/lib/galaxy/managers/configuration.py @@ -203,6 +203,9 @@ class ConfigSerializer(base.ModelSerializer): "user_library_import_dir_available": lambda item, key, **context: bool(item.get("user_library_import_dir")), "welcome_directory": _use_config, "themes": _use_config, + "tool_training_recommendations": _use_config, + "tool_training_recommendations_link": _use_config, + "tool_training_recommendations_api_url": _use_config, }