diff --git a/data/schema.graphql b/data/schema.graphql index 7b4b3123ab2..5ba56c9889d 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -641,6 +641,11 @@ type Alert { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -684,6 +689,11 @@ type Alert { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -2610,6 +2620,11 @@ type Artist implements EntityWithFilterArtworksConnectionInterface & Node & Sear Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -2653,6 +2668,11 @@ type Artist implements EntityWithFilterArtworksConnectionInterface & Node & Sear """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -3265,6 +3285,11 @@ type ArtistPartnerEdge { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -3308,6 +3333,11 @@ type ArtistPartnerEdge { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -3517,6 +3547,11 @@ type ArtistSeries implements Node { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -3560,6 +3595,11 @@ type ArtistSeries implements Node { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -3785,6 +3825,105 @@ type ArtistsWithAlertCountsEdge { totalAlertCount: Int } +""" +Artnet record of an artwork. +""" +type ArtnetArtwork { + artnetEditionSets: [ArtnetEditionSet] + artnetId: String + + """ + Artnet availability vocabulary, e.g. For Sale or Price on Request. + """ + availability: String + createdAt( + format: String + + """ + A tz database time zone, otherwise falls back to "X-TIMEZONE" header. See http://www.iana.org/time-zones, https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + """ + timezone: String + ): String + + """ + A globally unique ID. + """ + id: ID! + + """ + A type-specific ID likely used as a database ID. + """ + internalID: ID! + + """ + Medium names + materials for this artwork, e.g. Paintings, Oil. + """ + mediums: [String] + priceCurrency: String + priceFrom: Money + priceTo: Money + + """ + Whether the artwork is published on Artnet. + """ + published: Boolean + updatedAt( + format: String + + """ + A tz database time zone, otherwise falls back to "X-TIMEZONE" header. See http://www.iana.org/time-zones, https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + """ + timezone: String + ): String +} + +""" +An Artnet edition set. +""" +type ArtnetEditionSet { + artnetId: String + + """ + Artnet availability vocabulary, e.g. For Sale or Price on Request. + """ + availability: String + + """ + The corresponding CatalogEditionSet, if this edition set has been matched to one. + """ + catalogEditionSetId: String + createdAt( + format: String + + """ + A tz database time zone, otherwise falls back to "X-TIMEZONE" header. See http://www.iana.org/time-zones, https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + """ + timezone: String + ): String + + """ + A globally unique ID. + """ + id: ID! + + """ + A type-specific ID likely used as a database ID. + """ + internalID: ID! + label: String + priceCurrency: String + priceFrom: Money + priceTo: Money + updatedAt( + format: String + + """ + A tz database time zone, otherwise falls back to "X-TIMEZONE" header. See http://www.iana.org/time-zones, https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + """ + timezone: String + ): String +} + type ArtsyShippingOptInMutationFailure { mutationError: GravityMutationError } @@ -4968,6 +5107,11 @@ type ArtworkFilterNode { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -5011,6 +5155,11 @@ type ArtworkFilterNode { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -8243,6 +8392,10 @@ type CareerHighlight implements Node { } type CatalogArtwork { + """ + Artnet record of this catalog artwork. + """ + artnetArtwork: ArtnetArtwork artworkId: String availability: String @@ -18373,6 +18526,11 @@ interface EntityWithFilterArtworksConnectionInterface { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -18416,6 +18574,11 @@ interface EntityWithFilterArtworksConnectionInterface { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean inquireableOnly: Boolean keyword: String @@ -18848,6 +19011,11 @@ type Fair implements EntityWithFilterArtworksConnectionInterface & Node { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -18891,6 +19059,11 @@ type Fair implements EntityWithFilterArtworksConnectionInterface & Node { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -19643,6 +19816,11 @@ input FilterArtworksInput { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -19686,6 +19864,11 @@ input FilterArtworksInput { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean inquireableOnly: Boolean keyword: String @@ -20439,6 +20622,11 @@ type Gene implements Node & Searchable { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -20482,6 +20670,11 @@ type Gene implements Node & Searchable { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -23596,6 +23789,11 @@ type MarketingCollection implements Node { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -23639,6 +23837,11 @@ type MarketingCollection implements Node { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -28978,6 +29181,11 @@ type Partner implements Node { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -29021,6 +29229,11 @@ type Partner implements Node { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -29548,6 +29761,11 @@ type PartnerArtist { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -29591,6 +29809,11 @@ type PartnerArtist { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -29847,6 +30070,11 @@ type PartnerArtistEdge { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -29890,6 +30118,11 @@ type PartnerArtistEdge { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -32225,6 +32458,11 @@ type Query { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -32268,6 +32506,11 @@ type Query { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -32835,6 +33078,11 @@ type Query { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -32883,6 +33131,11 @@ type Query { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -34146,6 +34399,11 @@ type Query { id: String! ): Tag targetSupply: TargetSupply + + """ + Artists and artworks trending on Artsy over a rolling window, ranked by search and view activity. + """ + trendingSearches(period: TrendingSearchPeriod = ONE_DAY): TrendingSearches user( """ Email to search for user by @@ -35968,6 +36226,9 @@ type SearchCriteriaLabel { } type SearchDropdown { + """ + Artists and artworks trending on Artsy over a rolling window, ranked by search and view activity. + """ trending(period: TrendingSearchPeriod = ONE_DAY): TrendingSearches } @@ -36634,6 +36895,11 @@ type Show implements EntityWithFilterArtworksConnectionInterface & Node { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -36677,6 +36943,11 @@ type Show implements EntityWithFilterArtworksConnectionInterface & Node { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -37504,6 +37775,11 @@ type Tag implements Node { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -37547,6 +37823,11 @@ type Tag implements Node { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -42129,6 +42410,11 @@ type Viewer { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -42172,6 +42458,11 @@ type Viewer { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -42528,6 +42819,11 @@ type Viewer { Filter by artwork completeness tiers. Accepts multiple values. """ completenessTier: [String] + + """ + When true, will only return artworks carrying the Curators' Pick collector signal. Cannot be combined with `marketingCollectionID`. + """ + curatorsPick: Boolean dimensionRange: String """ @@ -42576,6 +42872,11 @@ type Viewer { """ includeNonArtsyListed: Boolean includeUnpublished: Boolean + + """ + When true, will only return artworks carrying the Increased Interest collector signal. + """ + increasedInterest: Boolean input: FilterArtworksInput inquireableOnly: Boolean keyword: String @@ -43697,6 +43998,11 @@ type Viewer { id: String! ): Tag targetSupply: TargetSupply + + """ + Artists and artworks trending on Artsy over a rolling window, ranked by search and view activity. + """ + trendingSearches(period: TrendingSearchPeriod = ONE_DAY): TrendingSearches user( """ Email to search for user by diff --git a/package.json b/package.json index 67012673204..ee49b675498 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,12 @@ "make-fetch-happen@npm:^13.0.1": "^14.0.3" }, "dependencies": { - "@artsy/cohesion": "4.393.0", + "@artsy/cohesion": "4.395.0", "@artsy/detect-responsive-traits": "^0.2.0", "@artsy/dismissible": "^0.9.0", "@artsy/express-reloadable": "^1.7.0", "@artsy/fresnel": "^8.1.0", - "@artsy/icons": "3.74.0", + "@artsy/icons": "3.76.0", "@artsy/img": "1.1.3", "@artsy/multienv": "^1.2.0", "@artsy/palette": "46.9.1", diff --git a/src/Components/Search/SearchBarInput.tsx b/src/Components/Search/SearchBarInput.tsx index 0ad8fd4dc39..a6f695679ca 100644 --- a/src/Components/Search/SearchBarInput.tsx +++ b/src/Components/Search/SearchBarInput.tsx @@ -7,6 +7,7 @@ import { type FocusEvent, type MouseEvent, useEffect, + useMemo, useRef, useState, } from "react" @@ -14,10 +15,13 @@ import styled from "styled-components" import { ActionType, + ContextModule, + type RailViewed, type SearchedWithNoResults, type SearchedWithResults, type SelectedItemFromSearch, } from "@artsy/cohesion" +import { buildUrlForCollectApp } from "Apps/Collect/Utils/urlBuilder" import { Z } from "Apps/Components/constants" import { ManageArtworkForSavesProvider } from "Components/Artwork/ManageArtworkForSaves" import { DESKTOP_NAV_BAR_TOP_TIER_HEIGHT } from "Components/NavBar/constants" @@ -35,6 +39,7 @@ import { useDebounce } from "use-debounce" import { SearchBarFooter } from "./SearchBarFooter" import { SearchInputPillsFragmentContainer } from "./SearchInputPills" import { StaticSearchContainer } from "./StaticSearchContainer" +import { SuggestedFiltersItem } from "./SuggestedFiltersItem" import { SuggestionItem, type SuggestionItemOptionProps, @@ -45,6 +50,7 @@ import { useRecentSearches } from "./hooks/useRecentSearches" import { useTrendingImpressionSession } from "./hooks/useTrendingImpressionSession" import { getLabel } from "./utils/getLabel" import { isModifiedClick } from "./utils/isModifiedClick" +import { parseFilterQuery } from "./utils/parseFilterQuery" import { searchResultsHref } from "./utils/searchResultsHref" import { shouldStartSearching } from "./utils/shouldStartSearching" @@ -99,12 +105,51 @@ export const SearchBarInput: FC< const edges = data?.viewer?.searchConnection?.edges ?? [] + const isSuggestedFiltersEnabled = useFlag("onyx_suggested-filters") + + // Debounced, not live: the row is prepended, so appearing per keystroke + // shifts option indices under the cursor. Gated here rather than at render + // so users who can't see the row don't pay to parse. + const parsedFilters = useMemo(() => { + if (!isSuggestedFiltersEnabled) return null + + return parseFilterQuery(debouncedValue) + }, [debouncedValue, isSuggestedFiltersEnabled]) + + // The entity pills scope the search to a single type, where a link out to + // browsing artworks is off-topic. + const shouldShowSuggestedFilters = + !!parsedFilters && selectedPill === TOP_PILL + + const suggestedFiltersHref = parsedFilters + ? buildUrlForCollectApp(parsedFilters.filters) + : "" + const formattedOptions: SuggestionItemOptionProps[] = [ + ...(shouldShowSuggestedFilters + ? [ + { + kind: "suggestedFilters" as const, + text: value, + value: value, + subtitle: "", + imageUrl: "", + showAuctionResultsButton: false, + href: suggestedFiltersHref, + typename: "SuggestedFilters", + item_id: "suggested-filters", + // Position within its own context module, not the entity ranking + item_number: 0, + item_type: "filter-suggestion", + }, + ] + : []), ...edges.flatMap((edge, index) => { const option = edge?.node if (!option) return [] return [ { + kind: "entity" as const, text: option.displayLabel ?? "Unknown", value: option.displayLabel ?? "unknown", subtitle: @@ -124,6 +169,7 @@ export const SearchBarInput: FC< ] }), { + kind: "footer" as const, text: value, value: value, subtitle: "", @@ -255,19 +301,25 @@ export const SearchBarInput: FC< } const trackSelection = (option: SuggestionItemOptionProps) => { - // Only track if this is an actual search result, not the footer - if (option.typename !== "Footer") { - const analyticsEvent: SelectedItemFromSearch = { - action: ActionType.selectedItemFromSearch, - context_module: selectedPill.analyticsContextModule, - destination_path: option.href, - query: value, - item_id: option.item_id!, - item_number: option.item_number!, - item_type: option.item_type!, - } - tracking.trackEvent(analyticsEvent) + // The "See all results" footer has never been tracked here + if (option.kind === "footer") return + + // Its own module, so the row's clicks are separable from entity results + const contextModule = + option.kind === "suggestedFilters" + ? ContextModule.suggestedFilters + : selectedPill.analyticsContextModule + + const analyticsEvent: SelectedItemFromSearch = { + action: ActionType.selectedItemFromSearch, + context_module: contextModule, + destination_path: option.href, + query: value, + item_id: option.item_id!, + item_number: option.item_number!, + item_type: option.item_type!, } + tracking.trackEvent(analyticsEvent) } const handleSelect = (option: SuggestionItemOptionProps) => { @@ -306,6 +358,34 @@ export const SearchBarInput: FC< redirect(`${option.href}/auction-results`) } + // Once per focus session, not per keystroke: the row changes as the query is + // refined, and counting each appearance would inflate the CTR denominator. + const hasTrackedSuggestedFiltersRef = useRef(false) + + useEffect(() => { + if (!isFocused) { + hasTrackedSuggestedFiltersRef.current = false + return + } + + if (!shouldShowSuggestedFilters) return + if (hasTrackedSuggestedFiltersRef.current) return + + hasTrackedSuggestedFiltersRef.current = true + + const event: RailViewed = { + action: ActionType.railViewed, + context_module: ContextModule.suggestedFilters, + context_screen: contextPageOwnerType, + } + tracking.trackEvent(event) + }, [ + isFocused, + shouldShowSuggestedFilters, + contextPageOwnerType, + tracking.trackEvent, + ]) + const handleFocus = () => { // Skip Palette's programmatic post-selection refocus (see closeDropdown) if (Date.now() < suppressFocusUntilRef.current) return @@ -426,7 +506,20 @@ export const SearchBarInput: FC< renderOption={option => { if (!value) return <> - if (option.typename === "Footer") { + if (option.kind === "suggestedFilters" && parsedFilters) { + return ( + { + handleSuggestionClick(option, event) + }} + /> + ) + } + + if (option.kind === "footer") { return ( { + return ( + + ) +} + +const DRIFT_DURATION = "2.4s" +// The viewBox is padded by this much on each side so the drift isn't clipped +const DRIFT_OFFSET = "4px" + +const driftLeft = keyframes` + 0%, 10% { transform: translateX(-${DRIFT_OFFSET}); } + 45%, 60% { transform: translateX(0); } + 100% { transform: translateX(-${DRIFT_OFFSET}); } +` + +const driftRight = keyframes` + 0%, 10% { transform: translateX(${DRIFT_OFFSET}); } + 45%, 60% { transform: translateX(0); } + 100% { transform: translateX(${DRIFT_OFFSET}); } +` + +const Eclipse = styled.svg` + flex: none; +` + +// transform-box/origin keep translateX relative to each shape, not the svg +const Dot = styled.path` + fill: ${themeGet("colors.mono100")}; + transform-box: fill-box; + transform-origin: center; + animation-duration: ${DRIFT_DURATION}; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + + /* Hold the dots at rest rather than looping indefinitely */ + @media (prefers-reduced-motion: reduce) { + animation: none; + transform: translateX(0); + } +` + +const Disc = styled(Dot)` + animation-name: ${driftLeft}; +` + +// A true hole rather than a background-coloured fill, so it works anywhere +const Ring = styled(Dot)` + animation-name: ${driftRight}; +` diff --git a/src/Components/Search/SuggestedFiltersItem.tsx b/src/Components/Search/SuggestedFiltersItem.tsx new file mode 100644 index 00000000000..a2c761d883c --- /dev/null +++ b/src/Components/Search/SuggestedFiltersItem.tsx @@ -0,0 +1,96 @@ +import { themeGet } from "@styled-system/theme-get" +import { Flex, Text } from "@artsy/palette" +import type { FC, MouseEvent } from "react" +import styled from "styled-components" +import { SuggestedFiltersIcon } from "./SuggestedFiltersIcon" +import { SuggestionItemLink } from "./SuggestionItem/SuggestionItemLink" +import { highlightMatchedTokens } from "./utils/highlightMatchedTokens" +import type { ParsedFilterQuery } from "./utils/parseFilterQuery" + +const ICON_TILE_SIZE = 48 +// Off the theme space scale (1 = 10px, 2 = 20px), so set directly +const ROW_PADDING_Y = "16px" +const ICON_GAP = "14px" +const LABEL_SEPARATOR = " · " + +interface SuggestedFiltersItemProps { + parsed: ParsedFilterQuery + href: string + /** The query the filters were parsed from, not the live input value */ + query: string + onClick: (event?: MouseEvent) => void +} + +export const SuggestedFiltersItem: FC = ({ + parsed, + href, + query, + onClick, +}) => { + const handleClick = (event: MouseEvent) => { + onClick(event) + } + + const handleMouseDown = (event: MouseEvent) => { + // Prevent AutocompleteInput mousedown selection so native link behavior is preserved. + event.stopPropagation() + } + + // Headline is the free text, second line is what it's filtered to. With no + // free text the filters become the headline — promoting just the first one + // made "unique prints under 10000" read as "Prints" in "Under $10,000". + const title = parsed.keyword || parsed.labels.join(LABEL_SEPARATOR) + const detail = parsed.keyword ? parsed.labels.join(LABEL_SEPARATOR) : null + + return ( + + + + + + + + + {highlightMatchedTokens(title, query)} + + + {detail && ( + + in {highlightMatchedTokens(detail, query)} + + )} + + + + ) +} + +// Tinted so the row reads as a suggestion. Its resting state is the entity +// rows' hover colour, so hover goes a step darker. +const SuggestedFiltersLink = styled(SuggestionItemLink)` + background-color: ${themeGet("colors.mono5")}; + padding-top: ${ROW_PADDING_Y}; + padding-bottom: ${ROW_PADDING_Y}; + + &:hover { + background-color: ${themeGet("colors.mono10")}; + } + + /* is a ; the design distinguishes matches by colour + alone, and bold reads far heavier at this row's 20px title */ + strong { + font-weight: inherit; + } +` diff --git a/src/Components/Search/SuggestionItem/SuggestionItem.tsx b/src/Components/Search/SuggestionItem/SuggestionItem.tsx index bb20b98bf12..16ce6487ab6 100644 --- a/src/Components/Search/SuggestionItem/SuggestionItem.tsx +++ b/src/Components/Search/SuggestionItem/SuggestionItem.tsx @@ -5,7 +5,14 @@ import { DefaultSuggestion } from "./DefaultSuggestion" import { SuggestionItemLink } from "./SuggestionItemLink" import type { SearchHighlightData } from "./parseHighlightFragments" +/** + * Distinguishes the synthetic dropdown rows from real search results. Absent + * means an entity row, so existing option builders need no change. + */ +export type SuggestionItemKind = "entity" | "footer" | "suggestedFilters" + export interface SuggestionItemOptionProps { + kind?: SuggestionItemKind text: string value: string subtitle: string diff --git a/src/Components/Search/__tests__/SearchBarInput.jest.tsx b/src/Components/Search/__tests__/SearchBarInput.jest.tsx index fd71aaddb53..db9ffbb8521 100644 --- a/src/Components/Search/__tests__/SearchBarInput.jest.tsx +++ b/src/Components/Search/__tests__/SearchBarInput.jest.tsx @@ -6,6 +6,7 @@ import { useRouter } from "System/Hooks/useRouter" import { useClientQuery } from "Utils/Hooks/useClientQuery" import { useTracking } from "react-tracking" import { SearchBarInput } from "../SearchBarInput" +import { parseFilterQuery } from "../utils/parseFilterQuery" jest.mock("@unleash/proxy-client-react", () => ({ useFlag: jest.fn() })) @@ -48,6 +49,12 @@ jest.mock("@artsy/palette", () => ({ jest.mock("System/Hooks/useRouter", () => ({ useRouter: jest.fn() })) +jest.mock("../utils/parseFilterQuery", () => ({ + parseFilterQuery: jest.fn( + jest.requireActual("../utils/parseFilterQuery").parseFilterQuery, + ), +})) + jest.mock("Utils/Hooks/useClientQuery", () => ({ useClientQuery: jest.fn() })) jest.mock("react-tracking") @@ -492,4 +499,163 @@ describe("SearchBarInput", () => { query: "andy", }) }) + + describe("suggested filters row", () => { + // Both flags on: the row ships behind its own flag, and the surrounding + // suite runs with the trending panel enabled + const enableSuggestedFilters = () => { + ;(useFlag as jest.Mock).mockImplementation((flag: string) => { + return ( + flag === "onyx_trending-searches" || flag === "onyx_suggested-filters" + ) + }) + } + + const row = () => screen.queryByTestId("suggestedFiltersRow") + + it("does not render when the feature flag is off", () => { + // beforeEach leaves onyx_suggested-filters off + render() + + expect(row()).not.toBeInTheDocument() + }) + + it("does not parse the query at all when the feature flag is off", () => { + // While this is a partial rollout, users who can't see the row shouldn't + // pay to parse every debounced keystroke + render() + + expect(parseFilterQuery).not.toHaveBeenCalled() + }) + + it("parses the query when the feature flag is on", () => { + enableSuggestedFilters() + render() + + expect(parseFilterQuery).toHaveBeenCalledWith("warhol prints under 5000") + }) + + it("renders the parsed filters and links to the collect page", () => { + enableSuggestedFilters() + render() + + // toHaveTextContent flattens the wrappers around matched terms + expect(row()).toHaveTextContent("warhol") + expect(row()).toHaveTextContent("in Prints · Under $5,000") + + const href = row()?.getAttribute("href") + expect(href).toContain("/collect/prints") + expect(href).toContain("keyword=warhol") + }) + + it("highlights every term the user typed, including inside derived labels", () => { + enableSuggestedFilters() + render() + + const highlighted = row()?.querySelectorAll("strong") + const texts = Array.from(highlighted ?? []).map(node => node.textContent) + + // "prints" lights up the Prints label; "under" and "5000" light up the + // formatted price label, since those are the words that were typed + expect(texts).toEqual(["warhol", "Prints", "Under", "$5,000"]) + }) + + it("leaves terms the user did not type unhighlighted", () => { + enableSuggestedFilters() + render() + + const highlighted = row()?.querySelectorAll("strong") + const texts = Array.from(highlighted ?? []).map(node => node.textContent) + + expect(texts).toEqual(["banksy", "Prints"]) + }) + + it("makes the filters the headline when the query leaves no keyword", () => { + enableSuggestedFilters() + render() + + // No "in" line: there is no free text for the filters to qualify + expect(row()).toHaveTextContent("Prints · $1,000–$5,000") + expect(row()).not.toHaveTextContent("in ") + }) + + it("does not promote a filter into the keyword slot", () => { + enableSuggestedFilters() + render() + + // Previously rendered as keyword "Prints" filtered "in Under $10,000" + expect(row()).toHaveTextContent("Prints · Unique · Under $10,000") + expect(row()).not.toHaveTextContent("in Unique") + }) + + it.each([ + ["no prints", "negated intent"], + ["warhol 1962-1964", "a date range, not a price"], + ["prints and photography", "two mediums"], + ["design miami", "a medium word inside a fair name"], + ["paintings under £5000", "non-USD"], + ["warhol", "a bare entity name"], + ])("does not render for %p — %s", query => { + enableSuggestedFilters() + render() + + expect(row()).not.toBeInTheDocument() + }) + + it("tracks the click under its own context module", async () => { + enableSuggestedFilters() + render() + + await userEvent.click(row() as HTMLElement) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: ActionType.selectedItemFromSearch, + context_module: ContextModule.suggestedFilters, + item_id: "suggested-filters", + item_number: 0, + item_type: "filter-suggestion", + }), + ) + }) + + it("leaves entity clicks reporting the selected pill", async () => { + enableSuggestedFilters() + render() + + await userEvent.click(screen.getByRole("link", { name: "Andy Warhol" })) + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: ActionType.selectedItemFromSearch, + context_module: ContextModule.topTab, + }), + ) + }) + + it("tracks one impression per focus session", () => { + enableSuggestedFilters() + render() + + fireEvent.focus(screen.getByRole("textbox")) + + const impressions = mockTrackEvent.mock.calls.filter(([event]) => { + return ( + event.action === ActionType.railViewed && + event.context_module === ContextModule.suggestedFilters + ) + }) + + expect(impressions).toHaveLength(1) + }) + + it("does not track an impression before the dropdown is focused", () => { + enableSuggestedFilters() + render() + + expect(mockTrackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ action: ActionType.railViewed }), + ) + }) + }) }) diff --git a/src/Components/Search/__tests__/SuggestedFiltersItem.jest.tsx b/src/Components/Search/__tests__/SuggestedFiltersItem.jest.tsx new file mode 100644 index 00000000000..49b998fce40 --- /dev/null +++ b/src/Components/Search/__tests__/SuggestedFiltersItem.jest.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { SuggestedFiltersItem } from "Components/Search/SuggestedFiltersItem" +import type { ParsedFilterQuery } from "Components/Search/utils/parseFilterQuery" +import { useRouter } from "System/Hooks/useRouter" + +jest.mock("System/Hooks/useRouter", () => ({ useRouter: jest.fn() })) + +/** + * The row has two lines and they mean different things: the headline is the + * free text the user is looking for, the second line is the filters it's + * narrowed to. These specs pin that rule — see the “fully consumed query” + * block for the case it originally got wrong. + */ +describe("SuggestedFiltersItem", () => { + beforeEach(() => { + ;(useRouter as jest.Mock).mockReturnValue({ + match: { location: { pathname: "/" } }, + router: { push: jest.fn() }, + }) + }) + + const parsed = (keyword: string, labels: string[]): ParsedFilterQuery => { + return { filters: {}, keyword, labels } + } + + const renderRow = ({ + keyword, + labels, + query, + onClick = jest.fn(), + }: { + keyword: string + labels: string[] + query: string + onClick?: () => void + }) => { + render( + , + ) + + return screen.getByTestId("suggestedFiltersRow") + } + + describe("when the query leaves free text", () => { + it("puts the free text in the headline and the filters behind “in”", () => { + const row = renderRow({ + keyword: "warhol", + labels: ["Prints", "Under $5,000"], + query: "warhol prints under 5000", + }) + + expect(row).toHaveTextContent("warhol") + expect(row).toHaveTextContent("in Prints · Under $5,000") + }) + + it("handles a single filter", () => { + const row = renderRow({ + keyword: "cat", + labels: ["Prints"], + query: "cat prints", + }) + + expect(row).toHaveTextContent("cat") + expect(row).toHaveTextContent("in Prints") + }) + }) + + describe("when the query is fully consumed by filters", () => { + // Regression: the first label used to be promoted into the headline, so + // “unique prints under 10000” read as the keyword “Prints” narrowed “in + // Under $10,000” — presenting a price as though it were a category. + it("makes the filters the headline and drops the “in” line", () => { + const row = renderRow({ + keyword: "", + labels: ["Prints", "Unique", "Under $10,000"], + query: "unique prints under 10000", + }) + + expect(row).toHaveTextContent("Prints · Unique · Under $10,000") + expect(row).not.toHaveTextContent("in ") + }) + + it("does not leave a filter standing alone in the headline", () => { + const row = renderRow({ + keyword: "", + labels: ["Prints", "$1,000–$5,000"], + query: "prints between 1k and 5k", + }) + + expect(row).toHaveTextContent("Prints · $1,000–$5,000") + expect(row).not.toHaveTextContent("in ") + }) + }) + + describe("highlighting", () => { + it("colours the terms the user typed, in both lines", () => { + const row = renderRow({ + keyword: "warhol", + labels: ["Prints", "Under $5,000"], + query: "warhol prints under 5000", + }) + + const highlighted = Array.from(row.querySelectorAll("strong")).map( + node => node.textContent, + ) + + expect(highlighted).toEqual(["warhol", "Prints", "Under", "$5,000"]) + }) + + it("leaves terms the user did not type unhighlighted", () => { + const row = renderRow({ + keyword: "cat", + labels: ["Prints", "Unique"], + query: "cat prints", + }) + + const highlighted = Array.from(row.querySelectorAll("strong")).map( + node => node.textContent, + ) + + expect(highlighted).toEqual(["cat", "Prints"]) + }) + }) + + it("links to the given href", () => { + const row = renderRow({ + keyword: "warhol", + labels: ["Prints"], + query: "warhol prints", + }) + + expect(row).toHaveAttribute("href", "/collect/prints") + }) + + it("calls onClick when the row is clicked", () => { + const onClick = jest.fn() + const row = renderRow({ + keyword: "warhol", + labels: ["Prints"], + query: "warhol prints", + onClick, + }) + + fireEvent.click(row) + + expect(onClick).toHaveBeenCalled() + }) +}) diff --git a/src/Components/Search/utils/__tests__/parseFilterQuery.jest.ts b/src/Components/Search/utils/__tests__/parseFilterQuery.jest.ts new file mode 100644 index 00000000000..98c6758c614 --- /dev/null +++ b/src/Components/Search/utils/__tests__/parseFilterQuery.jest.ts @@ -0,0 +1,194 @@ +import { parseFilterQuery } from "../parseFilterQuery" + +/** + * Query strings marked “(production)” are real, taken from 30 days of search + * logs. They are the reason the corresponding guard exists — please don’t + * replace them with invented equivalents. + */ +describe("parseFilterQuery", () => { + describe("suggests filters", () => { + it("combines a medium, a price and a leftover artist name", () => { + const result = parseFilterQuery("warhol prints under 5000") + + expect(result).not.toBeNull() + expect(result?.filters).toEqual({ + medium: "prints", + priceRange: "*-5000", + attributionClass: undefined, + keyword: "warhol", + }) + expect(result?.labels).toEqual(["Prints", "Under $5,000"]) + }) + + it("combines a medium, a rarity and a price", () => { + const result = parseFilterQuery("banksy limited edition prints under 10k") + + expect(result?.filters).toEqual({ + medium: "prints", + priceRange: "*-10000", + attributionClass: ["limited edition"], + keyword: "banksy", + }) + }) + + it("suggests a medium alongside free text", () => { + const result = parseFilterQuery("kaws sculpture") + + expect(result?.filters.medium).toEqual("sculpture") + expect(result?.keyword).toEqual("kaws") + }) + + it("reads a two-sided range with an explicit operator", () => { + const result = parseFilterQuery("prints between 1k and 5k") + + expect(result?.filters.priceRange).toEqual("1000-5000") + }) + + it("reads a bare range carrying a money marker", () => { + const result = parseFilterQuery("prints $1000-5000") + + expect(result?.filters.priceRange).toEqual("1000-5000") + }) + }) + + describe("stays silent when a single filter is the whole query", () => { + // The gene/collection entity the backend already returns is a better + // answer than a link out to /collect + it.each(["warhol", "prints", "yayoi kusama", "gagosian", "painting"])( + "returns null for %p", + query => { + expect(parseFilterQuery(query)).toBeNull() + }, + ) + }) + + describe("does not read dates or numbering as prices", () => { + it.each([ + ["jack dowling (american, 1931-2021)", "artist life dates (production)"], + ["yun hyong-keun 1977-1978", "work dates (production)"], + ["bridget riley brouillard 1981-2003", "work dates (production)"], + ["untitled 1980-1990", "date range"], + ["warhol 1962-1964", "working period"], + ["abstract 1960 - 1970", "spaced dash"], + ])("returns null for %p — %s", query => { + expect(parseFilterQuery(query)).toBeNull() + }) + + it.each([ + ["edition 5-10", "edition numbering"], + ["1-54", "an art fair (production)"], + ["chen qiang 23-20", "descending pair (production)"], + ["50-50 collective", "numbers inside a name"], + ])("returns null for %p — %s", query => { + expect(parseFilterQuery(query)).toBeNull() + }) + + it("does not read a bare year as a price floor", () => { + // The medium still stands; only the price is withheld + const result = parseFilterQuery("prints from 1990") + + expect(result?.filters.priceRange).toBeUndefined() + expect(result?.filters.medium).toEqual("prints") + }) + + it("still reads a price floor when the operator is unambiguous", () => { + expect(parseFilterQuery("prints over 2000")?.filters.priceRange).toEqual( + "2000-*", + ) + }) + + it("still reads a price floor from an ambiguous operator plus a marker", () => { + expect(parseFilterQuery("prints from $1990")?.filters.priceRange).toEqual( + "1990-*", + ) + }) + }) + + describe("does not invert negated intent", () => { + it.each([ + "no prints", + "everything except sculpture", + "not painting", + "no photography", + ])("returns null for %p", query => { + expect(parseFilterQuery(query)).toBeNull() + }) + + it("only guards terms it would otherwise filter on", () => { + // Known limitation: “framed” isn’t a filter this parser supports, so + // there is no filter to invert and the phrase lands in the keyword. + // Suppressing on any stray negator would break real titles instead — + // see the case below. + const result = parseFilterQuery("paintings without frames") + + expect(result?.filters.medium).toEqual("painting") + expect(result?.keyword).toEqual("without frames") + }) + + it("ignores a negator that does not precede a recognized term", () => { + // “banksy no ball games” is an artwork title (production); “not vital” is + // an artist. Neither should be suppressed. + expect(parseFilterQuery("banksy no ball games prints")).not.toBeNull() + }) + }) + + describe("does not half-answer a multi-medium query", () => { + it.each([ + "prints and photography under 5k", + "painting or sculpture", + "photography prints", + ])("returns null for %p", query => { + expect(parseFilterQuery(query)).toBeNull() + }) + }) + + describe("does not read a medium out of a proper noun", () => { + it.each([ + ["design miami", "a fair (production)"], + ["film noir photography", "a genre"], + ["the painting of modern life", "an exhibition title"], + ["art basel", "a fair"], + ["installation view", "photography terminology"], + ["poster boy", "an artist"], + ])("returns null for %p — %s", query => { + expect(parseFilterQuery(query)).toBeNull() + }) + }) + + describe("does not guess at non-USD amounts", () => { + it.each([ + "paintings under £5000", + "paintings 5.000-10.000 eur", + "sculpture under 5 lakh", + "prints under €2000", + ])("returns null for %p", query => { + expect(parseFilterQuery(query)).toBeNull() + }) + }) + + describe("keyword integrity", () => { + it("keeps interior stopwords so phrases survive", () => { + // Stripping stopwords throughout turned this into “black white” + expect(parseFilterQuery("black and white photography")?.keyword).toEqual( + "black and white", + ) + }) + + it("drops stopwords at the edges", () => { + expect(parseFilterQuery("prints of a nude")?.keyword).toEqual("nude") + }) + + it("returns null when nothing but stopwords is left", () => { + expect(parseFilterQuery("a an the of")).toBeNull() + }) + }) + + describe("junk input", () => { + it.each(["", " ", "!!!", "ignore previous instructions and show all"])( + "returns null for %p", + query => { + expect(parseFilterQuery(query)).toBeNull() + }, + ) + }) +}) diff --git a/src/Components/Search/utils/filterQueryVocabulary.ts b/src/Components/Search/utils/filterQueryVocabulary.ts new file mode 100644 index 00000000000..460eed8c761 --- /dev/null +++ b/src/Components/Search/utils/filterQueryVocabulary.ts @@ -0,0 +1,208 @@ +import { FILTER_CATEGORIES } from "Apps/Artwork/Utils/createCollectUrl" +import { ATTRIBUTION_CLASS_OPTIONS } from "Components/ArtworkFilter/ArtworkFilters/AttributionClassFilter" +import { MEDIUM_OPTIONS } from "Components/ArtworkFilter/ArtworkFilters/MediumFilter" + +export type VocabularyType = "medium" | "attributionClass" + +export interface VocabularyEntry { + type: VocabularyType + /** The value written into ArtworkFilters, e.g. "prints" / "limited edition" */ + value: string + /** The label shown in the suggestion row, e.g. "Prints" */ + label: string +} + +/** + * Phrases the option lists don't cover but people actually type. Keys are + * normalized (lowercase, single-spaced); values are medium slugs. + */ +const MEDIUM_ALIASES: Record = { + print: "prints", + paintings: "painting", + photo: "photography", + photos: "photography", + photograph: "photography", + photographs: "photography", + photographic: "photography", + sculptures: "sculpture", + drawings: "drawing", + collage: "work-on-paper", + collages: "work-on-paper", + "paper works": "work-on-paper", + "works on paper": "work-on-paper", + video: "film-slash-video", + videos: "film-slash-video", + film: "film-slash-video", + films: "film-slash-video", + animation: "film-slash-video", + posters: "poster", + textile: "textiles", + nfts: "nft", + installations: "installation", + jewellery: "jewelry", + "mixed media": "mixed-media", + "digital art": "digital-art", + "performance art": "performance-art", + "books and portfolios": "books-and-portfolios", +} + +const ATTRIBUTION_ALIASES: Record = { + "one of a kind": "unique", + "one-of-a-kind": "unique", + "limited-edition": "limited edition", + "limited editions": "limited edition", + "open-edition": "open edition", + "open editions": "open edition", +} + +/** + * Words that carry no filter meaning and shouldn't survive into the keyword. + * Multi-word phrases ("work on paper", "digital art") are consumed by the + * vocabulary before stopword stripping runs, so "work" and "art" are safe here. + */ +export const STOPWORDS = new Set([ + "a", + "an", + "and", + "art", + "artwork", + "artworks", + "buck", + "bucks", + "by", + "dollar", + "dollars", + "for", + "from", + "in", + "of", + "piece", + "pieces", + "the", + "usd", + "with", + "work", + "works", +]) + +/** + * Only positive filters are expressible, so a negated term is withheld rather + * than inverted — "no prints" must not mean Prints. + */ +export const NEGATORS = new Set([ + "except", + "excluding", + "no", + "non", + "not", + "without", +]) + +/** + * Medium words belonging to a proper noun rather than a medium. A mitigation, + * not a fix — the set is unbounded, so add new collisions as they surface. + */ +export const COLLISION_PHRASES = [ + "art basel", + "design miami", + "film noir", + "installation view", + "poster boy", + "print club", + "the painting of modern life", +] + +export const normalizePhrase = (phrase: string): string => { + return phrase.toLowerCase().replace(/\s+/g, " ").trim() +} + +const deslugify = (slug: string): string => { + return slug.replace(/-/g, " ") +} + +/** + * Descriptive option names longer than this ("Drawing, Collage or other Work + * on Paper") are never typed into a search box, and indexing them would widen + * the matcher's lookahead window for nothing. + */ +const MAX_INDEXED_WORDS = 3 + +const buildVocabulary = (): Map => { + const vocabulary = new Map() + + const add = (phrase: string, entry: VocabularyEntry) => { + const key = normalizePhrase(phrase) + + // First writer wins, so curated labels aren't clobbered by later sources + if (!key || vocabulary.has(key)) return + if (key.split(" ").length > MAX_INDEXED_WORDS) return + if (/[,/]/.test(key)) return + + vocabulary.set(key, entry) + } + + // Canonical medium options — these own the display labels + MEDIUM_OPTIONS.forEach(({ value, name, plural }) => { + const entry: VocabularyEntry = { type: "medium", value, label: name } + + add(name, entry) + add(plural, entry) + add(deslugify(value), entry) + }) + + // Superset of slugs valid as a /collect/:medium path segment. Where a slug + // already came from MEDIUM_OPTIONS, keep that label so "print" and "prints" + // don't render as two different things. + const mediumLabels = new Map( + [...vocabulary.values()].map(entry => [entry.value, entry.label]), + ) + + Object.entries(FILTER_CATEGORIES).forEach(([name, value]) => { + const entry: VocabularyEntry = { + type: "medium", + value, + label: mediumLabels.get(value) ?? name, + } + + add(name, entry) + add(deslugify(value), entry) + }) + + ATTRIBUTION_CLASS_OPTIONS.forEach(({ name, value }) => { + const entry: VocabularyEntry = { + type: "attributionClass", + value, + label: name, + } + + add(name, entry) + add(value, entry) + }) + + // Aliases resolve to an entry already in the map so labels stay consistent + const addAliases = (aliases: Record) => { + Object.entries(aliases).forEach(([alias, value]) => { + const canonical = [...vocabulary.values()].find( + entry => entry.value === value, + ) + + if (!canonical) return + + add(alias, canonical) + }) + } + + addAliases(MEDIUM_ALIASES) + addAliases(ATTRIBUTION_ALIASES) + + return vocabulary +} + +export const FILTER_VOCABULARY: ReadonlyMap = + buildVocabulary() + +/** Longest phrase in the vocabulary, so the matcher window adapts to the data */ +export const MAX_PHRASE_WORDS: number = [...FILTER_VOCABULARY.keys()].reduce( + (max, key) => Math.max(max, key.split(" ").length), + 1, +) diff --git a/src/Components/Search/utils/highlightMatchedTokens.tsx b/src/Components/Search/utils/highlightMatchedTokens.tsx new file mode 100644 index 00000000000..ceed310cf38 --- /dev/null +++ b/src/Components/Search/utils/highlightMatchedTokens.tsx @@ -0,0 +1,35 @@ +import { Highlight } from "Components/Search/SuggestionItem/Highlight" +import type { ReactNode } from "react" + +/** + * Colours the words the user typed. The entity rows use server-provided + * fragments instead — see parseHighlightFragments; this row is parsed locally. + */ +export const highlightMatchedTokens = ( + text: string, + query: string, +): ReactNode[] => { + const queryKeys = new Set(query.split(/\s+/).map(toKey).filter(Boolean)) + + // Split retaining whitespace so the original spacing survives + return text.split(/(\s+)/).map((part, index) => { + const key = toKey(part) + + if (!key || !queryKeys.has(key)) return part + + return {part} + }) +} + +const normalize = (word: string): string => { + return word.toLowerCase().replace(/[^a-z0-9]/g, "") +} + +/** So a typed "prints" still lights up a "Print" label, and vice versa */ +const singularize = (word: string): string => { + return word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word +} + +const toKey = (word: string): string => { + return singularize(normalize(word)) +} diff --git a/src/Components/Search/utils/parseFilterQuery.ts b/src/Components/Search/utils/parseFilterQuery.ts new file mode 100644 index 00000000000..99a91f8774c --- /dev/null +++ b/src/Components/Search/utils/parseFilterQuery.ts @@ -0,0 +1,386 @@ +import type { ArtworkFilters } from "Components/ArtworkFilter/ArtworkFilterTypes" +import { + COLLISION_PHRASES, + FILTER_VOCABULARY, + MAX_PHRASE_WORDS, + NEGATORS, + STOPWORDS, + type VocabularyEntry, + normalizePhrase, +} from "./filterQueryVocabulary" + +/** + * `medium`, not `additionalGeneIDs`: urlBuilder turns it into the + * `/collect/:medium` path segment. Hence one medium per query. + */ +export type SuggestedFilters = Pick< + ArtworkFilters, + "medium" | "priceRange" | "attributionClass" | "keyword" +> + +export interface ParsedFilterQuery { + filters: SuggestedFilters + /** Everything the parser didn't consume; goes into the keyword filter */ + keyword: string + /** Human labels in display order: medium, rarity, price */ + labels: string[] +} + +interface PricePattern { + regex: RegExp + toRange: (match: RegExpMatchArray) => string + /** + * Guards against numbers that aren't money. Defaults to true when a pattern + * can't be misread. + */ + isPlausible?: (match: RegExpMatchArray) => boolean +} + +/** A "$" or a k/m suffix survives `clean` and marks an amount as money */ +const MONEY_MARKER = /[$]|[km]\s*$/i + +const hasMoneyMarker = (raw: string): boolean => { + return MONEY_MARKER.test(raw.trim()) +} + +/** Artist life dates and work dates: "jack dowling (american, 1931-2021)" */ +const YEAR_RANGE = { min: 1000, max: 2100 } + +const isYearLike = (amount: number): boolean => { + return amount >= YEAR_RANGE.min && amount <= YEAR_RANGE.max +} + +/** + * Below this a bare range is edition or lot numbering: "edition 5-10", "1-54" + * (a fair). Bare ranges only — "under 50" still parses. + */ +const MIN_PLAUSIBLE_BARE_PRICE = 100 + +/** Currencies the priceRange filter can't express — it is USD-only */ +const NON_USD = + /[£€¥₹₩]|\b(eur|gbp|chf|hkd|jpy|cad|aud|krw|rmb|cny|inr|lakh|crore)\b/i + +/** "from 1990" is a date far more often than a price floor */ +const DATE_AMBIGUOUS_OPERATOR = /^(from|starting\s+at)/i + +const AMOUNT = String.raw`\$?\d[\d,.]*\s?[km]?` + +/** + * Ordered: the two-sided range must win before the one-sided operators, so + * "between 1k and 5k" isn't read as "from 1k". + * + * Every pattern requires an operator word or two amounts joined by a + * separator. A bare number is therefore never a price — "warhol 1954" stays + * free text. + */ +const PRICE_PATTERNS: PricePattern[] = [ + { + regex: new RegExp( + String.raw`\b(between\s+|from\s+)?(${AMOUNT})\s*(?:-|–|—|to|and)\s*(${AMOUNT})\b`, + "i", + ), + toRange: match => { + return `${normalizeAmount(match[2])}-${normalizeAmount(match[3])}` + }, + isPlausible: match => { + // An explicit operator word is intent enough: "between 1k and 5k" + if (match[1]) return true + // So is a money marker on either side: "$1000-5000", "1k-5k" + if (hasMoneyMarker(match[2]) || hasMoneyMarker(match[3])) return true + + const min = normalizeAmount(match[2]) + const max = normalizeAmount(match[3]) + + if (isYearLike(min) && isYearLike(max)) return false + if (max <= MIN_PLAUSIBLE_BARE_PRICE) return false + + return true + }, + }, + { + regex: new RegExp( + String.raw`\b(under|below|less\s+than|cheaper\s+than|up\s+to|max|maximum)\s+(${AMOUNT})`, + "i", + ), + toRange: match => { + return `*-${normalizeAmount(match[2])}` + }, + }, + { + regex: new RegExp( + String.raw`\b(over|above|more\s+than|at\s+least|starting\s+at|from|min|minimum)\s+(${AMOUNT})`, + "i", + ), + toRange: match => { + return `${normalizeAmount(match[2])}-*` + }, + isPlausible: match => { + // "over 2000" reads as money; "from 1990" reads as a date. Only the + // ambiguous operators need a year check. + if (!DATE_AMBIGUOUS_OPERATOR.test(match[1])) return true + if (hasMoneyMarker(match[2])) return true + + return !isYearLike(normalizeAmount(match[2])) + }, + }, +] + +const normalizeAmount = (raw: string): number => { + const cleaned = raw.replace(/[$,\s]/g, "").toLowerCase() + const multiplier = getMultiplier(cleaned) + const amount = Number.parseFloat(cleaned.replace(/[km]$/, "")) + + return Math.round(amount * multiplier) +} + +const getMultiplier = (cleaned: string): number => { + if (cleaned.endsWith("k")) { + return 1_000 + } + + if (cleaned.endsWith("m")) { + return 1_000_000 + } + + return 1 +} + +/** Drop punctuation the grammar doesn't use, but keep case for the keyword */ +const clean = (query: string): string => { + return query + .replace(/[^\w\s$,.–—-]/g, " ") + .replace(/\s+/g, " ") + .trim() +} + +interface PriceResult { + priceRange?: string + rest: string +} + +const extractPrice = (query: string): PriceResult => { + const hit = PRICE_PATTERNS.map(pattern => { + return { pattern, match: query.match(pattern.regex) } + }).find(({ pattern, match }) => { + if (!match) return false + + return pattern.isPlausible ? pattern.isPlausible(match) : true + }) + + const match = hit?.match + + if (!match || match.index === undefined) { + return { rest: query } + } + + const rest = `${query.slice(0, match.index)} ${query.slice( + match.index + match[0].length, + )}` + + return { priceRange: hit.pattern.toRange(match), rest } +} + +interface PhraseAccumulator { + matches: VocabularyEntry[] + leftover: string[] + skipUntil: number + /** A recognized term was preceded by "no" / "not" / "without" / … */ + hasNegatedMatch: boolean +} + +const extractVocabulary = (tokens: string[]): PhraseAccumulator => { + return tokens.reduce( + (acc, _token, index) => { + if (index < acc.skipUntil) return acc + + // Longest match first, so "work on paper" beats "work" + const maxSize = Math.min(MAX_PHRASE_WORDS, tokens.length - index) + const sizes = Array.from({ length: maxSize }, (_, offset) => { + return maxSize - offset + }) + + const hit = sizes + .map(size => { + const phrase = normalizePhrase( + tokens.slice(index, index + size).join(" "), + ) + return { size, entry: FILTER_VOCABULARY.get(phrase) } + }) + .find(({ entry }) => { + return !!entry + }) + + if (!hit?.entry) { + return { ...acc, leftover: [...acc.leftover, tokens[index]] } + } + + const previousToken = index > 0 ? tokens[index - 1] : undefined + const isNegated = + !!previousToken && NEGATORS.has(normalizePhrase(previousToken)) + + return { + matches: [...acc.matches, hit.entry], + leftover: acc.leftover, + skipUntil: index + hit.size, + hasNegatedMatch: acc.hasNegatedMatch || isNegated, + } + }, + { matches: [], leftover: [], skipUntil: 0, hasNegatedMatch: false }, + ) +} + +/** + * Drops stopwords from the edges only. Stripping them throughout would mangle + * interior phrases — "black and white photography" became "black white". + */ +const trimStopwords = (words: string[]): string[] => { + const isStopword = (word: string): boolean => { + return STOPWORDS.has(word.toLowerCase()) + } + + const first = words.findIndex(word => { + return !isStopword(word) + }) + + if (first === -1) return [] + + const fromEnd = words + .slice() + .reverse() + .findIndex(word => { + return !isStopword(word) + }) + + return words.slice(first, words.length - fromEnd) +} + +const formatPriceLabel = (range: string): string => { + const [min, max] = range.split("-") + const format = (amount: string) => { + return CURRENCY_FORMATTER.format(Number(amount)) + } + + if (min === "*") { + return `Under ${format(max)}` + } + + if (max === "*") { + return `${format(min)} and up` + } + + return `${format(min)}–${format(max)}` +} + +const CURRENCY_FORMATTER = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}) + +/** + * Parses artwork-filter intent out of a raw search query, entirely on the + * client. Returns null when there's nothing worth suggesting. + */ +export const parseFilterQuery = (query: string): ParsedFilterQuery | null => { + if (!query.trim()) return null + + const normalizedQuery = normalizePhrase(query) + + // A medium word inside a fair, gallery or exhibition name is not a medium + // request: "design miami", "film noir photography" + if ( + COLLISION_PHRASES.some(phrase => { + return normalizedQuery.includes(phrase) + }) + ) { + return null + } + + // priceRange is USD-only, and `clean` strips the currency symbol — parsing on + // would reinterpret "under £5000" as $5,000. Dropping only the price is no + // better: it leaves a keyword of "under 5000" and quietly discards the + // constraint that was asked for. + if (NON_USD.test(query)) return null + + const { priceRange, rest } = extractPrice(clean(query)) + + const tokens = rest.split(/\s+/).filter(Boolean) + const { matches, leftover, hasNegatedMatch } = extractVocabulary(tokens) + + // Only positive filters are expressible, so an exclusion can't be honoured — + // and filtering *to* the excluded value is worse than staying quiet + if (hasNegatedMatch) return null + + const mediumEntries = matches.filter(entry => { + return entry.type === "medium" + }) + const mediumValues = [ + ...new Set( + mediumEntries.map(entry => { + return entry.value + }), + ), + ] + + // The collect URL carries a single medium as its path segment, so a second + // one would be dropped without the user seeing it go + if (mediumValues.length > 1) return null + + const medium = mediumEntries[0] + + const attributionEntries = matches.filter(entry => { + return entry.type === "attributionClass" + }) + const attributionClass = [ + ...new Set(attributionEntries.map(entry => entry.value)), + ] + + const keyword = trimStopwords( + // A price like "$1000-5000" leaves its "$" behind, since the amount itself + // starts at a word boundary + leftover.filter(token => { + return /\w/.test(token) + }), + ) + .join(" ") + .trim() + + const filterCount = + (medium ? 1 : 0) + (priceRange ? 1 : 0) + attributionClass.length + + if (!shouldSuggestFilters({ filterCount, keyword })) return null + + const labels = [ + medium?.label, + ...attributionEntries.map(entry => entry.label), + priceRange ? formatPriceLabel(priceRange) : undefined, + ].filter(Boolean) as string[] + + return { + filters: { + medium: medium?.value, + priceRange, + attributionClass: attributionClass.length ? attributionClass : undefined, + keyword: keyword || undefined, + }, + keyword, + labels, + } +} + +/** + * A single recognized filter with no keyword ("prints") is better served by the + * gene/collection entity the backend already returns, so hold the row back + * until there's either free text to carry over or a second filter. + */ +const shouldSuggestFilters = ({ + filterCount, + keyword, +}: { + filterCount: number + keyword: string +}): boolean => { + if (filterCount === 0) return false + + return keyword.length > 0 || filterCount >= 2 +} diff --git a/src/__generated__/ArtistArtworkFilterQuery.graphql.ts b/src/__generated__/ArtistArtworkFilterQuery.graphql.ts index 0ed48cf2787..ab4bd0a1fcc 100644 --- a/src/__generated__/ArtistArtworkFilterQuery.graphql.ts +++ b/src/__generated__/ArtistArtworkFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<5e5b86fce3e561c154124d024c7b4f69>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ArtistArtworkFilterQueryRendererQuery.graphql.ts b/src/__generated__/ArtistArtworkFilterQueryRendererQuery.graphql.ts index 19b517d03d2..8f6c8629024 100644 --- a/src/__generated__/ArtistArtworkFilterQueryRendererQuery.graphql.ts +++ b/src/__generated__/ArtistArtworkFilterQueryRendererQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<05a7cdc650463805b8b8d945ff58c80f>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ArtistSeriesArtworksFilterQuery.graphql.ts b/src/__generated__/ArtistSeriesArtworksFilterQuery.graphql.ts index cd62c04cf42..140ef1ecdba 100644 --- a/src/__generated__/ArtistSeriesArtworksFilterQuery.graphql.ts +++ b/src/__generated__/ArtistSeriesArtworksFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<99d32fd32570b89126362defa0fbfd58>> + * @generated SignedSource<<5b5f7fe0fa165fc265dae492f7fd8560>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ArtistSeriesArtworksFilterRefetchQuery.graphql.ts b/src/__generated__/ArtistSeriesArtworksFilterRefetchQuery.graphql.ts index ffdde110fb3..a9e4dd4515f 100644 --- a/src/__generated__/ArtistSeriesArtworksFilterRefetchQuery.graphql.ts +++ b/src/__generated__/ArtistSeriesArtworksFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ArtistSeriesOptionsQuery.graphql.ts b/src/__generated__/ArtistSeriesOptionsQuery.graphql.ts index 42ad660b62c..f77487f348c 100644 --- a/src/__generated__/ArtistSeriesOptionsQuery.graphql.ts +++ b/src/__generated__/ArtistSeriesOptionsQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<999093ec4023550d7798ad9a808d3bd6>> * @lightSyntaxTransform * @nogrep */ @@ -33,6 +33,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -52,6 +53,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ArtworkQueryFilterQuery.graphql.ts b/src/__generated__/ArtworkQueryFilterQuery.graphql.ts index 729053f867c..59889be95e0 100644 --- a/src/__generated__/ArtworkQueryFilterQuery.graphql.ts +++ b/src/__generated__/ArtworkQueryFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<9f9d57b1147c446ef0f8e3c6fec808ae>> + * @generated SignedSource<<3478b1fdbd9641768b020c8f1884a5a3>> * @lightSyntaxTransform * @nogrep */ @@ -35,6 +35,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -54,6 +55,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/AuctionArtworkFilterQuery.graphql.ts b/src/__generated__/AuctionArtworkFilterQuery.graphql.ts index 647778ebc59..1a2e11f4f7e 100644 --- a/src/__generated__/AuctionArtworkFilterQuery.graphql.ts +++ b/src/__generated__/AuctionArtworkFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<0a1fcf65726c919e63ac9049dfe02028>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/AuctionArtworkFilterRefetchQuery.graphql.ts b/src/__generated__/AuctionArtworkFilterRefetchQuery.graphql.ts index 4ba14e8c60e..a5a35deb728 100644 --- a/src/__generated__/AuctionArtworkFilterRefetchQuery.graphql.ts +++ b/src/__generated__/AuctionArtworkFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<8fa093be857d18ed0934aa3c96041718>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/CollectArtworkFilterQuery.graphql.ts b/src/__generated__/CollectArtworkFilterQuery.graphql.ts index 1b3465e1a03..4384bbbe5c4 100644 --- a/src/__generated__/CollectArtworkFilterQuery.graphql.ts +++ b/src/__generated__/CollectArtworkFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/CollectionArtworksFilterQuery.graphql.ts b/src/__generated__/CollectionArtworksFilterQuery.graphql.ts index e279454e627..0d50bdc3026 100644 --- a/src/__generated__/CollectionArtworksFilterQuery.graphql.ts +++ b/src/__generated__/CollectionArtworksFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<458d0337db0c5b1f93fd9b693667f4b3>> + * @generated SignedSource<<84dbd06eb987a24f486bc75515a933c2>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/CollectionArtworksFilterTestQuery.graphql.ts b/src/__generated__/CollectionArtworksFilterTestQuery.graphql.ts index 1a473cb8497..098346036a7 100644 --- a/src/__generated__/CollectionArtworksFilterTestQuery.graphql.ts +++ b/src/__generated__/CollectionArtworksFilterTestQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/CollectionArtworksQuery.graphql.ts b/src/__generated__/CollectionArtworksQuery.graphql.ts index e79475a921c..31d1b4f3062 100644 --- a/src/__generated__/CollectionArtworksQuery.graphql.ts +++ b/src/__generated__/CollectionArtworksQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<4d6e474219334b4b58c3505e3f093e48>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ConfirmationArtworksGridQuery.graphql.ts b/src/__generated__/ConfirmationArtworksGridQuery.graphql.ts index 8ea571f85ef..f62005c2dcc 100644 --- a/src/__generated__/ConfirmationArtworksGridQuery.graphql.ts +++ b/src/__generated__/ConfirmationArtworksGridQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<618983e5ee306e9ca6ca0a94eb27ca79>> + * @generated SignedSource<<35c1d520be129967f8d4758e98523fd6>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ConfirmationArtworksGrid_Test_Query.graphql.ts b/src/__generated__/ConfirmationArtworksGrid_Test_Query.graphql.ts index cda8377b19f..58f2f5da3fa 100644 --- a/src/__generated__/ConfirmationArtworksGrid_Test_Query.graphql.ts +++ b/src/__generated__/ConfirmationArtworksGrid_Test_Query.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/FairArtworksFilterQuery.graphql.ts b/src/__generated__/FairArtworksFilterQuery.graphql.ts index 9d79d5a4056..8e931905c1c 100644 --- a/src/__generated__/FairArtworksFilterQuery.graphql.ts +++ b/src/__generated__/FairArtworksFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/FairArtworksFilterRefetchQuery.graphql.ts b/src/__generated__/FairArtworksFilterRefetchQuery.graphql.ts index a6425798298..fff02d7fb04 100644 --- a/src/__generated__/FairArtworksFilterRefetchQuery.graphql.ts +++ b/src/__generated__/FairArtworksFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<10f87f158d278dd2d2c9e6d1bb416700>> + * @generated SignedSource<<70b6ec2b7574a52aa63b7ad17b6e5b4a>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/GeneArtworkFilterRefetchQuery.graphql.ts b/src/__generated__/GeneArtworkFilterRefetchQuery.graphql.ts index d7f6f4ad766..63916ad852f 100644 --- a/src/__generated__/GeneArtworkFilterRefetchQuery.graphql.ts +++ b/src/__generated__/GeneArtworkFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<1b2489d644dd896e88cdc5c0fdd325a0>> + * @generated SignedSource<<32f834eaaca0a4f5d8bfc3deda2f36e8>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/MyCollectionArworkSearchQuery.graphql.ts b/src/__generated__/MyCollectionArworkSearchQuery.graphql.ts index 364263d91dc..f33b5dcc440 100644 --- a/src/__generated__/MyCollectionArworkSearchQuery.graphql.ts +++ b/src/__generated__/MyCollectionArworkSearchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/SaleArtworksFilterQuery.graphql.ts b/src/__generated__/SaleArtworksFilterQuery.graphql.ts index bc13a23de2b..d4cb2ea3c71 100644 --- a/src/__generated__/SaleArtworksFilterQuery.graphql.ts +++ b/src/__generated__/SaleArtworksFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ShowArtworksFilterQuery.graphql.ts b/src/__generated__/ShowArtworksFilterQuery.graphql.ts index aacee1cfe3a..c7f56745c68 100644 --- a/src/__generated__/ShowArtworksFilterQuery.graphql.ts +++ b/src/__generated__/ShowArtworksFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<8a13d090bdaabe162ed7af1017ed949f>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/ShowArtworksFilterRefetchQuery.graphql.ts b/src/__generated__/ShowArtworksFilterRefetchQuery.graphql.ts index e6f832463cd..744cdd88658 100644 --- a/src/__generated__/ShowArtworksFilterRefetchQuery.graphql.ts +++ b/src/__generated__/ShowArtworksFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<399daa9ad1b4281e1795aedf971ffde3>> + * @generated SignedSource<<73e9dfc69d19cde255212312bb92ee30>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/SuggestedArtworksModalGridQuery.graphql.ts b/src/__generated__/SuggestedArtworksModalGridQuery.graphql.ts index 0623d740d2c..52d2b835647 100644 --- a/src/__generated__/SuggestedArtworksModalGridQuery.graphql.ts +++ b/src/__generated__/SuggestedArtworksModalGridQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<28bb460d42207bb03a30eef8eb1c982e>> + * @generated SignedSource<<7461807069771f82ab1917ed8dabdf24>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/SuggestedArtworksShelfQuery.graphql.ts b/src/__generated__/SuggestedArtworksShelfQuery.graphql.ts index 8943e8c32ef..61e3eb213a6 100644 --- a/src/__generated__/SuggestedArtworksShelfQuery.graphql.ts +++ b/src/__generated__/SuggestedArtworksShelfQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<18a3c28a08c88bfe1bf401c0b097cfd8>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/TagArtworkFilterQuery.graphql.ts b/src/__generated__/TagArtworkFilterQuery.graphql.ts index f21e34dd106..75d23419448 100644 --- a/src/__generated__/TagArtworkFilterQuery.graphql.ts +++ b/src/__generated__/TagArtworkFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<8e7e321190e7b736f8f0605ea6da9c00>> + * @generated SignedSource<<7251d9b88b2825f0ab822547030e6be8>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/TagArtworkFilterRefetchQuery.graphql.ts b/src/__generated__/TagArtworkFilterRefetchQuery.graphql.ts index 299be34e4db..c67e73b4dd7 100644 --- a/src/__generated__/TagArtworkFilterRefetchQuery.graphql.ts +++ b/src/__generated__/TagArtworkFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<142d1d03fb25d853b479f1b6d27209de>> + * @generated SignedSource<<230e246b3f8e04bce41a08f03a043ac9>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/WorksFilterQuery.graphql.ts b/src/__generated__/WorksFilterQuery.graphql.ts index 3dddec5ac91..f64eca13c6b 100644 --- a/src/__generated__/WorksFilterQuery.graphql.ts +++ b/src/__generated__/WorksFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<0bb36d067bbe22f84760b449c38cda52>> + * @generated SignedSource<<54981e9958a8996c64b5a65d8b204b8e>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/WorksFilterRefetchQuery.graphql.ts b/src/__generated__/WorksFilterRefetchQuery.graphql.ts index 0d235e06f1e..94c550f76df 100644 --- a/src/__generated__/WorksFilterRefetchQuery.graphql.ts +++ b/src/__generated__/WorksFilterRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<737ef770e752ff943cad8a880bb32102>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/exampleRoutes_ArtworkFilterQuery.graphql.ts b/src/__generated__/exampleRoutes_ArtworkFilterQuery.graphql.ts index bee1fc46c5e..cda06ee9b9b 100644 --- a/src/__generated__/exampleRoutes_ArtworkFilterQuery.graphql.ts +++ b/src/__generated__/exampleRoutes_ArtworkFilterQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<56fad28939b61eff13e0623397641820>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/geneRoutes_GeneShowQuery.graphql.ts b/src/__generated__/geneRoutes_GeneShowQuery.graphql.ts index 015bf1f6a1b..214945eb52f 100644 --- a/src/__generated__/geneRoutes_GeneShowQuery.graphql.ts +++ b/src/__generated__/geneRoutes_GeneShowQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<72f3732936172aa1ae9d941009369054>> + * @generated SignedSource<<5dc4ec9f51bb73977824d28dfe5d72df>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/saleRoutes_SaleQuery.graphql.ts b/src/__generated__/saleRoutes_SaleQuery.graphql.ts index 1780072fca6..c82910ccfca 100644 --- a/src/__generated__/saleRoutes_SaleQuery.graphql.ts +++ b/src/__generated__/saleRoutes_SaleQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<13641da7f4ef0521a23bd3f9755b65a8>> + * @generated SignedSource<<38581b73758cd6e9b2f25fcad4d767c5>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/src/__generated__/searchRoutes_ArtworksViewerQuery.graphql.ts b/src/__generated__/searchRoutes_ArtworksViewerQuery.graphql.ts index 33e9ddb4d32..b4b4eb42a41 100644 --- a/src/__generated__/searchRoutes_ArtworksViewerQuery.graphql.ts +++ b/src/__generated__/searchRoutes_ArtworksViewerQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<7d7ec95b1f6d4356f5d32054c44edc58>> * @lightSyntaxTransform * @nogrep */ @@ -34,6 +34,7 @@ export type FilterArtworksInput = { color?: string | null | undefined; colors?: ReadonlyArray | null | undefined; completenessTier?: ReadonlyArray | null | undefined; + curatorsPick?: boolean | null | undefined; dimensionRange?: string | null | undefined; disableNotForSaleSorting?: boolean | null | undefined; excludeArtworkIDs?: ReadonlyArray | null | undefined; @@ -53,6 +54,7 @@ export type FilterArtworksInput = { includeMediumFilterInAggregation?: boolean | null | undefined; includeNonArtsyListed?: boolean | null | undefined; includeUnpublished?: boolean | null | undefined; + increasedInterest?: boolean | null | undefined; inquireableOnly?: boolean | null | undefined; keyword?: string | null | undefined; keywordMatchExact?: boolean | null | undefined; diff --git a/yarn.lock b/yarn.lock index eb876be2c21..763dd9a789b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -21,12 +21,12 @@ __metadata: languageName: node linkType: hard -"@artsy/cohesion@npm:4.393.0": - version: 4.393.0 - resolution: "@artsy/cohesion@npm:4.393.0" +"@artsy/cohesion@npm:4.395.0": + version: 4.395.0 + resolution: "@artsy/cohesion@npm:4.395.0" dependencies: core-js: "npm:3" - checksum: 10c0/0d373cc71b9d82df4a58b8a7e7c589b0b51ce32f0a1bc1d2f21e20fdd3fb58cc648da03493ac43ba4d6b735f6f3e7ec88da77c4d97358cee111c0baab7cd0b14 + checksum: 10c0/cb9f51258819e25fb0544c848f59b93020748c5f8b1e549a79c7f7a26c813bab4c4e1d3a5c8ffca30019f2c5ddadfb3677f98068966f0ab1c18cdddb5ced50a5 languageName: node linkType: hard @@ -67,9 +67,9 @@ __metadata: languageName: node linkType: hard -"@artsy/icons@npm:3.74.0": - version: 3.74.0 - resolution: "@artsy/icons@npm:3.74.0" +"@artsy/icons@npm:3.76.0": + version: 3.76.0 + resolution: "@artsy/icons@npm:3.76.0" peerDependencies: react: ">=16.2.0" react-native: ">=0.70.15" @@ -81,7 +81,7 @@ __metadata: optional: true react-native-svg: optional: true - checksum: 10c0/9921e2fd0b3f48db99e5ec9155c87d19024f8951f0b7519db649712c352558eb7d6c6abe56021d9343d46d90ee53b3dbb8e5a21a5c0dd5072257cefc1ddbfd08 + checksum: 10c0/d600a131f78cc0bb03179b614f153aa40e999c53446ea5710c0e3f775bf96fa46993ee5297b69ae6f8189f6b3e38fdd7ba78e05cfd8f80118ae79df8cef0bfa5 languageName: node linkType: hard @@ -9437,12 +9437,12 @@ __metadata: resolution: "force@workspace:." dependencies: "@artaio/arta-browser": "npm:^2.16.1" - "@artsy/cohesion": "npm:4.393.0" + "@artsy/cohesion": "npm:4.395.0" "@artsy/detect-responsive-traits": "npm:^0.2.0" "@artsy/dismissible": "npm:^0.9.0" "@artsy/express-reloadable": "npm:^1.7.0" "@artsy/fresnel": "npm:^8.1.0" - "@artsy/icons": "npm:3.74.0" + "@artsy/icons": "npm:3.76.0" "@artsy/img": "npm:1.1.3" "@artsy/multienv": "npm:^1.2.0" "@artsy/palette": "npm:46.9.1"