Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ node_modules
.env.secrets
.env.site-configs
coverage
.claude
13 changes: 12 additions & 1 deletion admin/src/common/MasterMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Assets, Dashboard, PageTree, Snips, Wrench } from "@comet/admin-icons";
import { Assets, Dashboard, PageTree, Snips, Tag, Wrench } from "@comet/admin-icons";
import {
ContentScopeIndicator,
createRedirectsPage,
Expand All @@ -14,6 +14,7 @@ import { DashboardPage } from "@src/dashboard/DashboardPage";
import { Link } from "@src/documents/links/Link";
import { Page } from "@src/documents/pages/Page";
import { EditFooterPage } from "@src/footers/EditFooterPage";
import { ProductsPage } from "@src/products/ProductsPage";
import { FormattedMessage } from "react-intl";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -50,6 +51,16 @@ export const masterMenuData: MasterMenuData = [
},
requiredPermission: "pageTree",
},
{
type: "route",
primary: <FormattedMessage id="menu.products" defaultMessage="Products" />,
icon: <Tag />,
route: {
path: "/products",
component: ProductsPage,
},
requiredPermission: "products",
},
{
type: "route",
primary: <FormattedMessage id="menu.dam" defaultMessage="Assets" />,
Expand Down
19 changes: 19 additions & 0 deletions admin/src/common/components/enums/chipIcon/ChipIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ChevronDown, ThreeDotSaving } from "@comet/admin-icons";
import { type ChipProps } from "@mui/material";
import { type FunctionComponent } from "react";

type ChipIconProps = {
loading: boolean;
onClick?: ChipProps["onClick"];
};

export const ChipIcon: FunctionComponent<ChipIconProps> = ({ loading, onClick }) => {
if (loading) {
return <ThreeDotSaving />;
}
if (onClick) {
return <ChevronDown />;
}

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { FunctionComponent, ReactNode } from "react";
import { FormattedMessage, type MessageDescriptor } from "react-intl";

type ComponentProps<T extends string> = {
value: T;
};
type TranslatableEnumResult<T extends string> = {
Component: FunctionComponent<ComponentProps<T>>;
messageDescriptorMap: Record<T, MessageDescriptor>;
formattedMessageMap: Record<T, ReactNode>;
};

export function createTranslatableEnum<T extends string>(messageDescriptorMap: Record<T, MessageDescriptor>): TranslatableEnumResult<T> {
const formattedMessageMap = Object.keys(messageDescriptorMap).reduce(
(acc, key) => {
const k = key as T;
acc[k] = <FormattedMessage {...messageDescriptorMap[k]} />;
return acc;
},
{} as Record<T, ReactNode>,
);

const Component: FunctionComponent<{ value: T }> = ({ value }) => {
return formattedMessageMap[value];
};

return {
Component,
messageDescriptorMap,
formattedMessageMap,
};
}
50 changes: 50 additions & 0 deletions admin/src/common/components/enums/enumChip/EnumChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type ChipProps as MuiChipProps, ListItemText, Menu, MenuItem } from "@mui/material";
import { type ComponentType, type ReactNode, useState } from "react";

export type EnumChipProps<T extends string> = {
chipMap: EnumChipMap<T>;
formattedMessageMap: EnumFormattedMessageMap<T>;
loading?: boolean;
onSelectItem?: (status: T) => void;
sortOrder?: T[];
value: T;
};
type EnumChipItemProps = { loading: boolean; onClick?: MuiChipProps["onClick"] };
type EnumChipMap<T extends string> = Record<T, ComponentType<EnumChipItemProps>>;
type EnumFormattedMessageMap<T extends string> = Record<T, ReactNode>;

function keysFromObject<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}

export function EnumChip<T extends string>({ chipMap, formattedMessageMap, loading = false, onSelectItem, sortOrder = [], value }: EnumChipProps<T>) {
const [anchorElement, setAnchorElement] = useState<Element | null>(null);

const StatusChip: ComponentType<EnumChipItemProps> = chipMap[value];
const open = !!anchorElement;

return (
<>
<StatusChip loading={loading} onClick={onSelectItem != null ? (event) => setAnchorElement(event.currentTarget) : undefined} />

<Menu anchorEl={anchorElement} onClose={() => setAnchorElement(null)} open={open}>
{keysFromObject(formattedMessageMap)
.sort((a, b) => {
return sortOrder.indexOf(a) - sortOrder.indexOf(b);
})
.map((currentValue) => (
<MenuItem
disabled={value === currentValue}
key={currentValue}
onClick={() => {
onSelectItem?.(currentValue);
setAnchorElement(null);
}}
>
<ListItemText>{formattedMessageMap[currentValue]}</ListItemText>
</MenuItem>
))}
</Menu>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { type IntlShape, type MessageDescriptor } from "react-intl";

type ValueOption<T extends string> = {
value: T;
label: string;
};

export function messageDescriptorMapToValueOptions<T extends string>(map: Record<T, MessageDescriptor>, intl: IntlShape): Array<ValueOption<T>> {
return (Object.entries(map) as Array<[T, MessageDescriptor]>).map(([value, descriptor]) => ({
value,
label: intl.formatMessage(descriptor),
}));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { type ReactNode } from "react";

type Option<T extends string> = {
value: T;
label: ReactNode;
};

export function recordToOptions<T extends string>(record: Record<T, ReactNode>): Array<Option<T>> {
return (Object.entries(record) as Array<[T, ReactNode]>).map(([value, label]) => ({
value,
label,
}));
}
10 changes: 10 additions & 0 deletions admin/src/common/validators/validatePositiveNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type ReactElement } from "react";
import { FormattedMessage } from "react-intl";

export const validatePositiveNumber = (value: number | undefined): ReactElement | undefined => {
if (value == null) return undefined;
if (value <= 0) {
return <FormattedMessage id="validation.positiveNumber" defaultMessage="Value must be positive" />;
}
return undefined;
};
17 changes: 17 additions & 0 deletions admin/src/common/validators/validateSkuFormat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type ReactElement } from "react";
import { FormattedMessage } from "react-intl";

const SKU_PATTERN = /^[A-Z]{2,4}-[0-9]{4,8}$/;

export const validateSkuFormat = (value: string | undefined): ReactElement | undefined => {
if (!value) return undefined;
if (!SKU_PATTERN.test(value)) {
return (
<FormattedMessage
id="validation.skuFormat"
defaultMessage="SKU must match format: 2-4 uppercase letters, hyphen, 4-8 digits (e.g. AB-1234)"
/>
);
}
return undefined;
};
17 changes: 17 additions & 0 deletions admin/src/common/validators/validateSlug.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type ReactElement } from "react";
import { FormattedMessage } from "react-intl";

const SLUG_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-_]*$/;

export const validateSlug = (value: string | undefined): ReactElement | undefined => {
if (!value) return undefined;
if (!SLUG_PATTERN.test(value)) {
return (
<FormattedMessage
id="validation.slug"
defaultMessage="Only letters, numbers, hyphens, and underscores allowed. Must start with a letter or number."
/>
);
}
return undefined;
};
64 changes: 64 additions & 0 deletions admin/src/products/ProductsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
Button,
FillSpace,
SaveBoundary,
Stack,
StackLink,
StackMainContent,
StackPage,
StackSwitch,
StackToolbar,
ToolbarActions,
ToolbarAutomaticTitleItem,
ToolbarBackButton,
} from "@comet/admin";
import { Add } from "@comet/admin-icons";
import { ContentScopeIndicator } from "@comet/cms-admin";
import { type FunctionComponent } from "react";
import { FormattedMessage } from "react-intl";

import { ProductForm } from "./components/productForm/ProductForm";
import { ProductsGrid } from "./components/productsDataGrid/ProductsGrid";
import { ProductToolbar } from "./components/productToolbar/ProductToolbar";

export const ProductsPage: FunctionComponent = () => {
return (
<Stack topLevelTitle={<FormattedMessage id="products.title" defaultMessage="Products" />}>
<StackSwitch>
<StackPage name="grid">
<StackToolbar scopeIndicator={<ContentScopeIndicator />}>
<ToolbarBackButton />
<ToolbarAutomaticTitleItem />
<FillSpace />
<ToolbarActions>
<Button variant="primary" component={StackLink} pageName="add" payload="add" startIcon={<Add />}>
<FormattedMessage id="products.addProduct" defaultMessage="Add Product" />
</Button>
</ToolbarActions>
</StackToolbar>
<StackMainContent fullHeight>
<ProductsGrid />
</StackMainContent>
</StackPage>
<StackPage name="add">
<SaveBoundary>
<ProductToolbar />
<StackMainContent>
<ProductForm />
</StackMainContent>
</SaveBoundary>
</StackPage>
<StackPage name="edit">
{(id) => (
<SaveBoundary>
<ProductToolbar id={id} />
<StackMainContent>
<ProductForm id={id} />
</StackMainContent>
</SaveBoundary>
)}
</StackPage>
</StackSwitch>
</Stack>
);
};
61 changes: 61 additions & 0 deletions admin/src/products/components/productForm/ProductForm.gql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { gql } from "@apollo/client";

export const productFormFragment = gql`
fragment ProductFormDetails on Product {
name
slug
description
sku
price
productType
productStatus
publishedAt
isPublished
}
`;

export const productQuery = gql`
query Product($id: ID!) {
product(id: $id) {
id
updatedAt
mainImage
...ProductFormDetails
}
}
${productFormFragment}
`;

export const createProductMutation = gql`
mutation CreateProduct($scope: ProductScopeInput!, $input: ProductInput!) {
createProduct(scope: $scope, input: $input) {
product {
id
updatedAt
...ProductFormDetails
}
errors {
code
field
}
}
}
${productFormFragment}
`;

export const updateProductMutation = gql`
mutation UpdateProduct($id: ID!, $input: ProductUpdateInput!) {
updateProduct(id: $id, input: $input) {
product {
id
updatedAt
...ProductFormDetails
}
errors {
code
field
}
}
}
${productFormFragment}
`;
Loading