diff --git a/mwdb/web/src/commons/ui/ButtonDropdown.tsx b/mwdb/web/src/commons/ui/ButtonDropdown.tsx
new file mode 100644
index 000000000..69e1f0fd0
--- /dev/null
+++ b/mwdb/web/src/commons/ui/ButtonDropdown.tsx
@@ -0,0 +1,34 @@
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { IconProp } from "@fortawesome/fontawesome-svg-core";
+
+type Props = {
+ title?: string;
+ color?: string;
+ icon?: IconProp;
+ elements: JSX.Element[];
+};
+
+export function ButtonDropdown(props: Props) {
+ if (!props.elements.length) return
;
+ return (
+
+
+
+
+ );
+}
diff --git a/mwdb/web/src/components/RecentView/Actions/QueryResultAddTagAction.tsx b/mwdb/web/src/components/RecentView/Actions/QueryResultAddTagAction.tsx
new file mode 100644
index 000000000..04e7fd0d8
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/Actions/QueryResultAddTagAction.tsx
@@ -0,0 +1,55 @@
+import { useContext, useState } from "react";
+
+import { Capability, ObjectData } from "@mwdb-web/types/types";
+import { QueryResultContext } from "../common/QueryResultContext";
+import { APIContext } from "@mwdb-web/commons/api";
+import { ConfirmationModal } from "@mwdb-web/commons/ui";
+import { AuthContext } from "@mwdb-web/commons/auth";
+import { useViewAlert } from "@mwdb-web/commons/hooks";
+import { ResultOptionItem } from "../common/ResultOptionItem";
+
+export function AddTagAction() {
+ const api = useContext(APIContext);
+ const auth = useContext(AuthContext);
+ const { items } = useContext(QueryResultContext);
+
+ const { setAlert } = useViewAlert();
+
+ const [tag, setTag] = useState("");
+ const [modalOpen, setIsModalOpen] = useState(false);
+
+
+ function addTag() {
+ items.forEach(async (e: ObjectData) => {
+ await api.addObjectTag(e.id, tag)
+ .catch((err) => setAlert({
+ error: `Error adding tag to object ${e.id}: ${err}`
+ }));
+ });
+ setIsModalOpen(false);
+ }
+
+ return (
+ setIsModalOpen(true)}
+ authenticated={() => auth.hasCapability(Capability.addingTags)}
+ >
+ setIsModalOpen(false)}
+ onCancel={() => setIsModalOpen(false)}
+ onConfirm={addTag}
+ >
+ setTag(e.target.value)}
+ />
+
+
+ );
+}
diff --git a/mwdb/web/src/components/RecentView/Actions/QueryResultHashesAction.tsx b/mwdb/web/src/components/RecentView/Actions/QueryResultHashesAction.tsx
new file mode 100644
index 000000000..d888f8a0e
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/Actions/QueryResultHashesAction.tsx
@@ -0,0 +1,34 @@
+import { useContext, useEffect, useState } from "react";
+
+import { QueryResultContext } from "../common/QueryResultContext";
+import { ResultOptionItem } from "../common/ResultOptionItem";
+import { ObjectData } from "@mwdb-web/types/types";
+
+
+export function QueryResultHashesAction() {
+ const { items } = useContext(QueryResultContext);
+ const [url, setUrl] = useState("");
+
+ function generateName() {
+ return `hashes_${new Date().toJSON().slice(0, 19)}`;
+ }
+
+ async function generateUrl() {
+ const hashes = items.map((item: ObjectData) => item.sha256);
+ const data = new Blob([JSON.stringify(hashes, null, '\t')], { type: 'application/json' })
+ setUrl(window.URL.createObjectURL(data));
+ }
+
+ useEffect(() => {
+ generateUrl();
+ }, [])
+
+ return (
+
+ );
+}
diff --git a/mwdb/web/src/components/RecentView/Actions/QueryResultJsonAction.tsx b/mwdb/web/src/components/RecentView/Actions/QueryResultJsonAction.tsx
new file mode 100644
index 000000000..1f1734671
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/Actions/QueryResultJsonAction.tsx
@@ -0,0 +1,32 @@
+import { useContext, useEffect, useState } from "react";
+
+import { QueryResultContext } from "../common/QueryResultContext";
+import { ResultOptionItem } from "../common/ResultOptionItem";
+
+
+export function QueryResultJsonAction() {
+ const { items } = useContext(QueryResultContext);
+ const [url, setUrl] = useState("");
+
+ function generateName() {
+ return `file_data_${new Date().toJSON().slice(0, 19)}`;
+ }
+
+ async function generateUrl() {
+ const data = new Blob([JSON.stringify(items, null, '\t')], { type: 'application/json' })
+ setUrl(window.URL.createObjectURL(data));
+ }
+
+ useEffect(() => {
+ generateUrl();
+ }, [])
+
+ return (
+
+ );
+}
diff --git a/mwdb/web/src/components/RecentView/Actions/QueryResultKartonReanalyzeAction.tsx b/mwdb/web/src/components/RecentView/Actions/QueryResultKartonReanalyzeAction.tsx
new file mode 100644
index 000000000..0b6aed838
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/Actions/QueryResultKartonReanalyzeAction.tsx
@@ -0,0 +1,47 @@
+import { useContext, useState } from "react";
+
+import { Capability, ObjectData } from "@mwdb-web/types/types";
+import { QueryResultContext } from "../common/QueryResultContext";
+import { APIContext } from "@mwdb-web/commons/api";
+import { ConfirmationModal } from "@mwdb-web/commons/ui";
+import { AuthContext } from "@mwdb-web/commons/auth";
+import { useViewAlert } from "@mwdb-web/commons/hooks";
+import { ResultOptionItem } from "../common/ResultOptionItem";
+
+export function KartonReanalyzeAction() {
+ const api = useContext(APIContext);
+ const auth = useContext(AuthContext);
+ const { items } = useContext(QueryResultContext);
+
+ const { setAlert } = useViewAlert();
+
+ const [modalOpen, setIsModalOpen] = useState(false);
+
+
+ function kartonReanalyze() {
+ items.forEach(async (e: ObjectData) => {
+ await api.resubmitKartonAnalysis(e.id)
+ .catch((err) => setAlert({
+ error: `Error submitting reanalysis for object ${e.id}: ${err}`
+ }));
+ });
+ setIsModalOpen(false);
+ }
+
+ return (
+ setIsModalOpen(true)}
+ authenticated={() => auth.hasCapability(Capability.kartonReanalyze)}
+ >
+ setIsModalOpen(false)}
+ onCancel={() => setIsModalOpen(false)}
+ onConfirm={kartonReanalyze}
+ />
+
+);
+}
diff --git a/mwdb/web/src/components/RecentView/Actions/QueryResultRemoveTagAction.tsx b/mwdb/web/src/components/RecentView/Actions/QueryResultRemoveTagAction.tsx
new file mode 100644
index 000000000..d2d0e9002
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/Actions/QueryResultRemoveTagAction.tsx
@@ -0,0 +1,55 @@
+import { useContext, useState } from "react";
+
+import { Capability, ObjectData } from "@mwdb-web/types/types";
+import { QueryResultContext } from "../common/QueryResultContext";
+import { APIContext } from "@mwdb-web/commons/api";
+import { ConfirmationModal } from "@mwdb-web/commons/ui";
+import { AuthContext } from "@mwdb-web/commons/auth";
+import { useViewAlert } from "@mwdb-web/commons/hooks";
+import { ResultOptionItem } from "../common/ResultOptionItem";
+
+export function RemoveTagAction() {
+ const api = useContext(APIContext);
+ const auth = useContext(AuthContext);
+ const { items } = useContext(QueryResultContext);
+
+ const { setAlert } = useViewAlert();
+
+ const [tag, setTag] = useState("");
+ const [modalOpen, setIsModalOpen] = useState(false);
+
+
+ function addTag() {
+ items.forEach(async (e: ObjectData) => {
+ await api.removeObjectTag(e.id, tag)
+ .catch((err) => setAlert({
+ error: `Error removing tag from object ${e.id}: ${err}`
+ }));
+ });
+ setIsModalOpen(false);
+ }
+
+ return (
+ setIsModalOpen(true)}
+ authenticated={() => auth.hasCapability(Capability.addingTags)}
+ >
+ setIsModalOpen(false)}
+ onCancel={() => setIsModalOpen(false)}
+ onConfirm={addTag}
+ >
+ setTag(e.target.value)}
+ />
+
+
+);
+}
diff --git a/mwdb/web/src/components/RecentView/Views/QueryResultOptions.tsx b/mwdb/web/src/components/RecentView/Views/QueryResultOptions.tsx
new file mode 100644
index 000000000..7c6b76607
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/Views/QueryResultOptions.tsx
@@ -0,0 +1,38 @@
+import { faExclamationCircle, faMagnifyingGlass, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
+import { ButtonDropdown } from "@mwdb-web/commons/ui/ButtonDropdown";
+import { ObjectType } from "@mwdb-web/types/types";
+import { QueryResultHashesAction } from "../Actions/QueryResultHashesAction";
+import { useContext } from "react";
+import { QueryResultContext } from "../common/QueryResultContext";
+import { QueryResultJsonAction } from "../Actions/QueryResultJsonAction";
+import { AddTagAction } from "../Actions/QueryResultAddTagAction";
+import { RemoveTagAction } from "../Actions/QueryResultRemoveTagAction";
+import { KartonReanalyzeAction } from "../Actions/QueryResultKartonReanalyzeAction";
+
+type Props = {
+ type: ObjectType,
+ query: string,
+ elements?: JSX.Element[],
+};
+
+export function QueryResultOptions(props: Props) {
+ const { items } = useContext(QueryResultContext);
+ return (
+
+ {props.query && items && items.length > 0 &&
+
+
,
+
,
+
,
+
,
+
,
+ ]}
+ />
+
}
+
+ );
+}
\ No newline at end of file
diff --git a/mwdb/web/src/components/RecentView/Views/RecentView.tsx b/mwdb/web/src/components/RecentView/Views/RecentView.tsx
index 2be9609d2..bc3a47caf 100644
--- a/mwdb/web/src/components/RecentView/Views/RecentView.tsx
+++ b/mwdb/web/src/components/RecentView/Views/RecentView.tsx
@@ -10,6 +10,9 @@ import { QuickQuery } from "../common/QuickQuery";
import { ObjectType } from "@mwdb-web/types/types";
import { AxiosError } from "axios";
import { isEmpty } from "lodash";
+import { Extendable } from "@mwdb-web/commons/plugins";
+import { QueryResultOptions } from "./QueryResultOptions";
+import { QueryResultContextProvider } from "../common/QueryResultContext";
type Props = {
type: ObjectType;
@@ -218,8 +221,8 @@ export function RecentView(props: Props) {
className="btn-group"
data-toggle="tooltip"
title={`Turn ${
- countingEnabled ? "off" : "on"
- } results counting`}
+ countingEnabled ? "off" : "on"
+ } results counting`}
>
-
+
+
+
+
+
+
+
+
);
diff --git a/mwdb/web/src/components/RecentView/Views/RecentViewList.tsx b/mwdb/web/src/components/RecentView/Views/RecentViewList.tsx
index be1ed0279..b77fe1e8c 100644
--- a/mwdb/web/src/components/RecentView/Views/RecentViewList.tsx
+++ b/mwdb/web/src/components/RecentView/Views/RecentViewList.tsx
@@ -10,6 +10,7 @@ import {
ObjectType,
} from "@mwdb-web/types/types";
import { AxiosError } from "axios";
+import { QueryResultContext } from "../common/QueryResultContext";
type Elements = ObjectData[] | BlobData[] | ConfigData[];
@@ -83,6 +84,7 @@ type Props = {
export function RecentViewList(props: Props) {
const api = useContext(APIContext);
+ const { setItems } = useContext(QueryResultContext);
const [listState, listDispatch] = useReducer(listStateReducer, {
pageToLoad: 0,
loadedPages: 0,
@@ -103,6 +105,10 @@ export function RecentViewList(props: Props) {
}
}, [props.query, props.disallowEmpty, api.remote]);
+ useEffect(() => {
+ setItems(listState.elements);
+ }, [listState.elements]);
+
// Load page on request (pageToLoad != loadedPages)
useEffect(() => {
let cancelled = false;
diff --git a/mwdb/web/src/components/RecentView/common/QueryResultContext.tsx b/mwdb/web/src/components/RecentView/common/QueryResultContext.tsx
new file mode 100644
index 000000000..3c408de79
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/common/QueryResultContext.tsx
@@ -0,0 +1,18 @@
+import { BlobData, ConfigData, ObjectData } from "@mwdb-web/types/types";
+import { ReactNode, createContext, useMemo, useState } from "react";
+
+interface Props {
+ children?: ReactNode;
+}
+type Objects = ObjectData[] | ConfigData[] | BlobData[];
+
+export const QueryResultContext = createContext(null);
+export function QueryResultContextProvider({ children }: Props) {
+ const [items, setItems] = useState(null);
+
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/mwdb/web/src/components/RecentView/common/ResultOptionItem.tsx b/mwdb/web/src/components/RecentView/common/ResultOptionItem.tsx
new file mode 100644
index 000000000..320abd96d
--- /dev/null
+++ b/mwdb/web/src/components/RecentView/common/ResultOptionItem.tsx
@@ -0,0 +1,51 @@
+import { ReactNode, useMemo } from "react";
+
+type Props = {
+ key: string;
+ url?: string;
+ size?: string;
+ title: string;
+ action?: () => void;
+ children?: ReactNode;
+ limit?: () => boolean;
+ authenticated?: () => boolean;
+ download?: string | (() => string);
+};
+
+function resolveDownload(download: string | (() => string)): string {
+ if (typeof download === "string") {
+ return download;
+ }
+ return download();
+}
+
+export function ResultOptionItem({ children, ...props }: Props) {
+ const isLimit = useMemo(() => {
+ if (props.limit) return props.limit();
+ return false;
+ }, [props.limit])
+
+ const isAuthenticated = useMemo(() => {
+ if (props.authenticated) return props.authenticated();
+ return true;
+ }, [props.authenticated])
+
+ return (
+
+ {!isLimit && isAuthenticated ? (
+ <>
+
+ {props.title}
+
+ {children}
+ > ) : []
+ }
+
+ );
+}
diff --git a/mwdb/web/src/styles/index.css b/mwdb/web/src/styles/index.css
index 1bfa7dd72..698e1ce28 100644
--- a/mwdb/web/src/styles/index.css
+++ b/mwdb/web/src/styles/index.css
@@ -151,6 +151,20 @@ div.quick-query-bar {
margin-bottom: 6pt;
}
+div.query-options {
+ column-gap: 10px;
+ display: inline-flex;
+}
+
+ul.button-menu {
+ min-width: 0;
+ width: 100%;
+}
+
+ul.button-menu li {
+ text-align: center;
+}
+
.sidenav .nav-link {
border-left: #eee solid;
}