Skip to content

Commit

Permalink
Merge pull request #15628 from ElectronicBlueberry/training-recommend…
Browse files Browse the repository at this point in the history
…ations

Add suggested Training material to Tool Form
  • Loading branch information
dannon authored Apr 26, 2023
2 parents eb4cd72 + 2a3bcf3 commit 4796634
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 7 deletions.
9 changes: 8 additions & 1 deletion client/src/components/Tool/ToolCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"
Expand Down
83 changes: 83 additions & 0 deletions client/src/components/Tool/ToolTutorialRecommendations.vue
Original file line number Diff line number Diff line change
@@ -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>
192 changes: 192 additions & 0 deletions client/src/composables/toolTrainingMaterial.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
5 changes: 5 additions & 0 deletions client/src/style/scss/ui.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions client/src/utils/regExp.ts
Original file line number Diff line number Diff line change
@@ -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, "\\$&");
}
33 changes: 33 additions & 0 deletions doc/source/admin/galaxy_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 4796634

Please sign in to comment.