diff --git a/package.json b/package.json index 9f1a7b0..36d4628 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "deploy": "gh-pages -d example/build" }, "peerDependencies": { - "react": ">=16.8.0", - "antd": ">=4.0.0" + "antd": ">=4.0.0", + "yaml": "^2.1.1" }, "devDependencies": { "@babel/preset-react": "^7.18.6", @@ -65,6 +65,7 @@ "number-to-words": "^1.2.4", "rc-queue-anim": "^2.0.0", "rc-texty": "^0.2.0", - "react-sizeme": "^3.0.2" + "react-sizeme": "^3.0.2", + "zod": "^3.22.2" } } diff --git a/src/cart-list/cart-list.js b/src/cart-list/cart-list.js index 72ab816..4e79838 100644 --- a/src/cart-list/cart-list.js +++ b/src/cart-list/cart-list.js @@ -4,7 +4,7 @@ import { Divider, Empty, Tabs, Select, Checkbox, Button, Dropdown, Menu, Tooltip, Anchor, Layout } from 'antd' -import { ShoppingCartOutlined, DeleteOutlined, FolderAddOutlined, CopyOutlined, CaretDownOutlined, PlusOutlined } from '@ant-design/icons' +import { ShoppingCartOutlined, DeleteOutlined, FolderAddOutlined, CopyOutlined, CaretDownOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons' import QueueAnim from 'rc-queue-anim' import Texty from 'rc-texty' import { SizeMe } from 'react-sizeme' @@ -626,7 +626,7 @@ export const CartListLayout = ({ onCheckout=() => {}, cartListProps={} }) => { - const { buckets, carts, activeCart, setActiveCart, updateCart, openCreateCartModal, openManageCartModal } = useShoppingCart() + const { buckets, carts, activeCart, setActiveCart, updateCart, openCreateCartModal, openImportCartModal, openManageCartModal } = useShoppingCart() return ( @@ -646,11 +646,19 @@ export const CartListLayout = ({ name: "Create new cart", label: "Create new cart", icon: + }, + { + key: "import-cart", + name: "Import cart", + label: "Import cart", + icon: } ]} onSelect={ ({ key }) => { if (key === "create-cart") { openCreateCartModal() + } else if (key === "import-cart") { + openImportCartModal() } else setActiveCart(key) } } /> diff --git a/src/dug-search-types.ts b/src/dug-search-types.ts new file mode 100644 index 0000000..4669954 --- /dev/null +++ b/src/dug-search-types.ts @@ -0,0 +1,71 @@ +/* eslint-disable prettier/prettier */ + +type APIError = { + detail: Array<{ + loc: [string, number], + msg: string, + type: string, + }> +} + +export type ConceptsResponseSuccess = { + message: string, + result: { + docs: Array<{ + _source: { + id: string, + name: string, + description: string, + type: string, + search_terms: string[], + optional_terms: string[], + concept_action: string, + identifiers: Array<{ + id: string, + label: string, + equivalent_identifiers: string[], + type: string, + synonyms: string[], + }>, + }, + }>, + }, + status: string, +} + +export type StudiesResponseSuccess = { + message: string, + result: Array<{ + c_id: string, + c_link: string, + c_name: string, + }>, + status: string, +} + +export type VariablesResponseSuccess = { + message: string, + result: { + docs: Array<{ + _source: { + element_id: string, + element_name: string, + element_desc: string, + search_terms: string[], + optional_terms: any[], + collection_id: string, + collection_name: string, + collection_desc: string, + element_action: string, + collection_action: string, + data_type: string, + identifiers: string[], + }, + }>, + }, + status: string, +} + +export type ConceptsResponse = ConceptsResponseSuccess | APIError; +export type StudiesResponse = StudiesResponseSuccess | APIError; +export type VariablesResponse = VariablesResponseSuccess | APIError; \ No newline at end of file diff --git a/src/modals/cart-modal.css b/src/modals/cart-modal.css new file mode 100644 index 0000000..e9de5fb --- /dev/null +++ b/src/modals/cart-modal.css @@ -0,0 +1,19 @@ +.import-modal-error-collapse .ant-collapse-header{ + padding-left: 0px !important; + padding-right: 0px !important; + padding-bottom: 0px !important; +} + +.import-modal-error-collapse .ant-collapse-content-box { + padding-left: 0px !important; + padding-right: 0px !important; + padding-bottom: 0px !important; +} + +.import-modal-error-collapse .ant-collapse-content-box pre { + margin: 0px; +} + +.import-modal-error-alert .ant-alert-close-icon { + display: none; +} \ No newline at end of file diff --git a/src/modals/create-cart-modal.js b/src/modals/create-cart-modal.js index 590bf12..882d121 100644 --- a/src/modals/create-cart-modal.js +++ b/src/modals/create-cart-modal.js @@ -64,7 +64,8 @@ export const CreateCartModalContent = ({ createShoppingCart, cartName, setCartNa - Add items to a cart to... + The Shopping Cart is a feature to allow users to export a file of Dug + search results to take outside of Dug for further analyses. ) diff --git a/src/modals/import-cart-modal.js b/src/modals/import-cart-modal.js new file mode 100644 index 0000000..f67a464 --- /dev/null +++ b/src/modals/import-cart-modal.js @@ -0,0 +1,361 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { + Modal, + Space, + Form, + Input, + Typography, + Alert, + Collapse, + message +} from 'antd' +import { StarOutlined, StarFilled, UploadOutlined } from '@ant-design/icons' +import Dragger from 'antd/lib/upload/Dragger' +import { z } from 'zod' +import { parse as yamlParse } from 'yaml' + +import './cart-modal.css' + +const { Text, Paragraph } = Typography + +/** + * Zod validator to check that the cart uploaded by the user conforms to the + * internal data structure + */ +const cartSchema = z.object({ + concept_id: z.string().array(), + study_id: z.string().array(), + variable_id: z.string().array(), + cde_id: z.string().array() +}) + +export const ImportCartModalContent = ({ + createShoppingCart, + cartName, + setCartName, + cartNameError, + favorited, + setFavorited, + setFileContents, + error, + setError +}) => { + const inputRef = useRef() + + const StarIcon = favorited ? StarFilled : StarOutlined + + useEffect(() => { + // Autofocus input + inputRef.current.focus({ + cursor: 'end' + }) + }, []) + + // Workaround for antd bug where Tooltip-based elements will close when interacting with a modal. + // Only occurs when the modal is not a child of the active Tooltip-based component. + useEffect(() => { + // This component is a child of the modal, so the modal wrapper is guarenteed to exist on mount. + const modalWrapper = document.querySelector('.cart-creation-modal-wrapper') + const modalMask = modalWrapper.parentNode + const modalRoot = modalMask.parentNode + const modalDOMRoot = modalRoot.parentNode + + const stopPropagation = (e) => { + e.stopPropagation() + } + modalDOMRoot.addEventListener('mousedown', stopPropagation) + + return () => { + modalDOMRoot.removeEventListener('mousedown', stopPropagation) + } + }, []) + + const [fileList, setFileList] = useState([]) + + useEffect(() => { + setFileContents({}) + ;(async () => { + if (fileList.length === 0) return + const [file] = fileList + + const fileText = await file.text() + + // all of the input file types need to become json anyway, so + // store in this variable + let translatedJson + + // this switch takes care of converting the various file formats into json + switch (file.type) { + case 'application/json': { + try { + translatedJson = JSON.parse(fileText) + } catch (e) { + setError({ + message: 'The file contains invalid JSON.', + raw: e + }) + return + } + setCartName(file.name.replace('.json', '')) + break + } + + case 'application/x-yaml': { + try { + translatedJson = yamlParse(fileText) + } catch (e) { + setError({ + message: 'The file contains invalid YAML.', + raw: e + }) + return + } + setCartName(file.name.replace('.yaml', '')) + break + } + + case 'text/csv': { + try { + const lines = fileText.split('\n') + if (lines.length === 0) { + throw new Error('CSV file is empty') + } + + // create a new object with the columns titles as keys and + // empty arrays (rows) to fill out + const columns = lines.shift().split(',') + translatedJson = columns.reduce( + (acc, col) => ({ + ...acc, + [col]: [] + }), + {} + ) + + // fill out the rows + for (const row of lines) { + const rowCells = row.split(',') + for (const [colIndex, cell] of Object.entries(rowCells)) { + if (cell !== '') translatedJson[columns[colIndex]].push(cell) + } + } + } catch (e) { + setError({ + message: 'There was an error parsing the CSV file.', + raw: e + }) + return + } + setCartName(file.name.replace('.csv', '')) + break + } + + default: { + setError({ + message: 'Uploaded an unsupported file type.', + raw: null + }) + return + } + } + + const validatedJson = cartSchema.safeParse(translatedJson) + if (validatedJson.success) { + const { data } = validatedJson + setFileContents(data) + setError({ + message: '', + raw: null + }) + } else { + setError({ + message: 'The file selected is not compatible with DUG.', + raw: validatedJson.error + }) + } + })() + }, [fileList]) + + return ( + + Import a Dug file saved locally on your computer. + { + setFileList([]) + setError({ + message: '', + raw: null + }) + }} + beforeUpload={(file) => { + setFileList([file]) + return false + }} + > + +

+ Click or drag file to this area to upload +

+

+ Supported file types: .json, .yaml,{' '} + .csv +

+
+ {error.message && ( + + +
{error.raw.toString()}
+
+ + } + type='error' + closable + /> + )} + + Name + +
+ setCartName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && createShoppingCart()} + ref={inputRef} + /> + setFavorited(!favorited)} + style={{ + fontSize: 16, + marginLeft: 16, + color: favorited ? '#1890ff' : undefined + }} + /> +
+
+
+
+ ) +} + +export const ImportCartModal = ({ + carts, + visible, + onVisibleChange, + onConfirm +}) => { + /** Form state */ + const [cartName, setCartName] = useState('') + const [cartNameError, setCartNameError] = useState(false) + const [favorited, setFavorited] = useState(false) + const [fileContents, setFileContents] = useState({}) + const [error, setError] = useState({ + message: '', + raw: null + }) + + useEffect(() => { + setCartName('') + setFavorited(false) + if (visible) { + const highestExistingDefault = carts + .map((cart) => /Shopping Cart (?\d+)/.exec(cart.name)?.groups.num) + .filter((match) => match !== undefined) + .sort((a, b) => b - a)[0] + const defaultName = `Shopping Cart ${ + highestExistingDefault !== undefined + ? parseInt(highestExistingDefault) + 1 + : 1 + }` + setCartName(defaultName) + } + }, [visible]) + + useEffect(() => setCartNameError(false), [cartName]) + + const onImport = useCallback((invalidIds) => { + if (invalidIds.length > 0) { + message.open({ + type: 'error', + duration: 10, + content: ( + <> + {invalidIds.length === 1 + ? 'An id was unable to be imported:' + : 'Several ids were unable to be imported:'} +
    + {invalidIds.map((id) => ( +
  • {id}
  • + ))} +
+ + ) + }) + } + }, []) + + const createShoppingCart = useCallback(() => { + if (carts.find((cart) => cart.name === cartName)) { + setCartNameError(true) + } else { + onConfirm(cartName, fileContents, onImport, favorited) + } + }, [carts, cartName, onConfirm, fileContents, favorited]) + + const isOkButtonDisabled = + error.message !== '' || Object.entries(fileContents).length === 0 + + return ( + onVisibleChange(false)} + zIndex={1032} + maskStyle={{ zIndex: 1031 }} + wrapClassName='cart-creation-modal-wrapper' + > + + + ) +} diff --git a/src/modals/index.js b/src/modals/index.js index 81a4087..9996601 100644 --- a/src/modals/index.js +++ b/src/modals/index.js @@ -1,2 +1,3 @@ export * from './create-cart-modal' +export * from './import-cart-modal' export * from './manage-cart-modal' \ No newline at end of file diff --git a/src/shopping-cart-context.ts b/src/shopping-cart-context.ts index f6aab2f..5cbbb33 100644 --- a/src/shopping-cart-context.ts +++ b/src/shopping-cart-context.ts @@ -2,9 +2,10 @@ import React, { createContext, createElement, Fragment, ReactNode, useCallback, import { message, notification } from 'antd' import { PlusOutlined, MinusOutlined } from '@ant-design/icons' import { toWords } from 'number-to-words' -import { CreateCartModal, ManageCartModal } from './modals' +import { CreateCartModal, ManageCartModal, ImportCartModal } from './modals' import { useLocalStorage } from './hooks/use-local-storage' import getSymbolFromCurrency from 'currency-symbol-map' +import type { ConceptsResponse, ConceptsResponseSuccess, StudiesResponse, StudiesResponseSuccess, VariablesResponse, VariablesResponseSuccess } from './dug-search-types' type ID = number | string @@ -14,6 +15,13 @@ type Total = { total: number | null } +interface CartImport { + concept_id?: string[]; + study_id?: string[]; + variable_id?: string[]; + cde_id?: string[]; +} + interface From { type: string, value: any @@ -42,7 +50,7 @@ interface Cart { name: string, canDelete: boolean, favorited: boolean, - items: Item[] + items: Item[], modifiedTime: number, createdTime: number, } @@ -103,6 +111,7 @@ interface IShoppingCartContext { currencyCode: string, currencySymbol: string, openCreateCartModal: () => void, closeCreateCartModal: () => void, + openImportCartModal: () => void, closeImportCartModal: () => void, openManageCartModal: (cartName: string | Cart) => void, closeManageCartModal: () => void } @@ -114,6 +123,7 @@ interface ShoppingCartProviderProps { defaultCartName?: string, localStorageKey?: string, currency?: string, + helxSearchUrl: string, children: ReactNode } export const ShoppingCartProvider = ({ @@ -121,6 +131,7 @@ export const ShoppingCartProvider = ({ defaultCartName="My cart", localStorageKey="shopping_carts", currency="USD", + helxSearchUrl, children }: ShoppingCartProviderProps) => { const [carts, setCarts] = useLocalStorage(localStorageKey, [ createCart({ @@ -128,6 +139,7 @@ export const ShoppingCartProvider = ({ canDelete: false }) ]) const [showCreateCartModal, setShowCreateCartModal] = useState(false) + const [showImportCartModal, setShowImportCartModal] = useState(false) const [showManageCartModal, setShowManageCartModal] = useState(null) const [activeCartName, setActiveCartName] = useState(defaultCartName) const activeCart = useMemo(() => carts.find((cart) => cart.name === activeCartName), [carts, activeCartName]) @@ -200,6 +212,159 @@ export const ShoppingCartProvider = ({ ]) }, [carts, getCart]) + const importCart = useCallback(async ( + name: string, + itemIds: CartImport, + onImport?: (invalidIds: string[]) => void, + favorited: boolean = false, + ) => { + if (getCart(name)) throw new Error("Cannot create a new cart with duplicate `name` key.") + + const fetchConcepts = (): Promise => + fetch(`${helxSearchUrl}/concepts?ids=${itemIds.concept_id.join('&ids=')}`) + .then(res => res.json() as Promise) + .then(json => ("detail" in json ? null : json)) + + const fetchStudies = (): Promise => + fetch(`${helxSearchUrl}/studies?ids=${itemIds.study_id.join('&ids=')}`) + .then(res => res.json() as Promise) + .then(json => ("detail" in json ? null : json)) + + const fetchVariables = (): Promise => + fetch(`${helxSearchUrl}/variables?ids=${itemIds.variable_id.join('&ids=')}`) + .then(res => res.json() as Promise) + .then(json => ("detail" in json ? null : json)) + + const fetchCdes = (): Promise => + fetch(`${helxSearchUrl}/variables?ids=${itemIds.cde_id.join('&ids=')}`) + .then(res => res.json() as Promise) + .then(json => ("detail" in json ? null : json)) + + const [concepts, studies, variables, cdes]: [ + ConceptsResponseSuccess | null, + StudiesResponseSuccess | null, + VariablesResponseSuccess | null, + VariablesResponseSuccess | null, + ] = await Promise.allSettled([ + Array.isArray(itemIds.concept_id) && itemIds.concept_id.length > 0 + ? fetchConcepts() + : null, + Array.isArray(itemIds.study_id) && itemIds.study_id.length > 0 + ? fetchStudies() + : null, + Array.isArray(itemIds.variable_id) && itemIds.variable_id.length > 0 + ? fetchVariables() + : null, + Array.isArray(itemIds.cde_id) && itemIds.cde_id.length > 0 + ? fetchCdes() + : null, + ]).then(([conceptRes, studyRes, varRes, cdeRes]) => [ + conceptRes.status === 'fulfilled' ? conceptRes.value : null, + studyRes.status === 'fulfilled' ? studyRes.value : null, + varRes.status === 'fulfilled' ? varRes.value : null, + cdeRes.status === 'fulfilled' ? cdeRes.value : null, + ]); + + const items: Item[] = []; + + if (concepts !== null) { + concepts.result.docs.map(({ _source }) => _source).forEach(concept => { + items.push({ + createdTime: Date.now(), + name: `${concept.name} (${concept.type})`, + id: concept.id, + description: concept.description, + price: null, + tax: null, + quantity: null, + from: { + type: "cart-import", + value: "cart-import" + }, + bucketId: "concepts", + item: concept, + }) + }) + } + + if (studies !== null) { + studies.result.forEach(study => { + items.push({ + createdTime: Date.now(), + name: study.c_name, + id: study.c_id, + description: study.c_link, + price: null, + tax: null, + quantity: null, + from: { + type: "cart-import", + value: "cart-import" + }, + bucketId: "studies", + item: study, + }) + }) + } + + if (variables !== null) { + variables.result.docs.map(({ _source }) => _source).forEach(variable => { + items.push({ + createdTime: Date.now(), + name: variable.element_name, + id: variable.element_id, + description: variable.element_desc, + price: null, + tax: null, + quantity: null, + from: { + type: "cart-import", + value: "cart-import" + }, + bucketId: "variables", + item: variable, + }) + }) + } + + if (cdes !== null) { + cdes.result.docs.map(({ _source }) => _source).forEach(cde => { + items.push({ + createdTime: Date.now(), + name: cde.element_name, + id: cde.element_id, + description: cde.element_desc, + price: null, + tax: null, + quantity: null, + from: { + type: "cart-import", + value: "cart-import" + }, + bucketId: "cdes", + item: cde, + }) + }) + } + + setCarts(prevCarts => [ + ...prevCarts, + createCart({ + name, + favorited, + items, + }) + ]) + + if (typeof onImport === 'function') { + const userIds = [ ...itemIds.concept_id, ...itemIds.study_id, ...itemIds.variable_id, ...itemIds.cde_id]; + const fetchedIds = items.map(({ id }) => id); + + const userIdsThatWereNotFetched = userIds.filter((id) => !fetchedIds.includes(id)) + onImport(userIdsThatWereNotFetched); + } + }, [getCart, setCarts, createCart]) + /** The `from` field will be appended to shopping cart elements to track where they originate from in the DUG UI. * * Structure is { type: string, value: any } where `value` depends on `type`. @@ -375,6 +540,12 @@ export const ShoppingCartProvider = ({ const closeCreateCartModal = useCallback(() => { setShowCreateCartModal(false) }, []) + const openImportCartModal = useCallback(() => { + setShowImportCartModal(true) + }, []) + const closeImportCartModal = useCallback(() => { + setShowImportCartModal(false) + }, []) const openManageCartModal = useCallback((cartName: string | Cart) => { const cart = getCart(cartName) setShowManageCartModal(cart.name) @@ -446,6 +617,7 @@ export const ShoppingCartProvider = ({ currencyCode, currencySymbol, openCreateCartModal, closeCreateCartModal, + openImportCartModal, closeImportCartModal, openManageCartModal, closeManageCartModal } }, @@ -470,6 +642,18 @@ export const ShoppingCartProvider = ({ onVisibleChange: setShowCreateCartModal } ), + createElement( + ImportCartModal, + { + carts, + onConfirm: (cartName: string, itemIds: CartImport, onImport: (invalidIds: string[]) => void, favorited: boolean) => { + importCart(cartName, itemIds, onImport, favorited) + setShowImportCartModal(false) + }, + visible: showImportCartModal, + onVisibleChange: setShowImportCartModal + } + ), createElement( ManageCartModal, { diff --git a/src/shopping-cart-popover/shopping-cart-popover.css b/src/shopping-cart-popover/shopping-cart-popover.css index 765fb62..7ce88c3 100644 --- a/src/shopping-cart-popover/shopping-cart-popover.css +++ b/src/shopping-cart-popover/shopping-cart-popover.css @@ -1,5 +1,6 @@ .shopping-cart-popover { width: 325px; + position: fixed !important; } .shopping-cart-popover .ant-popover-inner-content { padding-top: 0;