Skip to content

Commit

Permalink
Implement history export dialog.
Browse files Browse the repository at this point in the history
  • Loading branch information
jmchilton committed Jan 5, 2021
1 parent e59ecf0 commit 8f6cb1a
Show file tree
Hide file tree
Showing 16 changed files with 571 additions and 49 deletions.
2 changes: 1 addition & 1 deletion client/src/components/History/HistoryDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
key="export-history-to-file"
title="Export History to File"
icon="fas fa-file-archive"
@click="iframeRedirect('/history/export_archive?preview=True')"
@click="backboneRoute(`/histories/${history.id}/export`)"
/>
</PriorityMenu>

Expand Down
18 changes: 18 additions & 0 deletions client/src/components/HistoryExport/Index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { shallowMount } from "@vue/test-utils";
import Index from "./Index.vue";
import { getLocalVue } from "jest/helpers";

const localVue = getLocalVue();

describe("Index.vue", () => {
it("should render tabs", () => {
// just make sure the component renders to catch obvious big errors
const wrapper = shallowMount(Index, {
propsData: {
historyId: "test_id",
},
localVue,
});
expect(wrapper.exists("b-tabs-stub")).toBeTruthy();
});
});
39 changes: 39 additions & 0 deletions client/src/components/HistoryExport/Index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<template>
<span>
<h2>Export history archive</h2>
<b-card no-body>
<b-tabs pills card vertical>
<b-tab title="to a link" active>
<b-card-text>
<ToLink :history-id="historyId" />
</b-card-text>
</b-tab>
<b-tab title="to a remote file">
<ToRemoteFile :history-id="historyId" />
</b-tab>
</b-tabs>
</b-card>
</span>
</template>

<script>
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import ToLink from "./ToLink.vue";
import ToRemoteFile from "./ToRemoteFile.vue";
Vue.use(BootstrapVue);
export default {
components: {
ToLink,
ToRemoteFile,
},
props: {
historyId: {
type: String,
required: true,
},
},
};
</script>
51 changes: 51 additions & 0 deletions client/src/components/HistoryExport/ToLink.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { shallowMount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import ToLink from "./ToLink.vue";
import flushPromises from "flush-promises";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";

const localVue = getLocalVue();
const TEST_HISTORY_ID = "hist1235";
const TEST_EXPORTS_URL = `/api/histories/${TEST_HISTORY_ID}/exports`;

describe("ToLink.vue", () => {
let axiosMock;
let wrapper;

async function mountWithInitialExports(exports) {
axiosMock.onGet(TEST_EXPORTS_URL).reply(200, exports);
wrapper = shallowMount(ToLink, {
propsData: {
historyId: TEST_HISTORY_ID,
},
localVue,
});
await wrapper.vm.$nextTick();
expect(wrapper.find("loading-span-stub").exists()).toBeTruthy();
await flushPromises();
}

beforeEach(async () => {
axiosMock = new MockAdapter(axios);
});

it("should display a link if no exports ever generated", async () => {
await mountWithInitialExports([]);
expect(wrapper.find(".export-link")).toBeTruthy();
expect(wrapper.find("loading-span-stub").exists()).toBeFalsy(); // loading span gone
});

it("should start polling if latest export is preparing", async () => {
await mountWithInitialExports([
{
preparing: true,
},
]);
expect(wrapper.find("loading-span-stub").attributes("message")).toContain("preparing");
});

afterEach(() => {
axiosMock.restore();
});
});
148 changes: 148 additions & 0 deletions client/src/components/HistoryExport/ToLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<template>
<div>
<b-alert show variant="warning" v-if="errorMessage">
{{ errorMessage }}
</b-alert>
<div v-if="loadingExports">
<loading-span message="Loading history export information from Galaxy server." />
</div>
<div v-else-if="latestExportReady">
Link for download ready
<b
><a :href="latestExportUrl">{{ latestExportUrl }}</a></b
>
<font-awesome-icon
v-b-tooltip.hover
title="Copy export URL to your clipboard"
icon="copy"
style="cursor: pointer;"
@click="copyUrl"
/>
. Use this link to download the archive or import it on another Galaxy server.
</div>
<div v-else-if="polling">
<loading-span message="Galaxy server is preparing history for download, this will likely take a while." />
</div>
<div v-else>
<p>No link for history export ready, {{ whyNoLink }}.</p>
<p>
<b
><a class="export-link" href="#" @click="putExportUntilReady"
>Click here to attempt to generate a new archive for this history.</a
></b
>
</p>
</div>
</div>
</template>

<script>
import { getAppRoot } from "onload/loadConfig";
import axios from "axios";
import Vue from "vue";
import BootstrapVue from "bootstrap-vue";
import { errorMessageAsString } from "utils/simple-error";
import LoadingSpan from "components/LoadingSpan";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCopy } from "@fortawesome/free-solid-svg-icons";
import { copy } from "utils/clipboard";
library.add(faCopy);
Vue.use(BootstrapVue);
export default {
components: { LoadingSpan, FontAwesomeIcon },
props: {
historyId: {
type: String,
required: true,
},
},
data() {
return {
timeout: null, // TODO: clear...
errorMessage: null,
exports: null,
polling: null,
};
},
created() {
this.loadExports();
},
computed: {
loadingExports() {
return this.exports === null;
},
hasExports() {
return this.exports !== null && this.exports.length > 0;
},
latestExport() {
return this.exports && this.exports[0];
},
latestExportReady() {
return this.latestExport?.ready && this.latestExport?.up_to_date;
},
latestExportUrl() {
return this.latestExport?.external_download_url;
},
latestExportPreparing() {
return this.latestExport?.preparing;
},
whyNoLink() {
if (!this.hasExports) {
return `no history export ever initiated for this history`;
} else if (!this.latestExport.up_to_date) {
return `previous history export not up-to-date (history has changed since export was generated)`;
} else {
return `previous history export archival likely failed`;
}
},
},
methods: {
loadExports() {
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
axios
.get(url)
.then((response) => {
this.exports = response.data;
if (this.latestExportPreparing) {
this.putExportUntilReady();
} else {
this.polling = false;
}
})
.catch(this.handleError);
},
putExportUntilReady() {
this.polling = true;
const url = `${getAppRoot()}api/histories/${this.historyId}/exports`;
axios
.put(url)
.then((response) => {
const status = response.status;
if (status == 200) {
this.loadExports();
} else if (status == 202) {
// still generating, keep polling...
this.timeout = setTimeout(() => {
this.putExportUntilReady();
});
} else {
// error ....
this.errorMessage = `Unexpected error while polling history export ${errorMessageAsString(
response
)}`;
}
})
.catch(this.handleError);
},
handleError(err) {
this.errorMessage = errorMessageAsString(err);
},
copyUrl() {
copy(this.latestExportUrl, "Export URL copied to your clipboard");
},
},
};
</script>
72 changes: 72 additions & 0 deletions client/src/components/HistoryExport/ToRemoteFile.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { shallowMount } from "@vue/test-utils";
import { getLocalVue } from "jest/helpers";
import ToRemoteFile from "./ToRemoteFile.vue";
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
import flushPromises from "flush-promises";
import { waitOnJob } from "mvc/history/job-states-model";

const localVue = getLocalVue();
const TEST_HISTORY_ID = "hist1235";
const TEST_JOB_ID = "job123789";
const TEST_EXPORTS_URL = `/api/histories/${TEST_HISTORY_ID}/exports`;

jest.mock("mvc/history/job-states-model");

describe("ToRemoteFile.vue", () => {
let axiosMock;
let wrapper;

beforeEach(async () => {
axiosMock = new MockAdapter(axios);
wrapper = shallowMount(ToRemoteFile, {
propsData: {
historyId: TEST_HISTORY_ID,
},
localVue,
});
});

it("should render a form with export disable because inputs empty", async () => {
expect(wrapper.find(".export-button").exists()).toBeTruthy();
expect(wrapper.find(".export-button").attributes("disabled")).toBeTruthy();
expect(wrapper.vm.canExport).toBeFalsy();
});

it("should allow export when name and directory available", async () => {
await wrapper.setData({
name: "export.tar.gz",
directory: "gxfiles://",
});
expect(wrapper.vm.directory).toEqual("gxfiles://");
expect(wrapper.vm.name).toEqual("export.tar.gz");
expect(wrapper.vm.canExport).toBeTruthy();
});

it("should issue export PUT request on export", async () => {
await wrapper.setData({
name: "export.tar.gz",
directory: "gxfiles://",
});
let request;
axiosMock.onPut(TEST_EXPORTS_URL).reply((request_) => {
request = request_;
return [200, { job_id: TEST_JOB_ID }];
});
waitOnJob.mockReturnValue(
new Promise((then) => {
then({ state: "ok" });
})
);
wrapper.vm.doExport();
await flushPromises();
const putData = JSON.parse(request.data);
expect(putData.directory_uri).toEqual("gxfiles://");
expect(putData.file_name).toEqual("export.tar.gz");
expect(wrapper.find("b-alert-stub").attributes("variant")).toEqual("success");
});

afterEach(() => {
axiosMock.restore();
});
});
Loading

0 comments on commit 8f6cb1a

Please sign in to comment.