diff --git a/apps/exports/src/components/Details/ExportDetails.tsx b/apps/exports/src/components/Details/ExportDetails.tsx index c3378763..66d3f2b8 100644 --- a/apps/exports/src/components/Details/ExportDetails.tsx +++ b/apps/exports/src/components/Details/ExportDetails.tsx @@ -1,11 +1,11 @@ import { - Badge, + Card, ListDetails, - ListDetailsItem, - Tag, + Text, withSkeletonTemplate, } from "@commercelayer/app-elements" import isEmpty from "lodash-es/isEmpty" +import { FiltersPreview } from "./FiltersPreview" import { useExportDetailsContext } from "./Provider" export const ExportDetails = withSkeletonTemplate(({ isLoading }) => { @@ -17,58 +17,60 @@ export const ExportDetails = withSkeletonTemplate(({ isLoading }) => { return null } + const showIncludes = data.includes != null && data.includes.length > 0 + const showOptions = data.dry_data === true || !isEmpty(data.fields) + return ( - - {data.includes != null && data.includes.length > 0 ? ( -
- {data.includes.map((inc) => ( - {inc} - ))} + + + {showIncludes && ( +
+ + Includes: + + +
+ {data.includes?.map((inc, idx) => ( + + {inc} + {idx < (data.includes ?? []).length - 1 ? "," : ""} + + ))} +
+
- ) : null} - - - - - - - - {data.dry_data === true || !isEmpty(data.fields) ? ( -
- {data.dry_data === true && ( - - Importable - - )} - {!isEmpty(data.fields) && ( - - Simple format - - )} + )} + {showOptions && ( +
+ + Options: + + + {data.dry_data === true && importable} + {data.dry_data === true && !isEmpty(data.fields) && ( + , + )} + {!isEmpty(data.fields) && simple format} +
- ) : null} - + )} + ) }) - -function JsonPreview({ json }: { json?: object | null }): React.JSX.Element { - return ( -
-      {json != null && Object.keys(json).length > 0 ? (
-        <>{JSON.stringify(json, null, 2)}
-      ) : (
-        <>-
-      )}
-    
- ) -} diff --git a/apps/exports/src/components/Details/FiltersPreview.tsx b/apps/exports/src/components/Details/FiltersPreview.tsx new file mode 100644 index 00000000..87251540 --- /dev/null +++ b/apps/exports/src/components/Details/FiltersPreview.tsx @@ -0,0 +1,198 @@ +import { + formatDate, + Text, + useCoreSdkProvider, + useTokenProvider, +} from "@commercelayer/app-elements" +import { useEffect, useState } from "react" +import { + fetchInitialResources, + type SearchableResource, +} from "#components/Form/ResourceFinder/utils" +import { + CODE_FILTER_FIELDS, + filterFieldLabel, + isDateFilterField, + isMetadataFilterField, + RESOURCE_FILTER_FIELDS, + VALUE_LABELS, +} from "./filtersConfig" + +interface Props { + filters?: Record | null +} + +interface FilterRow { + label: string + values: string[] +} + +export function FiltersPreview({ filters }: Props): React.JSX.Element { + const { sdkClient } = useCoreSdkProvider() + const { user } = useTokenProvider() + const [namesByResourceType, setNamesByResourceType] = useState< + Record> + >({}) + + const idsByResourceType = getIdsByResourceType(filters) + const idsByResourceTypeKey = JSON.stringify(idsByResourceType) + + useEffect(() => { + if (sdkClient == null || Object.keys(idsByResourceType).length === 0) { + return + } + + void Promise.allSettled( + Object.entries(idsByResourceType).map(async ([resourceType, ids]) => { + const suggestions = await fetchInitialResources({ + sdkClient, + resourceType: resourceType as SearchableResource, + filters: { id_in: ids.join(",") }, + fieldForValue: "id", + fieldForLabel: "name", + }) + return [ + resourceType, + new Map(suggestions.map((s) => [String(s.value), s.label])), + ] as const + }), + ).then((results) => { + const entries = results.flatMap((result) => { + if (result.status === "rejected") { + console.error( + "Export filters preview: could not resolve resource names", + result.reason, + ) + return [] + } + return [result.value] + }) + setNamesByResourceType(Object.fromEntries(entries)) + }) + }, [sdkClient, idsByResourceTypeKey]) + + if (filters == null || Object.keys(filters).length === 0) { + return ( +
+ + No filters set for this export + +
+ ) + } + + const rows = buildFilterRows(filters, namesByResourceType, user?.timezone) + + return ( + <> + {rows.map((row) => ( +
+ + {row.label}: + + + {row.values.map((value, idx) => ( + + {value} + {idx < row.values.length - 1 ? "," : ""} + + ))} + +
+ ))} + + ) +} + +function getIdsByResourceType( + filters?: Record | null, +): Record { + if (filters == null) { + return {} + } + + const idsByResourceType: Record> = {} + + for (const [field, resourceType] of Object.entries(RESOURCE_FILTER_FIELDS)) { + const value = filters[field] + if (value == null) { + continue + } + + const ids = splitValues(value) + const set = idsByResourceType[resourceType] ?? new Set() + ids.forEach((id) => set.add(id)) + idsByResourceType[resourceType] = set + } + + return Object.fromEntries( + Object.entries(idsByResourceType).map(([resourceType, ids]) => [ + resourceType, + Array.from(ids), + ]), + ) +} + +function buildFilterRows( + filters: Record, + namesByResourceType: Record>, + timezone?: string, +): FilterRow[] { + return Object.entries(filters) + .filter(([, value]) => value != null && value !== "") + .map(([field, value]) => { + if (isMetadataFilterField(field)) { + return { label: filterFieldLabel(field), values: [String(value)] } + } + + const values = splitValues(value) + + const resourceType = RESOURCE_FILTER_FIELDS[field] + if (resourceType != null) { + const names = namesByResourceType[resourceType] + return { + label: filterFieldLabel(field), + values: values.map((id) => names?.get(id) ?? id), + } + } + + if (CODE_FILTER_FIELDS.has(field)) { + return { label: filterFieldLabel(field), values } + } + + if (isDateFilterField(field)) { + return { + label: filterFieldLabel(field), + values: values.map((isoDate) => + formatDate({ isoDate, format: "full", timezone }), + ), + } + } + + const valueLabels = VALUE_LABELS[field] + if (valueLabels != null) { + return { + label: filterFieldLabel(field), + values: values.map((v) => valueLabels[v] ?? v), + } + } + + return { label: filterFieldLabel(field), values } + }) +} + +/** Splits a filter value into individual values, handling both arrays and comma-separated strings (Ransack `_in`-style filters). */ +function splitValues(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map(String) + } + + if (typeof value === "string") { + return value + .split(",") + .map((v) => v.trim()) + .filter(Boolean) + } + + return [String(value)] +} diff --git a/apps/exports/src/components/Details/filtersConfig.ts b/apps/exports/src/components/Details/filtersConfig.ts new file mode 100644 index 00000000..a70b0e08 --- /dev/null +++ b/apps/exports/src/components/Details/filtersConfig.ts @@ -0,0 +1,169 @@ +import type { SearchableResource } from "#components/Form/ResourceFinder/utils" + +/** + * Filter fields whose value is a related resource id. The referenced + * resource must be fetched to resolve the id into a human-readable name. + */ +export const RESOURCE_FILTER_FIELDS: Record = { + market_in: "markets", + market_id_in: "markets", + tags_id_in: "tags", + shipping_category_id_in: "shipping_categories", + price_list_id_eq: "price_lists", + promotion_rule_promotion_id_eq: "promotions", + stock_location_id_in: "stock_locations", +} + +/** + * Filter fields whose value is already a human-readable code (a SKU code), + * so no extra fetch is needed to display it. + */ +export const CODE_FILTER_FIELDS = new Set(["code_in", "sku_code_in"]) + +/** Overrides for the deduced label of specific fields whose name is redundant/confusing. */ +const LABEL_OVERRIDES: Record = { + promotion_rule_promotion_id_eq: "Promotion", +} + +/** Friendlier values for filter fields whose raw value isn't self-explanatory. */ +export const VALUE_LABELS: Record> = { + do_not_ship_false: { + true: "Shippable SKU", + false: "Non-shippable SKU", + }, +} + +/** + * Human-readable phrase for each Commerce Layer filtering predicate, to be + * appended after the field label. + * @see https://docs.commercelayer.io/core/filtering-data#list-of-predicates + */ +const OPERATOR_PHRASES: Record = { + eq: "", + eq_or_null: "or empty", + not_eq: "not", + not_eq_or_null: "not or empty", + not_eq_all: "none of", + matches: "matches", + does_not_match: "not matches", + matches_any: "matches any", + matches_all: "matches all", + does_not_match_any: "not matches any", + does_not_match_all: "not matches all", + lt: "under", + lteq: "up to", + gt: "over", + gteq: "from", + lt_any: "under any", + lteq_any: "up to any", + gt_any: "over any", + gteq_any: "from any", + lt_all: "under all", + lteq_all: "up to all", + gt_all: "over all", + gteq_all: "from all", + present: "present", + blank: "blank", + null: "empty", + not_null: "not empty", + in: "", + in_or_null: "or empty", + not_in: "none of", + not_in_or_null: "none of or empty", + start: "starts", + not_start: "not starts", + start_any: "starts any", + start_all: "starts all", + not_start_any: "not starts any", + not_start_all: "not starts all", + end: "ends", + not_end: "not ends", + end_any: "ends any", + end_all: "ends all", + not_end_any: "not ends any", + not_end_all: "not ends all", + cont: "contains", + not_cont: "not contains", + cont_any: "contains any", + not_cont_any: "not contains any", + cont_all: "contains all", + not_cont_all: "not contains all", + i_cont: "contains", + not_i_cont: "not contains", + i_cont_any: "contains any", + not_i_cont_any: "not contains any", + i_cont_all: "contains all", + not_i_cont_all: "not contains all", + jcont: "contains", + true: "true", + false: "false", +} + +const MAX_OPERATOR_TOKENS = Math.max( + ...Object.keys(OPERATOR_PHRASES).map( + (operator) => operator.split("_").length, + ), +) + +interface ParsedFilterField { + /** field name tokens with the trailing operator removed, e.g. ["placed", "at"] */ + labelTokens: string[] + /** the matched predicate suffix, e.g. "gteq", or null when not recognized */ + operator: string | null +} + +function parseFilterField(field: string): ParsedFilterField { + const tokens = field.split("_") + + for ( + let length = Math.min(MAX_OPERATOR_TOKENS, tokens.length - 1); + length > 0; + length-- + ) { + const candidate = tokens.slice(-length).join("_") + if (candidate in OPERATOR_PHRASES) { + return { labelTokens: tokens.slice(0, -length), operator: candidate } + } + } + + return { labelTokens: tokens, operator: null } +} + +/** A field is considered a date field when its label ends with "at" (placed_at, created_at, updated_at, ...). */ +export function isDateFilterField(field: string): boolean { + const { labelTokens } = parseFilterField(field) + return labelTokens.at(-1) === "at" +} + +/** A field is considered a metadata field when its label starts with "metadata" (metadata_jcont, ...). */ +export function isMetadataFilterField(field: string): boolean { + const { labelTokens } = parseFilterField(field) + return labelTokens[0] === "metadata" +} + +/** Deduces a human-readable label from a filter field name, e.g. "placed_at_gteq" -> "Placed from". */ +export function filterFieldLabel(field: string): string { + const { labelTokens, operator } = parseFilterField(field) + const phrase = operator == null ? "" : OPERATOR_PHRASES[operator] + + const labelOverride = LABEL_OVERRIDES[field] + if (labelOverride != null) { + return phrase ? `${labelOverride} ${phrase}` : labelOverride + } + + // The related resource is displayed by name, so the "id" token is redundant. + const isResourceField = field in RESOURCE_FILTER_FIELDS + const filteredTokens = + isResourceField && labelTokens.at(-1) === "id" + ? labelTokens.slice(0, -1) + : labelTokens.at(-1) === "at" + ? labelTokens.slice(0, -1) + : labelTokens + + const label = filteredTokens + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + return phrase ? `${label} ${phrase}` : label +}