Skip to content

Commit b6662d2

Browse files
authored
Explain locked Channel rows and empty filter panels (#6883)
* Explain locked Channel rows and empty filter panels * Replace list-page docs subtitles with TopNav help icons Gift cards now share the Products card layout; search placeholders use sentence-case example copy * Add changeset * Use Macaw flex token strings so typecheck passes
1 parent 255a40f commit b6662d2

28 files changed

Lines changed: 681 additions & 228 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"saleor-dashboard": patch
3+
---
4+
5+
Clarify list filters and search.
6+
7+
Auto-added Channel and Currency rows now show why they are required. Empty filter panels say “No filters”. List pages put docs behind a help icon instead of a subtitle.

locale/defaultMessages.json

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,10 @@
623623
"0HBlkO": {
624624
"string": "Search channels"
625625
},
626+
"0HZg9/": {
627+
"context": "tooltip on the add-preset button in list headers",
628+
"string": "Save filters and search as preset"
629+
},
626630
"0I+Z8l": {
627631
"context": "stock exceeded dialog description",
628632
"string": "{count, plural, one {# item exceeds available stock.} other {# items exceed available stock.}} Go back to adjust quantities, or fulfill anyway to oversell."
@@ -1592,6 +1596,10 @@
15921596
"context": "gift card tag field label",
15931597
"string": "Card tag"
15941598
},
1599+
"4IVdnm": {
1600+
"context": "Empty state when the filter panel or modal has no conditions",
1601+
"string": "No filters"
1602+
},
15951603
"4IawKc": {
15961604
"context": "onboarding step description",
15971605
"string": "Go to all orders where you can create an fulfilment and refund and review corresponding statuses. View the order in GraphQL"
@@ -3184,6 +3192,9 @@
31843192
"context": "dialog content",
31853193
"string": "{counter,plural,one{Are you sure you want to unassign this product?} other{Are you sure you want to unassign {displayQuantity} products?}}"
31863194
},
3195+
"AHOQr2": {
3196+
"string": "Search products..."
3197+
},
31873198
"AJF7N5": {
31883199
"context": "scope note for model type tab grouping help tooltip",
31893200
"string": "Browser-only display — does not rename, merge, or change your model types or data."
@@ -3393,10 +3404,6 @@
33933404
"B52Em/": {
33943405
"string": "Address line 1"
33953406
},
3396-
"B5KI59": {
3397-
"context": "Empty filters text",
3398-
"string": "Add filter to start"
3399-
},
34003407
"B77MSl": {
34013408
"context": "empty state for variant attributes on a product type",
34023409
"string": "No variant options yet"
@@ -5646,10 +5653,6 @@
56465653
"context": "export scope option disabled when list has no active filters",
56475654
"string": "Apply filters or search on the list to enable this option."
56485655
},
5649-
"JDvxO6": {
5650-
"context": "search gift card placeholder",
5651-
"string": "Search Gift Cards (e.g. by code, email, user name)"
5652-
},
56535656
"JE0TAx": {
56545657
"context": "section name",
56555658
"string": "Values"
@@ -6467,6 +6470,10 @@
64676470
"context": "column title used by/customer",
64686471
"string": "Used by"
64696472
},
6473+
"MKCw7u": {
6474+
"context": "Tooltip on the info icon before a locked Channel or Currency filter listing the filters that require it",
6475+
"string": "Needed for filtering on {fields}"
6476+
},
64706477
"MKTuIa": {
64716478
"context": "voucher percentage channel input aria label",
64726479
"string": "Percentage for {channelName}"
@@ -7528,6 +7535,10 @@
75287535
"Q8Qw5B": {
75297536
"string": "Description"
75307537
},
7538+
"Q8RwBy": {
7539+
"context": "search gift card placeholder; examples, not an exhaustive list",
7540+
"string": "Search by code, email, user name, etc..."
7541+
},
75317542
"Q8wHwJ": {
75327543
"string": "Deleted collection"
75337544
},
@@ -7835,6 +7846,10 @@
78357846
"RaQzn2": {
78367847
"string": "Rule created"
78377848
},
7849+
"RalDnV": {
7850+
"context": "empty state when the list has no saved presets yet",
7851+
"string": "Saved presets will appear here"
7852+
},
78387853
"RaycYK": {
78397854
"context": "button",
78407855
"string": "Save"
@@ -11378,9 +11393,6 @@
1137811393
"eVrDft": {
1137911394
"string": "No value"
1138011395
},
11381-
"eW36Jx": {
11382-
"string": "Saved search queries will appear here"
11383-
},
1138411396
"eWV760": {
1138511397
"string": "Attribute with this slug already exists"
1138611398
},
@@ -12950,9 +12962,6 @@
1295012962
"context": "column header",
1295112963
"string": "Slug"
1295212964
},
12953-
"kIvvax": {
12954-
"string": "Search Products..."
12955-
},
1295612965
"kJQczl": {
1295712966
"context": "sales section name",
1295812967
"string": "Sales"
@@ -15348,9 +15357,6 @@
1534815357
"context": "button",
1534915358
"string": "Assign product"
1535015359
},
15351-
"scTuDZ": {
15352-
"string": "Save search as preset"
15353-
},
1535415360
"sdA14A": {
1535515361
"context": "Status label when object is published in a channel",
1535615362
"string": "Published"
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Box, Text } from "@saleor/macaw-ui-next";
2+
import type { Meta, StoryObj } from "@storybook/react-vite";
3+
4+
import { ConstraintReasonHint } from "./ConstraintReasonHint";
5+
6+
const meta: Meta<typeof ConstraintReasonHint> = {
7+
title: "ConditionalFilter / ConstraintReasonHint",
8+
component: ConstraintReasonHint,
9+
};
10+
11+
export default meta;
12+
13+
type Story = StoryObj<typeof ConstraintReasonHint>;
14+
15+
const FilterRowChrome = ({ fields }: { fields: string[] }): JSX.Element => (
16+
<Box
17+
__width="560px"
18+
padding={4}
19+
display="grid"
20+
__gridTemplateColumns="auto minmax(0, 1fr)"
21+
columnGap={2}
22+
rowGap={3}
23+
alignItems="start"
24+
backgroundColor="default1"
25+
borderRadius={2}
26+
>
27+
<Text size={2} color="default2" paddingTop={1.5}>
28+
where
29+
</Text>
30+
<Box display="flex" alignItems="center" gap={2}>
31+
<Box paddingX={2} paddingY={1} borderRadius={2} backgroundColor="default2" __minWidth="120px">
32+
<Text size={3}>Price</Text>
33+
</Box>
34+
<Box paddingX={2} paddingY={1} borderRadius={2} backgroundColor="default2">
35+
<Text size={3}>is</Text>
36+
</Box>
37+
<Box paddingX={2} paddingY={1} borderRadius={2} backgroundColor="default2" flexGrow="1">
38+
<Text size={3}>10</Text>
39+
</Box>
40+
</Box>
41+
<Text size={2} color="default2" paddingTop={1.5}>
42+
and
43+
</Text>
44+
<Box display="flex" alignItems="center" gap={2}>
45+
<ConstraintReasonHint fields={fields} />
46+
<Box paddingX={2} paddingY={1} borderRadius={2} backgroundColor="default2" __minWidth="120px">
47+
<Text size={3}>Channel</Text>
48+
</Box>
49+
<Box paddingX={2} paddingY={1} borderRadius={2} backgroundColor="default2">
50+
<Text size={3}>is</Text>
51+
</Box>
52+
<Box paddingX={2} paddingY={1} borderRadius={2} backgroundColor="default2" flexGrow="1">
53+
<Text size={3} color="default2">
54+
Select channel
55+
</Text>
56+
</Box>
57+
</Box>
58+
</Box>
59+
);
60+
61+
export const Price: Story = {
62+
render: () => <FilterRowChrome fields={["Price"]} />,
63+
};
64+
65+
export const Several: Story = {
66+
render: () => <FilterRowChrome fields={["Price", "Is published", "Is available"]} />,
67+
};
68+
69+
export const Currency: Story = {
70+
render: () => <FilterRowChrome fields={["Current balance"]} />,
71+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import Wrapper from "@test/wrapper";
2+
import { render, screen } from "@testing-library/react";
3+
4+
import { ConstraintReasonHint } from "./ConstraintReasonHint";
5+
6+
describe("ConstraintReasonHint", () => {
7+
it("exposes a single dependent field on the icon", () => {
8+
// Arrange // Act
9+
render(
10+
<Wrapper>
11+
<ConstraintReasonHint fields={["Price"]} />
12+
</Wrapper>,
13+
);
14+
15+
// Assert
16+
expect(
17+
screen.getByRole("button", { name: "Needed for filtering on Price" }),
18+
).toBeInTheDocument();
19+
});
20+
21+
it("joins several dependent fields", () => {
22+
// Arrange // Act
23+
render(
24+
<Wrapper>
25+
<ConstraintReasonHint fields={["Price", "Is published", "Is available"]} />
26+
</Wrapper>,
27+
);
28+
29+
// Assert
30+
expect(
31+
screen.getByRole("button", {
32+
name: "Needed for filtering on Price, Is published, and Is available",
33+
}),
34+
).toBeInTheDocument();
35+
});
36+
37+
it("renders nothing without dependents", () => {
38+
// Arrange // Act
39+
render(
40+
<Wrapper>
41+
<ConstraintReasonHint fields={[]} />
42+
</Wrapper>,
43+
);
44+
45+
// Assert
46+
expect(
47+
screen.queryByRole("button", { name: /Needed for filtering on/ }),
48+
).not.toBeInTheDocument();
49+
});
50+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons";
2+
import { Box, Tooltip } from "@saleor/macaw-ui-next";
3+
import { Info } from "lucide-react";
4+
import { useIntl } from "react-intl";
5+
6+
import { constraintReasonMessages } from "./constraintReasonMessages";
7+
8+
interface ConstraintReasonHintProps {
9+
fields: string[];
10+
testId?: string;
11+
}
12+
13+
export const ConstraintReasonHint = ({
14+
fields,
15+
testId,
16+
}: ConstraintReasonHintProps): JSX.Element | null => {
17+
const intl = useIntl();
18+
19+
if (fields.length === 0) {
20+
return null;
21+
}
22+
23+
const list = new Intl.ListFormat(intl.locale, { type: "conjunction" }).format(fields);
24+
const label = intl.formatMessage(constraintReasonMessages.neededFor, { fields: list });
25+
26+
return (
27+
<Tooltip>
28+
<Tooltip.Trigger>
29+
<Box
30+
as="button"
31+
type="button"
32+
display="flex"
33+
alignItems="center"
34+
cursor="pointer"
35+
padding={0}
36+
borderWidth={0}
37+
backgroundColor="transparent"
38+
color="default2"
39+
flexShrink="0"
40+
aria-label={label}
41+
data-test-id={testId}
42+
>
43+
<Info size={iconSize.small} strokeWidth={iconStrokeWidthBySize.small} aria-hidden />
44+
</Box>
45+
</Tooltip.Trigger>
46+
<Tooltip.Content side="bottom" align="start">
47+
<Tooltip.Arrow />
48+
{label}
49+
</Tooltip.Content>
50+
</Tooltip>
51+
);
52+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Wrapper from "@test/wrapper";
2+
import { render, screen } from "@testing-library/react";
3+
4+
import { FilterEventEmitter } from "./EventEmitter";
5+
import { Filters } from "./Filters";
6+
import { type Row } from "./types";
7+
8+
const intersectionObserverMock = (): { observe: () => null; unobserve: () => null } => ({
9+
observe: (): null => null,
10+
unobserve: (): null => null,
11+
});
12+
13+
window.IntersectionObserver = jest.fn().mockImplementation(intersectionObserverMock);
14+
15+
const locale = {
16+
WHERE: "Where",
17+
AND: "And",
18+
};
19+
20+
const channelRow = (dependsOn: string[]): Row => ({
21+
value: { label: "Channel", value: "channel", type: "channel" },
22+
isAttribute: false,
23+
constraint: { dependsOn, removable: false, disabled: ["left", "condition"] },
24+
condition: {
25+
options: [],
26+
selected: {
27+
value: "",
28+
conditionValue: { type: "text", label: "is", value: "input-1" },
29+
},
30+
},
31+
});
32+
33+
const filterRow = (value: string, label: string): Row => ({
34+
value: { label, value, type: value },
35+
isAttribute: false,
36+
condition: {
37+
options: [],
38+
selected: {
39+
value: "",
40+
conditionValue: { type: "number", label: "is", value: "input-2" },
41+
},
42+
},
43+
});
44+
45+
const renderFilters = (value: Array<Row | string>): void => {
46+
render(
47+
<Wrapper>
48+
<Filters value={value} leftOptions={[]} emitter={new FilterEventEmitter()} locale={locale} />
49+
</Wrapper>,
50+
);
51+
};
52+
53+
const follows = (earlier: HTMLElement, later: HTMLElement): boolean =>
54+
(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
55+
56+
describe("Filters constraint hint", () => {
57+
it("squeezes the info icon in before Channel on the same row as And", () => {
58+
// Arrange // Act
59+
renderFilters([filterRow("price", "Price"), "AND", channelRow(["price"])]);
60+
61+
// Assert
62+
const and = screen.getByText("And");
63+
const hint = screen.getByTestId("constraint-reason-2");
64+
const channel = screen.getByTestId("left-2");
65+
66+
expect(hint).toHaveAccessibleName("Needed for filtering on Price");
67+
expect(follows(and, hint)).toBe(true);
68+
expect(follows(hint, channel)).toBe(true);
69+
});
70+
71+
it("places the icon before a leading Channel selector", () => {
72+
// Arrange // Act
73+
renderFilters([channelRow(["price"]), "AND", filterRow("price", "Price")]);
74+
75+
// Assert
76+
const where = screen.getByText("Where");
77+
const hint = screen.getByTestId("constraint-reason-0");
78+
const channel = screen.getByTestId("left-0");
79+
80+
expect(hint).toHaveAccessibleName("Needed for filtering on Price");
81+
expect(follows(where, hint)).toBe(true);
82+
expect(follows(hint, channel)).toBe(true);
83+
});
84+
});

0 commit comments

Comments
 (0)