From e77f1369f7ad33f7497706e611bb6e876541086a Mon Sep 17 00:00:00 2001 From: Matthew Evans <7916000+ml-evs@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:16:32 +0100 Subject: [PATCH] Add QR code generation and scanning to UI (#808) * Add clickable QRCode modal in navbar * Add qr code library * Add QRCodeModal component to formatted refcode * Add QRScanner functionality using `vue-qrcode-reader` * Add QR scanning from image * Add alert and spinner to handle camera authorisation/availaility * Add textual label to QRCode * Update default pURL resolver * Add query parameter redirect-to-ui to `/items` endpoint to allow link resolver to work more smoothly * List all QR codes in stream * Use auxiliary variable in store to map refcodes to IDs * Make sure item_id is set at top level of item response when querying by refcode * Redirect to the correct UI URL * Update config for qr code resolver and add warnings if unconfigured --- pydatalab/pydatalab/routes/v0_1/items.py | 8 +- pydatalab/tests/server/test_items.py | 6 ++ webapp/package.json | 6 +- webapp/src/components/FormattedRefcode.vue | 27 ++++++- webapp/src/components/QRCode.vue | 78 +++++++++++++++++++ webapp/src/components/QRCodeModal.vue | 54 +++++++++++++ webapp/src/components/QRScannerModal.vue | 90 ++++++++++++++++++++++ webapp/src/main.js | 2 + webapp/src/resources.js | 4 + webapp/src/router/index.js | 5 ++ webapp/src/server_fetch_utils.js | 17 ++++ webapp/src/store/index.js | 4 +- webapp/src/views/EditPage.vue | 35 ++++++--- webapp/src/views/Samples.vue | 7 ++ webapp/yarn.lock | 88 ++++++++++++++++++++- 15 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 webapp/src/components/QRCode.vue create mode 100644 webapp/src/components/QRCodeModal.vue create mode 100644 webapp/src/components/QRScannerModal.vue diff --git a/pydatalab/pydatalab/routes/v0_1/items.py b/pydatalab/pydatalab/routes/v0_1/items.py index e8e78d225..ad1890bdc 100644 --- a/pydatalab/pydatalab/routes/v0_1/items.py +++ b/pydatalab/pydatalab/routes/v0_1/items.py @@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Set, Union from bson import ObjectId -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, redirect, request from flask_login import current_user from pydantic import ValidationError from pymongo.command_cursor import CommandCursor @@ -644,6 +644,9 @@ def get_item_data( call its render function). """ + redirect_to_ui = bool(request.args.get("redirect-to-ui", default=False, type=json.loads)) + if refcode and redirect_to_ui and CONFIG.APP_URL: + return redirect(f"{CONFIG.APP_URL}/items/{refcode}", code=307) if item_id: match = {"item_id": item_id} @@ -767,6 +770,9 @@ def get_item_data( # Must be exported to JSON first to apply the custom pydantic JSON encoders return_dict = json.loads(doc.json(exclude_unset=True)) + if item_id is None: + item_id = return_dict["item_id"] + # create the files_data dictionary keyed by file ObjectId files_data: Dict[ObjectId, Dict] = { f["immutable_id"]: f for f in return_dict.get("files") or [] diff --git a/pydatalab/tests/server/test_items.py b/pydatalab/tests/server/test_items.py index c0bd1cba3..4ddf74ae7 100644 --- a/pydatalab/tests/server/test_items.py +++ b/pydatalab/tests/server/test_items.py @@ -2,13 +2,19 @@ def test_single_item_endpoints(client, inserted_default_items): for item in inserted_default_items: response = client.get(f"/items/{item.refcode}") assert response.status_code == 200, response.json + assert response.json["item_id"] == item.item_id + assert response.json["item_data"]["item_id"] == item.item_id assert response.json["status"] == "success" test_ref = item.refcode.split(":")[1] response = client.get(f"/items/{test_ref}") assert response.status_code == 200, response.json + assert response.json["item_id"] == item.item_id + assert response.json["item_data"]["item_id"] == item.item_id assert response.json["status"] == "success" response = client.get(f"/get-item-data/{item.item_id}") assert response.status_code == 200, response.json assert response.json["status"] == "success" + assert response.json["item_id"] == item.item_id + assert response.json["item_data"]["item_id"] == item.item_id diff --git a/webapp/package.json b/webapp/package.json index c167d4397..872e880bc 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "datalab-vue", - "version": "0.2.5", + "version": "0.4.5", "private": true, "scripts": { "serve": "vue-cli-service serve", @@ -43,7 +43,9 @@ "vue-router": "^4.0.0-0", "vue-select": "^4.0.0-beta.6", "vue3-easy-data-table": "^1.5.45", - "vuex": "^4.0.0-0" + "vuex": "^4.0.0-0", + "qrcode-vue3": "^1.6.8", + "vue-qrcode-reader": "^5.5.7" }, "devDependencies": { "@babel/core": "^7.24.8", diff --git a/webapp/src/components/FormattedRefcode.vue b/webapp/src/components/FormattedRefcode.vue index 679555225..d24eee322 100644 --- a/webapp/src/components/FormattedRefcode.vue +++ b/webapp/src/components/FormattedRefcode.vue @@ -1,4 +1,11 @@ + + diff --git a/webapp/src/components/QRCodeModal.vue b/webapp/src/components/QRCodeModal.vue new file mode 100644 index 000000000..ac17d222e --- /dev/null +++ b/webapp/src/components/QRCodeModal.vue @@ -0,0 +1,54 @@ + + + diff --git a/webapp/src/components/QRScannerModal.vue b/webapp/src/components/QRScannerModal.vue new file mode 100644 index 000000000..c087df506 --- /dev/null +++ b/webapp/src/components/QRScannerModal.vue @@ -0,0 +1,90 @@ + + + diff --git a/webapp/src/main.js b/webapp/src/main.js index 5885aaef3..42493a4f7 100644 --- a/webapp/src/main.js +++ b/webapp/src/main.js @@ -15,6 +15,7 @@ import { faCode, faEnvelope, faCog, + faQrcode, faUsersCog, faChevronRight, faArrowUp, @@ -51,6 +52,7 @@ library.add( faSave, faPen, faCode, + faQrcode, faEnvelope, faCog, faUsersCog, diff --git a/webapp/src/resources.js b/webapp/src/resources.js index e7169fe66..cb2d719eb 100644 --- a/webapp/src/resources.js +++ b/webapp/src/resources.js @@ -24,6 +24,10 @@ export const API_URL = process.env.VUE_APP_API_URL != null ? process.env.VUE_APP_API_URL : "http://localhost:5001"; export const API_TOKEN = process.env.VUE_APP_API_TOKEN; +export const QR_CODE_RESOLVER_URL = process.env.VUE_APP_QR_CODE_RESOLVER_URL; + +export const FEDERATION_QR_CODE_RESOLVER_URL = "https://purl.datalab-org.io"; + export const LOGO_URL = process.env.VUE_APP_LOGO_URL; export const HOMEPAGE_URL = process.env.VUE_APP_HOMEPAGE_URL; diff --git a/webapp/src/router/index.js b/webapp/src/router/index.js index e36ea6c15..502a8bff0 100644 --- a/webapp/src/router/index.js +++ b/webapp/src/router/index.js @@ -38,6 +38,11 @@ const routes = [ name: "edit", component: EditPage, }, + { + path: "/items/:refcode", + name: "edit item", + component: EditPage, + }, { path: "/starting-materials", name: "starting-materials", diff --git a/webapp/src/server_fetch_utils.js b/webapp/src/server_fetch_utils.js index 8cf2f9591..ee98cd286 100644 --- a/webapp/src/server_fetch_utils.js +++ b/webapp/src/server_fetch_utils.js @@ -430,6 +430,23 @@ export async function getItemData(item_id) { .catch((error) => alert("Error getting sample data: " + error)); } +export async function getItemByRefcode(refcode) { + return fetch_get(`${API_URL}/items/${refcode}`) + .then((response_json) => { + store.commit("createItemData", { + refcode: refcode, + item_id: response_json.item_data.item_id, + item_data: response_json.item_data, + child_items: response_json.child_items, + parent_items: response_json.parent_items, + }); + store.commit("updateFiles", response_json.files_data); + + return "success"; + }) + .catch((error) => alert("Error getting item data: " + error)); +} + export async function getCollectionData(collection_id) { return fetch_get(`${API_URL}/collections/${collection_id}`) .then((response_json) => { diff --git a/webapp/src/store/index.js b/webapp/src/store/index.js index fed93173b..b565c613c 100644 --- a/webapp/src/store/index.js +++ b/webapp/src/store/index.js @@ -11,6 +11,7 @@ export default createStore({ all_collection_data: {}, all_collection_children: {}, all_collection_parents: {}, + refcode_to_id: {}, sample_list: [], equipment_list: [], starting_material_list: [], @@ -113,12 +114,13 @@ export default createStore({ }, createItemData(state, payload) { // payload should have the following fields: - // item_id, item_data, child_items, parent_items + // refcode, item_id, item_data, child_items, parent_items // Object.assign(state.all_sample_data[payload.item_data], payload.item_data) state.all_item_data[payload.item_id] = payload.item_data; state.all_item_children[payload.item_id] = payload.child_items; state.all_item_parents[payload.item_id] = payload.parent_items; state.saved_status_items[payload.item_id] = true; + state.refcode_to_id[payload.refcode] = payload.item_id; }, setCollectionData(state, payload) { // payload should have the following fields: diff --git a/webapp/src/views/EditPage.vue b/webapp/src/views/EditPage.vue index 4a5e007dc..54c1af292 100644 --- a/webapp/src/views/EditPage.vue +++ b/webapp/src/views/EditPage.vue @@ -96,6 +96,7 @@ import FileList from "@/components/FileList"; import FileSelectModal from "@/components/FileSelectModal"; import { getItemData, + getItemByRefcode, addABlock, saveItem, updateBlockFromServer, @@ -137,7 +138,8 @@ export default { }, data() { return { - item_id: this.$route.params.id, + item_id: this.$route.params?.id || null, + refcode: this.$route.params?.refcode || null, itemDataLoaded: false, isMenuDropdownVisible: false, selectedRemoteFiles: [], @@ -149,7 +151,7 @@ export default { }, computed: { itemType() { - return this.$store.state.all_item_data[this.item_id]?.type; + return this.$store.state.all_item_data[this.item_id]?.type || null; }, itemTypeEntry() { return itemTypes[this.itemType] || null; @@ -158,7 +160,7 @@ export default { return this.itemTypeEntry?.navbarColor || "DarkGrey"; }, item_data() { - return this.$store.state.all_item_data[this.item_id] || {}; + return this.$store.state.all_item_data[this.item_id] || { display_order: [] }; }, blocks() { return this.item_data.blocks_obj; @@ -185,6 +187,9 @@ export default { blocksInfos() { return this.$store.state.blocksInfos; }, + itemApiUrl() { + return API_URL + "/items/" + this.refcode; + }, }, watch: { // add a warning before leaving page if unsaved @@ -203,7 +208,6 @@ export default { }, beforeMount() { this.blockTypes = blockTypes; // bind blockTypes as a NON-REACTIVE object to the this context so that it is accessible by the template. - this.itemApiUrl = API_URL + "/get-item-data/" + this.item_id; }, mounted() { // overwrite ctrl-s and cmd-s to save the page @@ -277,16 +281,23 @@ export default { this.lastModified = "just now"; }, getSampleData() { - getItemData(this.item_id).then(() => { - this.itemDataLoaded = true; - - // update each block asynchronously - this.item_data.display_order.forEach((block_id) => { - console.log(`calling update on block ${block_id}`); - updateBlockFromServer(this.item_id, block_id, this.item_data.blocks_obj[block_id]); + if (this.item_id == null) { + getItemByRefcode(this.refcode).then(() => { + this.item_id = this.$store.state.refcode_to_id[this.refcode]; + }); + } else { + getItemData(this.item_id).then(() => { + this.refcode = this.item_data.refcode; }); - this.setLastModified(); + } + this.itemDataLoaded = true; + + // update each block asynchronously + this.item_data.display_order.forEach((block_id) => { + console.log(`calling update on block ${block_id}`); + updateBlockFromServer(this.item_id, block_id, this.item_data.blocks_obj[block_id]); }); + this.setLastModified(); }, leavePageWarningListener(event) { event.preventDefault; diff --git a/webapp/src/views/Samples.vue b/webapp/src/views/Samples.vue index fadbc428a..c67d00272 100644 --- a/webapp/src/views/Samples.vue +++ b/webapp/src/views/Samples.vue @@ -8,6 +8,9 @@ +
@@ -18,6 +21,7 @@
+