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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 53 additions & 51 deletions apps/exports/src/components/Details/ExportDetails.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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 (
<ListDetails title="Info" isLoading={isLoading}>
<ListDetailsItem label="Includes" gutter="none">
{data.includes != null && data.includes.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{data.includes.map((inc) => (
<Tag key={inc}>{inc}</Tag>
))}
<Card
gap="6"
overflow="visible"
backgroundColor="light"
className="flex flex-col gap-2 mt-6 print:p-4 print:rounded-sm"
>
<FiltersPreview filters={data.filters} />
{showIncludes && (
<div className="flex gap-2 px-1">
<Text
size="small"
variant="info"
wrap="nowrap"
className="font-mono"
>
Includes:
</Text>
<Text size="small" className="font-mono">
<div className="flex flex-wrap" style={{ columnGap: "0.5rem" }}>
{data.includes?.map((inc, idx) => (
<span key={inc} style={{ overflowWrap: "normal" }}>
{inc}
{idx < (data.includes ?? []).length - 1 ? "," : ""}
</span>
))}
</div>
</Text>
</div>
) : null}
</ListDetailsItem>

<ListDetailsItem label="Filters" gutter="none">
<JsonPreview json={data.filters} />
</ListDetailsItem>

<ListDetailsItem label="Options" gutter="none">
{data.dry_data === true || !isEmpty(data.fields) ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{data.dry_data === true && (
<Badge variant="teal" icon="check">
Importable
</Badge>
)}
{!isEmpty(data.fields) && (
<Badge variant="teal" icon="check">
Simple format
</Badge>
)}
)}
{showOptions && (
<div className="flex gap-2 px-1">
<Text
size="small"
variant="info"
wrap="nowrap"
className="font-mono"
>
Options:
</Text>
<Text size="small" className="font-mono">
{data.dry_data === true && <span>importable</span>}
{data.dry_data === true && !isEmpty(data.fields) && (
<span className="mr-2">,</span>
)}
{!isEmpty(data.fields) && <span>simple format</span>}
</Text>
</div>
) : null}
</ListDetailsItem>
)}
</Card>
</ListDetails>
)
})

function JsonPreview({ json }: { json?: object | null }): React.JSX.Element {
return (
<pre
style={{
backgroundColor: "#f8f8f8", // .bg-gray-50
overflowX: "auto", // .overflow-x-auto
padding: "1rem", // .p-4
fontSize: ".75rem", // .text-xs
borderRadius: "5px", // .rounded
}}
>
{json != null && Object.keys(json).length > 0 ? (
<>{JSON.stringify(json, null, 2)}</>
) : (
<>-</>
)}
</pre>
)
}
198 changes: 198 additions & 0 deletions apps/exports/src/components/Details/FiltersPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, Map<string, string>>
>({})

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 (
<div className="flex flex-wrap items-center gap-2 px-1">
<Text size="small" variant="info" className="font-mono">
No filters set for this export
</Text>
</div>
)
}

const rows = buildFilterRows(filters, namesByResourceType, user?.timezone)

return (
<>
{rows.map((row) => (
<div key={row.label} className="flex flex-wrap items-center gap-2 px-1">
<Text size="small" variant="info" className="font-mono">
{row.label}:
</Text>
<Text size="small" className="font-mono">
{row.values.map((value, idx) => (
<span key={value} className="mr-2">
{value}
{idx < row.values.length - 1 ? "," : ""}
</span>
))}
</Text>
</div>
))}
</>
)
}

function getIdsByResourceType(
filters?: Record<string, unknown> | null,
): Record<string, string[]> {
if (filters == null) {
return {}
}

const idsByResourceType: Record<string, Set<string>> = {}

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<string>()
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<string, unknown>,
namesByResourceType: Record<string, Map<string, string>>,
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)]
}
Loading