Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app-elements/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ const en = {
back: "Back",
go_back: "Go back",
cancel: "Cancel",
clear_all: "Clear all",
clear_text: "Clear text",
close: "Close",
continue: "Continue",
Expand Down
1 change: 1 addition & 0 deletions packages/app-elements/src/locales/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const it: typeof en = {
go_back: "Torna indietro",
cancel: "Annulla",
close: "Chiudi",
clear_all: "Cancella tutto",
clear_text: "Svuota testo",
continue: "Continua",
could_not_retrieve_data: "Impossibile recuperare i dati",
Expand Down
55 changes: 55 additions & 0 deletions packages/app-elements/src/ui/atoms/ButtonFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cn from "classnames"
import type { JSX } from "react"
import { Icon } from "./Icon"
import { StatusIcon, type StatusIconProps } from "./StatusIcon"

export interface ButtonFilterProps
Expand All @@ -8,6 +9,22 @@ export interface ButtonFilterProps
onRemoveRequest?: () => void
icon?: StatusIconProps["name"]
label: string
/**
* Visual style.
* - `button` (default): compact grey button where the whole label is clickable
* to re-open the filter.
* - `pill`: rounded chip rendering `label: value` with the value in bold, where
* only the remove (`x`) button is interactive. Matches the style used by the
* dashboard metrics pages.
*/
variant?: "button" | "pill"
/**
* Value(s) rendered in bold next to the label. Long values are truncated and
* shown in full through the native tooltip.
*
* Only used by the `pill` variant.
*/
value?: string
}

function ButtonFilter({
Expand All @@ -16,8 +33,46 @@ function ButtonFilter({
label,
icon,
className,
variant = "button",
value,
...rest
}: ButtonFilterProps): JSX.Element {
if (variant === "pill") {
return (
<div
className={cn(
"flex items-center gap-2 px-3 py-1 leading-5",
"bg-gray-100 border border-gray-100 rounded-[8px] text-[13px] max-w-75",
className,
)}
// the value is truncated when too long, so keep it reachable on hover
title={value}
{...rest}
>
<span className="truncate">
{value == null ? (
label
) : (
<>
{label}: <span className="font-semibold">{value}</span>
</>
)}
</span>
{onRemoveRequest != null ? (
<button
type="button"
data-testid="ButtonFilter-remove"
className="flex items-center justify-center hover:opacity-70 shrink-0"
onClick={onRemoveRequest}
aria-label={`Remove ${label}`}
>
<Icon name="x" size={14} />
</button>
) : null}
</div>
)
}

return (
<div
className={cn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { JSX } from "react"
import { useFormContext } from "react-hook-form"
import { HookedInputResourceGroup } from "#ui/forms/InputResourceGroup"
import { HookedInputToggleButton } from "#ui/forms/InputToggleButton"
import { FieldOptionsSelect } from "./FieldOptionsSelect"
import type { FilterItemOptions } from "./types"
import { computeFilterLabel } from "./utils"

Expand Down Expand Up @@ -43,5 +44,12 @@ export function FieldOptions({ item }: FieldProps): JSX.Element | null {
{...item.render.props}
/>
)

case "inputSelect":
return (
<FieldOptionsSelect
item={item as FilterItemOptions & { render: typeof item.render }}
/>
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { QueryParamsList } from "@commercelayer/sdk"
import castArray from "lodash-es/castArray"
import uniqBy from "lodash-es/uniqBy"
import type { JSX } from "react"
import { useFormContext } from "react-hook-form"
import { useCoreApi, useCoreSdkProvider } from "#providers/CoreSdkProvider"
import { HookedInputSelect, type InputSelectValue } from "#ui/forms/InputSelect"
import type { FilterItemOptions } from "./types"

type SelectRender = Extract<
FilterItemOptions["render"],
{ component: "inputSelect" }
>

/** Core caps `pageSize` at 25, so this is also the most we can load in one go. */
const defaultLimit = 25

/**
* Renders an `options` filter as a (multi) select dropdown, the style used by the
* dashboard metrics filters.
*
* Options come from the Core API. Anything already selected is fetched
* separately, so its label resolves even when it is not in the first page, and
* when `searchBy` is set typing searches server-side instead of filtering only
* what has been loaded.
*/
export function FieldOptionsSelect({
item,
}: {
item: FilterItemOptions & { render: SelectRender }
}): JSX.Element | null {
const { watch } = useFormContext()
const { sdkClient } = useCoreSdkProvider()

const {
resource,
fieldForLabel,
fieldForValue,
searchBy,
sortBy,
filters = {},
limit = defaultLimit,
placeholder,
isClearable,
isMulti = true,
hideWhenSingleItem,
} = item.render.props

const selectedValues = castArray(watch(item.sdk.predicate) ?? []).map(
(value) => String(value),
)

const listQuery: QueryParamsList = {
fields: {
[resource]: [fieldForValue, fieldForLabel],
},
pageSize: limit as QueryParamsList["pageSize"],
...(sortBy != null
? { sort: { [sortBy.attribute]: sortBy.direction } }
: {}),
filters,
}

const toOption = (item: Record<string, unknown>): InputSelectValue => ({
value: String(item[fieldForValue]),
label: String(item[fieldForLabel] ?? item[fieldForValue]),
})

const { data: firstPage, isLoading } = useCoreApi(
resource,
"list",
[listQuery],
{ revalidateOnFocus: false },
)

// Selected options may live on a later page, so they are fetched on their own
// to keep their labels resolvable. With nothing selected this is the very same
// query as above, which swr dedupes.
const { data: selectedResources } = useCoreApi(
resource,
"list",
[
selectedValues.length === 0
? listQuery
: {
...listQuery,
filters: {
...filters,
[`${fieldForValue}_in`]: selectedValues.join(","),
},
},
],
{ revalidateOnFocus: false },
)

const initialValues = uniqBy(
[...(selectedResources ?? []), ...(firstPage ?? [])].map((resource) =>
toOption(resource as unknown as Record<string, unknown>),
),
"value",
)

// parity with `inputResourceGroup`: a filter over a single possible value is
// not worth showing, unless the user already picked something
if (
hideWhenSingleItem === true &&
firstPage?.meta?.recordCount === 1 &&
selectedValues.length === 0
) {
return null
}

return (
<HookedInputSelect
name={item.sdk.predicate}
label={item.label}
initialValues={initialValues}
isLoading={isLoading}
isMulti={isMulti}
isClearable={isClearable}
placeholder={placeholder}
loadAsyncValues={
searchBy == null
? undefined
: async (hint) => {
// the sdk resource is only known at runtime, so the `list` shape
// cannot be inferred from the union of all listable resources
const results = await (
sdkClient[resource] as unknown as {
list: (
params: QueryParamsList,
) => Promise<Array<Record<string, unknown>>>
}
).list({
...listQuery,
filters: { ...filters, [searchBy]: hint },
})

return results.map(toOption)
}
}
/>
)
}
Loading
Loading