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.";
+ }
+});
+
+
+
+
+
Tutorials
+
+
+ {{ tutorialText }}
+ These tutorials include training for the current version of the tool.
+
+
+ View all tutorials referencing this tool.
+
+
+
+
+
+ Tutorials available in {{ trainingCategories.length }}
+ {{ trainingCategories.length > 1 ? "categories" : "category" }}
+
+
+
+
+
+
+ {{ category }} ({{ tutorialsInCategory(category).length }})
+
+
+
+
+ -
+
+ {{ tutorial.title }}
+
+
+
+
+
+
+
+
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,
}