@@ -127,6 +133,7 @@ import ItemRelationshipVisualization from "@/components/ItemRelationshipVisualiz
import ToggleableCreatorsFormGroup from "@/components/ToggleableCreatorsFormGroup";
import ToggleableGroupsFormGroup from "@/components/ToggleableGroupsFormGroup";
import LocationInput from "@/components/LocationInput";
+import ToggleableTagsFormGroup from "@/components/ToggleableTagsFormGroup";
import AutoComplete from "primevue/autocomplete";
import { getStartingMaterialList, getEquipmentList } from "@/server_fetch_utils.js";
@@ -148,6 +155,7 @@ export default {
ToggleableCreatorsFormGroup,
ToggleableGroupsFormGroup,
LocationInput,
+ ToggleableTagsFormGroup,
},
props: {
item_id: { type: String, required: true },
@@ -176,15 +184,19 @@ export default {
Location: createComputedSetterForItemField("location"),
ItemDescription: createComputedSetterForItemField("description"),
Collections: createComputedSetterForItemField("collections"),
+ Tags: createComputedSetterForItemField("tags"),
Refcode: createComputedSetterForItemField("refcode"),
Status: createComputedSetterForItemField("status"),
ItemCreators: createComputedSetterForItemField("creators"),
ItemGroups: createComputedSetterForItemField("groups"),
+ enableTags() {
+ return this.$store.state.serverInfo?.features?.tags ?? false;
+ },
schema() {
return this.$store.state.schemas[this.item?.type];
},
possibleItemStatuses() {
- return this.schema?.attributes?.schema?.definitions?.StartingMaterialsStatus?.enum;
+ return this.schema?.attributes?.schema?.["$defs"]?.StartingMaterialsStatus?.enum;
},
Barcode: createComputedSetterForItemField("barcode"),
uniqueSuppliers() {
diff --git a/webapp/src/components/TagActionsCell.vue b/webapp/src/components/TagActionsCell.vue
new file mode 100644
index 000000000..3b22c4d92
--- /dev/null
+++ b/webapp/src/components/TagActionsCell.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/TagBadge.vue b/webapp/src/components/TagBadge.vue
new file mode 100644
index 000000000..10e6d6faf
--- /dev/null
+++ b/webapp/src/components/TagBadge.vue
@@ -0,0 +1,90 @@
+
+
+
+
+ {{
+ tag.name
+ }}
+
+
+
+
+ {{ tag.description || tag.name }}
+ (user-defined tag)
+
+
+
+
+
+
+
diff --git a/webapp/src/components/TagColorPicker.vue b/webapp/src/components/TagColorPicker.vue
new file mode 100644
index 000000000..03a82704b
--- /dev/null
+++ b/webapp/src/components/TagColorPicker.vue
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webapp/src/components/TagFormModal.vue b/webapp/src/components/TagFormModal.vue
new file mode 100644
index 000000000..11a1397c8
--- /dev/null
+++ b/webapp/src/components/TagFormModal.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+
diff --git a/webapp/src/components/TagList.vue b/webapp/src/components/TagList.vue
new file mode 100644
index 000000000..b36a34941
--- /dev/null
+++ b/webapp/src/components/TagList.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
diff --git a/webapp/src/components/TagManagementTable.vue b/webapp/src/components/TagManagementTable.vue
new file mode 100644
index 000000000..a29d8565d
--- /dev/null
+++ b/webapp/src/components/TagManagementTable.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
diff --git a/webapp/src/components/TagScopeBadge.vue b/webapp/src/components/TagScopeBadge.vue
new file mode 100644
index 000000000..303a6066a
--- /dev/null
+++ b/webapp/src/components/TagScopeBadge.vue
@@ -0,0 +1,28 @@
+
+
+
+ {{ isUserDefined ? "User-defined" : "Global" }}
+
+
+
+
diff --git a/webapp/src/components/TagSelect.vue b/webapp/src/components/TagSelect.vue
new file mode 100644
index 000000000..0d13b5305
--- /dev/null
+++ b/webapp/src/components/TagSelect.vue
@@ -0,0 +1,178 @@
+
+
+
+
+ Couldn't reach the server to search tags.
+
+ No matching tags.
+ Type to search for a tag...
+
+
+
+
+
+ {{ name }}
+
+
+ {{ scope === "user" ? "user-defined" : "global" }}
+
+
+
+
+
+
+ {{ name }}
+
+
+
+
+
+
+
diff --git a/webapp/src/components/ToggleableTagsFormGroup.vue b/webapp/src/components/ToggleableTagsFormGroup.vue
new file mode 100644
index 000000000..88cc0e039
--- /dev/null
+++ b/webapp/src/components/ToggleableTagsFormGroup.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
diff --git a/webapp/src/field_utils.js b/webapp/src/field_utils.js
index f17aaa1f1..dffef9fd6 100644
--- a/webapp/src/field_utils.js
+++ b/webapp/src/field_utils.js
@@ -185,3 +185,27 @@ export function validateEntryID(id, takenIds = [], existingIds = []) {
}
return "";
}
+
+export function readableTextColor(hexColor) {
+ // Return a readable text color ("#000" or "#fff") for a given background hex
+ // color, based on its perceptual luminance. Falls back to black for invalid input.
+ if (!hexColor || typeof hexColor !== "string") {
+ return "#000";
+ }
+ let hex = hexColor.trim().replace(/^#/, "");
+ if (hex.length === 3) {
+ hex = hex
+ .split("")
+ .map((c) => c + c)
+ .join("");
+ }
+ if (hex.length !== 6 || /[^0-9a-fA-F]/.test(hex)) {
+ return "#000";
+ }
+ const r = parseInt(hex.slice(0, 2), 16);
+ const g = parseInt(hex.slice(2, 4), 16);
+ const b = parseInt(hex.slice(4, 6), 16);
+ // Perceptual luminance (sRGB weights), normalised to [0, 1].
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+ return luminance > 0.6 ? "#000" : "#fff";
+}
diff --git a/webapp/src/main.js b/webapp/src/main.js
index f75d0aa99..545dade39 100644
--- a/webapp/src/main.js
+++ b/webapp/src/main.js
@@ -77,6 +77,7 @@ import {
faCaretDown,
faLock,
faClock,
+ faUser,
} from "@fortawesome/free-solid-svg-icons";
import { faPlusSquare } from "@fortawesome/free-regular-svg-icons";
import { faGithub, faOrcid, faGoogle, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
@@ -154,6 +155,7 @@ library.add(
faCaretDown,
faLock,
faClock,
+ faUser,
);
// import "@uppy/vue"
diff --git a/webapp/src/resources.js b/webapp/src/resources.js
index e4ed4d6d1..73a0a7948 100644
--- a/webapp/src/resources.js
+++ b/webapp/src/resources.js
@@ -143,6 +143,25 @@ export const SAMPLE_TABLE_TYPES = ["samples", "cells"];
export const INVENTORY_TABLE_TYPES = ["starting_materials"];
export const EQUIPMENT_TABLE_TYPES = ["equipment"];
+// Curated palette of distinguishable preset colors offered for tag colors.
+export const TAG_COLOR_PALETTE = [
+ "#e74c3c",
+ "#e67e22",
+ "#f1c40f",
+ "#2ecc71",
+ "#1abc9c",
+ "#3498db",
+ "#9b59b6",
+ "#34495e",
+ "#95a5a6",
+ "#e84393",
+ "#00b894",
+ "#fdcb6e",
+];
+
+// The color assigned to a newly created tag.
+export const DEFAULT_TAG_COLOR = "#95a5a6";
+
export const cellFormats = {
coin: "coin",
pouch: "pouch",
diff --git a/webapp/src/router/index.js b/webapp/src/router/index.js
index 3b96fc9a7..80a500c59 100644
--- a/webapp/src/router/index.js
+++ b/webapp/src/router/index.js
@@ -3,6 +3,7 @@ import Samples from "../views/Samples.vue";
import Equipment from "../views/Equipment.vue";
import StartingMaterials from "../views/StartingMaterials.vue";
import Collections from "@/views/Collections.vue";
+import Tags from "@/views/Tags.vue";
import NotFound from "../views/NotFound.vue";
import EditPage from "../views/EditPage.vue";
import CollectionPage from "../views/CollectionPage.vue";
@@ -13,6 +14,8 @@ import Login from "../views/Login.vue";
import Login2 from "../views/Login2.vue";
import Login3 from "../views/Login3.vue";
import { API_URL } from "@/resources.js";
+import { getInfo } from "@/server_fetch_utils.js";
+import store from "@/store/index.js";
const routes = [
{
@@ -73,6 +76,20 @@ const routes = [
name: "collections",
component: Collections,
},
+ {
+ path: "/tags",
+ name: "tags",
+ component: Tags,
+ // Only reachable when the backend reports the tags feature as enabled.
+ beforeEnter: async (to, from, next) => {
+ const serverInfo = store.state.serverInfo ?? (await getInfo());
+ if (serverInfo.features?.tags) {
+ next();
+ } else {
+ next({ path: "/" });
+ }
+ },
+ },
{
path: "/collections/:id",
name: "Collection",
diff --git a/webapp/src/server_fetch_utils.js b/webapp/src/server_fetch_utils.js
index d8865df3c..efaf9b556 100644
--- a/webapp/src/server_fetch_utils.js
+++ b/webapp/src/server_fetch_utils.js
@@ -561,6 +561,61 @@ export function searchCollections(query, nresults = 100) {
});
}
+export function createTag(data) {
+ // data: { name, description?, color?, scope? }. `scope` is "user" (user-defined,
+ // default) or "global" (admins only). The caller refreshes the list via
+ // getTags(). Rejects with the server message on error (e.g. 409 duplicate name).
+ return fetch_put(`${API_URL}/tags`, { data }).then(function (response_json) {
+ return response_json.data;
+ });
+}
+
+export function updateTag(tagId, data) {
+ // Update a tag's metadata (name/description/color). Rejects with the server message (e.g. 409).
+ return fetch_patch(`${API_URL}/tags/${tagId}`, { data });
+}
+
+export function deleteTag(tagId) {
+ return fetch_delete(`${API_URL}/tags/${tagId}`)
+ .then(function (response_json) {
+ if (response_json.status !== "success") {
+ throw new Error("Failed to delete tag: " + response_json.message);
+ }
+ store.commit("deleteFromTagList", tagId);
+ })
+ .catch((error) => {
+ DialogService.error({
+ title: "Unable to delete tag",
+ message: `Failed to delete tag: ${error}`,
+ });
+ throw error;
+ });
+}
+
+export function getTags() {
+ return fetch_get(`${API_URL}/tags`)
+ .then(function (response_json) {
+ store.commit("setTagList", response_json.data);
+ })
+ .catch((error) => {
+ if (error === "UNAUTHORIZED") {
+ store.commit("setTagList", []);
+ } else {
+ throw error;
+ }
+ });
+}
+
+export function searchTags(query, nresults = 100) {
+ // construct a url with parameters:
+ var url = new URL(`${API_URL}/search-tags`);
+ var params = { query: query, nresults: nresults };
+ Object.keys(params).forEach((key) => url.searchParams.append(key, params[key]));
+ return fetch_get(url).then(function (response_json) {
+ return response_json.data;
+ });
+}
+
export function searchGroups(query, nresults = 100) {
// construct a url with parameters:
var url = new URL(`${API_URL}/search/groups`);
diff --git a/webapp/src/store/index.js b/webapp/src/store/index.js
index 8dd7cf01e..7ec2e38f4 100644
--- a/webapp/src/store/index.js
+++ b/webapp/src/store/index.js
@@ -18,6 +18,7 @@ export default createStore({
equipment_list: null,
starting_material_list: null,
collection_list: null,
+ tag_list: null,
groups_list: null,
saved_status_items: {},
saved_status_blocks: {},
@@ -72,6 +73,10 @@ export default createStore({
page: 0,
rows: 10,
},
+ tags: {
+ page: 0,
+ rows: 20,
+ },
},
block_errors: {},
block_infos: {},
@@ -98,6 +103,20 @@ export default createStore({
// collectionSummaries is an array of json objects summarizing the available collections
state.collection_list = collectionSummaries || [];
},
+ setTagList(state, tags) {
+ // tags is an array of tag objects
+ state.tag_list = tags || [];
+ },
+ deleteFromTagList(state, tagId) {
+ if (state.tag_list === null) return;
+
+ const index = state.tag_list.map((t) => t.immutable_id).indexOf(tagId);
+ if (index > -1) {
+ state.tag_list.splice(index, 1);
+ } else {
+ console.warn(`deleteFromTagList couldn't find the tag with id ${tagId}`);
+ }
+ },
setGroupsList(state, groups) {
state.groups_list = groups;
},
diff --git a/webapp/src/views/Tags.vue b/webapp/src/views/Tags.vue
new file mode 100644
index 000000000..77ebe9a67
--- /dev/null
+++ b/webapp/src/views/Tags.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+