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; }