Contributing a New Feature to Galaxy Core

Authors: AvatarJohn Chilton

Overview

Questions:
  • How can I develop extensions to Galaxy data model?

  • How can I implement new API functionality within Galaxy?

  • How can I extend the Galaxy user interface with VueJS components?

Objectives:
Requirements:
Time estimation: 3 hours
Supporting Materials:
Last modification: Jun 24, 2021
License: Tutorial Content is licensed under Creative Commons Attribution 4.0 International License The GTN Framework is licensed under MIT

Contributing a New Feature to Galaxy Core

This tutorial walks you through an extension to Galaxy and how to contribute back to the core project.

To setup the proposed extension imagine you’re running a specialized Galaxy server and each of your users only use a few of Galaxy datatypes. You’d like to tailor the UI experience by allowing users of Galaxy to select their favorite extensions for additional filtering in downstream applications, UI extensions, etc..

Like many extensions to Galaxy, the proposed change requires persistent state. Galaxy stores most persistent state in a relational database. The Python layer that defines Galaxy data model is setup by defining SQLAlchemy models.

The proposed extension could be implemented in several different ways on Galaxy’s backend. We will choose one for this example for its simplicity, not for its correctness or cleverness, because our purpose here is to demonstrate modifying and extending various layers of Galaxy.

With simplicity in mind, we will implement our proposed extension to Galaxy by adding a single new table to Galaxy’s data model called user_favorite_extension. The concept of a favorite extension will be represented by a one-to-many relationship from the table that stores Galaxy’s user records to this new table. The extension itself that will be favorited will be stored as a Text field in this new table. This table will also need to include an integer primary key named id to follow the example set by the rest of the Galaxy data model.

Forking Galaxy

Tip: How to Contribute to Galaxy

To contribute to galaxy, a GitHub account is required. Changes are proposed via a pull request. This allows the project maintainers to review the changes and suggest improvements.

The general steps are as follows:

  1. Fork the Galaxy repository
  2. Clone your fork
  3. Make changes in a new branch
  4. Commit your changes, push branch to your fork
  5. Open a pull request for this branch in the upstream Galaxy repository

details Git, Github, and Galaxy Core

For a lot more information about Git branching and managing a repository on Githubsee the Contributing with GitHub via command-linetutorial.

The Galaxy Core Architecture slides have a lot of import Galaxy core related information related to branches,project management, and contributing to Galaxy - under the Project Management section of the slides.

hands_on Hands-on: Setup your local Galaxy instance

  1. Use GitHub UI to fork Galaxy’s repository at galaxyproject/galaxy.
  2. Clone your forked repository to a local path, further referred to as GALAXY_ROOT and cd into GALAXY_ROOT. Note that we specify the tutorial branch with the -b option:

    code-in Input: Bash

    git clone https://github.com/<your-username>/galaxy GALAXY_ROOT
    cd GALAXY_ROOT
    
  3. Before we can use Galaxy, we need to create a virtual environment and install the required dependencies. This is generally done with the common_startup.sh script:

    code-in Input: Bash

    bash scripts/common_startup.sh --dev-wheels
    

    Make sure your Python version is at least 3.6 (you can check your Python version with python --version). If your system uses an older version, you may specify an alternative Python interpreter using the GALAXY_PYTHON environment variable (GALAXY_PYTHON=/path/to/alt/python bash scripts/common_startup.sh --dev-wheels).

  4. Activate your new virtual environment:

    code-in Input: Bash

    . .venv/bin/activate
    

    Once activated, you’ll see the name of the virtual environment prepended to your shell prompt: (.venv)$.

  5. Finally, let’s create a new branch for your edits:

    code-in Input: Bash

    git checkout -b my-feature
    

    Now when you run git branch you’ll see that your new branch is activated:

    code-in Input: Bash

    git branch
    

    code-out Output

      dev
    * my-feature
    

    Note: my-feature is just an example; you can call your new branch anything you like.

Models

The relational database tables consumed by Galaxy are defined in lib/galaxy/model/mapping.py.

question Questions about Mapping

  1. What should be the SQLAlchemy model named corresponding to the table user_favorite_extension based on other examples in the mapping file?
  2. What table stores Galaxy’s user records?
  3. What is another simple table with a relationship with the Galaxy’s user table?

solution Solution

  1. UserFavoriteExtension
  2. galaxy_user
  3. An example table might be the user_preference table.

Implement the required changes in mapping.py to add a mapping for the proposed user_favorite_extension table.

solution lib/galaxy/model/mapping.py

Possible changes to file lib/galaxy/model/mapping.py:

index 5aaef76a03..0fdba0e4b7 100644
--- a/lib/galaxy/model/mapping.py
+++ b/lib/galaxy/model/mapping.py
@@ -1583,6 +1583,12 @@ model.UserPreference.table = Table(
     Column("name", Unicode(255), index=True),
     Column("value", Text))
 
+model.UserFavoriteExtension.table = Table(
+    "user_favorite_extension", metadata,
+    Column("id", Integer, primary_key=True),
+    Column("user_id", Integer, ForeignKey("galaxy_user.id"), index=True),
+    Column("value", Text))
+
 model.UserAction.table = Table(
     "user_action", metadata,
     Column("id", Integer, primary_key=True),
@@ -1671,6 +1677,7 @@ simple_mapping(model.WorkerProcess)
 
 # User tables.
 mapper_registry.map_imperatively(model.UserPreference, model.UserPreference.table, properties={})
+mapper_registry.map_imperatively(model.UserFavoriteExtension, model.UserFavoriteExtension.table, properties={})
 mapper_registry.map_imperatively(model.UserAction, model.UserAction.table, properties=dict(
     # user=relation( model.User.mapper )
     user=relation(model.User)
@@ -1928,6 +1935,8 @@ mapper_registry.map_imperatively(model.User, model.User.table, properties=dict(
     _preferences=relation(model.UserPreference,
         backref="user",
         collection_class=attribute_mapped_collection('name')),
+    favorite_extensions=relation(model.UserFavoriteExtension,
+        backref="user"),
     # addresses=relation( UserAddress,
     #     primaryjoin=( User.table.c.id == UserAddress.table.c.user_id ) ),
     values=relation(model.FormValues,

The Python model objects used by Galaxy corresponding to these tables are defined in lib/galaxy/model/__init__.py.

Modify lib/galaxy/model/__init__.py to add a model class called UserFavoriteExtension as described above.

solution lib/galaxy/model/__init__.py

Possible changes to file lib/galaxy/model/__init__.py:

index 2c0b8a4dc4..69a74b0ad2 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -6617,6 +6617,11 @@ class UserPreference(RepresentById):
         self.value = value
 
 
+class UserFavoriteExtension(RepresentById):
+    def __init__(self, value=None):
+        self.value = value
+
+
 class UserAction(RepresentById):
     def __init__(self, id=None, create_time=None, user_id=None, session_id=None, action=None, params=None, context=None):
         self.id = id

Migrations

There is one last database issue to consider before moving on to considering the API. Each successive release of Galaxy requires recipes for how to migrate old database schemes to updated ones. These recipes are called versions and are currently implemented using SQLAlchemy Migrate. These versions are stored in lib/galaxy/model/migrate.

Each of these versions is prefixed with a 4-digit number (e.g. 0125) to specify the linear order these migrations should be applied in.

We’ve developed a lot of abstractions around the underlying migration library we use, so it is probably better to find existing examples inside of Galaxy for writing migrations than consulting the SQLAlchemy Migrate documentation.

A good example for the table we need to construct for this example is again based on the existing user preferences concept in Galaxy.

question Questions about Migrations

What existing Galaxy migration added the concept of user preferences to the Galaxy codebase?

solution Solution

lib/galaxy/model/migrate/versions/0021_user_prefs.py

Add a new file to lib/galaxy/model/migrate/versions/ prefixed appropriately.

solution lib/galaxy/model/migrate/versions/0177_add_user_favorite_extensions.py

Possible changes to file lib/galaxy/model/migrate/versions/0177_add_user_favorite_extensions.py:

new file mode 100644
index 0000000000..e0d1a4dded
--- /dev/null
+++ b/lib/galaxy/model/migrate/versions/0177_add_user_favorite_extensions.py
@@ -0,0 +1,37 @@
+"""
+Migration script to add the user_favorite_extension table.
+"""
+
+import logging
+
+from sqlalchemy import Column, ForeignKey, Integer, MetaData, Table, Text
+
+log = logging.getLogger(__name__)
+metadata = MetaData()
+
+UserFavoriteExtension_table = Table(
+    "user_favorite_extension", metadata,
+    Column("id", Integer, primary_key=True),
+    Column("user_id", Integer, ForeignKey("galaxy_user.id"), index=True),
+    Column("value", Text, index=True, unique=True)
+)
+
+
+def upgrade(migrate_engine):
+    metadata.bind = migrate_engine
+    print(__doc__)
+    metadata.reflect()
+    try:
+        UserFavoriteExtension_table.create()
+    except Exception:
+        log.exception("Creating user_favorite_extension table failed.")
+
+
+def downgrade(migrate_engine):
+    metadata.bind = migrate_engine
+    # Load existing tables
+    metadata.reflect()
+    try:
+        UserFavoriteExtension_table.drop()
+    except Exception:
+        log.exception("Dropping user_favorite_extension table failed.")

Test Driven Development

With the database model in place, we need to start adding the rest of the Python plumbing required to implement this feature. We will do this with a test-driven approach and start by implementing an API test that exercises operations we would like to have available for favorite extensions.

We will stick a test case for user extensions in lib/galaxy_test/api/test_users.py which is a relatively straightforward file that contains tests for other user API endpoints.

Various user-centered operations have endpoints under api/user/<user_id> and api/user/current is sometimes substituable as the current user.

We will keep things very simple and only implement this functionality for the current user.

We will implement three simple API endpoints.

Method Route Definition
GET <galaxy_root_url>/api/users/current/favorites/extensions This should return a list of favorited extensions for the current user.
POST <galaxy_root_url>/api/users/current/favorites/extensions/<extension> This should mark an extension as a favorite for the current user.
DELETE <galaxy_root_url>/api/users/current/favorites/extensions/<extension> This should unmark an extension as a favorite for the current user.

Please review test_users.py and attempt to write a test case that:

  • Verifies the test user’s initially favorited extensions is an empty list.
  • Verifies that a POST to <galaxy_root_url>/api/users/current/favorites/extensions/fasta returns a 200 status code indicating success.
  • Verifies that after this POST the list of user favorited extensions contains fasta and is of size 1.
  • Verifies that a DELETE to <galaxy_root_url>/api/users/current/favorites/extensions/fasta succeeds.
  • Verifies that after this DELETE the favorited extensions list is again empty.

solution lib/galaxy_test/api/test_users.py

Possible changes to file lib/galaxy_test/api/test_users.py:

index e6fbfec6ee..7dc82d7179 100644
--- a/lib/galaxy_test/api/test_users.py
+++ b/lib/galaxy_test/api/test_users.py
@@ -171,6 +171,33 @@ class UsersApiTestCase(ApiTestCase):
         search_response = get(url).json()
         assert "cat1" in search_response
 
+    def test_favorite_extensions(self):
+        index_response = self._get("users/current/favorites/extensions")
+        index_response.raise_for_status()
+        index = index_response.json()
+        assert isinstance(index, list)
+        assert len(index) == 0
+
+        create_response = self._post("users/current/favorites/extensions/fasta")
+        create_response.raise_for_status()
+
+        index_response = self._get("users/current/favorites/extensions")
+        index_response.raise_for_status()
+        index = index_response.json()
+        assert isinstance(index, list)
+        assert len(index) == 1
+
+        assert "fasta" in index
+
+        delete_response = self._delete("users/current/favorites/extensions/fasta")
+        delete_response.raise_for_status()
+
+        index_response = self._get("users/current/favorites/extensions")
+        index_response.raise_for_status()
+        index = index_response.json()
+        assert isinstance(index, list)
+        assert len(index) == 0
+
     def __url(self, action, user):
         return self._api_url(f"users/{user['id']}/{action}", params=dict(key=self.master_api_key))
 
-- 
2.30.1 (Apple Git-130)
 

Run the Tests

Verify this test fails when running stand-alone.

code-in Input: Bash

./run_tests.sh -api lib/galaxy_test/api/test_users.py::UsersApiTestCase::test_favorite_extensions

Implementing the API

Add a new API implementation file to lib/galaxy/webapps/galaxy/api/ called user_favorites.py with an API implementation of the endpoints we just outlined.

To implement the API itself, add three methods to the user manager in lib/galaxy/managers/users.py.

  • get_favorite_extensions(user)
  • add_favorite_extension(user, extension)
  • delete_favorite_extension(user, extension)

solution lib/galaxy/webapps/galaxy/api/user_favorites.py

Possible changes to file lib/galaxy/webapps/galaxy/api/user_favorites.py:

new file mode 100644
index 0000000000..3587a9056e
--- /dev/null
+++ b/lib/galaxy/webapps/galaxy/api/user_favorites.py
@@ -0,0 +1,66 @@
+"""
+API operations allowing clients to determine datatype supported by Galaxy.
+"""
+import logging
+from typing import List
+
+from fastapi import Path
+
+from galaxy.managers.context import ProvidesUserContext
+from galaxy.managers.users import UserManager
+from . import (
+    depends,
+    DependsOnTrans,
+    Router,
+)
+
+log = logging.getLogger(__name__)
+
+router = Router(tags=['user_favorites'])
+
+ExtensionPath: str = Path(
+    ...,  # Mark this Path parameter as required
+    title="Extension",
+    description="Target file extension for target operation."
+)
+
+
+@router.cbv
+class FastAPIUserFavorites:
+    user_manager: UserManager = depends(UserManager)
+
+    @router.get(
+        '/api/users/current/favorites/extensions',
+        summary="List user favroite data types",
+        response_description="List of data types",
+    )
+    def index(
+        self,
+        trans: ProvidesUserContext = DependsOnTrans,
+    ) -> List[str]:
+        """Gets the list of user's favorite data types."""
+        return self.user_manager.get_favorite_extensions(trans.user)
+
+    @router.post(
+        '/api/users/current/favorites/extensions/{extension}',
+        summary="Mark an extension as the current user's favorite.",
+        response_description="The extension.",
+    )
+    def create(
+        self,
+        extension: str = ExtensionPath,
+        trans: ProvidesUserContext = DependsOnTrans,
+    ) -> str:
+        self.user_manager.add_favorite_extension(trans.user, extension)
+        return extension
+
+    @router.delete(
+        '/api/users/current/favorites/extensions/{extension}',
+        summary="Unmark an extension as the current user's favorite.",
+    )
+    def delete(
+        self,
+        extension: str = ExtensionPath,
+        trans: ProvidesUserContext = DependsOnTrans,
+    ) -> str:
+        self.user_manager.delete_favorite_extension(trans.user, extension)

solution lib/galaxy/managers/users.py

Possible changes to file lib/galaxy/managers/users.py:

index 3407a6bd75..f15bb4c5d6 100644
--- a/lib/galaxy/managers/users.py
+++ b/lib/galaxy/managers/users.py
@@ -590,6 +590,23 @@ class UserManager(base.ModelManager, deletable.PurgableManagerMixin):
                 log.exception('Subscribing to the mailing list has failed.')
                 return "Subscribing to the mailing list has failed."
 
+    def get_favorite_extensions(self, user):
+        return [fe.value for fe in user.favorite_extensions]
+
+    def add_favorite_extension(self, user, extension):
+        fe = model.UserFavoriteExtension(value=extension)
+        user.favorite_extensions.append(fe)
+        self.session().add(user)
+        self.session().flush()
+
+    def delete_favorite_extension(self, user, extension):
+        fes = [fe for fe in user.favorite_extensions if fe.value == extension]
+        if len(fes) == 0:
+            raise exceptions.RequestParameterInvalidException("Attempted to unfavorite extension not marked as a favorite.")
+        fe = fes[0]
+        self.session().delete(fe)
+        self.session().flush()
+
     def activate(self, user):
         user.active = True
         self.session().add(user)

This part is relatively challenging and takes time to really become an expert at - it requires greping around the backend to find similar examples, lots of trial and error, debugging the test case and the implementation in unison, etc..

Ideally, you’d start at the top of test case - make sure it fails on the first API request, implement get_favorite_extensions on the manager and the API code to wire it up, and continue with add_favorite_extension before finishing with delete_favorite_extension.

code-in Input: Bash

./run_tests.sh -api lib/galaxy_test/api/test_users.py::UsersApiTestCase::test_favorite_extensions

Building the UI

Once the API test is done, it is time to build a user interface for this addition to Galaxy. Let’s get some of the plumbing out of the way right away. We’d like to have a URL for viewing the current user’s favorite extensions in the UI. This URL needs to be registered as a client route in lib/galaxy/webapps/galaxy/buildapp.py.

Add /user/favorite/extensions as a route for the client in buildapp.py.

solution lib/galaxy/webapps/galaxy/buildapp.py

Possible changes to file lib/galaxy/webapps/galaxy/buildapp.py:

index 7fbe248c66..76fe7cd819 100644
--- a/lib/galaxy/webapps/galaxy/buildapp.py
+++ b/lib/galaxy/webapps/galaxy/buildapp.py
@@ -155,6 +155,7 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs):
     webapp.add_client_route('/tours/{tour_id}')
     webapp.add_client_route('/user')
     webapp.add_client_route('/user/{form_id}')
+    webapp.add_client_route('/user/favorite/extensions')
     webapp.add_client_route('/visualizations')
     webapp.add_client_route('/visualizations/edit')
     webapp.add_client_route('/visualizations/sharing')

Let’s add the ability to navigate to this URL and future component to the “User” menu in the Galaxy masthead. The file client/src/layout/menu.js contains the “model” data describing the masthead. Add a link to the route we described above to menu.js with an entry titled “Favorite Extensions”.

solution client/src/layout/menu.js

Possible changes to file client/src/layout/menu.js:

index 062191b492..de91d9670c 100644
--- a/client/src/layout/menu.js
+++ b/client/src/layout/menu.js
@@ -314,6 +314,11 @@ export function fetchMenu(options = {}) {
                     url: "workflows/invocations",
                     target: "__use_router__",
                 },
+                {
+                    title: _l("Favorite Extensions"),
+                    url: "/user/favorite/extensions",
+                    target: "__use_router__",
+                },
             ],
         };
         if (Galaxy.config.visualizations_visible) {

The next piece of this plumbing is to respond to this route in the analysis router. The analysis router maps URLs to UI components to render.

Assume a VueJS component will be available called FavoriteExtensions in the file components/User/FavoriteExtensions/index.js. In client/src/entry/analysis/AnalysisRouter.js respond to the route added above in buildapp.py and render the fictious VueJS component FavoriteExtensions.

solution client/src/entry/analysis/AnalysisRouter.js

Possible changes to file client/src/entry/analysis/AnalysisRouter.js:

index b409c8374f..61b01f85fa 100644
--- a/client/src/entry/analysis/AnalysisRouter.js
+++ b/client/src/entry/analysis/AnalysisRouter.js
@@ -32,6 +32,7 @@ import InteractiveTools from "components/InteractiveTools/InteractiveTools.vue";
 import WorkflowList from "components/Workflow/WorkflowList.vue";
 import HistoryImport from "components/HistoryImport.vue";
 import { HistoryExport } from "components/HistoryExport/index";
+import { FavoriteExtensions } from "components/User/FavoriteExtensions/index";
 import HistoryView from "components/HistoryView.vue";
 import WorkflowInvocationReport from "components/Workflow/InvocationReport.vue";
 import WorkflowRun from "components/Workflow/Run/WorkflowRun.vue";
@@ -67,6 +68,7 @@ export const getAnalysisRouter = (Galaxy) => {
             "(/)user(/)cloud_auth": "show_cloud_auth",
             "(/)user(/)external_ids": "show_external_ids",
             "(/)user(/)(:form_id)": "show_user_form",
+            "(/)user(/)favorite(/)extensions": "show_user_favorite_extensions",
             "(/)pages(/)create(/)": "show_pages_create",
             "(/)pages(/)edit(/)": "show_pages_edit",
             "(/)pages(/)sharing(/)": "show_pages_sharing",
@@ -157,6 +159,10 @@ export const getAnalysisRouter = (Galaxy) => {
             this.page.display(new FormWrapper.View(_.extend(model[form_id], { active_tab: "user" })));
         },
 
+        show_user_favorite_extensions: function () {
+            this._display_vue_helper(FavoriteExtensions, {});
+        },
+
         show_interactivetool_list: function () {
             this._display_vue_helper(InteractiveTools);
         },

There are many ways to perform the next steps, but like the API entry-point lets start with a test case describing the UI component we want to write. Below is a Jest unit test for a VueJS component that mocks out some API calls to /api/datatypes and the API entry points we implemented earlier and renders an editable list of extensions based on it.

solution client/src/components/User/FavoriteExtensions/List.test.js

Possible changes to file client/src/components/User/FavoriteExtensions/List.test.js:

new file mode 100644
index 0000000000..7efa1c76ec
--- /dev/null
+++ b/client/src/components/User/FavoriteExtensions/List.test.js
@@ -0,0 +1,97 @@
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { mount } from "@vue/test-utils";
+import { getLocalVue } from "jest/helpers";
+import List from "./List";
+import flushPromises from "flush-promises";
+
+jest.mock("app");
+
+const localVue = getLocalVue();
+const propsData = {};
+
+describe("User/FavoriteExtensions/List.vue", () => {
+    let wrapper;
+    let axiosMock;
+
+    beforeEach(() => {
+        axiosMock = new MockAdapter(axios);
+        axiosMock.onGet("api/datatypes").reply(200, ["fasta", "fastq"]);
+        axiosMock.onGet("api/users/current/favorites/extensions").reply(200, ["fasta"]);
+    });
+
+    afterEach(() => {
+        axiosMock.restore();
+    });
+
+    it("should start loading and then render list", async () => {
+        wrapper = mount(List, {
+            propsData,
+            localVue,
+        });
+        expect(wrapper.vm.loading).toBeTruthy();
+        await flushPromises();
+        expect(wrapper.vm.loading).toBeFalsy();
+        const el = wrapper.find("#favorite-extensions");
+        expect(el.exists()).toBe(true);
+        expect(el.find("[data-extension-target]").exists()).toBe(true);
+    });
+
+    it("should mark favorite and not favorite with different links", async () => {
+        wrapper = mount(List, {
+            propsData,
+            localVue,
+        });
+        await flushPromises();
+        const el = wrapper.find("#favorite-extensions");
+        const els = el.findAll("[data-extension-target]");
+        expect(els.length).toBe(2);
+        const fastaEntry = els.at(0);
+        expect(fastaEntry.attributes("data-extension-target")).toBe("fasta");
+        expect(fastaEntry.find(".unmark-favorite").exists()).toBe(true);
+        expect(fastaEntry.find(".mark-favorite").exists()).toBe(false);
+
+        const fastqEntry = els.at(1);
+        expect(fastqEntry.attributes("data-extension-target")).toBe("fastq");
+        expect(fastqEntry.find(".mark-favorite").exists()).toBe(true);
+        expect(fastqEntry.find(".unmark-favorite").exists()).toBe(false);
+    });
+
+    it("should post to mark favorites", async () => {
+        wrapper = mount(List, {
+            propsData,
+            localVue,
+        });
+        await flushPromises();
+        const el = wrapper.find("#favorite-extensions");
+        const els = el.findAll("[data-extension-target]");
+        const fastqEntry = els.at(1);
+        const markFastq = fastqEntry.find(".mark-favorite a");
+        expect(markFastq.exists()).toBe(true);
+
+        axiosMock.onPost("api/users/current/favorites/extensions/fastq").reply(200, "fastq");
+        axiosMock.onGet("api/users/current/favorites/extensions").reply(200, ["fasta", "fastq"]);
+        markFastq.trigger("click");
+        await flushPromises();
+        expect(wrapper.vm.favoriteExtensions.indexOf("fastq") >= 0).toBe(true);
+    });
+
+    it("should delete to unmark favorites", async () => {
+        wrapper = mount(List, {
+            propsData,
+            localVue,
+        });
+        await flushPromises();
+        const el = wrapper.find("#favorite-extensions");
+        const els = el.findAll("[data-extension-target]");
+        const fastaEntry = els.at(0);
+        const unmarkFasta = fastaEntry.find(".unmark-favorite a");
+        expect(unmarkFasta.exists()).toBe(true);
+
+        axiosMock.onDelete("api/users/current/favorites/extensions/fasta").reply(200);
+        axiosMock.onGet("api/users/current/favorites/extensions").reply(200, []);
+        unmarkFasta.trigger("click");
+        await flushPromises();
+        expect(wrapper.vm.favoriteExtensions.indexOf("fasta") < 0).toBe(true);
+    });
+});

Sketching out this unit test would take a lot of practice, this is a step that might be best done just by copying the file over. Make sure the individual components make sense before continuing though.

Next implement a VueJS component in that same directory called List.vue that fullfills the contract described by the unit test.

solution client/src/components/User/FavoriteExtensions/List.vue

Possible changes to file client/src/components/User/FavoriteExtensions/List.vue:

new file mode 100644
index 0000000000..e5e7d07ab2
--- /dev/null
+++ b/client/src/components/User/FavoriteExtensions/List.vue
@@ -0,0 +1,82 @@
+<template>
+    <div class="favorite-extensions-card">
+        <b-alert variant="error" show v-if="errorMessage">
+            
+        </b-alert>
+        <loading-span v-if="loading" message="Loading favorite extensions" />
+        <ul id="favorite-extensions" v-else>
+            <li v-for="extension in extensions" :key="extension" :data-extension-target="extension">
+                <span
+                    class="favorite-link unmark-favorite"
+                    v-if="favoriteExtensions.indexOf(extension) >= 0"
+                    title="Unmark as favorite"
+                >
+                    <a href="#" @click="unmarkAsFavorite(extension)">(X)</a>
+                </span>
+                <span class="favorite-link mark-favorite" v-else title="Mark as favorite">
+                    <a href="#" @click="markAsFavorite(extension)">(+)</a>
+                </span>
+                
+            </li>
+        </ul>
+    </div>
+</template>
+
+<script>
+import axios from "axios";
+import { getGalaxyInstance } from "app";
+import LoadingSpan from "components/LoadingSpan";
+import { errorMessageAsString } from "utils/simple-error";
+
+export default {
+    components: {
+        LoadingSpan,
+    },
+    data() {
+        const Galaxy = getGalaxyInstance();
+        return {
+            datatypesUrl: `${Galaxy.root}api/datatypes`,
+            favoriteExtensionsUrl: `${Galaxy.root}api/users/current/favorites/extensions`,
+            extensions: null,
+            favoriteExtensions: null,
+            errorMessage: null,
+        };
+    },
+    created() {
+        this.loadDatatypes();
+        this.loadFavorites();
+    },
+    computed: {
+        loading() {
+            return this.extensions == null || this.favoriteExtensions == null;
+        },
+    },
+    methods: {
+        loadDatatypes() {
+            axios
+                .get(this.datatypesUrl)
+                .then((response) => {
+                    this.extensions = response.data;
+                })
+                .catch(this.handleError);
+        },
+        loadFavorites() {
+            axios
+                .get(this.favoriteExtensionsUrl)
+                .then((response) => {
+                    this.favoriteExtensions = response.data;
+                })
+                .catch(this.handleError);
+        },
+        markAsFavorite(extension) {
+            axios.post(`${this.favoriteExtensionsUrl}/${extension}`).then(this.loadFavorites).catch(this.handleError);
+        },
+        unmarkAsFavorite(extension) {
+            axios.delete(`${this.favoriteExtensionsUrl}/${extension}`).then(this.loadFavorites).catch(this.handleError);
+        },
+        handleError(error) {
+            this.errorMessage = errorMessageAsString(error);
+        },
+    },
+};
+</script>

Finally, we added a level of indirection when we utilized this component from the analysis router above by importing it from index.js. Let’s setup that file and import the component from List.vue and export as a component called FavoriteExtensions.

solution client/src/components/User/FavoriteExtensions/index.js

Possible changes to file client/src/components/User/FavoriteExtensions/index.js:

new file mode 100644
index 0000000000..c897f34a3b
--- /dev/null
+++ b/client/src/components/User/FavoriteExtensions/index.js
@@ -0,0 +1 @@
+export { default as FavoriteExtensions } from "./List.vue";

Key points

  • Galaxy database interactions are mitigated via SQL Alchemy code in lib/galaxy/model.

  • Galaxy API endpoints are implemented in lib/galaxy/webapps/galaxy, but generally defer to application logic in lib/galaxy/managers.

  • Galaxy client code should do its best to separate API interaction logic from display components.

Frequently Asked Questions

Have questions about this tutorial? Check out the FAQ page for the Development in Galaxy topic to see if your question is listed there. If not, please ask your question on the GTN Gitter Channel or the Galaxy Help Forum

Feedback

Did you use this material as an instructor? Feel free to give us feedback on how it went.

Click here to load Google feedback frame

Citing this Tutorial

  1. John Chilton, 2021 Contributing a New Feature to Galaxy Core (Galaxy Training Materials). https://training.galaxyproject.org/training-material/topics/dev/tutorials/core-contributing/tutorial.html Online; accessed TODAY
  2. Batut et al., 2018 Community-Driven Data Analysis Training for Biology Cell Systems 10.1016/j.cels.2018.05.012

details BibTeX

@misc{dev-core-contributing,
author = "John Chilton",
title = "Contributing a New Feature to Galaxy Core (Galaxy Training Materials)",
year = "2021",
month = "06",
day = "24"
url = "\url{https://training.galaxyproject.org/training-material/topics/dev/tutorials/core-contributing/tutorial.html}",
note = "[Online; accessed TODAY]"
}
@article{Batut_2018,
    doi = {10.1016/j.cels.2018.05.012},
    url = {https://doi.org/10.1016%2Fj.cels.2018.05.012},
    year = 2018,
    month = {jun},
    publisher = {Elsevier {BV}},
    volume = {6},
    number = {6},
    pages = {752--758.e1},
    author = {B{\'{e}}r{\'{e}}nice Batut and Saskia Hiltemann and Andrea Bagnacani and Dannon Baker and Vivek Bhardwaj and Clemens Blank and Anthony Bretaudeau and Loraine Brillet-Gu{\'{e}}guen and Martin {\v{C}}ech and John Chilton and Dave Clements and Olivia Doppelt-Azeroual and Anika Erxleben and Mallory Ann Freeberg and Simon Gladman and Youri Hoogstrate and Hans-Rudolf Hotz and Torsten Houwaart and Pratik Jagtap and Delphine Larivi{\`{e}}re and Gildas Le Corguill{\'{e}} and Thomas Manke and Fabien Mareuil and Fidel Ram{\'{\i}}rez and Devon Ryan and Florian Christoph Sigloch and Nicola Soranzo and Joachim Wolff and Pavankumar Videm and Markus Wolfien and Aisanjiang Wubuli and Dilmurat Yusuf and James Taylor and Rolf Backofen and Anton Nekrutenko and Björn Grüning},
    title = {Community-Driven Data Analysis Training for Biology},
    journal = {Cell Systems}
}
                

Congratulations on successfully completing this tutorial!