Contributing a New Feature to Galaxy Core

Overview
Creative Commons License: CC-BY Questions:
  • How can I add a new feature to Galaxy that involves modifications to the database, the API, and the UI?

Objectives:
  • Learn to develop extensions to the Galaxy data model

  • Learn to implement new API functionality within Galaxy

  • Learn to extend the Galaxy user interface with VueJS components

Requirements:
Time estimation: 3 hours
Supporting Materials:
Published: Jun 8, 2021
Last modification: Nov 3, 2023
License: Tutorial Content is licensed under Creative Commons Attribution 4.0 International License. The GTN Framework is licensed under MIT
purl PURL: https://gxy.io/GTN:T00113
version Revision: 28

This tutorial walks you through developing 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’s 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.

Agenda
  1. Forking Galaxy
  2. Models
  3. Migrations
  4. Test Driven Development
  5. Run the Tests
  6. Implementing the API
  7. Building the UI

Forking 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

For a lot more information about Git branching and managing a repository on Github, see the Contributing with GitHub via command-line tutorial.

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

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:

    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:

    Input: Bash
    bash scripts/common_startup.sh --dev-wheels
    

    Make sure your Python version is at least 3.7 (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:

    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:

    Input: Bash
    git checkout -b my-feature
    

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

    Input: Bash
    git branch
    
    Output
      dev
    * my-feature
    

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

  6. As one last step, you need to initialize your database. This only applies if you are working on a clean clone and have not started Galaxy (starting Galaxy will initialize the database). Initializing the database is necessary because you will be making changes to the database schema, which cannot be applied to a database that has not been initialized.

    To initialize the database, you can either start Galaxy (might take some time when executing for the first time):

    Input: Bash
    sh run.sh
    

    or you may run the following script (faster):

    Input: Bash
    sh create_db.sh
    

Models

Galaxy uses a relational database to persist objects and object relationships. Galaxy’s data model represents the object view of this data. To map objects and their relationships onto tables and rows in the database, Galaxy relies on SQLAlchemy, which is a SQL toolkit and object-relational mapper.

The mapping between objects and the database is defined in lib/galaxy/model/__init__.py via “declarative mapping”, which means that models are defined as Python classes together with the database metadata that describes the database table corresponding to each class. For example, the definition of the JobParameter class includes the database table name:

__tablename__ = "job_parameter"

and four Column attributes that correspond to table columns with the same names:

id = Column(Integer, primary_key=True)
job_id = Column(Integer, ForeignKey("job.id"), index=True)
name = Column(String(255))
value = Column(TEXT)

Associations between objects are usually defined with the relationship construct. For example, the UserAddress model has an association with the User model and is defined with the relationship construct as the user attribute.

Question: about Mapping
  1. What should be the SQLAlchemy model named corresponding to the table user_favorite_extension based on other examples?
  2. What table stores Galaxy’s user records?
  3. What is another simple table with a relationship with the Galaxy’s user table?
  1. UserFavoriteExtension
  2. galaxy_user
  3. An example table might be the user_preference table.

To implement the required changes to add the new model, you need to create a new class in lib/galaxy/model/__init__.py with appropriate database metadata:

  1. Add a class definition for your new class
  2. Your class should be a subclass of Base
  3. Add a __tablename__ attribute
  4. Add a Column attribute for the primary key (should be named id)
  5. Add a Column attribute to store the extension
  6. Add a Column attribute that will serve as a foreign key to the User model
  7. Use the relationship function to define an association between your new model and the User model.

To define a regular Column attribute you must include the datatype:

foo = Column(Integer)

To define an attribute that is the primary key, you need to include the primary_key argument: (a primary key is one or more columns that uniquely identify a row in a database table)

id = Column(Integer, primary_key=True)

To define an attribute that is a foreign key, you need to reference the associated table + its primary key column using the ForeignKey construct (the datatype will be derived from that column, so you don’t have to include it):

bar_id = Column(ForeignKey("bar.id"))

To define a relationship between tables, you need to set it on both tables:

class Order(Base):
    
    items = relationship("Item", back_populates="order")

class Item(Base):
    
    order_id = Column(ForeignKey("order.id")
    awesome_order = relationship("Order", back_populates="items")
    # relationship not named "order" to avoid confusion: this is NOT the table name for Order

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

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

index 76004a716e..c5f2ea79a8 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -593,6 +593,7 @@ class User(Base, Dictifiable, RepresentById):
             & not_(Role.name == User.email)  # type: ignore[has-type]
         ),
     )
+    favorite_extensions = relationship("UserFavoriteExtension", back_populates="user")
 
     preferences: association_proxy  # defined at the end of this module
 
@@ -9998,3 +9999,12 @@ def receive_init(target, args, kwargs):
         if obj:
             add_object_to_object_session(target, obj)
             return  # Once is enough.
+
+class UserFavoriteExtension(Base):
+    __tablename__ = "user_favorite_extension"
+
+    id = Column(Integer, primary_key=True)
+    user_id = Column(ForeignKey("galaxy_user.id"))
+    value = Column(TEXT)
+    user = relationship("User", back_populates="favorite_extensions")

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 schemas to updated ones. These recipes are called versions, or revisions, and are implemented using Alembic.

Galaxy’s data model is split into the galaxy model and the install model. These models are persisted in one combined database or two separate databases and are represented by two migration branches: “gxy” (the galaxy branch) and “tsi” (the tool shed install branch). Schema changes for these branches are defined in these revision modules:

  • lib/galaxy/model/migrations/alembic/versions_gxy (galaxy model)
  • lib/galaxy/model/migrations/alembic/versions_tsi (install model)

We encourage you to read Galaxy’s documentation on migrations, as well as relevant Alembic documentation.

For this tutorial, you’ll need to do the following:

  1. Create a revision template
  2. Edit the revision template, filling in the body of the upgrade and downgrade functions.
  3. Run the migration.
Question: about generating a revision template

What command should you run to generate a revision template?

sh run_alembic.sh revision --head=gxy@head -m "Add user_favorite_extentions table"

The title of the revision is an example only.

To fill in the revision template, you need to populate the body of the upgrade and downgrade functions. The upgrade function is executed during a schema upgrade, so it should create your table. The downgrade function is executed during a schema downgrade, so it should drop your table.

Note that although the table creation command looks similar to the one we used to define the model, it is not the same. Here, the Column definitions are arguments to the create_table function. Also, while you didn’t have to specify the datatype of the user_id column in the model, you must do that here.

Possible changes to revision template:

new file mode 100644
index 0000000000..e8e5fe0ea3
--- /dev/null
+++ b/lib/galaxy/model/migrations/alembic/versions_gxy/2ad8047d652e_add_user_favorite_extentions_table.py
@@ -0,0 +1,29 @@
+"""Add user_favorite_extentions table
+
+Revision ID: 2ad8047d652e
+Revises: 186d4835587b
+Create Date: 2022-07-07 00:44:21.992162
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '2ad8047d652e'
+down_revision = '186d4835587b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table(
+        'user_favorite_extension',
+        sa.Column('id', sa.Integer, primary_key=True),
+        sa.Column('user_id', sa.Integer, sa.ForeignKey("galaxy_user.id")),
+        sa.Column('value', sa.String),
+    )
+
+
+def downgrade():
+    op.drop_table('user_favorite_extension')
Question: about running the migration

What command should you run to upgrade your database to include the new table?

sh manage_db.sh upgrade

To verify that the table has been added to your database, you may use the SQLite CLI tool. First, you login to your database; then you display the schema of the new table; and, finally, you verify that the database version has been updated (the first record stored in the alembic_version table is the revision identifier that corresponds to the revision identifier in the revision file you added in a previous step.

(.venv) rivendell$ sqlite3 database/universe.sqlite 
SQLite version 3.34.1 2021-01-20 14:10:07
Enter ".help" for usage hints.
sqlite> .schema user_favorite_extension 
CREATE TABLE user_favorite_extension (
	id INTEGER NOT NULL, 
	user_id INTEGER, 
	value VARCHAR, 
	PRIMARY KEY (id), 
	FOREIGN KEY(user_id) REFERENCES galaxy_user (id)
);
sqlite> select * from alembic_version;
fe1ce5cacb20
d4a650f47a3c

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.

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.

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)

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)

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 the 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.

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.

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

index 83757e5307..c9e0feeb77 100644
--- a/lib/galaxy/webapps/galaxy/buildapp.py
+++ b/lib/galaxy/webapps/galaxy/buildapp.py
@@ -213,6 +213,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("/welcome/new")
     webapp.add_client_route("/visualizations")
     webapp.add_client_route("/visualizations/edit")
-- 
2.30.1 (Apple Git-130)

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”.

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

index b4f6c46a2f..d2eab16f6f 100644
--- a/client/src/layout/menu.js
+++ b/client/src/layout/menu.js
@@ -303,6 +303,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/router.js respond to the route added above in buildapp.py and render the fictitious VueJS component FavoriteExtensions.

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

index e4b3ce87cc..73332bf4ac 100644
--- a/client/src/entry/analysis/router.js
+++ b/client/src/entry/analysis/router.js
@@ -22,6 +22,7 @@ import Grid from "components/Grid/Grid";
 import GridShared from "components/Grid/GridShared";
 import GridHistory from "components/Grid/GridHistory";
 import HistoryImport from "components/HistoryImport";
+import { FavoriteExtensions } from "components/User/FavoriteExtensions/index";
 import HistoryView from "components/HistoryView";
 import InteractiveTools from "components/InteractiveTools/InteractiveTools";
 import InvocationReport from "components/Workflow/InvocationReport";
@@ -299,6 +300,11 @@ export function getRouter(Galaxy) {
                         props: true,
                         redirect: redirectAnon(),
                     },
+                    {
+                        path: "user/favorite/extensions",
+                        component: FavoriteExtensions,
+                        redirect: redirectAnon(),
+                    },
                     {
                         path: "visualizations",
                         component: VisualizationsList,

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.

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.

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

new file mode 100644
index 0000000000..053b354f63
--- /dev/null
+++ b/client/src/components/User/FavoriteExtensions/List.vue
@@ -0,0 +1,78 @@
+<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.

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";