From 1024838abe6b794a6eb0deda18c25b584567d69d Mon Sep 17 00:00:00 2001 From: Chloe Date: Mon, 13 Feb 2023 13:41:37 +0700 Subject: [PATCH 1/9] feat: integrate with introspect endpoint Signed-off-by: Chloe --- src/components/TextField/InputWithLabel.tsx | 7 ++-- src/graphql/generates.ts | 29 ++++++++++++++ src/hooks/useIntrospectSchema/index.ts | 18 +++++++++ .../introspectSchema.graphql | 6 +++ .../Promotions/Details/ConditionField.tsx | 38 ++++++++++++------- .../Promotions/Details/EligibleItems.tsx | 2 +- src/pages/Promotions/Details/validation.ts | 5 ++- src/types/schema.ts | 22 +++++++++++ 8 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 src/hooks/useIntrospectSchema/index.ts create mode 100644 src/hooks/useIntrospectSchema/introspectSchema.graphql create mode 100644 src/types/schema.ts 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 1b2843fe..c2f52dc5 100644 --- a/src/graphql/generates.ts +++ b/src/graphql/generates.ts @@ -8855,6 +8855,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; }>; @@ -9412,6 +9419,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..f1af5c0a --- /dev/null +++ b/src/hooks/useIntrospectSchema/index.ts @@ -0,0 +1,18 @@ +import { startCase } from "lodash-es"; + +import { useGetIntrospectSchemaQuery } from "@graphql/generates"; +import { client } from "@graphql/graphql-request-client"; +import { FieldProperty, SchemaProperties } from "types/schema"; + +const normalizeSchemaProperties = (schemaProperties: SchemaProperties, filterFn?: (property: FieldProperty) => boolean) => { + const normalizedResults = Object.entries(schemaProperties).map(([field, property]) => ({ label: startCase(field), value: property.path, ...property })); + + return filterFn ? normalizedResults.filter(filterFn) : normalizedResults; +}; + +export const useIntrospectSchema = ({ schemaName, filterFn }:{schemaName: string, filterFn?: (property: FieldProperty) => boolean}) => { + const { data, isLoading } = useGetIntrospectSchemaQuery(client, { schemaName }); + const schemaProperties: SchemaProperties = data?.introspectSchema.schema.properties; + + return { schemaProperties: schemaProperties ? normalizeSchemaProperties(schemaProperties, filterFn) : [], originalSchema: schemaProperties, 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/pages/Promotions/Details/ConditionField.tsx b/src/pages/Promotions/Details/ConditionField.tsx index a744875a..9fdba2e9 100644 --- a/src/pages/Promotions/Details/ConditionField.tsx +++ b/src/pages/Promotions/Details/ConditionField.tsx @@ -1,13 +1,15 @@ import Stack from "@mui/material/Stack"; -import { FastField } from "formik"; +import { FastField, Field } from "formik"; import { AutocompleteRenderInputParams } from "@mui/material/Autocomplete"; import Typography from "@mui/material/Typography"; import { memo } from "react"; 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 { AutocompleteField, isOptionEqualToValue } from "@components/AutocompleteField"; +import { useIntrospectSchema } from "@hooks/useIntrospectSchema"; +import { Type } from "types/schema"; type ConditionFieldProps = { name: string @@ -15,8 +17,10 @@ type ConditionFieldProps = { operator: string } -export const ConditionField = memo(({ name, index, operator }: ConditionFieldProps) => - ( +export const ConditionField = memo(({ name, index, operator }: ConditionFieldProps) => { + const { schemaProperties, isLoading } = useIntrospectSchema({ schemaName: "CartItem", filterFn: ({ type }) => type !== Type.Array }); + + return ( {index > 0 ? @@ -24,14 +28,21 @@ export const ConditionField = memo(({ name, index, operator }: ConditionFieldPro : null} - ( + + )} /> - )); + ); +}); diff --git a/src/pages/Promotions/Details/EligibleItems.tsx b/src/pages/Promotions/Details/EligibleItems.tsx index fedb8f45..ed4ccc5a 100644 --- a/src/pages/Promotions/Details/EligibleItems.tsx +++ b/src/pages/Promotions/Details/EligibleItems.tsx @@ -17,7 +17,7 @@ type EligibleItemsProps = { exclusionFieldName: string } -const initialValue = { fact: "item", path: "", value: [], operator: "" }; +const initialValue = { fact: "item", path: null, value: [], operator: "" }; export const EligibleItems = ({ inclusionFieldName, exclusionFieldName }: EligibleItemsProps) => { const { setFieldValue, values } = useFormikContext(); diff --git a/src/pages/Promotions/Details/validation.ts b/src/pages/Promotions/Details/validation.ts index 77af1341..e257b8f7 100644 --- a/src/pages/Promotions/Details/validation.ts +++ b/src/pages/Promotions/Details/validation.ts @@ -1,7 +1,10 @@ import * as Yup from "yup"; const ruleSchema = Yup.object({ - path: Yup.string().required("This field is required"), + path: Yup.object({ + value: Yup.string().required("This field is required"), + label: Yup.string() + }), 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..0d43a6a8 --- /dev/null +++ b/src/types/schema.ts @@ -0,0 +1,22 @@ + +export enum Type { + String = "string", + Object = "object", + Array = "array", + Number = "number", + Boolean = "boolean", + Integer = "integer" +} + +export type FieldProperty = { + type: Type + path: string + format?: string + items: FieldProperty["type"] extends Type.Array ? Array : undefined + properties: FieldProperty["type"] extends Type.Object ? {[key: string]: FieldProperty} : undefined + required: string[] +} + +export type SchemaProperties = { + [key: string]: FieldProperty +} From d9985195b9177473eebfd8d8f87fd3ee2a59757f Mon Sep 17 00:00:00 2001 From: Chloe Date: Mon, 13 Feb 2023 14:40:22 +0700 Subject: [PATCH 2/9] on path field change Signed-off-by: Chloe --- .../Promotions/Details/ConditionField.tsx | 20 +++++++++++++++---- src/pages/Promotions/Details/validation.ts | 5 +---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pages/Promotions/Details/ConditionField.tsx b/src/pages/Promotions/Details/ConditionField.tsx index 9fdba2e9..0d229f0b 100644 --- a/src/pages/Promotions/Details/ConditionField.tsx +++ b/src/pages/Promotions/Details/ConditionField.tsx @@ -1,15 +1,17 @@ import Stack from "@mui/material/Stack"; -import { FastField, Field } from "formik"; +import { FastField, Field, useFormikContext } from "formik"; import { AutocompleteRenderInputParams } from "@mui/material/Autocomplete"; import Typography from "@mui/material/Typography"; -import { memo } from "react"; +import { memo, SyntheticEvent } from "react"; import { SelectField } from "@components/SelectField"; import { CONDITION_OPERATORS, OPERATOR_OPTIONS } from "../constants"; import { InputWithLabel } from "@components/TextField"; -import { AutocompleteField, isOptionEqualToValue } from "@components/AutocompleteField"; +import { AutocompleteField } from "@components/AutocompleteField"; import { useIntrospectSchema } from "@hooks/useIntrospectSchema"; import { Type } from "types/schema"; +import { Promotion } from "types/promotions"; +import { SelectOptionType } from "types/common"; type ConditionFieldProps = { name: string @@ -19,6 +21,11 @@ type ConditionFieldProps = { export const ConditionField = memo(({ name, index, operator }: ConditionFieldProps) => { const { schemaProperties, isLoading } = useIntrospectSchema({ schemaName: "CartItem", filterFn: ({ type }) => type !== Type.Array }); + const { setFieldValue } = useFormikContext(); + + const onPathChange = (_: SyntheticEvent, selectedOption: SelectOptionType | null) => { + setFieldValue(`${name}.path`, selectedOption ? selectedOption.value : null); + }; return ( @@ -33,7 +40,12 @@ export const ConditionField = memo(({ name, index, operator }: ConditionFieldPro component={AutocompleteField} options={schemaProperties} loading={isLoading} - isOptionEqualToValue={isOptionEqualToValue} + isOptionEqualToValue={(option: SelectOptionType, value: string) => option.value === value} + onChange={onPathChange} + getOptionLabel={(optionValue: string | SelectOptionType) => { + if (typeof optionValue === "string") return schemaProperties.find((option) => option.value === optionValue)?.label || "Unknown"; + return optionValue.label; + }} renderInput={(params: AutocompleteRenderInputParams) => ( Date: Mon, 13 Feb 2023 15:24:51 +0700 Subject: [PATCH 3/9] some improvements on equality check Signed-off-by: Chloe --- src/hooks/useIntrospectSchema/index.ts | 21 +++++++++++++++---- .../Promotions/Details/ConditionField.tsx | 13 ++++++------ src/pages/Promotions/Details/validation.ts | 2 +- src/types/schema.ts | 12 +++++------ 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/hooks/useIntrospectSchema/index.ts b/src/hooks/useIntrospectSchema/index.ts index f1af5c0a..96d60f27 100644 --- a/src/hooks/useIntrospectSchema/index.ts +++ b/src/hooks/useIntrospectSchema/index.ts @@ -1,11 +1,24 @@ import { startCase } from "lodash-es"; +import { SelectOptionType } from "types/common"; import { useGetIntrospectSchemaQuery } from "@graphql/generates"; import { client } from "@graphql/graphql-request-client"; -import { FieldProperty, SchemaProperties } from "types/schema"; +import { FieldProperty, SchemaProperties, Type } from "types/schema"; -const normalizeSchemaProperties = (schemaProperties: SchemaProperties, filterFn?: (property: FieldProperty) => boolean) => { - const normalizedResults = Object.entries(schemaProperties).map(([field, property]) => ({ label: startCase(field), value: property.path, ...property })); +type MergedOptionType = SelectOptionType & FieldProperty + +const normalizeSchemaProperties = ({ schemaProperties, filterFn, prependFieldName }: + { schemaProperties: SchemaProperties, + filterFn?: (property: MergedOptionType) => boolean, + prependFieldName?: string + }) : MergedOptionType[] => { + const normalizedResults = Object.entries(schemaProperties).flatMap(([field, property]) => { + if (property.type === Type.Object && Object.keys((property as FieldProperty).properties).length) { + const objectNestedProperties = property.properties as SchemaProperties; + return normalizeSchemaProperties({ schemaProperties: objectNestedProperties, filterFn, prependFieldName: field }); + } + return [{ label: startCase(prependFieldName ? `${prependFieldName} ${field}` : field), value: property.path, ...property }]; + }); return filterFn ? normalizedResults.filter(filterFn) : normalizedResults; }; @@ -14,5 +27,5 @@ export const useIntrospectSchema = ({ schemaName, filterFn }:{schemaName: string const { data, isLoading } = useGetIntrospectSchemaQuery(client, { schemaName }); const schemaProperties: SchemaProperties = data?.introspectSchema.schema.properties; - return { schemaProperties: schemaProperties ? normalizeSchemaProperties(schemaProperties, filterFn) : [], originalSchema: schemaProperties, isLoading }; + return { schemaProperties: schemaProperties ? normalizeSchemaProperties({ schemaProperties, filterFn }) : [], originalSchema: schemaProperties, isLoading }; }; diff --git a/src/pages/Promotions/Details/ConditionField.tsx b/src/pages/Promotions/Details/ConditionField.tsx index 0d229f0b..1c8dc2fe 100644 --- a/src/pages/Promotions/Details/ConditionField.tsx +++ b/src/pages/Promotions/Details/ConditionField.tsx @@ -22,11 +22,15 @@ type ConditionFieldProps = { export const ConditionField = memo(({ name, index, operator }: ConditionFieldProps) => { const { schemaProperties, isLoading } = useIntrospectSchema({ schemaName: "CartItem", filterFn: ({ type }) => type !== Type.Array }); const { setFieldValue } = useFormikContext(); - const onPathChange = (_: SyntheticEvent, selectedOption: SelectOptionType | null) => { setFieldValue(`${name}.path`, selectedOption ? selectedOption.value : null); }; + const getOptionLabel = (optionValue: string | SelectOptionType) => { + if (typeof optionValue === "string") return schemaProperties.find((option) => option.value === optionValue)?.label || ""; + return optionValue.label; + }; + return ( @@ -40,12 +44,9 @@ export const ConditionField = memo(({ name, index, operator }: ConditionFieldPro component={AutocompleteField} options={schemaProperties} loading={isLoading} - isOptionEqualToValue={(option: SelectOptionType, value: string) => option.value === value} + isOptionEqualToValue={(option: SelectOptionType, value: string) => (value ? option.value === value : false)} onChange={onPathChange} - getOptionLabel={(optionValue: string | SelectOptionType) => { - if (typeof optionValue === "string") return schemaProperties.find((option) => option.value === optionValue)?.label || "Unknown"; - return optionValue.label; - }} + getOptionLabel={getOptionLabel} renderInput={(params: AutocompleteRenderInputParams) => ( = { + type: FieldType path: string format?: string - items: FieldProperty["type"] extends Type.Array ? Array : undefined - properties: FieldProperty["type"] extends Type.Object ? {[key: string]: FieldProperty} : undefined + items: FieldProperty["type"] extends Type.Array ? Array> : undefined + properties: FieldProperty["type"] extends Type.Object ? {[key: string]: FieldProperty} : undefined required: string[] } -export type SchemaProperties = { - [key: string]: FieldProperty +export type SchemaProperties = { + [key: string]: FieldProperty } From c8304b867990a4506caa56c6c2e26acfa70c223d Mon Sep 17 00:00:00 2001 From: Chloe Date: Thu, 16 Feb 2023 13:30:04 +0700 Subject: [PATCH 4/9] improvements on fetching schema Signed-off-by: Chloe --- src/hooks/useIntrospectSchema/index.ts | 37 +++++++++------- .../Promotions/Details/ConditionField.tsx | 10 ++--- .../Promotions/Details/EligibleItems.tsx | 42 ++++++++++++++----- src/types/schema.ts | 2 +- 4 files changed, 61 insertions(+), 30 deletions(-) diff --git a/src/hooks/useIntrospectSchema/index.ts b/src/hooks/useIntrospectSchema/index.ts index 96d60f27..bba7db12 100644 --- a/src/hooks/useIntrospectSchema/index.ts +++ b/src/hooks/useIntrospectSchema/index.ts @@ -1,31 +1,40 @@ 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"; -type MergedOptionType = SelectOptionType & FieldProperty +export type FieldPropertySelectOption = SelectOptionType & FieldProperty -const normalizeSchemaProperties = ({ schemaProperties, filterFn, prependFieldName }: - { schemaProperties: SchemaProperties, - filterFn?: (property: MergedOptionType) => boolean, +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 - }) : MergedOptionType[] => { - const normalizedResults = Object.entries(schemaProperties).flatMap(([field, property]) => { - if (property.type === Type.Object && Object.keys((property as FieldProperty).properties).length) { - const objectNestedProperties = property.properties as SchemaProperties; - return normalizeSchemaProperties({ schemaProperties: objectNestedProperties, filterFn, prependFieldName: field }); + }) : 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 }); } - return [{ label: startCase(prependFieldName ? `${prependFieldName} ${field}` : field), value: property.path, ...property }]; + return [{ label: startCase(prependFieldName ? `${prependFieldName} ${field}` : field), value: fieldProperty.path, ...fieldProperty }]; }); return filterFn ? normalizedResults.filter(filterFn) : normalizedResults; }; -export const useIntrospectSchema = ({ schemaName, filterFn }:{schemaName: string, filterFn?: (property: FieldProperty) => boolean}) => { - const { data, isLoading } = useGetIntrospectSchemaQuery(client, { schemaName }); - const schemaProperties: SchemaProperties = data?.introspectSchema.schema.properties; +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: schemaProperties ? normalizeSchemaProperties({ schemaProperties, filterFn }) : [], originalSchema: schemaProperties, isLoading }; + return { schemaProperties, originalSchema: schemaProperties, isLoading }; }; diff --git a/src/pages/Promotions/Details/ConditionField.tsx b/src/pages/Promotions/Details/ConditionField.tsx index 1c8dc2fe..12abaf9a 100644 --- a/src/pages/Promotions/Details/ConditionField.tsx +++ b/src/pages/Promotions/Details/ConditionField.tsx @@ -8,8 +8,7 @@ import { SelectField } from "@components/SelectField"; import { CONDITION_OPERATORS, OPERATOR_OPTIONS } from "../constants"; import { InputWithLabel } from "@components/TextField"; import { AutocompleteField } from "@components/AutocompleteField"; -import { useIntrospectSchema } from "@hooks/useIntrospectSchema"; -import { Type } from "types/schema"; +import { FieldPropertySelectOption } from "@hooks/useIntrospectSchema"; import { Promotion } from "types/promotions"; import { SelectOptionType } from "types/common"; @@ -17,10 +16,11 @@ type ConditionFieldProps = { name: string index: number operator: string + schemaProperties: FieldPropertySelectOption[] + isLoadingSchema: boolean } -export const ConditionField = memo(({ name, index, operator }: ConditionFieldProps) => { - const { schemaProperties, isLoading } = useIntrospectSchema({ schemaName: "CartItem", filterFn: ({ type }) => type !== Type.Array }); +export const ConditionField = memo(({ name, index, operator, isLoadingSchema, schemaProperties }: ConditionFieldProps) => { const { setFieldValue } = useFormikContext(); const onPathChange = (_: SyntheticEvent, selectedOption: SelectOptionType | null) => { setFieldValue(`${name}.path`, selectedOption ? selectedOption.value : null); @@ -43,7 +43,7 @@ export const ConditionField = memo(({ name, index, operator }: ConditionFieldPro name={`${name}.path`} component={AutocompleteField} options={schemaProperties} - loading={isLoading} + loading={isLoadingSchema} isOptionEqualToValue={(option: SelectOptionType, value: string) => (value ? option.value === value : false)} onChange={onPathChange} getOptionLabel={getOptionLabel} diff --git a/src/pages/Promotions/Details/EligibleItems.tsx b/src/pages/Promotions/Details/EligibleItems.tsx index ed4ccc5a..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"; @@ -18,11 +18,15 @@ type EligibleItemsProps = { } 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/types/schema.ts b/src/types/schema.ts index e67f8bb5..d32d3edc 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -12,7 +12,7 @@ export type FieldProperty = { type: FieldType path: string format?: string - items: FieldProperty["type"] extends Type.Array ? Array> : undefined + items: FieldProperty["type"] extends Type.Array ? Array> : undefined properties: FieldProperty["type"] extends Type.Object ? {[key: string]: FieldProperty} : undefined required: string[] } From cb3fa5c3b9a886badc91549ea2c2749854f3bcb2 Mon Sep 17 00:00:00 2001 From: Chloe Date: Thu, 16 Feb 2023 13:52:32 +0700 Subject: [PATCH 5/9] fix failed tests Signed-off-by: Chloe --- src/mocks/handlers/index.ts | 3 +- .../handlers/introspectSchemaHandlers.ts | 41 +++++++++++++++++++ .../Details/PromotionDetails.test.tsx | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 src/mocks/handlers/introspectSchemaHandlers.ts 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..3b112a18 --- /dev/null +++ b/src/mocks/handlers/introspectSchemaHandlers.ts @@ -0,0 +1,41 @@ +import { graphql } from "msw"; + +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" + } + } + }, + schemaName: "CartItem" +}; + +const getIntrospectSchemaHandler = graphql.query("getIntrospectSchema", (req, res, ctx) => + res(ctx.data({ introspectSchema: mockCartItemSchema }))); + + +export const handlers = [ + getIntrospectSchemaHandler +]; diff --git a/src/pages/Promotions/Details/PromotionDetails.test.tsx b/src/pages/Promotions/Details/PromotionDetails.test.tsx index a736d0ed..3c478b54 100644 --- a/src/pages/Promotions/Details/PromotionDetails.test.tsx +++ b/src/pages/Promotions/Details/PromotionDetails.test.tsx @@ -67,7 +67,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}"); From 4996cb9dd2794eaa29b20433265b1d60ea618631 Mon Sep 17 00:00:00 2001 From: Chloe Date: Fri, 17 Feb 2023 10:56:44 +0700 Subject: [PATCH 6/9] add test for introspect schema Signed-off-by: Chloe --- src/hooks/useIntrospectSchema/index.ts | 2 +- .../useIntrospectSchema.test.tsx | 24 +++++++++++++++++++ .../handlers/introspectSchemaHandlers.ts | 19 ++++++++++++++- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx diff --git a/src/hooks/useIntrospectSchema/index.ts b/src/hooks/useIntrospectSchema/index.ts index bba7db12..d1ae3e95 100644 --- a/src/hooks/useIntrospectSchema/index.ts +++ b/src/hooks/useIntrospectSchema/index.ts @@ -36,5 +36,5 @@ export const useIntrospectSchema = ({ schemaName, filterFn, enabled }: return normalizeSchemaProperties({ schemaProperties: properties, filterFn }); }, [data, filterFn]); - return { schemaProperties, originalSchema: schemaProperties, isLoading }; + return { schemaProperties, originalSchema: data?.introspectSchema.schema.properties, isLoading }; }; diff --git a/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx b/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx new file mode 100644 index 00000000..232ecfee --- /dev/null +++ b/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx @@ -0,0 +1,24 @@ +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 } + ]); + }); + }); +}); diff --git a/src/mocks/handlers/introspectSchemaHandlers.ts b/src/mocks/handlers/introspectSchemaHandlers.ts index 3b112a18..5a16c0ca 100644 --- a/src/mocks/handlers/introspectSchemaHandlers.ts +++ b/src/mocks/handlers/introspectSchemaHandlers.ts @@ -1,6 +1,6 @@ import { graphql } from "msw"; -const mockCartItemSchema = { +export const mockCartItemSchema = { schema: { properties: { productId: { @@ -26,12 +26,29 @@ const mockCartItemSchema = { ], 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" } } }, schemaName: "CartItem" }; +export const cartItemProperties = mockCartItemSchema.schema.properties; const getIntrospectSchemaHandler = graphql.query("getIntrospectSchema", (req, res, ctx) => res(ctx.data({ introspectSchema: mockCartItemSchema }))); From 6473041ff7f00000829f9530c7fbac19fbc432f0 Mon Sep 17 00:00:00 2001 From: Chloe Date: Sat, 18 Feb 2023 16:18:35 +0700 Subject: [PATCH 7/9] add validation based on schema Signed-off-by: Chloe --- .../Promotions/Details/ConditionField.tsx | 55 +++++++++++++++---- src/types/schema.ts | 5 +- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/pages/Promotions/Details/ConditionField.tsx b/src/pages/Promotions/Details/ConditionField.tsx index 12abaf9a..aa5f6220 100644 --- a/src/pages/Promotions/Details/ConditionField.tsx +++ b/src/pages/Promotions/Details/ConditionField.tsx @@ -1,8 +1,9 @@ import Stack from "@mui/material/Stack"; -import { FastField, Field, useFormikContext } from "formik"; +import { FastField, Field, getIn, useFormikContext } from "formik"; import { AutocompleteRenderInputParams } from "@mui/material/Autocomplete"; import Typography from "@mui/material/Typography"; -import { memo, SyntheticEvent } from "react"; +import { SyntheticEvent, useRef } from "react"; +import * as Yup from "yup"; import { SelectField } from "@components/SelectField"; import { CONDITION_OPERATORS, OPERATOR_OPTIONS } from "../constants"; @@ -11,6 +12,7 @@ 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 @@ -20,17 +22,46 @@ type ConditionFieldProps = { isLoadingSchema: boolean } -export const ConditionField = memo(({ name, index, operator, isLoadingSchema, schemaProperties }: ConditionFieldProps) => { - const { setFieldValue } = useFormikContext(); - const onPathChange = (_: SyntheticEvent, selectedOption: SelectOptionType | null) => { + +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 | SelectOptionType) => { + 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 ( @@ -66,17 +97,18 @@ export const ConditionField = memo(({ name, index, operator, isLoadingSchema, sc placeholder="Operator" displayEmpty /> - ( @@ -85,5 +117,4 @@ export const ConditionField = memo(({ name, index, operator, isLoadingSchema, sc ); -}); - +}; diff --git a/src/types/schema.ts b/src/types/schema.ts index d32d3edc..f0d5b4d6 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -5,7 +5,8 @@ export enum Type { Array = "array", Number = "number", Boolean = "boolean", - Integer = "integer" + Integer = "integer", + DateTime = "date-time", } export type FieldProperty = { @@ -15,6 +16,8 @@ export type FieldProperty = { 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 = { From 4f3d7e317196ae724691c5ad2cd42d853435a159 Mon Sep 17 00:00:00 2001 From: Chloe Date: Sat, 18 Feb 2023 16:33:51 +0700 Subject: [PATCH 8/9] handle array type Signed-off-by: Chloe --- src/hooks/useIntrospectSchema/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useIntrospectSchema/index.ts b/src/hooks/useIntrospectSchema/index.ts index d1ae3e95..f42c9365 100644 --- a/src/hooks/useIntrospectSchema/index.ts +++ b/src/hooks/useIntrospectSchema/index.ts @@ -21,6 +21,9 @@ const normalizeSchemaProperties = ({ schemaProperties = {}, filterFn, prependFie 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 }]; }); From 34b055a53c0074ac90624723786bbe2ac5797cee Mon Sep 17 00:00:00 2001 From: Chloe Date: Mon, 20 Feb 2023 19:51:48 +0700 Subject: [PATCH 9/9] add array case test Signed-off-by: Chloe --- .../useIntrospectSchema.test.tsx | 4 ++- .../handlers/introspectSchemaHandlers.ts | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx b/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx index 232ecfee..966592a4 100644 --- a/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx +++ b/src/hooks/useIntrospectSchema/useIntrospectSchema.test.tsx @@ -17,7 +17,9 @@ describe("useIntrospectSchema", () => { { 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: "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/introspectSchemaHandlers.ts b/src/mocks/handlers/introspectSchemaHandlers.ts index 5a16c0ca..7a39094a 100644 --- a/src/mocks/handlers/introspectSchemaHandlers.ts +++ b/src/mocks/handlers/introspectSchemaHandlers.ts @@ -42,6 +42,31 @@ export const mockCartItemSchema = { 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" } } },