Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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`.
28 changes: 28 additions & 0 deletions locale/defaultMessages.json
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,10 @@
"context": "VariantDetailsChannelsAvailabilityCard item subtitle published",
"string": "Published since {publishedAt}"
},
"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 @@ -1957,6 +1961,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 @@ -9703,6 +9715,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 @@ -11209,6 +11225,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 @@ -12121,6 +12141,10 @@
"hbNxgV": {
"string": "Are you sure you want to activate {email}?"
},
"hfMvPp": {
"context": "clears the model type icon",
"string": "Reset"
},
"hg79SY": {
"context": "Label for collection toggle row when publication is scheduled",
"string": "Scheduled"
Expand Down Expand Up @@ -16837,6 +16861,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
@@ -1,5 +1,7 @@
import { type FetchResult } from "@apollo/client";
import { type AttributeInput, type AttributeInputData } from "@dashboard/components/Attributes";
import { type ModelTypeIcon } from "@dashboard/components/ModelTypeIcon/constants";
import { getModelTypeIcon } from "@dashboard/components/ModelTypeIcon/getModelTypeIcon";
import {
AttributeEntityTypeEnum,
type AttributeErrorFragment,
Expand Down Expand Up @@ -68,6 +70,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 @@ -494,6 +498,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
9 changes: 9 additions & 0 deletions src/components/AssignModelDialog/AssignModelDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,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
2 changes: 2 additions & 0 deletions src/components/Attributes/SingleReferenceField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getSingleReferenceDisplayValue,
} from "@dashboard/components/Attributes/utils";
import { ChipField } from "@dashboard/components/ChipField/ChipField";
import { ModelTypeChipIcon } from "@dashboard/components/ChipField/ModelTypeChipIcon";
import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons";
import { Box, Button, Text } from "@saleor/macaw-ui-next";
import { Pencil, Plus } from "lucide-react";
Expand Down Expand Up @@ -39,6 +40,7 @@ export const SingleReferenceField = ({
<>
<ChipField
label={selected.label}
startAdornment={<ModelTypeChipIcon icon={selected.icon} />}
url={selected.url}
loading={loading}
onClose={() => onReferencesRemove(attribute.id, [])}
Expand Down
2 changes: 2 additions & 0 deletions src/components/Attributes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function getReferenceDisplayValue(attribute: AttributeInput): SortableChi
return {
label: referenceData.label,
value: referenceData.value,
icon: referenceData.icon,
url: getEntityUrl({
entityType: attribute.data.entityType,
entityId: referenceData.value,
Expand All @@ -74,6 +75,7 @@ export function getSingleReferenceDisplayValue(
return {
label: reference.label,
value: reference.value,
icon: reference.icon,
url: getEntityUrl({
entityType: attribute.data.entityType,
entityId: reference.value,
Expand Down
6 changes: 4 additions & 2 deletions src/components/ChipField/ChipField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ const ChipLabel = ({ url, label }: { url?: string; label: ReactNode }) => {

type ChipFieldProps = {
label: ReactNode;
startAdornment?: ReactNode;
onClose?: () => void;
loading?: boolean;
url?: string;
} & BoxProps;

export const ChipField = forwardRef<HTMLDivElement, ChipFieldProps>(
({ label, onClose, loading, url, className, style, ...props }, ref) => {
({ label, startAdornment, onClose, loading, url, className, style, ...props }, ref) => {
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();

Expand All @@ -50,7 +51,8 @@ export const ChipField = forwardRef<HTMLDivElement, ChipFieldProps>(
opacity={"1"}
{...props}
>
<Box display="flex" alignItems="center" paddingLeft={2}>
<Box display="flex" alignItems="center" gap={1.5} paddingLeft={2}>
{startAdornment}
<ChipLabel label={label} url={url} />
<Box marginLeft={1}>
<Button
Expand Down
10 changes: 10 additions & 0 deletions src/components/ChipField/ModelTypeChipIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type ModelTypeIcon as ModelTypeIconValue } from "@dashboard/components/ModelTypeIcon/constants";
import { ModelTypeIcon } from "@dashboard/components/ModelTypeIcon/ModelTypeIcon";

interface ModelTypeChipIconProps {
/** Only model references carry one; chips for other entity types render without an icon. */
icon?: ModelTypeIconValue;
}

export const ModelTypeChipIcon = ({ icon }: ModelTypeChipIconProps) =>
icon ? <ModelTypeIcon icon={icon} size={14} /> : null;
70 changes: 70 additions & 0 deletions src/components/Datagrid/customCells/ModelTypeCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Canvas / Datagrid only — React lists should render `ModelTypeIcon` instead.
*
* The icon is resolved from the lazy Lucide cache, so callers must await
* `preloadLucideIcons` before the grid paints; an unresolved name falls back to `Shapes`.
*/
import {
attributeInputTypeIconPixelSize,
attributeInputTypeIconStrokeWidthBySize,
} from "@dashboard/components/AttributeInputTypeIcon/types";
import { type ModelTypeIcon } from "@dashboard/components/ModelTypeIcon/constants";
import { renderModelTypeIconSvg } from "@dashboard/components/ModelTypeIcon/renderModelTypeIconSvg";
import {
type CustomCell,
type CustomRenderer,
type GridCell,
GridCellKind,
} from "@glideapps/glide-data-grid";

import { drawIconLabelCell, ICON_LABEL_CELL_ICON_SIZE } from "./drawIconLabelCell";

const CELL_KIND = "model-type-cell";

interface ModelTypeCellProps {
readonly kind: typeof CELL_KIND;
readonly icon: ModelTypeIcon;
readonly label: string;
readonly isDark: boolean;
}

export type ModelTypeCell = CustomCell<ModelTypeCellProps>;

export const modelTypeCell = (
icon: ModelTypeIcon,
label: string,
isDark: boolean,
opts?: Partial<GridCell>,
): ModelTypeCell => ({
allowOverlay: false,
readonly: true,
cursor: "pointer",
copyData: label,
...opts,
kind: GridCellKind.Custom,
data: {
kind: CELL_KIND,
icon,
label,
isDark,
},
});

export const modelTypeCellRenderer: CustomRenderer<ModelTypeCell> = {
kind: GridCellKind.Custom,
isMatch: (cell: CustomCell): cell is ModelTypeCell =>
(cell.data as ModelTypeCellProps).kind === CELL_KIND,
draw: (args, cell) =>
drawIconLabelCell(
args,
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(
renderModelTypeIconSvg(
cell.data.icon,
attributeInputTypeIconPixelSize[ICON_LABEL_CELL_ICON_SIZE],
attributeInputTypeIconStrokeWidthBySize[ICON_LABEL_CELL_ICON_SIZE],
cell.data.isDark,
),
)}`,
cell.data.label,
),
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { attributeInputTypeCellRenderer } from "./AttributeInputTypeCell"; // ca
import { attributeTypeCellRenderer } from "./AttributeTypeCell"; // canvas-only
import { chevronCellRenderer } from "./ChevronCell";
import { dropdownCellRenderer } from "./DropdownCell";
import { modelTypeCellRenderer } from "./ModelTypeCell"; // canvas-only
import { moneyCellRenderer } from "./Money/MoneyCell";
import { moneyDiscountedCellRenderer } from "./Money/MoneyDiscountedCell";
import { numberCellRenderer } from "./NumberCell";
Expand All @@ -32,6 +33,7 @@ export function useCustomCellRenderers() {
statusCellRenderer(themeValues),
primarySecondaryTextCellRenderer,
voucherScopeCellRenderer,
modelTypeCellRenderer,
moneyCellRenderer(locale),
moneyDiscountedCellRenderer(),
numberCellRenderer(locale),
Expand Down
Loading
Loading