diff --git a/src/components/TextField/InputWithLabel.tsx b/src/components/TextField/InputWithLabel.tsx index 22dee3a0..dcce1585 100644 --- a/src/components/TextField/InputWithLabel.tsx +++ b/src/components/TextField/InputWithLabel.tsx @@ -22,8 +22,9 @@ export const InputWithLabel = forwardRef(( InputProps, inputProps, placeholder, - name - }: TextFieldProps, + name, + ariaLabel + }: TextFieldProps & {ariaLabel?: string}, ref ) => { const labelId = useRef(uniqueId("label")).current; @@ -45,7 +46,7 @@ export const InputWithLabel = forwardRef(( error={error} disabled={disabled} ref={ref} - inputProps={{ ...inputProps, "aria-labelledby": labelId }} + inputProps={{ ...inputProps, "aria-labelledby": labelId, "aria-label": ariaLabel }} placeholder={placeholder} name={name} {...InputProps} diff --git a/src/graphql/generates.ts b/src/graphql/generates.ts index 3743c58c..812492c2 100644 --- a/src/graphql/generates.ts +++ b/src/graphql/generates.ts @@ -9066,6 +9066,13 @@ export type GetViewerQueryVariables = Exact<{ [key: string]: never; }>; export type GetViewerQuery = { __typename?: 'Query', viewer?: { __typename?: 'Account', _id: string, firstName?: string | null, language?: string | null, lastName?: string | null, name?: string | null, primaryEmailAddress: any, adminUIShops?: Array<{ __typename?: 'Shop', _id: string, name: string, slug?: string | null, shopType?: string | null, language: string, brandAssets?: { __typename?: 'ShopBrandAssets', navbarBrandImage?: { __typename?: 'ImageSizes', large?: string | null } | null } | null, storefrontUrls?: { __typename?: 'StorefrontUrls', storefrontHomeUrl?: string | null } | null, shopLogoUrls?: { __typename?: 'ShopLogoUrls', primaryShopLogoUrl?: string | null } | null, currency: { __typename?: 'Currency', _id: string, code: string, format: string, symbol: string } } | null> | null, groups?: { __typename?: 'GroupConnection', nodes?: Array<{ __typename?: 'Group', _id: string, name: string, permissions?: Array | null } | null> | null } | null } | null }; +export type GetIntrospectSchemaQueryVariables = Exact<{ + schemaName: Scalars['String']; +}>; + + +export type GetIntrospectSchemaQuery = { __typename?: 'Query', introspectSchema: { __typename?: 'IntrospectSchemaPayload', schemaName: string, schema?: any | null } }; + export type CreateShopMutationVariables = Exact<{ input: CreateShopInput; }>; @@ -9644,6 +9651,28 @@ export const useGetViewerQuery = < fetcher(client, GetViewerDocument, variables, headers), options ); +export const GetIntrospectSchemaDocument = ` + query getIntrospectSchema($schemaName: String!) { + introspectSchema(schemaName: $schemaName) { + schemaName + schema + } +} + `; +export const useGetIntrospectSchemaQuery = < + TData = GetIntrospectSchemaQuery, + TError = unknown + >( + client: GraphQLClient, + variables: GetIntrospectSchemaQueryVariables, + options?: UseQueryOptions, + headers?: RequestInit['headers'] + ) => + useQuery( + ['getIntrospectSchema', variables], + fetcher(client, GetIntrospectSchemaDocument, variables, headers), + options + ); export const CreateShopDocument = ` mutation createShop($input: CreateShopInput!) { createShop(input: $input) { diff --git a/src/hooks/useIntrospectSchema/index.ts b/src/hooks/useIntrospectSchema/index.ts new file mode 100644 index 00000000..f42c9365 --- /dev/null +++ b/src/hooks/useIntrospectSchema/index.ts @@ -0,0 +1,43 @@ +import { startCase } from "lodash-es"; +import { useMemo } from "react"; + +import { SelectOptionType } from "types/common"; +import { useGetIntrospectSchemaQuery } from "@graphql/generates"; +import { client } from "@graphql/graphql-request-client"; +import { FieldProperty, SchemaProperties, Type } from "types/schema"; + +export type FieldPropertySelectOption = SelectOptionType & FieldProperty + +export const isObjectType = (fieldProperty: FieldProperty): fieldProperty is FieldProperty => fieldProperty.type === Type.Object; + +export const isArrayType = (fieldProperty: FieldProperty): fieldProperty is FieldProperty => fieldProperty.type === Type.Array; + +const normalizeSchemaProperties = ({ schemaProperties = {}, filterFn, prependFieldName }: + { schemaProperties?: SchemaProperties, + filterFn?: (property: FieldPropertySelectOption) => boolean, + prependFieldName?: string + }) : FieldPropertySelectOption[] => { + const normalizedResults = Object.entries(schemaProperties).flatMap(([field, fieldProperty]) => { + if (isObjectType(fieldProperty) && Object.keys(fieldProperty.properties).length) { + return normalizeSchemaProperties({ schemaProperties: fieldProperty.properties, filterFn, prependFieldName: field }); + } + if (isArrayType(fieldProperty) && isObjectType(fieldProperty.items[0])) { + return normalizeSchemaProperties({ schemaProperties: fieldProperty.items[0].properties, filterFn, prependFieldName: field }); + } + return [{ label: startCase(prependFieldName ? `${prependFieldName} ${field}` : field), value: fieldProperty.path, ...fieldProperty }]; + }); + + return filterFn ? normalizedResults.filter(filterFn) : normalizedResults; +}; + +export const useIntrospectSchema = ({ schemaName, filterFn, enabled }: + {schemaName: string, filterFn?: (property: FieldProperty) => boolean, enabled: boolean}) => { + const { data, isLoading } = useGetIntrospectSchemaQuery(client, { schemaName }, { enabled }); + + const schemaProperties = useMemo(() => { + const properties = data?.introspectSchema.schema.properties; + return normalizeSchemaProperties({ schemaProperties: properties, filterFn }); + }, [data, filterFn]); + + return { schemaProperties, originalSchema: data?.introspectSchema.schema.properties, isLoading }; +}; diff --git a/src/hooks/useIntrospectSchema/introspectSchema.graphql b/src/hooks/useIntrospectSchema/introspectSchema.graphql new file mode 100644 index 00000000..12566883 --- /dev/null +++ b/src/hooks/useIntrospectSchema/introspectSchema.graphql @@ -0,0 +1,6 @@ +query getIntrospectSchema($schemaName: String!) { + introspectSchema(schemaName: $schemaName) { + schemaName + schema + } +} \ No newline at end of file diff --git a/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx b/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx new file mode 100644 index 00000000..966592a4 --- /dev/null +++ b/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from "react-query"; +import { cartItemProperties } from "@mocks/handlers/introspectSchemaHandlers"; + +import { renderHook, waitFor } from "@utils/testUtils"; + +import { useIntrospectSchema } from "."; + +const client = new QueryClient(); +const wrapper = ({ children }: {children: JSX.Element}) => {children}; + +describe("useIntrospectSchema", () => { + it("should return normalize schema data", async () => { + const { result } = renderHook(() => useIntrospectSchema({ schemaName: "CartItem", enabled: true }), { wrapper }); + await waitFor(() => { + expect(result.current.schemaProperties).toEqual([ + { label: "Product Id", value: "$.productId", ...cartItemProperties.productId }, + { label: "Price Type", value: "$.priceType", ...cartItemProperties.priceType }, + { label: "Product Tag Ids", value: "$.productTagIds", ...cartItemProperties.productTagIds }, + { label: "Parcel Containers", value: "$.parcel.containers", ...cartItemProperties.parcel.properties.containers }, + { label: "Parcel Length", value: "$.parcel.length", ...cartItemProperties.parcel.properties.length }, + { label: "Attributes Label", value: "$.attributes.[0].label", ...cartItemProperties.attributes.items[0].properties.label }, + { label: "Attributes Value", value: "$.attributes.[0].value", ...cartItemProperties.attributes.items[0].properties.value } + ]); + }); + }); +}); diff --git a/src/mocks/handlers/index.ts b/src/mocks/handlers/index.ts index 4d440a6b..e2301398 100644 --- a/src/mocks/handlers/index.ts +++ b/src/mocks/handlers/index.ts @@ -6,8 +6,9 @@ import { handlers as transactionalEmailHandlers } from "./transactionalEmailHand import { handlers as checkoutSettingsHandlers } from "./checkoutSettingsHandlers"; import { handlers as customersHandlersHandlers } from "./customersHandlers"; import { handlers as promotionsHandlersHandlers } from "./promotionsHandlers"; +import { handlers as introspectSchemaHandlers } from "./introspectSchemaHandlers"; export const handlers = [ ...shippingMethodsHandlers, ...accountHandlers, ...userHandlers, ...shopSettingsHandlers, ...transactionalEmailHandlers, ...checkoutSettingsHandlers, - ...customersHandlersHandlers, ...promotionsHandlersHandlers + ...customersHandlersHandlers, ...promotionsHandlersHandlers, ...introspectSchemaHandlers ]; diff --git a/src/mocks/handlers/introspectSchemaHandlers.ts b/src/mocks/handlers/introspectSchemaHandlers.ts new file mode 100644 index 00000000..7a39094a --- /dev/null +++ b/src/mocks/handlers/introspectSchemaHandlers.ts @@ -0,0 +1,83 @@ +import { graphql } from "msw"; + +export const mockCartItemSchema = { + schema: { + properties: { + productId: { + type: "string", + path: "$.productId" + }, + priceType: { + type: "string", + enum: [ + "full", + "clearance", + "sale" + ], + path: "$.priceType" + }, + productTagIds: { + type: "array", + items: [ + { + type: "string", + path: "$.productTagIds.[0]" + } + ], + additionalItems: false, + path: "$.productTagIds" + }, + parcel: { + type: "object", + properties: { + containers: { + type: "string", + path: "$.parcel.containers" + }, + length: { + type: "number", + path: "$.parcel.length" + } + }, + required: [], + additionalProperties: false, + path: "$.parcel" + }, + attributes: { + type: "array", + items: [ + { + type: "object", + properties: { + label: { + type: "string", + path: "$.attributes.[0].label" + }, + value: { + type: "string", + path: "$.attributes.[0].value" + } + }, + required: [ + "label" + ], + additionalProperties: false, + path: "$.attributes.[0]" + } + ], + additionalItems: false, + path: "$.attributes" + } + } + }, + schemaName: "CartItem" +}; + +export const cartItemProperties = mockCartItemSchema.schema.properties; +const getIntrospectSchemaHandler = graphql.query("getIntrospectSchema", (req, res, ctx) => + res(ctx.data({ introspectSchema: mockCartItemSchema }))); + + +export const handlers = [ + getIntrospectSchemaHandler +]; diff --git a/src/pages/Promotions/Details/ConditionField.tsx b/src/pages/Promotions/Details/ConditionField.tsx index a744875a..aa5f6220 100644 --- a/src/pages/Promotions/Details/ConditionField.tsx +++ b/src/pages/Promotions/Details/ConditionField.tsx @@ -1,22 +1,68 @@ import Stack from "@mui/material/Stack"; -import { FastField } from "formik"; +import { FastField, Field, getIn, useFormikContext } from "formik"; import { AutocompleteRenderInputParams } from "@mui/material/Autocomplete"; import Typography from "@mui/material/Typography"; -import { memo } from "react"; +import { SyntheticEvent, useRef } from "react"; +import * as Yup from "yup"; import { SelectField } from "@components/SelectField"; -import { CONDITION_OPERATORS, CONDITION_PROPERTIES_OPTIONS, OPERATOR_OPTIONS } from "../constants"; +import { CONDITION_OPERATORS, OPERATOR_OPTIONS } from "../constants"; import { InputWithLabel } from "@components/TextField"; import { AutocompleteField } from "@components/AutocompleteField"; +import { FieldPropertySelectOption } from "@hooks/useIntrospectSchema"; +import { Promotion } from "types/promotions"; +import { SelectOptionType } from "types/common"; +import { Type } from "types/schema"; type ConditionFieldProps = { name: string index: number operator: string + schemaProperties: FieldPropertySelectOption[] + isLoadingSchema: boolean } -export const ConditionField = memo(({ name, index, operator }: ConditionFieldProps) => - ( + +export const ConditionField = ({ name, index, operator, isLoadingSchema, schemaProperties }: ConditionFieldProps) => { + const { setFieldValue, values } = useFormikContext(); + + const selectedProperty = useRef(schemaProperties.find((option) => option.value === getIn(values, `${name}.path`)) || null); + const availableValueOptions = useRef([]); + + const onPathChange = (_: SyntheticEvent, selectedOption: FieldPropertySelectOption | null) => { + setFieldValue(`${name}.path`, selectedOption ? selectedOption.value : null); + selectedProperty.current = selectedOption; + + if (selectedOption?.type === Type.Boolean) { + availableValueOptions.current = ["true", "false"]; + } else if (selectedOption?.enum) { + availableValueOptions.current = selectedOption.enum; + } + }; + + const getOptionLabel = (optionValue: string | FieldPropertySelectOption) => { + if (typeof optionValue === "string") return schemaProperties.find((option) => option.value === optionValue)?.label || ""; + return optionValue.label; + }; + + const validateConditionValue = (selectedValues: string[]) => { + let error; + if (selectedProperty.current?.type === Type.Number || selectedProperty.current?.type === Type.Integer) { + if (selectedValues.some((value) => !Yup.number().isValidSync(value))) { + error = "Please enter number values"; + } + if (typeof selectedProperty.current?.minimum !== "undefined" + && selectedValues.some((value) => !Yup.number().min(Number(selectedProperty.current?.minimum)).isValidSync(value))) { + error = `All values must be greater than or equal to ${selectedProperty.current.minimum}`; + } + } + if (selectedProperty.current?.format === Type.DateTime && selectedValues.some((value) => !Yup.date().isValidSync(value))) { + error = "Please enter valid date time values"; + } + return error; + }; + + return ( {index > 0 ? @@ -24,14 +70,23 @@ export const ConditionField = memo(({ name, index, operator }: ConditionFieldPro : null} - (value ? option.value === value : false)} + onChange={onPathChange} + getOptionLabel={getOptionLabel} + renderInput={(params: AutocompleteRenderInputParams) => ( + + )} /> - ( @@ -60,5 +116,5 @@ export const ConditionField = memo(({ name, index, operator }: ConditionFieldPro /> - )); - + ); +}; diff --git a/src/pages/Promotions/Details/EligibleItems.tsx b/src/pages/Promotions/Details/EligibleItems.tsx index fedb8f45..13564d67 100644 --- a/src/pages/Promotions/Details/EligibleItems.tsx +++ b/src/pages/Promotions/Details/EligibleItems.tsx @@ -1,13 +1,13 @@ import Paper from "@mui/material/Paper"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { FieldArray, useFormikContext } from "formik"; -// eslint-disable-next-line you-dont-need-lodash-underscore/get -import { get } from "lodash-es"; +import { FieldArray, getIn, useFormikContext } from "formik"; import { useState } from "react"; import { FieldArrayRenderer } from "@components/FieldArrayRenderer"; import { Promotion } from "types/promotions"; +import { isArrayType, useIntrospectSchema } from "@hooks/useIntrospectSchema"; +import { FieldProperty, Type } from "types/schema"; import { ConditionField } from "./ConditionField"; import { ConditionOperators } from "./ConditionOperators"; @@ -17,12 +17,16 @@ type EligibleItemsProps = { exclusionFieldName: string } -const initialValue = { fact: "item", path: "", value: [], operator: "" }; +const initialValue = { fact: "item", path: null, value: [], operator: "" }; + +const filterSchemaFn = (fieldProperty: FieldProperty) => + fieldProperty.type !== Type.Array || (isArrayType(fieldProperty) && fieldProperty.items[0].type === Type.String); + export const EligibleItems = ({ inclusionFieldName, exclusionFieldName }: EligibleItemsProps) => { const { setFieldValue, values } = useFormikContext(); - const currentInclusionRules = get(values, inclusionFieldName); - const currentExclusionRules = get(values, exclusionFieldName); + const currentInclusionRules = getIn(values, inclusionFieldName); + const currentExclusionRules = getIn(values, exclusionFieldName); const [conditionOperator, setConditionOperator] = useState>({ @@ -34,7 +38,7 @@ export const EligibleItems = ({ inclusionFieldName, exclusionFieldName }: Eligib const getFieldNameWithCondition = (name: string) => `${name}.${conditionOperator[name]}`; const handleChangeOperator = (name:string, value: string) => { - const currentValues = get(values, getFieldNameWithCondition(name)); + const currentValues = getIn(values, getFieldNameWithCondition(name)); setFieldValue(name, { [value]: currentValues }); setConditionOperator((current) => ({ ...current, [name]: value })); }; @@ -42,6 +46,12 @@ export const EligibleItems = ({ inclusionFieldName, exclusionFieldName }: Eligib const _inclusionFieldName = getFieldNameWithCondition(inclusionFieldName); const _exclusionFieldName = getFieldNameWithCondition(exclusionFieldName); + const { schemaProperties, isLoading } = useIntrospectSchema({ + schemaName: "CartItem", + filterFn: filterSchemaFn, + enabled: !!(getIn(values, _inclusionFieldName)?.length || getIn(values, _exclusionFieldName)?.length) + }); + return ( Eligible Items @@ -49,7 +59,7 @@ export const EligibleItems = ({ inclusionFieldName, exclusionFieldName }: Eligib name={_inclusionFieldName} render={(props) => ( - {get(props.form.values, _inclusionFieldName, []).length ? + {getIn(props.form.values, _inclusionFieldName, []).length ? Including products based on ( - + )} /> @@ -76,7 +92,7 @@ export const EligibleItems = ({ inclusionFieldName, exclusionFieldName }: Eligib name={_exclusionFieldName} render={(props) => ( - {get(props.form.values, _exclusionFieldName, []).length ? + {getIn(props.form.values, _exclusionFieldName, []).length ? Excluding products based on ( - + )} /> diff --git a/src/pages/Promotions/Details/PromotionDetails.test.tsx b/src/pages/Promotions/Details/PromotionDetails.test.tsx index 602655d3..efb67f36 100644 --- a/src/pages/Promotions/Details/PromotionDetails.test.tsx +++ b/src/pages/Promotions/Details/PromotionDetails.test.tsx @@ -70,7 +70,7 @@ describe("Promotion Details", () => { await user.type(screen.getByLabelText("Trigger Value"), "12"); await user.click(screen.getAllByText("Add Condition")[0]); await user.click(screen.getByLabelText("Property")); - await user.click(within(screen.getByRole("listbox")).getByText("Vendor")); + await user.click(within(screen.getByRole("listbox")).getByText("Product Id")); await user.click(screen.getByLabelText("Operator")); await user.click(within(screen.getByRole("listbox")).getByText("Is")); await user.type(screen.getByPlaceholderText("Enter Values"), "value{enter}"); diff --git a/src/pages/Promotions/Details/validation.ts b/src/pages/Promotions/Details/validation.ts index be85e144..fb8a064b 100644 --- a/src/pages/Promotions/Details/validation.ts +++ b/src/pages/Promotions/Details/validation.ts @@ -5,7 +5,7 @@ import { NOOP_ACTION } from "../constants"; import { urlSchema } from "@utils/validate"; const ruleSchema = Yup.object({ - path: Yup.string().required("This field is required"), + path: Yup.string().required("This field is required").nullable(), operator: Yup.string().required("This field is required"), value: Yup.array().min(1, "This field must have at least 1 value").when("operator", { is: "equal", diff --git a/src/types/schema.ts b/src/types/schema.ts new file mode 100644 index 00000000..f0d5b4d6 --- /dev/null +++ b/src/types/schema.ts @@ -0,0 +1,25 @@ + +export enum Type { + String = "string", + Object = "object", + Array = "array", + Number = "number", + Boolean = "boolean", + Integer = "integer", + DateTime = "date-time", +} + +export type FieldProperty = { + type: FieldType + path: string + format?: string + items: FieldProperty["type"] extends Type.Array ? Array> : undefined + properties: FieldProperty["type"] extends Type.Object ? {[key: string]: FieldProperty} : undefined + required: string[] + enum?: string[] + minimum: FieldProperty["type"] extends (Type.Integer | Type.Number) ? number : undefined +} + +export type SchemaProperties = { + [key: string]: FieldProperty +}