@graphcommerce/hygraph-dynamic-rows-ui 7.1.0-canary.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @graphcommerce/hygraph-dynamic-rows-ui
2
+
3
+ ## 7.1.0-canary.54
4
+
5
+ ### Minor Changes
6
+
7
+ - [#2100](https://github.com/graphcommerce-org/graphcommerce/pull/2100) [`4df891a4c`](https://github.com/graphcommerce-org/graphcommerce/commit/4df891a4c18b29cc52447eab3a97c66948b6c18f) - Add Dynamic Row UI for property UI field through a custom Hygraph application ([@JoshuaS98](https://github.com/JoshuaS98))
@@ -0,0 +1,124 @@
1
+ import { useFieldExtension } from '@hygraph/app-sdk-react'
2
+ // eslint-disable-next-line @typescript-eslint/no-restricted-imports
3
+ import { TextField } from '@mui/material'
4
+ import { useEffect, useMemo, useState } from 'react'
5
+ import { ApolloClient, InMemoryCache } from '@apollo/client'
6
+ import { fetchGraphQLInterface } from '../lib/fetchGraphQLInterface'
7
+ import { createOptionsFromInterfaceObject, objectifyGraphQLInterface } from '../lib'
8
+
9
+ export function PropertyPicker() {
10
+ const { value, onChange, field, extension } = useFieldExtension()
11
+ const [localValue, setLocalValue] = useState<string | undefined | null>(
12
+ typeof value === 'string' ? value : undefined,
13
+ )
14
+ const [fields, setFields] = useState<any>(null)
15
+
16
+ useEffect(() => {
17
+ onChange(localValue).catch((err) => console.log(err))
18
+ }, [localValue, onChange])
19
+
20
+ const client = new ApolloClient({
21
+ uri:
22
+ typeof extension.config.backend === 'string'
23
+ ? extension.config.backend
24
+ : 'https://graphcommerce.vercel.app/api/graphql', // fallback on the standard GraphCommerce Schema
25
+ cache: new InMemoryCache(),
26
+ })
27
+
28
+ const graphQLInterfaceQuery = useMemo(() => fetchGraphQLInterface(client), [client])
29
+
30
+ // Prepare options
31
+ const numberOptions = useMemo(
32
+ () =>
33
+ createOptionsFromInterfaceObject(
34
+ objectifyGraphQLInterface(fields, 'number', ['ProductInterface']),
35
+ ),
36
+ [fields],
37
+ )
38
+ const textOptions = useMemo(
39
+ () =>
40
+ createOptionsFromInterfaceObject(
41
+ objectifyGraphQLInterface(fields, 'text', ['ProductInterface']),
42
+ ),
43
+ [fields],
44
+ )
45
+ const allOptions = useMemo(
46
+ () => ({
47
+ text: [...textOptions, { label: 'url', id: 'url' }].sort((a, b) => {
48
+ if (!a.label.includes('.') && !b.label.includes('.')) {
49
+ return a.label.localeCompare(b.label)
50
+ }
51
+ if (a.label.includes('.')) {
52
+ return 1
53
+ }
54
+ return -1
55
+ }),
56
+ number: [...numberOptions, { label: 'url', id: 'url' }],
57
+ }),
58
+ [numberOptions, textOptions],
59
+ )
60
+
61
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
62
+ // @ts-ignore - outdated types from @hygraph/app-sdk-react
63
+ const fieldType = field.parent.apiId ?? 'ConditionText'
64
+ const options = fieldType === 'ConditionNumber' ? allOptions.number : allOptions.text
65
+
66
+ if (!fields) {
67
+ Promise.resolve(graphQLInterfaceQuery).then((res) => {
68
+ const fields = res?.data.__type?.fields
69
+
70
+ setFields(fields)
71
+ })
72
+ return <div>Loading fields...</div>
73
+ }
74
+ if (options.length < 1) return <div>No properties available</div>
75
+ if (options.length > 10000) return <div>Too many properties to display</div>
76
+
77
+ return (
78
+ <TextField
79
+ id='property-selector'
80
+ select
81
+ SelectProps={{
82
+ native: true,
83
+ variant: 'outlined',
84
+ }}
85
+ value={localValue}
86
+ onChange={(v) => {
87
+ const val = v.target.value
88
+ setLocalValue(val)
89
+ }}
90
+ fullWidth
91
+ sx={{
92
+ mt: '4px',
93
+ '& .MuiInputBase-root': {
94
+ borderRadius: { xs: '2px!important' },
95
+ },
96
+ '& .MuiOutlinedInput-root': {
97
+ '& fieldset.MuiOutlinedInput-notchedOutline': {
98
+ borderColor: { xs: 'rgb(208, 213, 231)' },
99
+ transition: 'border-color 0.25s ease 0s',
100
+ },
101
+ '&:hover': {
102
+ '& fieldset.MuiOutlinedInput-notchedOutline': {
103
+ borderColor: { xs: 'rgb(208, 213, 231)' },
104
+ },
105
+ },
106
+ '&.Mui-focused': {
107
+ '& fieldset.MuiOutlinedInput-notchedOutline': {
108
+ borderColor: { xs: 'rgb(90, 92, 236)' },
109
+ },
110
+ },
111
+ },
112
+ '& .MuiInputLabel-root.Mui-focused': {
113
+ color: { xs: 'rgb(90, 92, 236)' },
114
+ },
115
+ }}
116
+ >
117
+ {options.map((o) => (
118
+ <option key={o.id} value={o.id}>
119
+ {o.label}
120
+ </option>
121
+ ))}
122
+ </TextField>
123
+ )
124
+ }
@@ -0,0 +1,103 @@
1
+ import { useApp, Wrapper } from '@hygraph/app-sdk-react'
2
+ import styles from './setup.module.css'
3
+ import { useState } from 'react'
4
+
5
+ function Install() {
6
+ // @ts-ignore - outdated types from @hygraph/app-sdk-react
7
+ const { updateInstallation, installation, showToast, extension } = useApp()
8
+ const installed = installation.status === 'COMPLETED'
9
+ const [gqlUri, setGqlUri] = useState('')
10
+
11
+ const saveOnClick = () => {
12
+ updateInstallation({
13
+ config: { backend: gqlUri },
14
+ status: 'COMPLETED',
15
+ }).then(() =>
16
+ showToast({
17
+ title: 'New GraphQL URI saved',
18
+ description: `${gqlUri} is now the GraphQL URI for this application.}`,
19
+ duration: 5000,
20
+ isClosable: true,
21
+ position: 'top-left',
22
+ variantColor: 'success',
23
+ }).catch((err) => console.log(err)),
24
+ )
25
+ }
26
+
27
+ const changedUri = extension.config.backend !== gqlUri
28
+
29
+ const installOnClick = () =>
30
+ updateInstallation({
31
+ config: { backend: gqlUri },
32
+ status: 'COMPLETED',
33
+ }).then(() =>
34
+ showToast({
35
+ title: 'Application enabled',
36
+ description: 'You can now use the Dynamic Row Property Selector field in your schema.',
37
+ duration: 5000,
38
+ isClosable: true,
39
+ position: 'top-left',
40
+ variantColor: 'success',
41
+ }).catch((err) => console.log(err)),
42
+ )
43
+
44
+ const uninstallOnClick = async () => {
45
+ updateInstallation({
46
+ config: {},
47
+ status: 'DISABLED',
48
+ })
49
+ .then(() => {
50
+ showToast({
51
+ title: 'Application disabled',
52
+ description: 'You can re-enable the application from the application configuration page.',
53
+ duration: 5000,
54
+ isClosable: true,
55
+ position: 'top-left',
56
+ variantColor: 'success',
57
+ })
58
+ })
59
+ .catch((error) => {
60
+ console.error('Error updating installation', error)
61
+ })
62
+
63
+ return 0
64
+ }
65
+
66
+ return (
67
+ <>
68
+ <>
69
+ <span>GraphQL API URI</span>
70
+ <input
71
+ name='gql-uri'
72
+ defaultValue={extension.config.backend}
73
+ onChange={(e) => setGqlUri(e.target.value)}
74
+ />
75
+ </>
76
+
77
+ <button
78
+ type='button'
79
+ className={styles.button}
80
+ onClick={changedUri ? saveOnClick : installed ? uninstallOnClick : installOnClick}
81
+ >
82
+ {changedUri ? 'Save' : installed ? 'Disable app' : 'Enable app'}
83
+ </button>
84
+ </>
85
+ )
86
+ }
87
+
88
+ export function Page() {
89
+ return (
90
+ <div className={styles.container}>
91
+ <h1 className={styles.title}>Dynamic Rows Property Selector</h1>
92
+ <p className={styles.description}>
93
+ Enhance your content management experience with Dynamic Rows, specifically designed to
94
+ integrate seamlessly with our Dynamic Row module. It features an intuitive property picker
95
+ field, allowing for effortless selection and organization of properties to customize your
96
+ content layout. Press install to get started!
97
+ </p>
98
+ <Wrapper>
99
+ <Install />
100
+ </Wrapper>
101
+ </div>
102
+ )
103
+ }
@@ -0,0 +1 @@
1
+ export * from './PropertyPicker'
@@ -0,0 +1,58 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ justify-content: center;
6
+ height: 100%;
7
+ max-width: 1200px;
8
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
9
+ }
10
+
11
+ .title {
12
+ font-size: 24px;
13
+ font-weight: 600;
14
+ line-height: 32px;
15
+ margin-bottom: 16px;
16
+ }
17
+
18
+ .desciption {
19
+ font-size: 14px;
20
+ font-weight: 300;
21
+ line-height: 20px;
22
+ margin-bottom: 16px;
23
+ }
24
+
25
+ .input {
26
+ display: inline;
27
+ }
28
+
29
+ .button {
30
+ user-select: none;
31
+ box-sizing: border-box;
32
+ appearance: none;
33
+ position: relative;
34
+ display: inline-flex;
35
+ -webkit-box-align: center;
36
+ align-items: center;
37
+ text-align: center;
38
+ vertical-align: middle;
39
+ align-self: center;
40
+ text-decoration: none;
41
+ font-weight: 500;
42
+ border: 0px;
43
+ margin: 16px 0px 0px;
44
+ border-radius: 4px;
45
+ font-size: 12px;
46
+ line-height: 16px;
47
+ height: 24px;
48
+ min-width: 24px;
49
+ padding-left: 8px;
50
+ padding-right: 8px;
51
+ color: rgb(255, 255, 255);
52
+ background-color: rgb(90, 92, 236);
53
+ }
54
+
55
+ .button:hover {
56
+ cursor: pointer;
57
+ background-color: rgb(58, 48, 166);
58
+ }
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './components/Setup'
2
+ export * from './components'
@@ -0,0 +1,42 @@
1
+ import { ProductProperty } from '../types'
2
+
3
+ export const createOptionsFromInterfaceObject = (
4
+ obj: object,
5
+ path = '',
6
+ inputs: ProductProperty[] = [],
7
+ parent = '',
8
+ ): ProductProperty[] => {
9
+ for (const [key, value] of Object.entries(obj)) {
10
+ /** Keep count of the current path and parent */
11
+ const currentPath: string = path ? `${path}.${key}` : key
12
+ const currentParent: string = parent ? `${parent}/` : ''
13
+
14
+ /**
15
+ * If the value is a string, number or boolean, add it to the inputs array. If the value is an
16
+ * array, recurse on the first item. If the value is an object, recurse on all it's keys.
17
+ */
18
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
19
+ inputs.push({
20
+ label: currentPath,
21
+ id: currentPath,
22
+ })
23
+ } else if (Array.isArray(value) && value.length > 0) {
24
+ createOptionsFromInterfaceObject(
25
+ value[0] as object,
26
+ `${currentPath}[0]`,
27
+ inputs,
28
+ `${currentParent}${key}`,
29
+ )
30
+ } else if (typeof value === 'object' && value !== null) {
31
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
32
+ createOptionsFromInterfaceObject(
33
+ value as object,
34
+ currentPath,
35
+ inputs,
36
+ `${currentParent}${key}`,
37
+ )
38
+ }
39
+ }
40
+
41
+ return inputs
42
+ }
@@ -0,0 +1,13 @@
1
+ export const createRecursiveIntrospectionQuery = (type: string, depth: number) => {
2
+ let baseQuery = `__type(name: "${type}") { name fields { name `
3
+ let endQuery = ' } }'
4
+
5
+ for (let i = 0; i < depth; i++) {
6
+ baseQuery += 'type { name ofType { name fields { name isDeprecated '
7
+ endQuery += ' } } }'
8
+ }
9
+
10
+ const result = baseQuery + endQuery
11
+
12
+ return result
13
+ }
@@ -0,0 +1,14 @@
1
+ import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client'
2
+ import { createRecursiveIntrospectionQuery } from './createRecursiveIntrospectionQuery'
3
+
4
+ export const fetchGraphQLInterface = (client: ApolloClient<NormalizedCacheObject>) => {
5
+ const introspectionQuery = createRecursiveIntrospectionQuery('ProductInterface', 4)
6
+
7
+ return client.query({
8
+ query: gql`
9
+ query getSchema {
10
+ ${introspectionQuery}
11
+ }
12
+ `,
13
+ })
14
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './createOptionsFromInterfaceObject'
2
+ export * from './createRecursiveIntrospectionQuery'
3
+ export * from './fetchGraphQLInterface'
4
+ export * from './objectifyGraphQLInterface'
@@ -0,0 +1,62 @@
1
+ import { __Field } from '../types'
2
+
3
+ /**
4
+ * In this function we create an object from the GraphQL interface.
5
+ * We need this so we can map out the properties of an interface that is needed
6
+ * for the Dynamic Rows Autocomplete.
7
+ * @param fields - The GraphQL interface object that is read from the schema.
8
+ * @returns
9
+ */
10
+ export const objectifyGraphQLInterface = (
11
+ fields: __Field[] | null,
12
+ conditionType: 'text' | 'number' | 'all',
13
+ skip: string[],
14
+ ): object => {
15
+ let objectifiedInterface: object = {}
16
+
17
+ if (!fields) return objectifiedInterface
18
+
19
+ for (const [, value] of Object.entries(fields)) {
20
+ const nestedFields = value?.type?.ofType?.fields
21
+ const { isDeprecated } = value
22
+ const typeOf = value?.type?.name
23
+ const typeName = value?.type?.ofType?.name ?? ''
24
+
25
+ /**
26
+ * With typevalue we can know of which type a property is, so we for example can determine to to hide string values in ConditionNumbers.
27
+ */
28
+ let typeValue: 'number' | 'text' | 'boolean'
29
+ switch (typeOf) {
30
+ case 'Float' || 'Int':
31
+ typeValue = 'number'
32
+ break
33
+ case 'Boolean':
34
+ typeValue = 'text' // Seperate booleans are not supported yet.
35
+ break
36
+ default:
37
+ typeValue = 'text'
38
+ break
39
+ }
40
+
41
+ if (skip.includes(typeName) || isDeprecated || !value?.name) {
42
+ // do nothing
43
+ } else if (nestedFields) {
44
+ objectifiedInterface = {
45
+ ...objectifiedInterface,
46
+ [value.name]: objectifyGraphQLInterface(nestedFields, conditionType, [...skip, typeName]),
47
+ }
48
+ } else if (typeOf && conditionType === 'all') {
49
+ objectifiedInterface = {
50
+ ...objectifiedInterface,
51
+ [value.name]: typeValue,
52
+ }
53
+ } else if (conditionType === typeValue) {
54
+ objectifiedInterface = {
55
+ ...objectifiedInterface,
56
+ [value.name]: typeValue,
57
+ }
58
+ }
59
+ }
60
+
61
+ return objectifiedInterface
62
+ }
package/next-env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/basic-features/typescript for more information.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@graphcommerce/hygraph-dynamic-rows-ui",
3
+ "homepage": "https://www.graphcommerce.org/",
4
+ "repository": "github:graphcommerce-org/graphcommerce",
5
+ "version": "7.1.0-canary.54",
6
+ "sideEffects": false,
7
+ "type": "commonjs",
8
+ "prettier": "@graphcommerce/prettier-config-pwa",
9
+ "eslintConfig": {
10
+ "extends": "@graphcommerce/eslint-config-pwa",
11
+ "parserOptions": {
12
+ "project": "./tsconfig.json"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "dev": "next dev"
17
+ },
18
+ "dependencies": {
19
+ "@apollo/client": "~3.8.7",
20
+ "@graphcommerce/next-config": "7.1.0-canary.54",
21
+ "@hygraph/app-sdk-react": "^0.0.2",
22
+ "@mui/material": "5.14.7",
23
+ "cross-env": "^7.0.3",
24
+ "dotenv": "16.3.1",
25
+ "graphql": "^16.8.1",
26
+ "next": "^13.2.0",
27
+ "react": "^18.2.0",
28
+ "react-dom": "^18.2.0",
29
+ "webpack": "5.88.2"
30
+ },
31
+ "devDependencies": {
32
+ "@graphcommerce/eslint-config-pwa": "7.1.0-canary.54",
33
+ "@graphcommerce/prettier-config-pwa": "7.1.0-canary.54",
34
+ "@graphcommerce/typescript-config-pwa": "7.1.0-canary.54",
35
+ "@types/react-is": "^18.2.0",
36
+ "eslint": "8.53.0",
37
+ "typescript": "5.1.3"
38
+ }
39
+ }
package/pages/_app.tsx ADDED
@@ -0,0 +1,5 @@
1
+ function MyApp({ Component, pageProps }) {
2
+ return <Component {...pageProps} />
3
+ }
4
+
5
+ export default MyApp
@@ -0,0 +1,71 @@
1
+ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
2
+ import { loadConfig } from '@graphcommerce/next-config'
3
+ import { Wrapper } from '@hygraph/app-sdk-react'
4
+ import React from 'react'
5
+ import { PropertyPicker } from '..'
6
+ import {
7
+ createOptionsFromInterfaceObject,
8
+ objectifyGraphQLInterface,
9
+ fetchGraphQLInterface,
10
+ } from '../lib'
11
+ import { Interface } from '../types'
12
+
13
+ type PropertyPickerProps = Interface
14
+
15
+ export default function DRPropertyPicker(props: PropertyPickerProps) {
16
+ const fieldContainer = React.useRef<HTMLDivElement | null>(null)
17
+
18
+ React.useEffect(() => {
19
+ /**
20
+ * Some styling is being undone here to resolve conflicts between Hygraph App SDK and CssAndFramerMotionProvider.
21
+ */
22
+
23
+ const frameBox1 = fieldContainer?.current?.parentElement
24
+ if (frameBox1) {
25
+ frameBox1.style.position = 'static'
26
+ frameBox1.style.minHeight = 'unset'
27
+ }
28
+
29
+ const frameBox2 = frameBox1?.previousSibling as HTMLDivElement | null
30
+ if (frameBox2) {
31
+ frameBox2.style.minHeight = 'unset'
32
+ }
33
+
34
+ const body = frameBox1?.parentElement
35
+ if (body) {
36
+ body.style.margin = '0'
37
+ }
38
+
39
+ const html = body?.parentElement
40
+ if (html) {
41
+ html.style.background = 'transparent'
42
+ html.style.overflow = 'hidden'
43
+ }
44
+ }, [fieldContainer])
45
+
46
+ return (
47
+ <div ref={fieldContainer}>
48
+ <Wrapper>
49
+ <PropertyPicker />
50
+ </Wrapper>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ export const getStaticProps = async () => {
56
+ const config = loadConfig(process.cwd())
57
+ const staticClient = new ApolloClient({
58
+ link: new HttpLink({
59
+ uri: config.magentoEndpoint,
60
+ fetch,
61
+ }),
62
+ cache: new InMemoryCache(),
63
+ })
64
+ const graphQLInterface = fetchGraphQLInterface(staticClient)
65
+
66
+ return {
67
+ props: {
68
+ ...(await graphQLInterface).data,
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,29 @@
1
+ import React, { MutableRefObject, ReactNode, RefObject } from 'react'
2
+ import { Page } from '..'
3
+
4
+ export default function Setup() {
5
+ const appContainer = React.useRef<HTMLDivElement | null>(null)
6
+
7
+ /**
8
+ * This is a hack to fix the height of the iframe, which was malfunctioning because of a conflict
9
+ * with FramerNextPages
10
+ */
11
+ React.useEffect(() => {
12
+ const framerParent = appContainer?.current?.parentElement
13
+ if (framerParent) {
14
+ framerParent.style.position = 'static'
15
+ framerParent.style.minHeight = 'unset'
16
+ }
17
+
18
+ const framerParent2 = framerParent?.previousSibling as HTMLDivElement | null
19
+ if (framerParent2) {
20
+ framerParent2.style.minHeight = 'unset'
21
+ }
22
+ }, [appContainer])
23
+
24
+ return (
25
+ <div ref={appContainer}>
26
+ <Page />
27
+ </div>
28
+ )
29
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "exclude": ["**/node_modules", "**/.*/"],
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "extends": "@graphcommerce/typescript-config-pwa/nextjs.json"
5
+ }
package/types/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type ProductProperty = {
2
+ label: string
3
+ id: string
4
+ type?: string
5
+ }
6
+
7
+ export type Interface = {
8
+ __type: __Type
9
+ }
10
+
11
+ export type __Type = {
12
+ kind?: __TypeKind
13
+ name?: string
14
+ description?: string
15
+ fields: __Field[]
16
+ ofType?: { name?: string; fields: __Field[] }
17
+ }
18
+
19
+ export type __TypeKind =
20
+ | 'SCALAR'
21
+ | 'OBJECT'
22
+ | 'INTERFACE'
23
+ | 'UNION'
24
+ | 'ENUM'
25
+ | 'INPUT_OBJECT'
26
+ | 'LIST'
27
+ | 'NON_NULL'
28
+
29
+ export type __Field = {
30
+ name: string
31
+ type: __Type
32
+ isDeprecated: boolean
33
+ description?: string
34
+ }
35
+
36
+ export type Option = { id: string; label: string }
37
+
38
+ export type Options = { text: Option[]; number: Option[] }