mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #15628 from ElectronicBlueberry/training-recommendations
Add suggested Training material to Tool Form
This commit is contained in:
@@ -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) {
|
||||
<slot name="buttons" />
|
||||
|
||||
<div>
|
||||
<div class="mt-2 mb-4">
|
||||
<div v-if="props.options.help" class="mt-2 mb-4">
|
||||
<Heading h2 separator bold size="sm"> Help </Heading>
|
||||
<ToolHelp :content="props.options.help" />
|
||||
</div>
|
||||
|
||||
<ToolTutorialRecommendations
|
||||
:id="props.options.id"
|
||||
:name="props.options.name"
|
||||
:version="props.options.version"
|
||||
:owner="props.options.tool_shed_repository?.owner" />
|
||||
|
||||
<ToolFooter
|
||||
:id="props.id"
|
||||
:has-citations="props.options.citations"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
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.";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="trainingAvailable" class="mt-2 mb-4">
|
||||
<Heading h2 separator bold size="sm">Tutorials</Heading>
|
||||
|
||||
<p>
|
||||
{{ tutorialText }}
|
||||
<span v-if="versionAvailable"> These tutorials include training for the current version of the tool. </span>
|
||||
|
||||
<ExternalLink v-if="allTutorialsUrl" :href="allTutorialsUrl">
|
||||
View all tutorials referencing this tool.
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
<BButton v-b-toggle="collapseId" class="ui-link">
|
||||
<b>
|
||||
Tutorials available in {{ trainingCategories.length }}
|
||||
{{ trainingCategories.length > 1 ? "categories" : "category" }}
|
||||
</b>
|
||||
<FontAwesomeIcon icon="caret-down" />
|
||||
</BButton>
|
||||
<BCollapse :id="collapseId">
|
||||
<div v-for="category in trainingCategories" :key="category">
|
||||
<BButton v-b-toggle="idForCategory(category)" class="ui-link ml-3">
|
||||
{{ category }} ({{ tutorialsInCategory(category).length }})
|
||||
<FontAwesomeIcon icon="caret-down" />
|
||||
</BButton>
|
||||
<BCollapse :id="idForCategory(category)">
|
||||
<ul class="d-flex flex-column my-1">
|
||||
<li v-for="tutorial in tutorialsInCategory(category)" :key="tutorial.title">
|
||||
<ExternalLink :href="tutorial.url.toString()" class="ml-2">
|
||||
{{ tutorial.title }}
|
||||
</ExternalLink>
|
||||
</li>
|
||||
</ul>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</BCollapse>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<TrainingMaterialResponse | null> = ref(null);
|
||||
|
||||
/** maps toolshed tool ids to training tool ids */
|
||||
const toolIdMap: Map<string, string> = 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<Config>; isLoaded: Ref<boolean> } = 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<string[]>(() => {
|
||||
if (!trainingDetails.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const categories = new Set<string>();
|
||||
|
||||
trainingDetails.value.tutorials.forEach((tutorial) => {
|
||||
categories.add(tutorial[2]);
|
||||
});
|
||||
|
||||
return Array.from(categories);
|
||||
});
|
||||
|
||||
const tutorialDetails = computed<TutorialDetails[]>(() => {
|
||||
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<string | undefined>(() => {
|
||||
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<boolean>(() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user