Contributing a New Feature to Galaxy Core
Author(s) | John Chilton John Davis |
Reviewers |
OverviewQuestions:Objectives:
How can I add a new feature to Galaxy that involves modifications to the database, the API, and the UI?
Requirements:
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
- slides Slides: Contributing with GitHub via command-line
- tutorial Hands-on: Contributing with GitHub via command-line
- slides Slides: Galaxy Code Architecture
Time estimation: 3 hoursSupporting Materials:Published: Jun 8, 2021Last modification: Jun 30, 2024License: Tutorial Content is licensed under Creative Commons Attribution 4.0 International License. The GTN Framework is licensed under MITpurl PURL: https://gxy.io/GTN:T00113version Revision: 8
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
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:
- Fork the Galaxy repository
- Clone your fork
- Make changes in a new branch
- Commit your changes, push branch to your fork
- 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
- Use GitHub UI to fork Galaxy’s repository at
galaxyproject/galaxy
.Clone your forked repository to a local path, further referred to as
GALAXY_ROOT
andcd
intoGALAXY_ROOT
. Note that we specify the tutorial branch with the-b
option:Input: Bashgit clone https://github.com/<your-username>/galaxy GALAXY_ROOT cd GALAXY_ROOT
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: Bashbash 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 theGALAXY_PYTHON
environment variable (GALAXY_PYTHON=/path/to/alt/python bash scripts/common_startup.sh --dev-wheels
).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)$
.Finally, let’s create a new branch for your edits:
Input: Bashgit checkout -b my-feature
Now when you run
git branch
you’ll see that your new branch is activated:Input: Bashgit branch
Outputdev * my-feature
Note:
my-feature
is just an example; you can call your new branch anything you like.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 start Galaxy by executing the following script (might take some time when executing for the first time):
Input: Bashsh run.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
- What should be the SQLAlchemy model named corresponding to the table
user_favorite_extension
based on other examples?- What table stores Galaxy’s user records?
- What is another simple table with a relationship with the Galaxy’s user table?
UserFavoriteExtension
galaxy_user
- 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:
- Add a class definition for your new class
- Your class should be a subclass of
Base
- Add a
__tablename__
attribute - Add a
Column
attribute for the primary key (should be namedid
) - Add a
Column
attribute to store the extension - Add a
Column
attribute that will serve as a foreign key to theUser
model - Use the
relationship
function to define an association between your new model and theUser
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:
- Create a revision template.
- Edit the revision template, filling in the body of the upgrade and downgrade functions.
- Run the migration.
Question: about generating a revision templateWhat 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 migrationWhat 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 containsfasta
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";