From 1ccbd1c4b8b44ebcc86e7d9577b7f8e5503f5485 Mon Sep 17 00:00:00 2001 From: Anthony Frehner Date: Thu, 27 Oct 2022 14:07:05 -0600 Subject: [PATCH] metafieldParser() (#45) * Save progress on a new metafield parser * Fix type issue, and get some generics passed in * Fix tests and add additional TS docs to the types * handle no 'type' field, and prep for list metafields * Save progress on metafield parser and tests * Finished the tests and docs * Deprecate parseMetafield; add changelog; export functions * fix bad variable name * Update packages/react/src/metafield-parser.test.ts Co-authored-by: Daniel Rios * Clarify wording around naming and deprecation * remove todo Co-authored-by: Daniel Rios --- .changeset/nine-garlics-fetch.md | 55 ++ packages/react/package.json | 1 + packages/react/src/Metafield.test.helpers.ts | 50 +- packages/react/src/Metafield.tsx | 17 +- packages/react/src/index.ts | 1 + packages/react/src/metafield-parser.test.ts | 538 +++++++++++++++++++ packages/react/src/metafield-parser.ts | 426 +++++++++++++++ yarn.lock | 5 + 8 files changed, 1056 insertions(+), 37 deletions(-) create mode 100644 .changeset/nine-garlics-fetch.md create mode 100644 packages/react/src/metafield-parser.test.ts create mode 100644 packages/react/src/metafield-parser.ts diff --git a/.changeset/nine-garlics-fetch.md b/.changeset/nine-garlics-fetch.md new file mode 100644 index 0000000000..caa7696d18 --- /dev/null +++ b/.changeset/nine-garlics-fetch.md @@ -0,0 +1,55 @@ +--- +'@shopify/hydrogen-react': patch +--- + +Introducing the new `metafieldParser()` function and `ParsedMetafield` type. + +## `metafieldParser()` + +`metafieldParser()` is a temporary name; it will be renamed to `parseMetafield()` in a future release. + +The `metafieldParser()` function is an improvement and enhancement upon the existing `parseMetafield()` and `parseMetafieldValue()` functions. `metafieldParser()` now supports all Metafield types as outlined in the [Storefront API](https://shopify.dev/apps/metafields/types) documentation, including the list types! + +The parsed value can be found on the newly-added `parsedValue` property of the returned object from `metafieldParser()`. For example: + +```js +const parsed = metafieldParser(metafield); + +console.log(parsed.parsedValue); +``` + +`parseMetafieldValue()` has been marked as deprecated and will be removed in a future version of Hydrogen-UI. + +## The `ParsedMetafield` type + +For TypeScript developers, we also introduce the new `ParsedMetafield` type to help improve your experience. The `ParsedMetafield` type is an object in which the keys map to the type that will be returned from `metafieldParser()`. For example: + +```ts +ParsedMetafield['boolean']; +// or +ParsedMetafield['list.collection']; +``` + +When used in conjunction with `metafieldParser()`, it will help TypeScript to understand what the returned object's `parsedValue` type is: + +```ts +const parsed = metafieldParser(booleanMetafield) + +// type of `parsedValue` is `boolean | null` +if(parsed.parsedValue) { + ... +} +``` + +or + +```ts +const parsed = metafieldParser( + listCollectionMetafield +); + +// type of `parsedValue` is `Array | null` +parsed.parsedValue?.map((collection) => { + console.log(collection?.name); +}); +``` diff --git a/packages/react/package.json b/packages/react/package.json index 922e327eae..2d1dadd366 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -93,6 +93,7 @@ "happy-dom": "7.5.10", "react": "^18.0.0", "react-dom": "^18.0.0", + "ts-expect": "^1.3.0", "typescript": "^4.8.4", "vite": "^3.1.8", "vitest": "^0.24.3" diff --git a/packages/react/src/Metafield.test.helpers.ts b/packages/react/src/Metafield.test.helpers.ts index e8b018b0f8..f20211ef8b 100644 --- a/packages/react/src/Metafield.test.helpers.ts +++ b/packages/react/src/Metafield.test.helpers.ts @@ -1,15 +1,21 @@ import {faker} from '@faker-js/faker'; import type {Metafield as MetafieldType} from './storefront-api-types.js'; import type {PartialDeep} from 'type-fest'; +import { + type MetafieldTypeTypes, + allMetafieldTypesArray, +} from './metafield-parser.js'; export function getRawMetafield( metafield: PartialDeep & { - type?: MetafieldTypeOptions; + type?: MetafieldTypeTypes; } = {} -): PartialDeep { - const type: MetafieldTypeOptions = +): PartialDeep & { + type: MetafieldTypeTypes; +} { + const type: MetafieldTypeTypes = metafield.type == null - ? faker.helpers.arrayElement(METAFIELD_TYPES) + ? faker.helpers.arrayElement(allMetafieldTypesArray) : metafield.type; return { @@ -23,20 +29,16 @@ export function getRawMetafield( updatedAt: metafield.updatedAt ?? faker.date.recent().toString(), value: metafield.value ?? getMetafieldValue(type), reference: metafield.reference, + references: metafield.references, }; } -export function getMetafieldValue(type: MetafieldTypeOptions) { +export function getMetafieldValue(type: MetafieldTypeTypes) { switch (type) { case 'single_line_text_field': return faker.random.words(); case 'multi_line_text_field': return `${faker.random.words()}\n${faker.random.words()}\n${faker.random.words()}`; - case 'page_reference': - case 'product_reference': - case 'variant_reference': - case 'file_reference': - return faker.random.words(); case 'number_integer': return faker.datatype.number().toString(); case 'number_decimal': @@ -89,30 +91,8 @@ export function getMetafieldValue(type: MetafieldTypeOptions) { value: faker.datatype.float({min, max, precision: 0.0001}), }); } - default: - return JSON.stringify(faker.datatype.json()); + default: { + return faker.random.words(); + } } } - -export const METAFIELD_TYPES = [ - 'single_line_text_field', - 'multi_line_text_field', - 'page_reference', - 'product_reference', - 'variant_reference', - 'file_reference', - 'number_integer', - 'number_decimal', - 'date', - 'date_time', - 'url', - 'json', - 'boolean', - 'color', - 'weight', - 'volume', - 'dimension', - 'rating', -] as const; - -type MetafieldTypeOptions = typeof METAFIELD_TYPES[number]; diff --git a/packages/react/src/Metafield.tsx b/packages/react/src/Metafield.tsx index fbeea12562..b26d9e333e 100644 --- a/packages/react/src/Metafield.tsx +++ b/packages/react/src/Metafield.tsx @@ -190,11 +190,19 @@ export function Metafield( * The `parseMetafield` utility transforms a [Metafield](https://shopify.dev/api/storefront/reference/common-objects/Metafield) * into a new object whose `values` have been parsed according to the metafield `type`. * If the metafield is `null`, then it returns `null` back. + * + * Note that `parseMetafield()` will have a breaking change in a future version; it will change to behave like `metafieldParser()`. */ export function parseMetafield( /** A [Metafield](https://shopify.dev/api/storefront/reference/common-objects/Metafield) or null */ metafield: PartialDeep | null ): PartialDeep | null { + if (__HYDROGEN_DEV__) { + console.info( + `'parseMetafield()' will have a breaking change in a future version; its behavior will match that of 'metafieldParser()'` + ); + } + if (!metafield) { if (__HYDROGEN_DEV__) { console.warn( @@ -220,10 +228,15 @@ export function parseMetafield( /** * The `parseMetafieldValue` function parses a [Metafield](https://shopify.dev/api/storefront/reference/common-objects/metafield)'s `value` from a string into a sensible type corresponding to the [Metafield](https://shopify.dev/api/storefront/reference/common-objects/metafield)'s `type`. + * @deprecated `parseMetafieldValue()` is unsupported and will be removed in a future version. */ export function parseMetafieldValue( metafield: PartialDeep | null ): ParsedMetafield['value'] { + if (__HYDROGEN_DEV__) { + console.info(`'parseMetafieldValue()' will be removed in a future version`); + } + if (!metafield) { return null; } @@ -304,7 +317,7 @@ export function getMeasurementAsString( locale = 'en-us', options: Intl.NumberFormatOptions = {} ) { - let measure: {value: number; unit: string} = { + let measure: Measurement = { value: measurement.value, unit: UNIT_MAPPING[measurement.unit], }; @@ -320,7 +333,7 @@ export function getMeasurementAsString( }).format(measure.value); } -function convertToSupportedUnit(value: number, unit: string) { +function convertToSupportedUnit(value: number, unit: string): Measurement { switch (unit) { case 'cl': return { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6ae8863e55..35d5e3d052 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,6 +10,7 @@ export {ExternalVideo} from './ExternalVideo.js'; export {flattenConnection} from './flatten-connection.js'; export {Image} from './Image.js'; export {MediaFile} from './MediaFile.js'; +export {metafieldParser, type ParsedMetafields} from './metafield-parser.js'; export {Metafield, parseMetafield, parseMetafieldValue} from './Metafield.js'; export {ModelViewer} from './ModelViewer.js'; export {Money} from './Money.js'; diff --git a/packages/react/src/metafield-parser.test.ts b/packages/react/src/metafield-parser.test.ts new file mode 100644 index 0000000000..221ffc9087 --- /dev/null +++ b/packages/react/src/metafield-parser.test.ts @@ -0,0 +1,538 @@ +import { + metafieldParser, + type ParsedMetafields, + type Measurement, + type Rating, +} from './metafield-parser.js'; +import {getRawMetafield} from './Metafield.test.helpers.js'; +import {expectType} from 'ts-expect'; +import type { + Collection, + GenericFile, + MoneyV2, + Page, + Product, + ProductVariant, +} from './storefront-api-types.js'; +import {faker} from '@faker-js/faker'; + +/** + * The tests in this file are written in the format `parsed.parsedValue? === ''` instead of `(parsed.parsedValue).toEqual()` + * The advantage of doing it this way for this test suite is that it helps ensure that the TS types are correct for the returned value + * In most other situations, the second way is probably better though + */ +describe(`metafieldParser`, () => { + describe(`base metafields`, () => { + it(`boolean`, () => { + const meta = getRawMetafield({ + type: 'boolean', + value: 'false', + }); + const parsed = metafieldParser(meta); + expect(parsed.parsedValue === false).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`collection_reference`, () => { + const parsed = metafieldParser({ + type: 'collection_reference', + reference: { + __typename: 'Collection', + }, + }); + expect(parsed?.parsedValue?.__typename === 'Collection').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`color`, () => { + const parsed = metafieldParser({ + type: 'color', + value: '#f0f0f0', + }); + expect(parsed?.parsedValue === '#f0f0f0').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`date`, () => { + const dateStamp = '2022-10-13'; + const parsed = metafieldParser({ + type: 'date', + value: dateStamp, + }); + expect( + parsed?.parsedValue?.toString() === new Date(dateStamp).toString() + ).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`date_time`, () => { + const dateStamp = '2022-10-13'; + const parsed = metafieldParser({ + type: 'date_time', + value: dateStamp, + }); + expect( + parsed?.parsedValue?.toString() === new Date(dateStamp).toString() + ).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`dimension`, () => { + const parsed = metafieldParser({ + type: 'dimension', + value: JSON.stringify({unit: 'mm', value: 2}), + }); + expect(parsed?.parsedValue?.unit === 'mm').toBe(true); + expect(parsed?.parsedValue?.value === 2).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`file_reference`, () => { + const parsed = metafieldParser({ + type: 'file_reference', + reference: { + __typename: 'GenericFile', + }, + }); + expect(parsed.parsedValue?.__typename === 'GenericFile').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`json`, () => { + type MyJson = { + test: string; + bool: boolean; + deep: { + numb: number; + }; + }; + + const myJson = { + type: 'json', + value: JSON.stringify({test: 'testing', bool: false, deep: {numb: 7}}), + }; + + // without an extra generic, we just mark it as "unknown" + const parsed = metafieldParser(myJson); + // note that with "unknown", you have to cast it as something + expect((parsed?.parsedValue as {test: string})?.test === 'testing').toBe( + true + ); + expectType(parsed?.parsedValue); + + // with an extra generic, we can use that as the type instead + const parsedOtherType = + metafieldParser['json']>(myJson); + expect(parsedOtherType.type === 'json').toBe(true); + expect(parsedOtherType.parsedValue?.test === 'testing').toBe(true); + expect(parsedOtherType.parsedValue?.bool === false).toBe(true); + expect(parsedOtherType.parsedValue?.deep?.numb === 7).toBe(true); + expectType(parsedOtherType.parsedValue); + }); + + it(`money`, () => { + const parsed = metafieldParser({ + type: 'money', + value: JSON.stringify({amount: '12', currencyCode: 'USD'}), + }); + expect(parsed?.parsedValue?.amount === '12').toBe(true); + expect(parsed?.parsedValue?.currencyCode === 'USD').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`multi_line_text_field`, () => { + const parsed = metafieldParser( + { + type: 'multi_line_text_field', + value: 'blah\nblah\nblah', + } + ); + expect(parsed?.parsedValue === 'blah\nblah\nblah').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`single_line_text_field`, () => { + const parsed = metafieldParser< + ParsedMetafields['single_line_text_field'] + >({ + type: 'single_line_text_field', + value: 'blah', + }); + expect(parsed?.parsedValue === 'blah').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`url`, () => { + const parsed = metafieldParser({ + type: 'url', + value: 'https://www.shopify.com', + }); + expect(parsed?.parsedValue === 'https://www.shopify.com').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`number_decimal`, () => { + const parsed = metafieldParser({ + type: 'number_decimal', + value: '2.2', + }); + expect(parsed?.parsedValue === 2.2).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`number_integer`, () => { + const parsed = metafieldParser({ + type: 'number_integer', + value: '2', + }); + expect(parsed?.parsedValue === 2).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`page_reference`, () => { + const parsed = metafieldParser({ + type: 'page_reference', + reference: { + __typename: 'Page', + }, + }); + expect(parsed.parsedValue?.__typename === 'Page').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`product_reference`, () => { + const parsed = metafieldParser({ + type: 'product_reference', + reference: { + __typename: 'Product', + }, + }); + expect(parsed.parsedValue?.__typename === 'Product').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`rating`, () => { + const parsed = metafieldParser({ + type: 'rating', + value: JSON.stringify({value: 3, scale_min: 1, scale_max: 5}), + }); + expect(parsed?.parsedValue?.value === 3).toBe(true); + expect(parsed?.parsedValue?.scale_min === 1).toBe(true); + expect(parsed?.parsedValue?.scale_max === 5).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`variant_reference`, () => { + const parsed = metafieldParser({ + type: 'variant_reference', + reference: { + __typename: 'ProductVariant', + }, + }); + expect(parsed.parsedValue?.__typename === 'ProductVariant').toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`volume`, () => { + const parsed = metafieldParser({ + type: 'volume', + value: JSON.stringify({unit: 'us_pt', value: 2}), + }); + expect(parsed?.parsedValue?.unit === 'us_pt').toBe(true); + expect(parsed?.parsedValue?.value === 2).toBe(true); + expectType(parsed?.parsedValue); + }); + + it(`weight`, () => { + const parsed = metafieldParser({ + type: 'weight', + value: JSON.stringify({unit: 'lbs', value: 2}), + }); + expect(parsed?.parsedValue?.unit === 'lbs').toBe(true); + expect(parsed?.parsedValue?.value === 2).toBe(true); + expectType(parsed?.parsedValue); + }); + }); + + describe(`list metafields`, () => { + it(`list.collection_reference`, () => { + const parsed = metafieldParser< + ParsedMetafields['list.collection_reference'] + >({ + type: 'list.collection_reference', + references: { + nodes: [ + { + __typename: 'Collection', + id: '0', + }, + { + __typename: 'Collection', + id: '1', + }, + ], + }, + }); + parsed.parsedValue?.forEach((coll, index) => { + expect(coll.__typename === 'Collection').toBe(true); + expect(index.toString() === coll.id).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.color`, () => { + const listOfColors = [faker.color.rgb(), faker.color.rgb()]; + const parsed = metafieldParser({ + type: 'list.color', + value: JSON.stringify(listOfColors), + }); + parsed.parsedValue?.forEach((color, index) => { + expect(color === listOfColors[index]).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.date`, () => { + const listOfDates = ['2022-10-24', '2022-10-25']; + const listOfParsedDates = listOfDates.map((date) => new Date(date)); + const parsed = metafieldParser({ + type: 'list.date', + value: JSON.stringify(listOfDates), + }); + parsed.parsedValue?.forEach((date, index) => { + // worried about flakiness here with comparing dates, and having that be consistent in tests + expect( + date.getUTCDate() === listOfParsedDates[index].getUTCDate() + ).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.date_time`, () => { + const listOfDates = ['2022-10-04T22:30:00Z', '2022-10-05T22:30:00Z']; + const listOfParsedDates = listOfDates.map((date) => new Date(date)); + const parsed = metafieldParser({ + type: 'list.date', + value: JSON.stringify(listOfDates), + }); + parsed.parsedValue?.forEach((date, index) => { + // worried about flakiness here with comparing dates, and having that be consistent in tests + expect( + date.toISOString() === listOfParsedDates[index].toISOString() + ).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.dimension`, () => { + const listDimensions = [ + {unit: 'mm', value: faker.datatype.number()}, + {unit: 'mm', value: faker.datatype.number()}, + ]; + const parsed = metafieldParser({ + type: 'list.dimension', + value: JSON.stringify(listDimensions), + }); + parsed.parsedValue?.forEach((dimension, index) => { + expect(dimension.unit === listDimensions[index].unit).toBe(true); + expect(dimension.value === listDimensions[index].value).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.file_reference`, () => { + const parsed = metafieldParser({ + type: 'list.file_reference', + references: { + nodes: [ + { + __typename: 'GenericFile', + id: '0', + }, + { + __typename: 'GenericFile', + id: '1', + }, + ], + }, + }); + parsed.parsedValue?.forEach((coll, index) => { + expect(coll.__typename === 'GenericFile').toBe(true); + expect(index.toString() === coll.id).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.number_integer`, () => { + const listOfNumbers = [faker.datatype.number(), faker.datatype.number()]; + const parsed = metafieldParser({ + type: 'list.number_integer', + value: JSON.stringify(listOfNumbers), + }); + parsed.parsedValue?.forEach((number, index) => { + expect(number === listOfNumbers[index]).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.number_decimal`, () => { + const listOfNumbers = [faker.datatype.float(), faker.datatype.float()]; + const parsed = metafieldParser({ + type: 'list.number_decimal', + value: JSON.stringify(listOfNumbers), + }); + parsed.parsedValue?.forEach((number, index) => { + expect(number === listOfNumbers[index]).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.page_reference`, () => { + const parsed = metafieldParser({ + type: 'list.page_reference', + references: { + nodes: [ + { + __typename: 'Page', + id: '0', + }, + { + __typename: 'Page', + id: '1', + }, + ], + }, + }); + parsed.parsedValue?.forEach((coll, index) => { + expect(coll.__typename === 'Page').toBe(true); + expect(index.toString() === coll.id).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.product_reference`, () => { + const parsed = metafieldParser< + ParsedMetafields['list.product_reference'] + >({ + type: 'list.product_reference', + references: { + nodes: [ + { + __typename: 'Product', + id: '0', + }, + { + __typename: 'Product', + id: '1', + }, + ], + }, + }); + parsed.parsedValue?.forEach((coll, index) => { + expect(coll.__typename === 'Product').toBe(true); + expect(index.toString() === coll.id).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.rating`, () => { + const listOfRatings: Rating[] = [ + {scale_min: 0, scale_max: 5, value: faker.datatype.number()}, + {scale_min: 0, scale_max: 5, value: faker.datatype.number()}, + ]; + const parsed = metafieldParser({ + type: 'list.rating', + value: JSON.stringify(listOfRatings), + }); + parsed.parsedValue?.forEach((rating, index) => { + expect(rating.value === listOfRatings[index].value).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.single_line_text_field`, () => { + const listOfStrings = [faker.random.words(), faker.random.words()]; + const parsed = metafieldParser< + ParsedMetafields['list.single_line_text_field'] + >({ + type: 'list.single_line_text_field', + value: JSON.stringify(listOfStrings), + }); + parsed.parsedValue?.forEach((strng, index) => { + expect(strng === listOfStrings[index]).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.url`, () => { + const listOfStrings = [faker.internet.url(), faker.internet.url()]; + const parsed = metafieldParser({ + type: 'list.url', + value: JSON.stringify(listOfStrings), + }); + parsed.parsedValue?.forEach((strng, index) => { + expect(strng === listOfStrings[index]).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.variant_reference`, () => { + const parsed = metafieldParser< + ParsedMetafields['list.variant_reference'] + >({ + type: 'list.variant_reference', + references: { + nodes: [ + { + __typename: 'ProductVariant', + id: '0', + }, + { + __typename: 'ProductVariant', + id: '1', + }, + ], + }, + }); + parsed.parsedValue?.forEach((coll, index) => { + expect(coll.__typename === 'ProductVariant').toBe(true); + expect(index.toString() === coll.id).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.volume`, () => { + const volumes: Measurement[] = [ + {unit: 'us_pt', value: 2}, + {unit: 'us_pt', value: 2}, + ]; + const parsed = metafieldParser({ + type: 'volume', + value: JSON.stringify(volumes), + }); + + parsed.parsedValue?.forEach((vol, index) => { + expect(vol?.unit === volumes[index].unit).toBe(true); + expect(vol?.value === volumes[index].value).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + + it(`list.weight`, () => { + const weights: Measurement[] = [ + {unit: 'lbs', value: 2}, + {unit: 'lbs', value: 2}, + ]; + const parsed = metafieldParser({ + type: 'volume', + value: JSON.stringify(weights), + }); + + parsed.parsedValue?.forEach((vol, index) => { + expect(vol?.unit === weights[index].unit).toBe(true); + expect(vol?.value === weights[index].value).toBe(true); + }); + expectType(parsed?.parsedValue); + }); + }); +}); diff --git a/packages/react/src/metafield-parser.ts b/packages/react/src/metafield-parser.ts new file mode 100644 index 0000000000..aea748c4a4 --- /dev/null +++ b/packages/react/src/metafield-parser.ts @@ -0,0 +1,426 @@ +import type { + Collection, + GenericFile, + Metafield as MetafieldBaseType, + MoneyV2, + Page, + Product, + ProductVariant, +} from './storefront-api-types.js'; +import type {PartialDeep, Simplify} from 'type-fest'; +import {parseJSON} from './Metafield.js'; +import {TypeEqual, expectType} from 'ts-expect'; +import {flattenConnection} from './flatten-connection.js'; + +/** + * A temporary function that will be renamed to `parseMetafield()` in a future release. + * + * A function that uses `metafield.type` to parse the Metafield's `value` or `reference` or `references` (depending on the `type`) and put it in `metafield.parsedValue` + * + * TypeScript developers can use the type `ParsedMetafields` from this package to get the returned object's type correct. For example: + * + * ``` + * metafieldParser({type: 'boolean', value: 'false'} + * ``` + */ +export function metafieldParser( + metafield: PartialDeep +): ReturnGeneric { + if (!metafield.type) { + const noTypeError = `metafieldParser(): The 'type' field is required in order to parse the Metafield.`; + if (__HYDROGEN_DEV__) { + throw new Error(noTypeError); + } else { + console.error(`${noTypeError} Returning 'parsedValue' of 'null'`); + return { + ...metafield, + parsedValue: null, + } as ReturnGeneric; + } + } + + switch (metafield.type) { + case 'boolean': + return { + ...metafield, + parsedValue: metafield.value === 'true', + } as ReturnGeneric; + + case 'collection_reference': + case 'file_reference': + case 'page_reference': + case 'product_reference': + case 'variant_reference': + return { + ...metafield, + parsedValue: metafield.reference, + } as ReturnGeneric; + + case 'color': + case 'multi_line_text_field': + case 'single_line_text_field': + case 'url': + return { + ...metafield, + parsedValue: metafield.value, + } as ReturnGeneric; + + // TODO: 'money' should probably be parsed even further to like `useMoney()`, but that logic needs to be extracted first so it's not a hook + case 'dimension': + case 'money': + case 'json': + case 'rating': + case 'volume': + case 'weight': + case 'list.color': + case 'list.dimension': + case 'list.number_integer': + case 'list.number_decimal': + case 'list.rating': + case 'list.single_line_text_field': + case 'list.url': + case 'list.volume': + case 'list.weight': { + let parsedValue = null; + try { + parsedValue = parseJSON(metafield.value ?? ''); + } catch (err) { + const parseError = `metafieldParser(): attempted to JSON.parse the 'metafield.value' property, but failed.`; + if (__HYDROGEN_DEV__) { + throw new Error(parseError); + } else { + console.error(`${parseError} Returning 'null' for 'parsedValue'`); + } + parsedValue = null; + } + return { + ...metafield, + parsedValue, + } as ReturnGeneric; + } + + case 'date': + case 'date_time': + return { + ...metafield, + parsedValue: new Date(metafield.value ?? ''), + } as ReturnGeneric; + + case 'list.date': + case 'list.date_time': { + const jsonParseValue = parseJSON(metafield?.value ?? '') as string[]; + return { + ...metafield, + parsedValue: jsonParseValue.map((dateString) => new Date(dateString)), + } as ReturnGeneric; + } + + case 'number_decimal': + case 'number_integer': + return { + ...metafield, + parsedValue: Number(metafield.value), + } as ReturnGeneric; + + case 'list.collection_reference': + case 'list.file_reference': + case 'list.page_reference': + case 'list.product_reference': + case 'list.variant_reference': + return { + ...metafield, + parsedValue: flattenConnection(metafield.references ?? undefined), + } as ReturnGeneric; + + default: { + const typeNotFoundError = `metafieldParser(): the 'metafield.type' you passed in is not supported. Your type: "${metafield.type}". If you believe this is an error, please open an issue on GitHub.`; + if (__HYDROGEN_DEV__) { + throw new Error(typeNotFoundError); + } else { + console.error( + `${typeNotFoundError} Returning 'parsedValue' of 'null'` + ); + return { + ...metafield, + parsedValue: null, + } as ReturnGeneric; + } + } + } +} + +// taken from https://shopify.dev/apps/metafields/types +export const allMetafieldTypesArray = [ + 'boolean', + 'collection_reference', + 'color', + 'date', + 'date_time', + 'dimension', + 'file_reference', + 'json', + 'money', + 'multi_line_text_field', + 'number_decimal', + 'number_integer', + 'page_reference', + 'product_reference', + 'rating', + 'single_line_text_field', + 'url', + 'variant_reference', + 'volume', + 'weight', + // list metafields + 'list.collection_reference', + 'list.color', + 'list.date', + 'list.date_time', + 'list.dimension', + 'list.file_reference', + 'list.number_integer', + 'list.number_decimal', + 'list.page_reference', + 'list.product_reference', + 'list.rating', + 'list.single_line_text_field', + 'list.url', + 'list.variant_reference', + 'list.volume', + 'list.weight', +] as const; + +/** A union of all the supported `metafield.type`s */ +export type MetafieldTypeTypes = typeof allMetafieldTypesArray[number]; + +/** + * A mapping of a Metafield's `type` to the TypeScript type that is returned from `metafieldParser()` + * For example, when using `metafieldParser()`, the type will be correctly returned when used like the following: + * + * ``` + * const parsedMetafield = metafieldParser(metafield);` + * ``` + * `parsedMetafield.parsedValue`'s type is now `boolean` + */ +export type ParsedMetafields = { + /** A Metafield that's been parsed, with a `parsedValue` of `boolean` */ + boolean: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of a `Collection` object (as defined by the Storefront API) */ + collection_reference: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `string` */ + color: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `Date` */ + date: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `Date` */ + date_time: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `Measurement` */ + dimension: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of a `GenericFile` object (as defined by the Storefront API) */ + file_reference: Simplify; + /** + * A Metafield that's been parsed, with a `parsedValue` of type `unknown`, unless you pass in the type as a generic. For example: + * + * ``` + * ParsedMetafields['json'] + * ``` + */ + json: Simplify>; + /** A Metafield that's been parsed, with a `parsedValue` of type `Money` */ + money: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `string` */ + multi_line_text_field: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `number` */ + number_decimal: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `number` */ + number_integer: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of a `Page` object (as defined by the Storefront API) */ + page_reference: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of a `Product` object (as defined by the Storefront API) */ + product_reference: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `Rating` */ + rating: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `string` */ + single_line_text_field: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `string` */ + url: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of a `ProductVariant` object (as defined by the Storefront API) */ + variant_reference: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `Measurement` */ + volume: Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of type `Measurement` */ + weight: Simplify; + // list metafields + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Collection` objects (as defined by the Storefront API) */ + 'list.collection_reference': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of strings */ + 'list.color': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of Date objects */ + 'list.date': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of Date objects */ + 'list.date_time': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Measurement` objects */ + 'list.dimension': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `GenericFile` objects (as defined by the Storefront API) */ + 'list.file_reference': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of numbers */ + 'list.number_integer': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of numbers */ + 'list.number_decimal': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Page` objects (as defined by the Storefront API) */ + 'list.page_reference': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Product` objects (as defined by the Storefront API) */ + 'list.product_reference': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Rating`s */ + 'list.rating': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of strings */ + 'list.single_line_text_field': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of strings */ + 'list.url': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `ProductVariant` objects (as defined by the Storefront API) */ + 'list.variant_reference': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Measurement`s */ + 'list.volume': Simplify; + /** A Metafield that's been parsed, with a `parsedValue` of an array of `Measurement`s */ + 'list.weight': Simplify; +}; + +// This test is to ensure that ParsedMetafields has a key for every item in 'allMetafieldsTypesArray' +expectType>(true); + +interface ParsedBase extends MetafieldBaseType { + type: MetafieldTypeTypes; + parsedValue: unknown; +} + +interface BooleanParsedMetafield extends ParsedBase { + type: 'boolean'; + parsedValue: boolean | null; +} +type CollectionParsedRefMetafield = MetafieldBaseType & { + type: 'collection_reference'; + parsedValue: Collection | null; +}; +type ColorParsedMetafield = MetafieldBaseType & { + type: 'color'; + parsedValue: string | null; +}; +type DatesParsedMetafield = MetafieldBaseType & { + type: 'date' | 'date_time'; + parsedValue: Date | null; +}; + +type MeasurementParsedMetafield = MetafieldBaseType & { + type: 'dimension' | 'weight' | 'volume'; + parsedValue: Measurement | null; +}; + +type FileRefParsedMetafield = MetafieldBaseType & { + type: 'file_reference'; + parsedValue: GenericFile | null; +}; + +type JsonParsedMetafield = MetafieldBaseType & { + type: 'json'; + parsedValue: JsonTypeGeneric extends void ? unknown : JsonTypeGeneric | null; +}; + +type MoneyParsedMetafield = MetafieldBaseType & { + type: 'money'; + parsedValue: MoneyV2 | null; +}; + +type TextParsedMetafield = MetafieldBaseType & { + type: 'single_line_text_field' | 'multi_line_text_field' | 'url'; + parsedValue: string | null; +}; + +type NumberParsedMetafield = MetafieldBaseType & { + type: 'number_decimal' | 'number_integer'; + parsedValue: number | null; +}; + +type PageParsedRefMetafield = MetafieldBaseType & { + type: 'page_reference'; + parsedValue: Page | null; +}; + +type ProductParsedRefMetafield = MetafieldBaseType & { + type: 'product_reference'; + parsedValue: Product | null; +}; + +type RatingParsedMetafield = MetafieldBaseType & { + type: 'rating'; + parsedValue: Rating | null; +}; + +type VariantParsedRefMetafield = MetafieldBaseType & { + type: 'variant_reference'; + parsedValue: ProductVariant | null; +}; + +type CollectionListParsedRefMetafield = MetafieldBaseType & { + type: 'list.collection_reference'; + parsedValue: Array | null; +}; + +type ColorListParsedMetafield = MetafieldBaseType & { + type: 'list.color'; + parsedValue: Array | null; +}; + +type DatesListParsedMetafield = MetafieldBaseType & { + type: 'list.date' | 'list.date_time'; + parsedValue: Array | null; +}; + +type MeasurementListParsedMetafield = MetafieldBaseType & { + type: 'list.dimension' | 'list.weight' | 'list.volume'; + parsedValue: Array | null; +}; + +type FileListParsedRefMetafield = MetafieldBaseType & { + type: 'list.file_reference'; + parsedValue: Array | null; +}; + +type TextListParsedMetafield = MetafieldBaseType & { + type: 'list.single_line_text_field' | 'list.url'; + parsedValue: Array | null; +}; + +type NumberListParsedMetafield = MetafieldBaseType & { + type: 'list.number_decimal' | 'list.number_integer'; + parsedValue: Array | null; +}; + +type PageListParsedRefMetafield = MetafieldBaseType & { + type: 'list.page_reference'; + parsedValue: Array | null; +}; + +type ProductListParsedRefMetafield = MetafieldBaseType & { + type: 'list.product_reference'; + parsedValue: Array | null; +}; + +type RatingListParsedMetafield = MetafieldBaseType & { + type: 'list.rating'; + parsedValue: Array | null; +}; + +type VariantListParsedRefMetafield = MetafieldBaseType & { + type: 'list.variant_reference'; + parsedValue: Array | null; +}; + +export type Measurement = { + unit: string; + value: number; +}; + +export interface Rating { + value: number; + scale_min: number; + scale_max: number; +} diff --git a/yarn.lock b/yarn.lock index b04704cdd1..4b7ecfdc0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9302,6 +9302,11 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876" integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g== +ts-expect@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-expect/-/ts-expect-1.3.0.tgz#3f8d3966e0e22b5e2bb88337eb99db6816a4c1cf" + integrity sha512-e4g0EJtAjk64xgnFPD6kTBUtpnMVzDrMb12N1YZV0VvSlhnVT3SGxiYTLdGy8Q5cYHOIC/FAHmZ10eGrAguicQ== + ts-log@^2.2.3: version "2.2.5" resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.2.5.tgz#aef3252f1143d11047e2cb6f7cfaac7408d96623"