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
11 changes: 11 additions & 0 deletions .changeset/model-type-icons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"saleor-dashboard": patch
---

Model types can now carry an icon and a colour, so they are recognisable at a glance instead of being told apart by name alone.

Pick one from the icon control next to the name on a model type's edit page: search the full Lucide set, choose one of six colours, and save with the rest of the form. "Reset" clears it again.

The icon then follows the model type everywhere it appears — sidebar pins and the pin manager, the model list tabs and its "Type" column, the model type list, the type picker and pin dialogs, and the chips of model references on other entities. Model types without an icon render a neutral fallback, so nothing shifts when one is configured.

Storage is public metadata on `PageType`, under `dashboard-icon-name` and `dashboard-icon-color`.
24 changes: 24 additions & 0 deletions locale/defaultMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,10 @@
"context": "staff notification description",
"string": "Sent to people in {alertsLink} when a customer completes checkout. Draft orders completed in the Dashboard are not included."
},
"0Sq+Ey": {
"context": "button opening the model type icon picker",
"string": "Change icon"
},
"0UeJGl": {
"context": "issue gift card dialog intro",
"string": "Set the gift card value and optional delivery details."
Expand Down Expand Up @@ -1961,6 +1965,10 @@
"context": "empty state in variant media selection dialog",
"string": "No product media available. Upload images on the product page first."
},
"5nSDBL": {
"context": "model type icon picker title",
"string": "Icon"
},
"5nrCxC": {
"string": "Go to model types"
},
Expand Down Expand Up @@ -3708,6 +3716,10 @@
"context": "deactivate app",
"string": "Are you sure you want to disable this extension? Your data will be kept until you reactivate the extension."
},
"C7GV73": {
"context": "model type icon picker empty state",
"string": "No icons match “{query}”"
},
"C7I2lg": {
"context": "input label",
"string": "Limit quantity per checkout (optional)"
Expand Down Expand Up @@ -9630,6 +9642,10 @@
"context": "variants grid range label with count of unsaved staged deletions",
"string": "{range} ({count} pending delete)"
},
"YHAiGl": {
"context": "model type icon picker search field",
"string": "Search icons"
},
"YI6Fhj": {
"context": "no address is set in draft order",
"string": "Not set"
Expand Down Expand Up @@ -11132,6 +11148,10 @@
"context": "staff detail top nav CTA when staff member is inactive",
"string": "Activate"
},
"diG2vm": {
"context": "model type icon colour swatch",
"string": "Color {color}"
},
"diq8M5": {
"context": "payment apps list truncated warning",
"string": "Showing the first 100 extensions. Open installed extensions to see payment apps beyond this limit."
Expand Down Expand Up @@ -16737,6 +16757,10 @@
"context": "shipping method detail cogs menu, opens GraphiQL with this method",
"string": "Open this shipping method in GraphiQL"
},
"xewIsS": {
"context": "model type icon picker result cap hint",
"string": "Showing first {count} matches — keep typing to narrow down"
},
"xfGZsi": {
"context": "configuration section name",
"string": "Configuration"
Expand Down
1 change: 1 addition & 0 deletions src/attributes/utils/computeTypeTabCounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe("computePageTypeTabCounts", () => {
id: "pt-1",
name: "Blog",
hasPages: true,
metadata: [],
attributes: [{ __typename: "Attribute", id: "a-1" }],
},
},
Expand Down
6 changes: 5 additions & 1 deletion src/attributes/utils/data.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FALLBACK_MODEL_TYPE_ICON } from "@dashboard/components/ModelTypeIcon/getModelTypeIcon";
import {
AttributeEntityTypeEnum,
AttributeInputTypeEnum,
Expand Down Expand Up @@ -72,6 +73,7 @@ const createMockReferenceData = (data: {
__typename: "Page",
id: p.id,
title: p.title,
pageType: { __typename: "PageType" as const, id: "page-type-1", metadata: [] },
}));
}

Expand Down Expand Up @@ -427,7 +429,9 @@ describe("attributes/utils/data", () => {
const result = getReferenceAttributeDisplayData(attribute, references);

// Assert
expect(result.data.references).toEqual([{ value: "test-page-1", label: "Page Title" }]);
expect(result.data.references).toEqual([
{ value: "test-page-1", label: "Page Title", icon: FALLBACK_MODEL_TYPE_ICON },
]);
});

it("should handle product variant references", () => {
Expand Down
5 changes: 5 additions & 0 deletions src/attributes/utils/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
type AttributeInput,
type AttributeInputData,
} from "@dashboard/components/Attributes/Attributes";
import { type ModelTypeIcon } from "@dashboard/components/ModelTypeIcon/constants";
import { getModelTypeIcon } from "@dashboard/components/ModelTypeIcon/getModelTypeIcon";
import {
AttributeEntityTypeEnum,
AttributeInputTypeEnum,
Expand Down Expand Up @@ -70,6 +72,8 @@ export const ENTITY_TYPES_WITH_TYPES_RESTRICTION = [
export interface AttributeReference {
label: string;
value: string;
/** Model references only — the icon configured on the referenced model's type. */
icon?: ModelTypeIcon;
}

export interface AttributeValueEditDialogFormData {
Expand Down Expand Up @@ -461,6 +465,7 @@ const findPageReference = (
return {
label: page.title,
value: valueId,
icon: getModelTypeIcon(page.pageType?.metadata),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("getAssignedModelTypesForAttribute", () => {
id: "type-1",
name: "Blog",
hasPages: true,
metadata: [],
attributes: [
{ __typename: "Attribute", id: "attr-1" },
{ __typename: "Attribute", id: "attr-2" },
Expand All @@ -31,6 +32,7 @@ describe("getAssignedModelTypesForAttribute", () => {
id: "type-2",
name: "Landing",
hasPages: false,
metadata: [],
attributes: [{ __typename: "Attribute", id: "attr-3" }],
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export interface AssignContainerDialogProps extends FetchMoreProps, DialogProps
backfillResetKey?: string;
/** Shown instead of `emptyMessage` when exclusion emptied every loaded page. */
backfillExhaustedMessage?: string;
/** Renders before a row's name — used by the model dialog to show the model type's icon. */
renderContainerAdornment?: (container: Container) => ReactNode;
selectionMode?: "single" | "multiple";
selectedId?: string;
filtersSlot?: ReactNode;
Expand Down Expand Up @@ -73,6 +75,7 @@ const AssignContainerDialog = ({
excludeContainer,
backfillResetKey,
backfillExhaustedMessage,
renderContainerAdornment,
selectionMode = "multiple",
selectedId,
filtersSlot,
Expand Down Expand Up @@ -215,12 +218,14 @@ const AssignContainerDialog = ({
containers={displayedContainers}
selectedItemId={singleSelection.selectedItemId}
onSelect={singleSelection.handleSelect}
renderAdornment={renderContainerAdornment}
/>
) : (
<MultiSelectionRows
containers={displayedContainers}
isSelected={multiSelection.isSelected}
onToggle={multiSelection.handleToggle}
renderAdornment={renderContainerAdornment}
/>
)}
</>
Expand Down
20 changes: 18 additions & 2 deletions src/components/AssignContainerDialog/AssignContainerRows.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import { TableCell } from "@dashboard/components/Table/Table";
import { type Container } from "@dashboard/types";
import { Radio } from "@material-ui/core";
import { Box } from "@saleor/macaw-ui-next";
import { type ReactNode } from "react";

import Checkbox from "../Checkbox/Checkbox";
import TableRowLink from "../TableRowLink/TableRowLink";

const ContainerLabel = ({ adornment, name }: { adornment?: ReactNode; name: string }) =>
adornment ? (
<Box display="flex" alignItems="center" gap={2}>
{adornment}
{name}
</Box>
) : (
<>{name}</>
);

interface SingleSelectionRowsProps {
containers: Container[];
selectedItemId: string;
onSelect: (id: string) => void;
renderAdornment?: (container: Container) => ReactNode;
}

export const SingleSelectionRows = ({
containers,
selectedItemId,
onSelect,
renderAdornment,
}: SingleSelectionRowsProps) => (
<>
{containers?.map(container => {
Expand All @@ -35,7 +49,7 @@ export const SingleSelectionRows = ({
/>
</TableCell>
<TableCell style={{ width: "100%" }} data-test-id={container.name}>
{container.name}
<ContainerLabel adornment={renderAdornment?.(container)} name={container.name} />
</TableCell>
</TableRowLink>
);
Expand All @@ -47,12 +61,14 @@ interface MultiSelectionRowsProps {
containers: Container[];
isSelected: (id: string) => boolean;
onToggle: (item: Container) => void;
renderAdornment?: (container: Container) => ReactNode;
}

export const MultiSelectionRows = ({
containers,
isSelected,
onToggle,
renderAdornment,
}: MultiSelectionRowsProps) => (
<>
{containers?.map(container => (
Expand All @@ -61,7 +77,7 @@ export const MultiSelectionRows = ({
<Checkbox checked={isSelected(container.id)} onChange={() => onToggle(container)} />
</TableCell>
<TableCell style={{ width: "100%" }} data-test-id={container.name}>
{container.name}
<ContainerLabel adornment={renderAdornment?.(container)} name={container.name} />
</TableCell>
</TableRowLink>
))}
Expand Down
15 changes: 14 additions & 1 deletion src/components/AssignModelDialog/AssignModelDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { render, screen, within } from "@testing-library/react";
import { ThemeProvider } from "@saleor/macaw-ui-next";
import { render as rtlRender, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type React from "react";

import AssignModelDialog from "./AssignModelDialog";

// Rows carry the model type's icon, which resolves its colour from the macaw theme.
const render = (ui: React.ReactElement) => rtlRender(ui, { wrapper: ThemeProvider });

jest.mock("react-intl", () => ({
FormattedMessage: ({ defaultMessage }: { defaultMessage: string }) => <>{defaultMessage}</>,
useIntl: () => ({
Expand Down Expand Up @@ -37,21 +41,30 @@ jest.mock("@dashboard/hooks/useModalSearchWithFilters", () => ({
}),
}));

const mockPageType = {
__typename: "PageType" as const,
id: "page-type-1",
metadata: [],
};

const mockPages = [
{
__typename: "Page" as const,
id: "page-1",
title: "Test Page 1",
pageType: mockPageType,
},
{
__typename: "Page" as const,
id: "page-2",
title: "Test Page 2",
pageType: mockPageType,
},
{
__typename: "Page" as const,
id: "page-3",
title: "Test Page 3",
pageType: mockPageType,
},
];

Expand Down
14 changes: 14 additions & 0 deletions src/components/AssignModelDialog/AssignModelDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton/ConfirmButton";
import { getModelTypeIcon } from "@dashboard/components/ModelTypeIcon/getModelTypeIcon";
import { ModelTypeIcon } from "@dashboard/components/ModelTypeIcon/ModelTypeIcon";
import { type PageWhereInput, type SearchPagesQuery } from "@dashboard/graphql";
import { useModalSearchWithFilters } from "@dashboard/hooks/useModalSearchWithFilters";
import {
Expand Down Expand Up @@ -64,6 +66,13 @@ const AssignModelDialogInner = ({
[pages],
);

// Kept off `Container`: that shape is also the submit payload, so the icon is looked up per row
// at render time rather than riding along with the selection.
const iconsByPageId = useMemo(
() => new Map(pages?.map(page => [page.id, getModelTypeIcon(page.pageType?.metadata)]) ?? []),
[pages],
);

const labels: AssignContainerDialogProps["labels"] = {
title: intl.formatMessage(messages.assignModelDialogHeader),
label: intl.formatMessage(messages.assignModelDialogSearch),
Expand All @@ -80,6 +89,11 @@ const AssignModelDialogInner = ({
<AssignContainerDialog
{...restProps}
containers={containers}
renderContainerAdornment={container => {
const icon = iconsByPageId.get(container.id);

return icon ? <ModelTypeIcon icon={icon} /> : null;
}}
labels={labels}
open={open}
onClose={onClose}
Expand Down
8 changes: 6 additions & 2 deletions src/components/Attributes/AttributeRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { MockedProvider } from "@apollo/client/testing";
import { AttributeInputTypeEnum, MeasurementUnitsEnum } from "@dashboard/graphql";
import { type RichTextGetters } from "@dashboard/utils/richText/useMultipleRichText";
import { render, screen } from "@testing-library/react";
import { render as rtlRender, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { type ReactElement, useState } from "react";

import AttributeRow from "./AttributeRow";
import { type AttributeInput } from "./Attributes";
import { type AttributeRowProps } from "./types";

// Reference rows look up the referenced models' type icons through Apollo.
const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MockedProvider });

jest.mock("react-intl", () => {
const actual = jest.requireActual("react-intl");

Expand Down
5 changes: 4 additions & 1 deletion src/components/Attributes/AttributeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { DateTimeField } from "../DateTimeField/DateTimeField";
import { DropdownRow } from "./DropdownRow";
import { SingleReferenceField } from "./SingleReferenceField";
import { type AttributeRowProps } from "./types";
import { useModelReferenceIcons } from "./useModelReferenceIcons";

const AttributeRow = ({
attribute,
Expand All @@ -45,12 +46,14 @@ const AttributeRow = ({
}: AttributeRowProps): JSX.Element => {
const intl = useIntl();
const labelProps = getAttributeRowLabelProps(attribute);
const referenceIcons = useModelReferenceIcons(attribute);

switch (attribute.data.inputType) {
case AttributeInputTypeEnum.SINGLE_REFERENCE:
return (
<SingleReferenceField
attribute={attribute}
referenceIcons={referenceIcons}
disabled={disabled}
loading={loading}
error={error}
Expand All @@ -62,7 +65,7 @@ const AttributeRow = ({
return (
<BasicAttributeRow label={attribute.label} {...labelProps}>
<SortableChipsField
values={getReferenceDisplayValue(attribute)}
values={getReferenceDisplayValue(attribute, referenceIcons)}
onValueDelete={value =>
onReferencesRemove(
attribute.id,
Expand Down
Loading
Loading