@sanity/personalization-plugin 2.0.0

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/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "@sanity/personalization-plugin",
3
+ "version": "2.0.0",
4
+ "description": "Plugin to help with personalization, a/b testing when using Sanity",
5
+ "keywords": [
6
+ "sanity",
7
+ "sanity-plugin"
8
+ ],
9
+ "homepage": "https://github.com/sanity-io/sanity-plugin-personalization#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/sanity-io/sanity-plugin-personalization/issues"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git@github.com:sanity-io/sanity-plugin-personalization.git"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Sanity <hello@sanity.io>",
19
+ "sideEffects": false,
20
+ "type": "commonjs",
21
+ "exports": {
22
+ ".": {
23
+ "source": "./src/index.ts",
24
+ "import": "./dist/index.mjs",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "files": [
32
+ "dist",
33
+ "sanity.json",
34
+ "src",
35
+ "v2-incompatible.js"
36
+ ],
37
+ "scripts": {
38
+ "build": "plugin-kit verify-package --silent && pkg-utils build --strict --check --clean",
39
+ "format": "prettier --write --cache --ignore-unknown .",
40
+ "link-watch": "plugin-kit link-watch",
41
+ "lint": "eslint . --fix",
42
+ "prepublishOnly": "npm run build",
43
+ "watch": "pkg-utils watch --strict",
44
+ "prepare": "husky"
45
+ },
46
+ "dependencies": {
47
+ "@sanity/incompatible-plugin": "^1.0.4",
48
+ "@sanity/ui": "^2.8.19",
49
+ "@sanity/uuid": "^3.0.2",
50
+ "fast-deep-equal": "^3.1.3",
51
+ "react-icons": "^5.4.0",
52
+ "suspend-react": "^0.1.3"
53
+ },
54
+ "devDependencies": {
55
+ "@commitlint/cli": "^19.7.1",
56
+ "@commitlint/config-conventional": "^19.7.1",
57
+ "@sanity/pkg-utils": "^6.13.4",
58
+ "@sanity/plugin-kit": "^4.0.19",
59
+ "@sanity/semantic-release-preset": "^5.0.0",
60
+ "@types/react": "^18.3.18",
61
+ "@typescript-eslint/eslint-plugin": "^8.23.0",
62
+ "@typescript-eslint/parser": "^8.23.0",
63
+ "eslint": "^8.57.1",
64
+ "eslint-config-prettier": "^9.1.0",
65
+ "eslint-config-sanity": "^7.1.4",
66
+ "eslint-plugin-prettier": "^5.2.3",
67
+ "eslint-plugin-react": "^7.37.4",
68
+ "eslint-plugin-react-hooks": "^5.1.0",
69
+ "husky": "^9.1.7",
70
+ "lint-staged": "^15.2.10",
71
+ "prettier": "^3.4.2",
72
+ "prettier-plugin-packagejson": "^2.5.8",
73
+ "react": "^18.3.1",
74
+ "react-dom": "^18.3.1",
75
+ "sanity": "^3.74.1",
76
+ "semantic-release": "^24.2.1",
77
+ "styled-components": "^6.1.15",
78
+ "typescript": "^5.7.3"
79
+ },
80
+ "peerDependencies": {
81
+ "react": "^18",
82
+ "sanity": "^3"
83
+ },
84
+ "engines": {
85
+ "node": ">=18"
86
+ },
87
+ "resolutions": {
88
+ "conventional-changelog-conventionalcommits": ">= 8.0.0"
89
+ },
90
+ "publishConfig": {
91
+ "access": "public"
92
+ }
93
+ }
package/sanity.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "parts": [
3
+ {
4
+ "implements": "part:@sanity/base/sanity-root",
5
+ "path": "./v2-incompatible.js"
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,69 @@
1
+ import {Button, Inline, Stack} from '@sanity/ui'
2
+ import {uuid} from '@sanity/uuid'
3
+ import {useCallback} from 'react'
4
+ import {useFormValue} from 'sanity'
5
+
6
+ import {ArrayInputProps, VariantType} from '../types'
7
+ import {useExperimentContext} from './ExperimentContext'
8
+
9
+ export const ArrayInput = (props: ArrayInputProps) => {
10
+ const fieldPath = props.path.slice(0, -1)
11
+ const experimentId = useFormValue([...fieldPath, 'experimentId'])
12
+
13
+ const {experiments} = useExperimentContext()
14
+
15
+ const {onItemAppend, objectName} = props
16
+
17
+ const handleClick = useCallback(
18
+ async (variant: VariantType) => {
19
+ const item = {
20
+ _key: uuid(),
21
+ variantId: variant.id,
22
+ experimentId: experimentId,
23
+ _type: objectName,
24
+ }
25
+
26
+ // Patch the document
27
+ onItemAppend(item)
28
+ },
29
+ [experimentId, objectName, onItemAppend],
30
+ )
31
+
32
+ const filteredVariants =
33
+ experiments.find((option) => {
34
+ return option.id === experimentId
35
+ })?.variants || []
36
+
37
+ type Value = {
38
+ experimentId: string
39
+ value?: unknown
40
+ variantId: string
41
+ _key: string
42
+ _type: string
43
+ }
44
+
45
+ // there is probably some better was of getting the type of this?
46
+ const values = props.value as Value[] | []
47
+
48
+ const usedVariants = values?.map((variant) => variant.variantId)
49
+
50
+ return (
51
+ <Stack space={3}>
52
+ {props.renderDefault({...props, arrayFunctions: () => null})}
53
+
54
+ <Inline space={1}>
55
+ {filteredVariants.map((variant) => {
56
+ return (
57
+ <Button
58
+ key={`${experimentId}-${variant.id}`}
59
+ text={`Add ${variant.label}`}
60
+ mode="ghost"
61
+ disabled={usedVariants?.includes(variant.id)}
62
+ onClick={() => handleClick(variant)}
63
+ />
64
+ )
65
+ })}
66
+ </Inline>
67
+ </Stack>
68
+ )
69
+ }
@@ -0,0 +1,60 @@
1
+ import equal from 'fast-deep-equal'
2
+ import {createContext, useContext, useMemo} from 'react'
3
+ import {type ObjectInputProps, useClient, useWorkspace} from 'sanity'
4
+ import {suspend} from 'suspend-react'
5
+
6
+ import {ExperimentContextProps, FieldPluginConfig} from '../types'
7
+
8
+ // This provider makes the plugin config available to all components in the document form
9
+ // But with experiments resolved
10
+
11
+ export const CONFIG_DEFAULT = {
12
+ fields: [],
13
+ apiVersion: '2024-11-07',
14
+ }
15
+
16
+ export const ExperimentContext = createContext<ExperimentContextProps>({
17
+ ...CONFIG_DEFAULT,
18
+ experiments: [],
19
+ })
20
+
21
+ export function useExperimentContext() {
22
+ return useContext(ExperimentContext)
23
+ }
24
+
25
+ type ExperimentProps = ObjectInputProps & {
26
+ experimentFieldPluginConfig: Required<FieldPluginConfig>
27
+ }
28
+
29
+ export function ExperimentProvider(props: ExperimentProps) {
30
+ const {experimentFieldPluginConfig} = props
31
+
32
+ const client = useClient({apiVersion: experimentFieldPluginConfig.apiVersion})
33
+ const workspace = useWorkspace()
34
+
35
+ // Fetch or return experiments
36
+ const experiments = Array.isArray(experimentFieldPluginConfig.experiments)
37
+ ? experimentFieldPluginConfig.experiments
38
+ : suspend(
39
+ // eslint-disable-next-line require-await
40
+ async () => {
41
+ if (typeof experimentFieldPluginConfig.experiments === 'function') {
42
+ return experimentFieldPluginConfig.experiments(client)
43
+ }
44
+ return experimentFieldPluginConfig.experiments
45
+ },
46
+ [workspace],
47
+ {equal},
48
+ )
49
+
50
+ const context = useMemo(
51
+ () => ({...experimentFieldPluginConfig, experiments}),
52
+ [experimentFieldPluginConfig, experiments],
53
+ )
54
+
55
+ return (
56
+ <ExperimentContext.Provider value={context}>
57
+ {props.renderDefault(props)}
58
+ </ExperimentContext.Provider>
59
+ )
60
+ }
@@ -0,0 +1,84 @@
1
+ import {CloseIcon} from '@sanity/icons'
2
+ import {useCallback, useMemo} from 'react'
3
+ import {GiSoapExperiment} from 'react-icons/gi'
4
+ import {
5
+ defineDocumentFieldAction,
6
+ DocumentFieldActionItem,
7
+ DocumentFieldActionProps,
8
+ FormPatch,
9
+ ObjectFieldProps,
10
+ PatchEvent,
11
+ set,
12
+ unset,
13
+ } from 'sanity'
14
+ type PatchStuff = {onChange: (patch: FormPatch | FormPatch[] | PatchEvent) => void; inputId: string}
15
+
16
+ const useAddExperimentAction = (
17
+ props: DocumentFieldActionProps & PatchStuff,
18
+ ): DocumentFieldActionItem => {
19
+ const patchActiveEvent = useMemo(() => {
20
+ return set(true, ['active'])
21
+ }, [])
22
+ const handleAction = useCallback(() => {
23
+ props.onChange([patchActiveEvent])
24
+ }, [patchActiveEvent, props])
25
+
26
+ return {
27
+ title: 'Add experiment',
28
+ type: 'action',
29
+ icon: GiSoapExperiment,
30
+ onAction: handleAction,
31
+ renderAsButton: true,
32
+ }
33
+ }
34
+
35
+ const useRemoveExperimentAction = (
36
+ props: DocumentFieldActionProps & PatchStuff,
37
+ ): DocumentFieldActionItem => {
38
+ const patchActiveEvent = useMemo(() => {
39
+ const activeId = ['active']
40
+ return set(false, activeId)
41
+ }, [])
42
+
43
+ const patchClearEvent = useMemo(() => {
44
+ const experimentId = ['experimentId'] // `${props.inputId}.experimentId`
45
+ const variants = ['variants'] //`${props.inputId}.variants`
46
+ return [unset(experimentId), unset(variants)]
47
+ }, [])
48
+ const handleAction = useCallback(() => {
49
+ props.onChange([patchActiveEvent, ...patchClearEvent])
50
+ }, [patchActiveEvent, patchClearEvent, props])
51
+
52
+ return {
53
+ title: 'Remove experiment',
54
+ type: 'action',
55
+ icon: CloseIcon,
56
+ onAction: handleAction,
57
+ renderAsButton: true,
58
+ }
59
+ }
60
+
61
+ const newActions = ({onChange, inputId, active}: PatchStuff & {active?: boolean}) =>
62
+ active
63
+ ? defineDocumentFieldAction({
64
+ name: 'Experiment',
65
+ useAction: (props) => useRemoveExperimentAction({...props, onChange, inputId}),
66
+ })
67
+ : defineDocumentFieldAction({
68
+ name: 'Experiment',
69
+ useAction: (props) => useAddExperimentAction({...props, onChange, inputId}),
70
+ })
71
+
72
+ export const ExperimentField = (props: ObjectFieldProps) => {
73
+ const {onChange} = props.inputProps
74
+ const {inputId} = props
75
+ const active = props.value?.active as boolean | undefined
76
+
77
+ const oldActions = props.actions || []
78
+
79
+ const withActionProps = {
80
+ ...props,
81
+ actions: [newActions({onChange, inputId, active}), ...oldActions],
82
+ }
83
+ return props.renderDefault(withActionProps)
84
+ }
@@ -0,0 +1,61 @@
1
+ import {FormEvent, useCallback, useMemo} from 'react'
2
+ import {
3
+ FormPatch,
4
+ PatchEvent,
5
+ set,
6
+ StringInputProps,
7
+ unset,
8
+ useDocumentOperation,
9
+ useFormValue,
10
+ } from 'sanity'
11
+
12
+ import {ExperimentType} from '..'
13
+ import {useExperimentContext} from './ExperimentContext'
14
+ import {Select} from './Select'
15
+
16
+ export type SelectOption = {title: string; value: string}
17
+ const formatlistOptions = (experiments: ExperimentType[]): SelectOption[] =>
18
+ experiments.map((experiment) => ({
19
+ title: experiment.label,
20
+ value: experiment.id,
21
+ }))
22
+
23
+ export const ExperimentInput = (props: StringInputProps) => {
24
+ const {experiments} = useExperimentContext()
25
+
26
+ const id = useFormValue(['_id']) as string
27
+ const aditionalChangePath = useMemo(() => [...props.path.slice(0, -1), 'variants'], [props.path])
28
+ const subValues = useFormValue(aditionalChangePath)
29
+
30
+ const {patch} = useDocumentOperation(id.replace('drafts.', ''), props.schemaType.name)
31
+
32
+ const handleChange = useCallback(
33
+ (
34
+ event: FormEvent<Element>,
35
+ onChange: (patchchange: FormPatch | FormPatch[] | PatchEvent) => void,
36
+ ) => {
37
+ const target = event.currentTarget as HTMLSelectElement
38
+ const inputValue = target.value
39
+
40
+ if (inputValue) {
41
+ onChange(set(inputValue))
42
+ } else {
43
+ onChange(unset())
44
+ }
45
+
46
+ if (subValues) {
47
+ const patchEvent = {
48
+ unset: [aditionalChangePath.join('.')],
49
+ }
50
+ patch.execute([patchEvent])
51
+ }
52
+ },
53
+ [patch, subValues, aditionalChangePath],
54
+ )
55
+
56
+ if (!experiments.length) return <></>
57
+
58
+ return (
59
+ <Select {...props} listOptions={formatlistOptions(experiments)} handleChange={handleChange} />
60
+ )
61
+ }
@@ -0,0 +1,43 @@
1
+ import {Select as SanitySelect} from '@sanity/ui'
2
+ import {FormEvent} from 'react'
3
+ import {FormPatch, PatchEvent, Path, StringInputProps} from 'sanity'
4
+
5
+ import {SelectOption} from './ExperimentInput'
6
+
7
+ export const Select = (
8
+ props: StringInputProps & {
9
+ listOptions: SelectOption[]
10
+ handleChange: (
11
+ event: FormEvent,
12
+ onChange: (patch: FormPatch | FormPatch[] | PatchEvent) => void,
13
+ ) => void
14
+ aditionalChangePath?: Path
15
+ clearSubValueOnChange?: boolean
16
+ },
17
+ ) => {
18
+ const {
19
+ value, // Current field value
20
+ onChange, // Method to handle patch events,
21
+ elementProps,
22
+ listOptions,
23
+ handleChange,
24
+ } = props
25
+
26
+ return (
27
+ <SanitySelect
28
+ {...elementProps}
29
+ fontSize={2}
30
+ padding={3}
31
+ space={[3, 3, 4]}
32
+ value={value || ''} // Current field value
33
+ onChange={(event) => handleChange(event, onChange)} // A function to call when the input value changes
34
+ >
35
+ <option value={''}>{'Select an option...'}</option>
36
+ {listOptions.map(({value: optionValue, title}) => (
37
+ <option key={optionValue} value={optionValue}>
38
+ {title}
39
+ </option>
40
+ ))}
41
+ </SanitySelect>
42
+ )
43
+ }
@@ -0,0 +1,71 @@
1
+ import {FormEvent, useCallback} from 'react'
2
+ import {
3
+ FormPatch,
4
+ PatchEvent,
5
+ set,
6
+ StringInputProps,
7
+ unset,
8
+ useDocumentOperation,
9
+ useFormValue,
10
+ } from 'sanity'
11
+
12
+ import {VariantType} from '../types'
13
+ import {useExperimentContext} from './ExperimentContext'
14
+ import {Select} from './Select'
15
+
16
+ const formatlistOptions = (varants: VariantType[]) =>
17
+ varants.map((variant) => ({
18
+ title: variant.label,
19
+ value: variant.id,
20
+ }))
21
+
22
+ export const VariantInput = (props: StringInputProps) => {
23
+ const experimentPath = props.path.slice(0, -3)
24
+
25
+ const experimentValue = useFormValue([...experimentPath, 'experimentValue'])
26
+
27
+ const id = useFormValue(['_id']) as string
28
+
29
+ const {patch} = useDocumentOperation(id.replace('drafts.', ''), props.schemaType.name)
30
+
31
+ const {experiments} = useExperimentContext()
32
+
33
+ const handleChange = useCallback(
34
+ (
35
+ event: FormEvent<Element>,
36
+ onChange: (patchchange: FormPatch | FormPatch[] | PatchEvent) => void,
37
+ ) => {
38
+ const target = event.currentTarget as HTMLSelectElement
39
+ const inputValue = target.value
40
+ const variantExperimentId = props.id.replace('variantId', 'experimentId')
41
+
42
+ if (inputValue) {
43
+ onChange(set(inputValue))
44
+ const patchEvent = {
45
+ set: {[variantExperimentId]: experimentValue},
46
+ }
47
+ patch.execute([patchEvent])
48
+ } else {
49
+ onChange(unset())
50
+ const patchEvent = {
51
+ unset: [variantExperimentId],
52
+ }
53
+ patch.execute([patchEvent])
54
+ }
55
+ },
56
+ [experimentValue, patch, props.id],
57
+ )
58
+
59
+ const filteredVariants =
60
+ experiments.find((option) => {
61
+ return option.id === experimentValue
62
+ })?.variants || []
63
+
64
+ return (
65
+ <Select
66
+ {...props}
67
+ listOptions={formatlistOptions(filteredVariants)}
68
+ handleChange={handleChange}
69
+ />
70
+ )
71
+ }
@@ -0,0 +1,75 @@
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 {useExperimentContext} from './ExperimentContext'
13
+
14
+ export const VariantPreview = (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 {experiments} = useExperimentContext()
21
+
22
+ const {experiment, variant, value} = props as VariantPreviewProps
23
+
24
+ const selectedExperiment = experiments.find((experimentItem) => {
25
+ return experimentItem.id === experiment
26
+ })
27
+
28
+ const selectedVariant = selectedExperiment?.variants.find((variantItem) => {
29
+ return variantItem.id === variant
30
+ })
31
+
32
+ useEffect(() => {
33
+ const getSubtitle = async () => {
34
+ setTitle(`${selectedExperiment?.label} - ${selectedVariant?.label}`)
35
+ if (typeof value === 'string') {
36
+ return setSubtitle(value)
37
+ }
38
+ if (isReference(value)) {
39
+ const doc = await client.getDocument(value._ref)
40
+ const type = props.schemaType as ObjectSchemaType
41
+ const valueField = type.fields.find((field) => field.name === 'value') as ObjectSchemaType
42
+ const referenceField = valueField?.type as ReferenceSchemaType
43
+ const referenceType = referenceField.to.find((field) => field.type?.name === doc?._type)
44
+
45
+ const selectFields = {} as Record<string, unknown>
46
+ const previewFields = referenceType?.preview?.select || {}
47
+ Object.keys(previewFields).forEach((key) => {
48
+ const valueKey = referenceType?.preview?.select?.[key]
49
+ selectFields[key] =
50
+ valueKey && doc
51
+ ? valueKey?.split('.').reduce((acc, index) => acc[index], doc)
52
+ : undefined
53
+ })
54
+
55
+ const previewContent = referenceType?.preview?.prepare?.(selectFields)
56
+ setMedia(previewContent?.media || selectFields.media)
57
+ return setSubtitle(previewContent?.title || (selectFields?.title as string))
58
+ }
59
+ if (isImage(value)) {
60
+ setMedia(value)
61
+ }
62
+ return ''
63
+ }
64
+ getSubtitle()
65
+ }, [value, client, selectedExperiment?.label, selectedVariant?.label, props.schemaType])
66
+
67
+ const previewProps = {
68
+ ...props,
69
+ title: title,
70
+ subtitle: subtitle,
71
+ media: media,
72
+ }
73
+
74
+ return props.renderDefault(previewProps)
75
+ }