@sanity/personalization-plugin 2.4.1 → 2.5.0-field-level-personalization.1

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.
Files changed (35) hide show
  1. package/README.md +107 -5
  2. package/dist/_chunks-cjs/fieldExperiments.js +507 -0
  3. package/dist/_chunks-cjs/fieldExperiments.js.map +1 -0
  4. package/dist/_chunks-es/fieldExperiments.mjs +511 -0
  5. package/dist/_chunks-es/fieldExperiments.mjs.map +1 -0
  6. package/dist/growthbook/index.js +3 -3
  7. package/dist/growthbook/index.js.map +1 -1
  8. package/dist/growthbook/index.mjs +1 -1
  9. package/dist/index.d.mts +33 -12
  10. package/dist/index.d.ts +33 -12
  11. package/dist/index.js +158 -277
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +160 -277
  14. package/dist/index.mjs.map +1 -1
  15. package/package.json +20 -20
  16. package/src/components/ArrayItem.tsx +9 -0
  17. package/src/components/Select.tsx +1 -1
  18. package/src/components/{Array.tsx → experiment/Array.tsx} +2 -2
  19. package/src/components/{ExperimentContext.tsx → experiment/Context.tsx} +2 -2
  20. package/src/components/{ExperimentField.tsx → experiment/Field.tsx} +11 -8
  21. package/src/components/{ExperimentInput.tsx → experiment/Input.tsx} +4 -4
  22. package/src/components/{VariantInput.tsx → experiment/VariantInput.tsx} +2 -1
  23. package/src/components/{VariantPreview.tsx → experiment/VariantPreview.tsx} +2 -2
  24. package/src/components/experiment/index.ts +6 -0
  25. package/src/components/personalization/Array.tsx +59 -0
  26. package/src/components/personalization/Context.tsx +61 -0
  27. package/src/components/personalization/Field.tsx +134 -0
  28. package/src/components/personalization/SegmentInput.tsx +19 -0
  29. package/src/components/personalization/SegmentPreview.tsx +71 -0
  30. package/src/components/personalization/index.ts +5 -0
  31. package/src/fieldExperiments.tsx +44 -12
  32. package/src/fieldPersonalization.tsx +254 -0
  33. package/src/index.ts +1 -0
  34. package/src/types.ts +20 -2
  35. package/src/utils/clearChildGroups.ts +33 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/personalization-plugin",
3
- "version": "2.4.1",
3
+ "version": "2.5.0-field-level-personalization.1",
4
4
  "description": "Plugin to help with personalization, a/b testing when using Sanity",
5
5
  "keywords": [
6
6
  "sanity",
@@ -49,39 +49,39 @@
49
49
  "prepare": "husky"
50
50
  },
51
51
  "dependencies": {
52
- "@sanity/incompatible-plugin": "^1.0.4",
53
- "@sanity/studio-secrets": "^3.0.0",
54
- "@sanity/ui": "^2.8.19",
52
+ "@sanity/incompatible-plugin": "^1.0.5",
53
+ "@sanity/studio-secrets": "^3.0.2",
54
+ "@sanity/ui": "^2.16.12",
55
55
  "@sanity/uuid": "^3.0.2",
56
56
  "fast-deep-equal": "^3.1.3",
57
- "react-icons": "^5.4.0",
57
+ "react-icons": "^5.5.0",
58
58
  "suspend-react": "^0.1.3"
59
59
  },
60
60
  "devDependencies": {
61
- "@commitlint/cli": "^19.7.1",
62
- "@commitlint/config-conventional": "^19.7.1",
61
+ "@commitlint/cli": "^19.8.1",
62
+ "@commitlint/config-conventional": "^19.8.1",
63
63
  "@sanity/pkg-utils": "^6.13.4",
64
64
  "@sanity/plugin-kit": "^4.0.19",
65
65
  "@sanity/semantic-release-preset": "^5.0.0",
66
- "@types/react": "^18.3.18",
67
- "@typescript-eslint/eslint-plugin": "^8.23.0",
68
- "@typescript-eslint/parser": "^8.23.0",
66
+ "@types/react": "^18.3.23",
67
+ "@typescript-eslint/eslint-plugin": "^8.39.1",
68
+ "@typescript-eslint/parser": "^8.39.1",
69
69
  "eslint": "^8.57.1",
70
- "eslint-config-prettier": "^9.1.0",
70
+ "eslint-config-prettier": "^9.1.2",
71
71
  "eslint-config-sanity": "^7.1.4",
72
- "eslint-plugin-prettier": "^5.2.3",
73
- "eslint-plugin-react": "^7.37.4",
74
- "eslint-plugin-react-hooks": "^5.1.0",
72
+ "eslint-plugin-prettier": "^5.5.4",
73
+ "eslint-plugin-react": "^7.37.5",
74
+ "eslint-plugin-react-hooks": "^5.2.0",
75
75
  "husky": "^9.1.7",
76
76
  "lint-staged": "^15.2.10",
77
- "prettier": "^3.4.2",
78
- "prettier-plugin-packagejson": "^2.5.8",
77
+ "prettier": "^3.6.2",
78
+ "prettier-plugin-packagejson": "^2.5.19",
79
79
  "react": "^18.3.1",
80
80
  "react-dom": "^18.3.1",
81
- "sanity": "^3.74.1",
82
- "semantic-release": "^24.2.1",
83
- "styled-components": "^6.1.15",
84
- "typescript": "^5.7.3"
81
+ "sanity": "^3.99.0",
82
+ "semantic-release": "^24.2.7",
83
+ "styled-components": "^6.1.19",
84
+ "typescript": "^5.9.2"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "^18 || ^19",
@@ -0,0 +1,9 @@
1
+ import {ObjectItem, ObjectItemProps, set} from 'sanity'
2
+
3
+ export const ArrayItem = (props: ObjectItemProps) => {
4
+ const {active} = props.value as ObjectItem & {active: boolean}
5
+ if (!active) {
6
+ props.inputProps.onChange(set(true, ['active']))
7
+ }
8
+ return props.renderDefault(props)
9
+ }
@@ -2,7 +2,7 @@ import {Select as SanitySelect} from '@sanity/ui'
2
2
  import {FormEvent} from 'react'
3
3
  import {FormPatch, PatchEvent, Path, StringInputProps} from 'sanity'
4
4
 
5
- import {SelectOption} from './ExperimentInput'
5
+ import {SelectOption} from './experiment/Input'
6
6
 
7
7
  export const Select = (
8
8
  props: StringInputProps & {
@@ -3,8 +3,8 @@ import {uuid} from '@sanity/uuid'
3
3
  import {useCallback} from 'react'
4
4
  import {useFormValue} from 'sanity'
5
5
 
6
- import {ArrayInputProps, VariantType} from '../types'
7
- import {useExperimentContext} from './ExperimentContext'
6
+ import {ArrayInputProps, VariantType} from '../../types'
7
+ import {useExperimentContext} from './Context'
8
8
 
9
9
  export const ArrayInput = (props: ArrayInputProps) => {
10
10
  const fieldPath = props.path.slice(0, -1)
@@ -3,7 +3,7 @@ import {createContext, useContext, useMemo} from 'react'
3
3
  import {type ObjectInputProps, useClient, useWorkspace} from 'sanity'
4
4
  import {suspend} from 'suspend-react'
5
5
 
6
- import {ExperimentContextProps, FieldPluginConfig} from '../types'
6
+ import {ExperimentContextProps, ExperimentFieldPluginConfig} from '../../types'
7
7
 
8
8
  // This provider makes the plugin config available to all components in the document form
9
9
  // But with experiments resolved
@@ -28,7 +28,7 @@ export function useExperimentContext() {
28
28
  }
29
29
 
30
30
  type ExperimentProps = ObjectInputProps & {
31
- experimentFieldPluginConfig: Required<FieldPluginConfig>
31
+ experimentFieldPluginConfig: Required<ExperimentFieldPluginConfig>
32
32
  }
33
33
 
34
34
  export function ExperimentProvider(props: ExperimentProps) {
@@ -11,6 +11,8 @@ import {
11
11
  set,
12
12
  unset,
13
13
  } from 'sanity'
14
+
15
+ import {clearChildrenGroups} from '../../utils/clearChildGroups'
14
16
  type PatchStuff = {onChange: (patch: FormPatch | FormPatch[] | PatchEvent) => void; inputId: string}
15
17
 
16
18
  const useAddExperimentAction = (
@@ -99,7 +101,7 @@ const createActions = ({
99
101
  return active ? removeAction : addAction
100
102
  }
101
103
 
102
- export const ExperimentField = (
104
+ export const Field = (
103
105
  props: ObjectFieldProps & {
104
106
  experimentNameOverride: string
105
107
  experimentId: string
@@ -127,12 +129,13 @@ export const ExperimentField = (
127
129
  return [createActions(actionProps), ...oldActions]
128
130
  }, [actionProps, props.actions])
129
131
 
130
- const withActionProps = useMemo(
131
- () => ({
132
- ...props,
132
+ const enhancedProps = useMemo(() => {
133
+ const propsWithClearedGroups = clearChildrenGroups(props)
134
+ return {
135
+ ...propsWithClearedGroups,
133
136
  actions: memoizedActions,
134
- }),
135
- [props, memoizedActions],
136
- )
137
- return props.renderDefault(withActionProps)
137
+ }
138
+ }, [props, memoizedActions])
139
+
140
+ return props.renderDefault(enhancedProps)
138
141
  }
@@ -11,9 +11,9 @@ import {
11
11
  useFormValue,
12
12
  } from 'sanity'
13
13
 
14
- import {ExperimentType} from '..'
15
- import {useExperimentContext} from './ExperimentContext'
16
- import {Select} from './Select'
14
+ import {ExperimentType} from '../..'
15
+ import {Select} from '../Select'
16
+ import {useExperimentContext} from './Context'
17
17
 
18
18
  export type SelectOption = {title: string; value: string}
19
19
  const formatlistOptions = (experiments: ExperimentType[]): SelectOption[] =>
@@ -22,7 +22,7 @@ const formatlistOptions = (experiments: ExperimentType[]): SelectOption[] =>
22
22
  value: experiment.id,
23
23
  }))
24
24
 
25
- export const ExperimentInput = (
25
+ export const Input = (
26
26
  props: StringInputProps & {variantNameOverride: string; experimentNameOverride: string},
27
27
  ) => {
28
28
  const {experiments} = useExperimentContext()
@@ -2,7 +2,8 @@ import {Button, Inline, Stack} from '@sanity/ui'
2
2
  import {ObjectInputProps, set, useFormValue} from 'sanity'
3
3
 
4
4
  export const VariantInput = (props: ObjectInputProps) => {
5
- const defaultValue = useFormValue([props.path[0], 'default'])
5
+ const experimentPath = props.path.slice(0, -2)
6
+ const defaultValue = useFormValue([...experimentPath, 'default'])
6
7
  const handleClick = () => {
7
8
  props.onChange(set(defaultValue, ['value']))
8
9
  }
@@ -8,8 +8,8 @@ import {
8
8
  useClient,
9
9
  } from 'sanity'
10
10
 
11
- import {VariantPreviewProps} from '../types'
12
- import {useExperimentContext} from './ExperimentContext'
11
+ import {VariantPreviewProps} from '../../types'
12
+ import {useExperimentContext} from './Context'
13
13
 
14
14
  export const VariantPreview = (props: PreviewProps) => {
15
15
  const [subtitle, setSubtitle] = useState<string | undefined>(undefined)
@@ -0,0 +1,6 @@
1
+ export * from './Array'
2
+ export * from './Context'
3
+ export * from './Field'
4
+ export * from './Input'
5
+ export * from './VariantInput'
6
+ export * from './VariantPreview'
@@ -0,0 +1,59 @@
1
+ import {Button, Inline, Stack} from '@sanity/ui'
2
+ import {uuid} from '@sanity/uuid'
3
+ import {useCallback} from 'react'
4
+
5
+ import {PersonalizationArrayInputProps, VariantType} from '../../types'
6
+ import {usePersonalizationContext} from './Context'
7
+
8
+ export const ArrayInput = (props: PersonalizationArrayInputProps) => {
9
+ const {onItemAppend, segmentName, segmentId} = props
10
+
11
+ const {segments} = usePersonalizationContext()
12
+
13
+ const handleClick = useCallback(
14
+ async (segment: VariantType) => {
15
+ const item = {
16
+ _key: uuid(),
17
+ [segmentId]: segment.id,
18
+ _type: segmentName,
19
+ }
20
+
21
+ // Patch the document
22
+ onItemAppend(item)
23
+ },
24
+ [segmentId, segmentName, onItemAppend],
25
+ )
26
+
27
+ type Value = {
28
+ value?: unknown
29
+ [key: string]: string | unknown
30
+ segmentId: string
31
+ _key: string
32
+ _type: string
33
+ }
34
+
35
+ // there is probably some better was of getting the type of this?
36
+ const values = (props.value as Value[]) || []
37
+
38
+ const usedSegments = values?.map((segment) => segment[segmentId])
39
+
40
+ return (
41
+ <Stack space={3}>
42
+ {props.renderDefault({...props, arrayFunctions: () => null})}
43
+
44
+ <Inline space={1}>
45
+ {segments.map((segment) => {
46
+ return (
47
+ <Button
48
+ key={`${segment.id}`}
49
+ text={`Add ${segment.label}`}
50
+ mode="ghost"
51
+ disabled={usedSegments?.includes(segment.id)}
52
+ onClick={() => handleClick(segment)}
53
+ />
54
+ )
55
+ })}
56
+ </Inline>
57
+ </Stack>
58
+ )
59
+ }
@@ -0,0 +1,61 @@
1
+ import equal from 'fast-deep-equal'
2
+ import {createContext, useContext, useMemo} from 'react'
3
+ import {ObjectInputProps, useClient, useWorkspace} from 'sanity'
4
+ import {suspend} from 'suspend-react'
5
+
6
+ import {PersonalizationContextProps, PersonalizationFieldPluginConfig} from '../../types'
7
+
8
+ export const CONFIG_DEFAULT = {
9
+ fields: [],
10
+ apiVersion: '2024-11-07',
11
+ personalizationNameOverride: 'personalization',
12
+ segmentNameOverride: 'segment',
13
+ segmentId: 'segmentId',
14
+ segmentArrayName: 'segments',
15
+ }
16
+
17
+ export const PersonalizationContext = createContext<PersonalizationContextProps>({
18
+ ...CONFIG_DEFAULT,
19
+ segments: [],
20
+ })
21
+
22
+ export function usePersonalizationContext() {
23
+ return useContext(PersonalizationContext)
24
+ }
25
+
26
+ type PersonalizationProps = ObjectInputProps & {
27
+ personalizationFieldPluginConfig: Required<PersonalizationFieldPluginConfig>
28
+ }
29
+
30
+ export function PersonalizationProvider(props: PersonalizationProps) {
31
+ const {personalizationFieldPluginConfig} = props
32
+
33
+ const client = useClient({apiVersion: personalizationFieldPluginConfig.apiVersion})
34
+ const workspace = useWorkspace()
35
+
36
+ // Fetch or return experiments
37
+ const segments = Array.isArray(personalizationFieldPluginConfig.segments)
38
+ ? personalizationFieldPluginConfig.segments
39
+ : suspend(
40
+ // eslint-disable-next-line require-await
41
+ async () => {
42
+ if (typeof personalizationFieldPluginConfig.segments === 'function') {
43
+ return personalizationFieldPluginConfig.segments(client)
44
+ }
45
+ return personalizationFieldPluginConfig.segments
46
+ },
47
+ [workspace],
48
+ {equal},
49
+ )
50
+
51
+ const context = useMemo(
52
+ () => ({...personalizationFieldPluginConfig, segments}),
53
+ [personalizationFieldPluginConfig, segments],
54
+ )
55
+
56
+ return (
57
+ <PersonalizationContext.Provider value={context}>
58
+ {props.renderDefault(props)}
59
+ </PersonalizationContext.Provider>
60
+ )
61
+ }
@@ -0,0 +1,134 @@
1
+ import {CloseIcon} from '@sanity/icons'
2
+ import React, {useCallback, useMemo} from 'react'
3
+ import {IoMdPeople} from 'react-icons/io'
4
+ import {
5
+ defineDocumentFieldAction,
6
+ DocumentFieldActionItem,
7
+ DocumentFieldActionProps,
8
+ FormPatch,
9
+ ObjectFieldProps,
10
+ PatchEvent,
11
+ set,
12
+ unset,
13
+ } from 'sanity'
14
+
15
+ import {clearChildrenGroups} from '../../utils/clearChildGroups'
16
+
17
+ type PatchStuff = {onChange: (patch: FormPatch | FormPatch[] | PatchEvent) => void; inputId: string}
18
+
19
+ const useAddExperimentAction = (
20
+ props: DocumentFieldActionProps &
21
+ PatchStuff & {personalizationNameOverride: string; active: boolean},
22
+ ): DocumentFieldActionItem => {
23
+ const {onChange, active, personalizationNameOverride} = props
24
+
25
+ const handleAddAction = useCallback(() => {
26
+ onChange([set(!active, ['active'])])
27
+ }, [onChange, active])
28
+
29
+ return {
30
+ title: `Add ${personalizationNameOverride}`,
31
+ type: 'action',
32
+ icon: IoMdPeople,
33
+ onAction: handleAddAction,
34
+ renderAsButton: true,
35
+ }
36
+ }
37
+
38
+ const useRemoveExperimentAction = (
39
+ props: DocumentFieldActionProps &
40
+ PatchStuff & {
41
+ personalizationNameOverride: string
42
+ active: boolean
43
+ segmentNameOverride: string
44
+ },
45
+ ): DocumentFieldActionItem => {
46
+ const {onChange, active, personalizationNameOverride, segmentNameOverride} = props
47
+ const handleClearAction = useCallback(() => {
48
+ const activeId = ['active']
49
+ const segments = [`${segmentNameOverride}s`]
50
+ onChange([set(!active, activeId), unset(segments)])
51
+ }, [onChange, active, segmentNameOverride])
52
+
53
+ return {
54
+ title: `Remove ${personalizationNameOverride}`,
55
+ type: 'action',
56
+ icon: CloseIcon,
57
+ onAction: handleClearAction,
58
+ renderAsButton: true,
59
+ }
60
+ }
61
+
62
+ const createActions = ({
63
+ onChange,
64
+ inputId,
65
+ active,
66
+ personalizationNameOverride,
67
+ segmentNameOverride,
68
+ }: PatchStuff & {
69
+ active?: boolean
70
+ personalizationNameOverride: string
71
+ segmentNameOverride: string
72
+ }) => {
73
+ const removeAction = defineDocumentFieldAction({
74
+ name: `Remove ${personalizationNameOverride}`,
75
+ useAction: (props) =>
76
+ useRemoveExperimentAction({
77
+ ...props,
78
+ active: true,
79
+ onChange,
80
+ inputId,
81
+ personalizationNameOverride,
82
+ segmentNameOverride,
83
+ }),
84
+ })
85
+ const addAction = defineDocumentFieldAction({
86
+ name: `Add ${personalizationNameOverride}`,
87
+ useAction: (props) =>
88
+ useAddExperimentAction({
89
+ ...props,
90
+ active: false,
91
+ onChange,
92
+ inputId,
93
+ personalizationNameOverride,
94
+ }),
95
+ })
96
+ return active ? removeAction : addAction
97
+ }
98
+
99
+ export const Field = (
100
+ props: ObjectFieldProps & {
101
+ personalizationNameOverride: string
102
+ segmentNameOverride: string
103
+ },
104
+ ): React.ReactElement => {
105
+ const {onChange} = props.inputProps
106
+ const {inputId, personalizationNameOverride, segmentNameOverride} = props
107
+ const active = props.value?.active as boolean | undefined
108
+
109
+ const actionProps = useMemo(
110
+ () => ({
111
+ onChange,
112
+ inputId,
113
+ active,
114
+ personalizationNameOverride,
115
+ segmentNameOverride,
116
+ }),
117
+ [onChange, inputId, active, personalizationNameOverride, segmentNameOverride],
118
+ )
119
+
120
+ const memoizedActions = useMemo(() => {
121
+ const oldActions = props.actions || []
122
+ return [createActions(actionProps), ...oldActions]
123
+ }, [actionProps, props.actions])
124
+
125
+ const enhancedProps = useMemo(() => {
126
+ const propsWithClearedGroups = clearChildrenGroups(props)
127
+ return {
128
+ ...propsWithClearedGroups,
129
+ actions: memoizedActions,
130
+ }
131
+ }, [props, memoizedActions])
132
+
133
+ return props.renderDefault(enhancedProps)
134
+ }
@@ -0,0 +1,19 @@
1
+ import {Button, Inline, Stack} from '@sanity/ui'
2
+ import {ObjectInputProps, set, useFormValue} from 'sanity'
3
+
4
+ export const SegmentInput = (props: ObjectInputProps) => {
5
+ const personalizationPath = props.path.slice(0, -2)
6
+ const defaultValue = useFormValue([...personalizationPath, 'default'])
7
+ const handleClick = () => {
8
+ props.onChange(set(defaultValue, ['value']))
9
+ }
10
+ return (
11
+ <Stack space={3}>
12
+ {props.renderDefault(props)}
13
+
14
+ <Inline space={1}>
15
+ <Button text="Copy default" mode="ghost" onClick={() => handleClick()} />
16
+ </Inline>
17
+ </Stack>
18
+ )
19
+ }
@@ -0,0 +1,71 @@
1
+ import {useEffect, useState} from 'react'
2
+ import {
3
+ isImage,
4
+ isReference,
5
+ ObjectSchemaType,
6
+ PreviewProps,
7
+ ReferenceSchemaType,
8
+ useClient,
9
+ } from 'sanity'
10
+
11
+ import {VariantPreviewProps} from '../../types'
12
+ import {usePersonalizationContext} from './Context'
13
+
14
+ export const SegmentPreview = (props: PreviewProps) => {
15
+ const [subtitle, setSubtitle] = useState<string | undefined>(undefined)
16
+ const [title, setTitle] = useState<string | undefined>(undefined)
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ const [media, setMedia] = useState<any>(undefined)
19
+ const client = useClient({apiVersion: '2025-01-01'})
20
+ const {segments} = usePersonalizationContext()
21
+
22
+ const {segment, value} = props as VariantPreviewProps
23
+
24
+ const selectedSegment = segments.find((segmentItem) => {
25
+ return segmentItem.id === segment
26
+ })
27
+
28
+ useEffect(() => {
29
+ const getSubtitle = async () => {
30
+ setTitle(`${selectedSegment?.label}`)
31
+ if (typeof value === 'string') {
32
+ return setSubtitle(value)
33
+ }
34
+ if (isReference(value)) {
35
+ const doc = await client.getDocument(value._ref)
36
+ const type = props.schemaType as ObjectSchemaType
37
+ const valueField = type.fields.find((field) => field.name === 'value') as ObjectSchemaType
38
+ const referenceField = valueField?.type as ReferenceSchemaType
39
+ const referenceType = referenceField.to.find((field) => field.type?.name === doc?._type)
40
+
41
+ const selectFields = {} as Record<string, unknown>
42
+ const previewFields = referenceType?.preview?.select || {}
43
+ Object.keys(previewFields).forEach((key) => {
44
+ const valueKey = referenceType?.preview?.select?.[key]
45
+ selectFields[key] =
46
+ valueKey && doc
47
+ ? valueKey?.split('.').reduce((acc, index) => acc[index], doc)
48
+ : undefined
49
+ })
50
+
51
+ const previewContent = referenceType?.preview?.prepare?.(selectFields)
52
+ setMedia(previewContent?.media || selectFields.media)
53
+ return setSubtitle(previewContent?.title || (selectFields?.title as string))
54
+ }
55
+ if (isImage(value)) {
56
+ setMedia(value)
57
+ }
58
+ return ''
59
+ }
60
+ getSubtitle()
61
+ }, [value, client, selectedSegment?.label, props.schemaType])
62
+
63
+ const previewProps = {
64
+ ...props,
65
+ title: title,
66
+ subtitle: subtitle,
67
+ media: media,
68
+ }
69
+
70
+ return props.renderDefault(previewProps)
71
+ }
@@ -0,0 +1,5 @@
1
+ export * from './Array'
2
+ export * from './Context'
3
+ export * from './Field'
4
+ export * from './SegmentInput'
5
+ export * from './SegmentPreview'