Skip to content

[Breaking] Fix generic result view tag types #104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
"tailwindcss-animate": "^1.0.7",
"tailwindcss-scoped-preflight": "^3.4.12",
"ts-loader": "^9.5.2",
"tsc-alias": "^1.8.10",
"tsc-alias": "1.8.13",
"typescript": "^5.7.3",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.9",
Expand Down
55 changes: 23 additions & 32 deletions src/components/result/GenericResultViewTag.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MouseEvent, ReactNode, useCallback, useEffect, useMemo, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Badge } from "@/components/ui/badge"
import { SearchResult } from "@elastic/search-ui"
import { SearchResult, FieldValue } from "@elastic/search-ui"
import { useCopyToClipboard } from "usehooks-ts"
import { CheckIcon } from "lucide-react"
import { autoUnwrap } from "@/lib/utils"
Expand All @@ -11,6 +11,10 @@ export interface GenericResultViewTagProps {
* The elasticsearch field that this tag will display
*/
field: string
/**
* When specified, does not read the field from elastic and instead just displays this value
*/
valueOverride?: string | number | boolean
result: SearchResult
/**
* Icon for this tag, can be any react component. Ideally a [lucide icon](https://lucide.dev) with 16px by 16px site.
Expand All @@ -25,27 +29,20 @@ export interface GenericResultViewTagProps {
* Can't the used together with `singleValueMapper`
* @param value
*/
valueMapper?: (value: string | string[]) => ReactNode
valueMapper?: (value: FieldValue) => ReactNode
/**
* Optional, here you can map each value of the elasticsearch field to a string or a React component. Can't be used
* together with `valueMapper`
* @param value
*/
singleValueMapper?: (value: string) => ReactNode
onClick?: (e: MouseEvent<HTMLDivElement>, tagValue: ReactNode, fieldValue: string | string[]) => void
singleValueMapper?: (value: string | number | boolean) => ReactNode
onClick?: (e: MouseEvent<HTMLDivElement>, tagValue: ReactNode, fieldValue: FieldValue) => void
clickBehavior?: "copy-text" | "follow-url" | string
}

export function GenericResultViewTag({
field,
result,
icon,
label,
valueMapper,
singleValueMapper,
clickBehavior = "copy-text",
onClick
}: GenericResultViewTagProps) {
export function GenericResultViewTag(props: GenericResultViewTagProps) {
const { field, valueOverride, result, icon, label, valueMapper, singleValueMapper, clickBehavior = "copy-text", onClick } = props

const [showCopiedNotice, setShowCopiedNotice] = useState(false)

useEffect(() => {
Expand All @@ -57,11 +54,12 @@ export function GenericResultViewTag({
}, [showCopiedNotice])

const fieldValue = useMemo(() => {
return autoUnwrap(result[field]) as string | string[]
}, [field, result])
if (valueOverride !== undefined) return valueOverride
return autoUnwrap(result[field]) as FieldValue
}, [field, result, valueOverride])

const value = useMemo(() => {
if (!fieldValue) return undefined
if (fieldValue === null || fieldValue === undefined) return undefined
if (valueMapper) return valueMapper(fieldValue)
if (singleValueMapper) return Array.isArray(fieldValue) ? fieldValue.map(singleValueMapper) : singleValueMapper(fieldValue)
else return fieldValue
Expand All @@ -79,13 +77,13 @@ export function GenericResultViewTag({
)

const handleClick = useCallback(
(fieldValue: string | string[], value: ReactNode, e: MouseEvent<HTMLDivElement>) => {
(fieldValue: FieldValue, value: ReactNode, e: MouseEvent<HTMLDivElement>) => {
if (onClick) onClick(e, value, fieldValue)
if (clickBehavior === "copy-text" && !showCopiedNotice) {
copyTagValue(e)
setShowCopiedNotice(true)
} else if (clickBehavior === "follow-url" && !Array.isArray(fieldValue)) {
window.open(fieldValue, "_blank")
window.open(fieldValue.toString(), "_blank")
}
},
[clickBehavior, copyTagValue, onClick, showCopiedNotice]
Expand All @@ -101,7 +99,7 @@ export function GenericResultViewTag({
}, [clickBehavior, onClick])

const base = useCallback(
(fieldValue: string | string[], value: ReactNode, key?: string) => {
(fieldValue: FieldValue, value: ReactNode, key?: string) => {
return (
<Badge key={key} variant="secondary" className="rfs-truncate" onClick={(e) => handleClick(fieldValue, value, e)}>
<span className="rfs-flex rfs-truncate">
Expand All @@ -121,19 +119,12 @@ export function GenericResultViewTag({
[handleClick, icon, showCopiedNotice]
)

if (!label) return Array.isArray(value) ? value.map((v, i) => base(fieldValue[value.indexOf(v)], v, field + i)) : base(fieldValue, value)
if (!value) return null
if (value === undefined) return null

if (Array.isArray(value)) {
return value.map((entry, i) => (
<Tooltip delayDuration={500} key={field + i}>
<TooltipTrigger>{base(fieldValue[value.indexOf(entry)], entry)}</TooltipTrigger>
<TooltipContent>
<div>{label}</div>
<div className="rfs-text-xs rfs-text-muted-foreground">{clickBehaviourText}</div>
</TooltipContent>
</Tooltip>
))
if (Array.isArray(value) && Array.isArray(fieldValue)) {
return value.map((entry, i) => <GenericResultViewTag {...props} key={field + i} valueOverride={fieldValue[value.indexOf(entry)]} />)
} else if (!label) {
return base(fieldValue, value)
}

return (
Expand Down
4 changes: 2 additions & 2 deletions src/lib/config/SearchConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FilterType, FilterValueValue, RequestState, SearchFieldConfiguration, SortOption } from "@elastic/search-ui"
import { FieldValue, FilterType, RequestState, SearchFieldConfiguration, SortOption } from "@elastic/search-ui"
import { ReactNode } from "react"

export interface CoreFacetConfig {
Expand Down Expand Up @@ -32,7 +32,7 @@ export interface CoreFacetConfig {
*
* @param value
*/
singleValueMapper?: (value: FilterValueValue) => ReactNode
singleValueMapper?: (value: FieldValue) => ReactNode

/**
* Not properly implemented at the moment. Use with caution.
Expand Down
2 changes: 1 addition & 1 deletion src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function prettyPrintURL(url: string) {
}

export function autoUnwrap<E>(item?: E | { raw?: E }) {
if (!item) {
if (item === undefined || item === null) {
return undefined
} else if (typeof item === "object" && "raw" in item) {
return item.raw
Expand Down
6 changes: 3 additions & 3 deletions src/stories/Others/GenericResultView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,14 @@ export const Full: Story = {
{
icon: <GlobeIcon className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
field: "hadPrimarySource",
singleValueMapper: prettyPrintURL,
singleValueMapper: (v) => prettyPrintURL(v + ""),
label: "Primary Source",
clickBehavior: "follow-url"
},
{
icon: <ScaleIcon className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
field: "licenseURL",
singleValueMapper: prettyPrintURL,
singleValueMapper: (v) => prettyPrintURL(v + ""),
label: "License URL",
clickBehavior: "follow-url"
},
Expand Down Expand Up @@ -161,7 +161,7 @@ export const Full: Story = {
},
{
field: "stringArrayTest",
singleValueMapper: (v) => v.toUpperCase()
singleValueMapper: (v) => (v + "").toUpperCase()
}
]
}
Expand Down
12 changes: 6 additions & 6 deletions src/stories/ReactSearchComponent.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const GenericResultRenderer: Story = {
icon: <UserIcon className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
label: "Contact",
field: "contact",
singleValueMapper: (v) => <OrcidDisplay orcid={v} />,
singleValueMapper: (v) => <OrcidDisplay orcid={v + ""} />,
clickBehavior: "follow-url"
},
{
Expand All @@ -170,7 +170,7 @@ export const GenericResultRenderer: Story = {
{
icon: <GlobeIcon className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
field: "hadPrimarySource",
singleValueMapper: (v) => mapPrimarySource(v),
singleValueMapper: (v) => mapPrimarySource(v + ""),
label: "Source",
onClick: (e) =>
"innerText" in e.target &&
Expand All @@ -180,7 +180,7 @@ export const GenericResultRenderer: Story = {
{
icon: <ScaleIcon className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
field: "licenseURL",
singleValueMapper: (v) => <PidNameDisplay pid={v} />,
singleValueMapper: (v) => <PidNameDisplay pid={v + ""} />,
label: "License URL",
clickBehavior: "follow-url"
},
Expand All @@ -194,13 +194,13 @@ export const GenericResultRenderer: Story = {
icon: <Microscope className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
label: "NMR Method",
field: "NMR_Method",
singleValueMapper: (v) => <PidNameDisplay pid={v} />
singleValueMapper: (v) => <PidNameDisplay pid={v + ""} />
},
{
icon: <FlaskConical className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
label: "NMR Solvent",
field: "NMR_Solvent",
singleValueMapper: (v) => <PidNameDisplay pid={v} />
singleValueMapper: (v) => <PidNameDisplay pid={v + ""} />
},
{
icon: <AudioLines className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
Expand All @@ -211,7 +211,7 @@ export const GenericResultRenderer: Story = {
icon: <CircleDot className="rfs-shrink-0 rfs-size-4 rfs-mr-2" />,
label: "Acquisition Nucleus",
field: "Acquisition_Nucleus",
singleValueMapper: (v) => <PidNameDisplay pid={v} />
singleValueMapper: (v) => <PidNameDisplay pid={v + ""} />
}
]}
titleField="name"
Expand Down