Skip to content

Commit 8300fe8

Browse files
mirekmEloncursoragentlkostrowski
authored
Swatch variant datagrid (#6705)
* Add swatch variant attributes to variants datagrid * Render swatch previews in variant datagrid cells * Harden product type tab count deduping and add missing tests (#6722) * Harden product type tab count deduping and add missing tests * Default attribute list group-by-type on and fix test types. New sessions see type tabs on product and model attribute lists by default, with ripple copy updated accordingly. Test helpers use NonNullable aliases for nullable GraphQL query fields. Co-authored-by: Cursor <cursoragent@cursor.com> * Extract messages --------- Co-authored-by: Cursor <cursoragent@cursor.com> * Product Type and Model Type views update (#6725) * Add PageTypeMetadataDialog for model type metadata editing Prepares the header metadata modal pattern by introducing a standalone dialog component aligned with other entity detail pages. * Move model type metadata editing to header modal Wire PageTypeMetadataDialog through URL state and TopNav, and decouple metadata from the main type save flow on edit. * Add skeleton title component to model type detail header * Add changeset for type page metadata in header Documents the user-facing move of metadata editing to the header on model type and product type detail pages. * Move product type metadata editing to header modal Mirror the model type refresh with ProductTypeMetadataDialog, TopNav metadata button, skeleton title, and decoupled metadata save on edit. * Add iconized cog menu actions on type detail pages * Polish type detail page layout and GraphiQL menu icon * Reorganize type pages and improve create form UX Move attribute schema to the main column and type settings to the sidebar, keep the variant toggle in main content, autofocus the name field on create, use Macaw inputs on model types, and suppress duplicate validation toasts when errors are shown inline. * Align type delete dialogs with standard delete modal patter * Fix type delete dialog links and remove blocking spinner * Fix dirty state handling on product and model type detail pages * Extract messages * Fix fulfilment warehouse guard (#6726) * Guard fulfill submit when warehouse is missing Skip zero-quantity allocations and lines without a warehouse before mapping warehouse IDs, preventing a crash when fulfilling a single line. * Guard fulfill submit and polish order fulfill page Extract getOrderFulfillSubmitItems with regression tests, add changeset, and align fulfill line controls with macaw patterns including warehouse pre-selection, zero-quantity disabling, and truncated variant captions. * Polish reason reference modal layout (#6727) Align add/edit reason dialog with dashboard modal patterns. * Alternative fullfilments view (#6728) * Line matrix order details view Add "Timeline" | "Line matrix" toggle on order details with lifecycle columns, expanded shipment panel, and shared warehouse labeling across matrix, timeline, and order history. Correlate canceled fulfillments with restock warehouse from events; default cancel-dialog restock warehouse to source; move shipment cancel into overflow menu for parity with timeline cards. * Complete line matrix Phase 1 polish Persist view mode in list settings with legacy key migration, wire refunded column tooltip, collapse canceled timeline fulfillments, add matrix tests and i18n extraction, and drop visible focus ring on the expanded shipment panel. * Add line-first return and refund actions to order matrix Deep-link return and transaction refund flows with lineId prefill, expose per-row actions in the matrix, and show fulfillment refunded amounts in the expanded panel. * Unify order refund navigation across dashboard entry points Introduce getOrderRefundNavigation and route order details, fulfillment cards, and matrix row actions through it so legacy and transaction refunds pick the same destination. * Polish order line matrix actions and shipment detail rows Make shipment status and return context scannable in the expanded panel, and wire matrix row menus through TopNav.Menu with guarded return/refund actions. * Polish line matrix refunds, failure surfacing, and needs-action UX * Add ripple on line matrix view switch * Correct column reorder off-by-one for pinned status, and scope expanded-panel refunds to the active line * Fix order matrix and refund test fixture types for strict checking * Close line matrix parity gaps and polish matrix UX for refunds and navigation * Scope return page to a single line when opened from the matrix * Unpin product column name in line matrix * Keep expanded line panel open when fullfilment dialogs open * Fix matrix column image cell invalidation * Align price breakdown modal with DashboardModal layout patterns * Use a 1px border for expanded matrix status icons * Fix matrix scroll jank when a line is focused via lineId The lineId sync effect called setViewMode("matrix") unconditionally; list settings always produce a new state object and write to localStorage, and the effect deps are recreated each render, so the whole order details tree re-rendered in an endless loop whenever lineId was in the URL. Guard the setViewMode call so the effect is idempotent. * Attach tooltip to the label instead of a help icon * Extract messages * Remove unused ts-jest (#6730) * Reuse existing SwatchPreview for variant datagrid swatch cells Avoid a duplicate preview component by extracting swatch data helpers and wrapping the attributes SwatchPreview for datagrid use. --------- Co-authored-by: Elon <yong.gu@coraool.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
1 parent a695985 commit 8300fe8

13 files changed

Lines changed: 314 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"saleor-dashboard": patch
3+
---
4+
5+
Swatch variant attributes now appear in the product variants grid with color previews, so merchants can edit color-style variant values alongside other variant columns.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
3+
import { DatagridSwatchPreview } from "./DatagridSwatchPreview";
4+
5+
const meta: Meta<typeof DatagridSwatchPreview> = {
6+
title: "Components/DatagridSwatchPreview",
7+
component: DatagridSwatchPreview,
8+
args: {
9+
colorValue: "#E53935",
10+
size: 8,
11+
},
12+
};
13+
14+
export default meta;
15+
type Story = StoryObj<typeof DatagridSwatchPreview>;
16+
17+
export const Color: Story = {};
18+
19+
export const Image: Story = {
20+
args: {
21+
colorValue: null,
22+
fileUrl: "https://placehold.co/32x32/png",
23+
size: 14,
24+
},
25+
};
26+
27+
export const Empty: Story = {
28+
args: {
29+
colorValue: null,
30+
fileUrl: null,
31+
},
32+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { SwatchPreview } from "@dashboard/attributes/components/SwatchPreview/SwatchPreview";
2+
3+
import { type AttributeSwatchData } from "./getAttributeSwatchData";
4+
5+
interface DatagridSwatchPreviewProps extends AttributeSwatchData {
6+
size?: number;
7+
}
8+
9+
export const DatagridSwatchPreview = ({
10+
colorValue,
11+
fileUrl,
12+
size = 8,
13+
}: DatagridSwatchPreviewProps) => (
14+
<SwatchPreview color={colorValue} imageUrl={fileUrl} size={size} />
15+
);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export interface AttributeSwatchData {
2+
colorValue?: string | null;
3+
fileUrl?: string | null;
4+
}
5+
6+
interface AttributeValueWithSwatch {
7+
file?: { url?: string } | null;
8+
value?: string | null;
9+
}
10+
11+
export const getAttributeSwatchData = (
12+
attributeValue: AttributeValueWithSwatch | null | undefined,
13+
): AttributeSwatchData | undefined => {
14+
if (!attributeValue) {
15+
return undefined;
16+
}
17+
18+
const fileUrl = attributeValue.file?.url;
19+
const colorValue = attributeValue.value;
20+
21+
if (!fileUrl && !colorValue) {
22+
return undefined;
23+
}
24+
25+
return { colorValue, fileUrl };
26+
};

src/components/Datagrid/customCells/DropdownCell.tsx

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DatagridSwatchPreview } from "@dashboard/components/Attributes/DatagridSwatchPreview";
2+
import { type AttributeSwatchData } from "@dashboard/components/Attributes/getAttributeSwatchData";
13
import {
24
type CustomCell,
35
type CustomRenderer,
6+
type DrawArgs,
47
getMiddleCenterBias,
58
GridCellKind,
69
type ProvideEditorCallback,
@@ -9,13 +12,19 @@ import { DynamicCombobox, type Option } from "@saleor/macaw-ui-next";
912
import { useCallback, useState } from "react";
1013

1114
type DropdownCellGetSuggestionsFn = (text: string) => Promise<Option[]>;
15+
16+
export type AttributeSearchOption = Option & {
17+
swatch?: AttributeSwatchData;
18+
};
19+
1220
export interface DropdownCellProps {
1321
readonly choices?: Option[];
1422
readonly update?: DropdownCellGetSuggestionsFn;
1523
readonly kind: "dropdown-cell";
1624
readonly value: Option | null;
1725
readonly allowCustomValues?: boolean;
1826
readonly emptyOption?: boolean;
27+
readonly swatch?: AttributeSwatchData;
1928
}
2029

2130
export type DropdownCell = CustomCell<DropdownCellProps>;
@@ -25,6 +34,12 @@ export const emptyDropdownCellValue: Option = {
2534
value: "",
2635
};
2736

37+
const SWATCH_SIZE = 14;
38+
const SWATCH_TEXT_GAP = 6;
39+
40+
const getOptionSwatch = (option: Option | null | undefined): AttributeSwatchData | undefined =>
41+
(option as AttributeSearchOption | null | undefined)?.swatch;
42+
2843
const DropdownCellEdit: ReturnType<ProvideEditorCallback<DropdownCell>> = ({
2944
value: cell,
3045
onFinishedEditing,
@@ -42,44 +57,99 @@ const DropdownCellEdit: ReturnType<ProvideEditorCallback<DropdownCell>> = ({
4257
? { fetchOnFocus: true, fetchChoices: getChoices, choices: data }
4358
: { fetchOnFocus: false, fetchChoices: () => Promise.resolve([]), choices: cell.data.choices };
4459

60+
const selectedSwatch = cell.data.swatch ?? getOptionSwatch(cell.data.value);
61+
4562
return (
4663
<DynamicCombobox
4764
options={props.choices ?? []}
4865
value={cell.data.value}
4966
onFocus={() => props.fetchChoices("")}
5067
loading={false}
5168
name=""
69+
startAdornment={() => (selectedSwatch ? <DatagridSwatchPreview {...selectedSwatch} /> : null)}
5270
/**
5371
* There is a bug - looks like it's properly changing with keyobard, but mouse event is somehow not passed
5472
* to the dropdown layer @fixme
5573
*/
5674
onChange={option => {
75+
const matchedOption =
76+
props.choices?.find(choice => choice.value === option?.value) ??
77+
({
78+
label: option?.label ?? "",
79+
value: option?.value ?? "",
80+
} satisfies Option);
81+
5782
return onFinishedEditing({
5883
...cell,
5984
data: {
6085
...cell.data,
61-
value: props.choices?.find(c => c.value === option?.value) ?? {
62-
label: option?.label ?? "",
63-
value: option?.value ?? "",
64-
},
86+
value: matchedOption,
87+
swatch: getOptionSwatch(matchedOption),
6588
},
6689
});
6790
}}
6891
/>
6992
);
7093
};
7194

95+
const drawSwatch = (
96+
args: DrawArgs<DropdownCell>,
97+
x: number,
98+
y: number,
99+
swatch: AttributeSwatchData,
100+
) => {
101+
const { ctx, theme, imageLoader, col, row } = args;
102+
const radius = SWATCH_SIZE / 2;
103+
const centerX = x + radius;
104+
const centerY = y + radius;
105+
106+
ctx.save();
107+
ctx.beginPath();
108+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
109+
ctx.clip();
110+
111+
if (swatch.fileUrl) {
112+
const imageResult = imageLoader.loadOrGetImage(swatch.fileUrl, col, row);
113+
114+
if (imageResult) {
115+
ctx.drawImage(imageResult, x, y, SWATCH_SIZE, SWATCH_SIZE);
116+
} else {
117+
ctx.fillStyle = theme.borderColor;
118+
ctx.fill();
119+
}
120+
} else if (swatch.colorValue) {
121+
ctx.fillStyle = swatch.colorValue;
122+
ctx.fill();
123+
}
124+
125+
ctx.restore();
126+
127+
ctx.beginPath();
128+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
129+
ctx.strokeStyle = theme.borderColor;
130+
ctx.lineWidth = 1;
131+
ctx.stroke();
132+
};
133+
72134
export const dropdownCellRenderer: CustomRenderer<DropdownCell> = {
73135
kind: GridCellKind.Custom,
74136
isMatch: (c): c is DropdownCell => (c.data as any).kind === "dropdown-cell",
75137
draw: (args, cell) => {
76138
const { ctx, theme, rect } = args;
77-
const { value } = cell.data;
139+
const { value, swatch } = cell.data;
140+
const cellSwatch = swatch ?? getOptionSwatch(value);
141+
const textX = cellSwatch ? rect.x + 8 + SWATCH_SIZE + SWATCH_TEXT_GAP : rect.x + 8;
142+
143+
if (cellSwatch) {
144+
const swatchY = rect.y + (rect.height - SWATCH_SIZE) / 2;
145+
146+
drawSwatch(args, rect.x + 8, swatchY, cellSwatch);
147+
}
78148

79149
ctx.fillStyle = theme.textDark;
80150
ctx.fillText(
81151
value?.label ?? "",
82-
rect.x + 8,
152+
textX,
83153
rect.y + rect.height / 2 + getMiddleCenterBias(ctx, theme),
84154
);
85155

@@ -95,11 +165,13 @@ export const dropdownCellRenderer: CustomRenderer<DropdownCell> = {
95165
...cell.data,
96166
display: "",
97167
value: null,
168+
swatch: undefined,
98169
},
99170
}),
100171
}),
101172
onPaste: (value, data) => ({
102173
...data,
103174
value: value ? { value, label: value } : null,
175+
swatch: undefined,
104176
}),
105177
};

src/components/Datagrid/customCells/cells.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export function moneyDiscountedCell(
171171

172172
export function dropdownCell(
173173
value: Option,
174-
dataOpts: Pick<DropdownCellProps, "allowCustomValues" | "emptyOption"> &
174+
dataOpts: Pick<DropdownCellProps, "allowCustomValues" | "emptyOption" | "swatch"> &
175175
({ choices: Option[] } | { update: (text: string) => Promise<Option[]> }),
176176
opts?: Partial<GridCell>,
177177
): DropdownCell {

src/products/components/ProductVariants/ProductVariants.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons";
1212
import { DashboardModal } from "@dashboard/components/Modal";
1313
import {
14-
AttributeInputTypeEnum,
1514
type ProductDetailsVariantFragment,
1615
type ProductFragment,
1716
type ProductVariantBulkCreateInput,
@@ -37,6 +36,7 @@ import {
3736
} from "../ProductVariantGenerator/types";
3837
import { ProductVariantsHeader } from "./components/ProductVariantsHeader";
3938
import {
39+
isVariantDatagridSupportedAttribute,
4040
useAttributesAdapter,
4141
useChannelAdapter,
4242
useChannelAvailabilityAdapter,
@@ -185,11 +185,7 @@ export const ProductVariants = ({
185185
]),
186186
...warehouses.map(warehouse => `warehouse:${warehouse.id}`),
187187
...(variantAttributes
188-
?.filter(
189-
attribute =>
190-
attribute.inputType === AttributeInputTypeEnum.DROPDOWN ||
191-
attribute.inputType === AttributeInputTypeEnum.PLAIN_TEXT,
192-
)
188+
?.filter(attribute => isVariantDatagridSupportedAttribute(attribute.inputType))
193189
.map(attribute => `attribute:${attribute.id}`) ?? []),
194190
]
195191
: undefined,
@@ -258,10 +254,11 @@ export const ProductVariants = ({
258254
row,
259255
channels,
260256
variants,
257+
variantAttributes,
261258
searchAttributeValues: onAttributeValuesSearch,
262259
...opts,
263260
}),
264-
[channels, visibleColumns, onAttributeValuesSearch, variants],
261+
[channels, visibleColumns, onAttributeValuesSearch, variantAttributes, variants],
265262
);
266263
const getCellError = useCallback(
267264
([column, row]: Item, opts: GetCellContentOpts) =>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { AttributeInputTypeEnum } from "@dashboard/graphql";
2+
3+
import { isVariantDatagridSupportedAttribute } from "./datagrid";
4+
5+
describe("isVariantDatagridSupportedAttribute", () => {
6+
it("should return true for attributes supported by variants datagrid", () => {
7+
// Arrange
8+
const supportedInputTypes = [
9+
AttributeInputTypeEnum.DROPDOWN,
10+
AttributeInputTypeEnum.PLAIN_TEXT,
11+
AttributeInputTypeEnum.SWATCH,
12+
];
13+
14+
// Act
15+
const result = supportedInputTypes.map(isVariantDatagridSupportedAttribute);
16+
17+
// Assert
18+
expect(result).toEqual([true, true, true]);
19+
});
20+
21+
it("should return false for attributes unsupported by variants datagrid", () => {
22+
// Arrange
23+
const unsupportedInputTypes = [AttributeInputTypeEnum.BOOLEAN, null, undefined];
24+
25+
// Act
26+
const result = unsupportedInputTypes.map(isVariantDatagridSupportedAttribute);
27+
28+
// Assert
29+
expect(result).toEqual([false, false, false]);
30+
});
31+
});

src/products/components/ProductVariants/datagrid.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ import { type IntlShape } from "react-intl";
1212

1313
import messages from "./messages";
1414

15+
export const isVariantDatagridSupportedAttribute = (
16+
inputType: AttributeInputTypeEnum | null | undefined,
17+
) =>
18+
inputType === AttributeInputTypeEnum.DROPDOWN ||
19+
inputType === AttributeInputTypeEnum.PLAIN_TEXT ||
20+
inputType === AttributeInputTypeEnum.SWATCH;
21+
1522
export const variantsStaticColumnsAdapter = (intl: IntlShape) => [
1623
{
1724
id: "name",
@@ -126,15 +133,9 @@ export const useAttributesAdapter = ({
126133
selectedColumns: string[] | undefined;
127134
attributes: ProductFragment["productType"]["variantAttributes"];
128135
}) => {
129-
const supportedAttributes = attributes?.filter(attribute => {
130-
if (!attribute.inputType) {
131-
return false;
132-
}
133-
134-
return [AttributeInputTypeEnum.DROPDOWN, AttributeInputTypeEnum.PLAIN_TEXT].includes(
135-
attribute.inputType,
136-
);
137-
});
136+
const supportedAttributes = attributes?.filter(attribute =>
137+
isVariantDatagridSupportedAttribute(attribute.inputType),
138+
);
138139
const [attributeQuery, setAttributeQuery] = useState("");
139140
const { paginate, currentPage, changeCurrentPage } = useClientPagination();
140141
const paginatedAttributes = paginate(

0 commit comments

Comments
 (0)