@sanity/personalization-plugin 2.3.0-launch-darkly.1 → 2.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/personalization-plugin",
3
- "version": "2.3.0-launch-darkly.1",
3
+ "version": "2.3.0",
4
4
  "description": "Plugin to help with personalization, a/b testing when using Sanity",
5
5
  "keywords": [
6
6
  "sanity",
@@ -24,6 +24,11 @@
24
24
  "import": "./dist/index.mjs",
25
25
  "default": "./dist/index.js"
26
26
  },
27
+ "./growthbook": {
28
+ "source": "./src/growthbook/index.ts",
29
+ "import": "./dist/growthbook/index.mjs",
30
+ "default": "./dist/growthbook/index.js"
31
+ },
27
32
  "./package.json": "./package.json"
28
33
  },
29
34
  "main": "./dist/index.js",
@@ -45,7 +50,7 @@
45
50
  },
46
51
  "dependencies": {
47
52
  "@sanity/incompatible-plugin": "^1.0.4",
48
- "@sanity/studio-secrets": "^3.0.1",
53
+ "@sanity/studio-secrets": "^3.0.0",
49
54
  "@sanity/ui": "^2.8.19",
50
55
  "@sanity/uuid": "^3.0.2",
51
56
  "fast-deep-equal": "^3.1.3",
@@ -1,5 +1,5 @@
1
1
  import equal from 'fast-deep-equal'
2
- import {createContext, useContext, useMemo, useState} from 'react'
2
+ import {createContext, useContext, useMemo} from 'react'
3
3
  import {type ObjectInputProps, useClient, useWorkspace} from 'sanity'
4
4
  import {suspend} from 'suspend-react'
5
5
 
@@ -21,8 +21,6 @@ export const CONFIG_DEFAULT = {
21
21
  export const ExperimentContext = createContext<ExperimentContextProps>({
22
22
  ...CONFIG_DEFAULT,
23
23
  experiments: [],
24
- setSecret: () => undefined,
25
- secret: undefined,
26
24
  })
27
25
 
28
26
  export function useExperimentContext() {
@@ -35,7 +33,6 @@ type ExperimentProps = ObjectInputProps & {
35
33
 
36
34
  export function ExperimentProvider(props: ExperimentProps) {
37
35
  const {experimentFieldPluginConfig} = props
38
- const [secret, setSecret] = useState<string | undefined>()
39
36
 
40
37
  const client = useClient({apiVersion: experimentFieldPluginConfig.apiVersion})
41
38
  const workspace = useWorkspace()
@@ -51,13 +48,13 @@ export function ExperimentProvider(props: ExperimentProps) {
51
48
  }
52
49
  return experimentFieldPluginConfig.experiments
53
50
  },
54
- [workspace, secret],
51
+ [workspace],
55
52
  {equal},
56
53
  )
57
54
 
58
55
  const context = useMemo(
59
- () => ({...experimentFieldPluginConfig, experiments, secret, setSecret}),
60
- [experimentFieldPluginConfig, experiments, secret, setSecret],
56
+ () => ({...experimentFieldPluginConfig, experiments}),
57
+ [experimentFieldPluginConfig, experiments],
61
58
  )
62
59
 
63
60
  return (
@@ -1,4 +1,5 @@
1
1
  import {CloseIcon} from '@sanity/icons'
2
+ import {useCallback, useMemo} from 'react'
2
3
  import {GiSoapExperiment} from 'react-icons/gi'
3
4
  import {
4
5
  defineDocumentFieldAction,
@@ -18,9 +19,9 @@ const useAddExperimentAction = (
18
19
  ): DocumentFieldActionItem => {
19
20
  const {onChange, active, experimentNameOverride} = props
20
21
 
21
- const handleAddAction = () => {
22
+ const handleAddAction = useCallback(() => {
22
23
  onChange([set(!active, ['active'])])
23
- }
24
+ }, [onChange, active])
24
25
 
25
26
  return {
26
27
  title: `Add ${experimentNameOverride}`,
@@ -33,23 +34,21 @@ const useAddExperimentAction = (
33
34
 
34
35
  const useRemoveExperimentAction = (
35
36
  props: DocumentFieldActionProps &
36
- PatchStuff & {experimentNameOverride: string; experimentId: string; active: boolean},
37
+ PatchStuff & {
38
+ experimentNameOverride: string
39
+ experimentId: string
40
+ active: boolean
41
+ variantNameOverride: string
42
+ },
37
43
  ): DocumentFieldActionItem => {
38
- const {onChange, active, experimentId, experimentNameOverride} = props
39
- const patchActiveFalseEvent = () => {
44
+ const {onChange, active, experimentId, experimentNameOverride, variantNameOverride} = props
45
+ const handleClearAction = useCallback(() => {
40
46
  const activeId = ['active']
41
- return set(!active, activeId)
42
- }
43
- const patchClearEvent = () => {
44
47
  const experiment = [experimentId]
45
- const variants = [experimentNameOverride]
46
- return [unset(experiment), unset(variants)]
47
- }
48
- const handleClearAction = () => {
49
- const clearEvents = patchClearEvent()
50
- const activeEvent = patchActiveFalseEvent()
51
- onChange([activeEvent, ...clearEvents])
52
- }
48
+ const variants = [`${variantNameOverride}s`]
49
+ onChange([set(!active, activeId), unset(experiment), unset(variants)])
50
+ }, [onChange, active, experimentId, variantNameOverride])
51
+
53
52
  return {
54
53
  title: `Remove ${experimentNameOverride}`,
55
54
  type: 'action',
@@ -59,13 +58,19 @@ const useRemoveExperimentAction = (
59
58
  }
60
59
  }
61
60
 
62
- const newActions = ({
61
+ const createActions = ({
63
62
  onChange,
64
63
  inputId,
65
64
  active,
66
65
  experimentNameOverride,
67
66
  experimentId,
68
- }: PatchStuff & {active?: boolean; experimentNameOverride: string; experimentId: string}) => {
67
+ variantNameOverride,
68
+ }: PatchStuff & {
69
+ active?: boolean
70
+ experimentNameOverride: string
71
+ experimentId: string
72
+ variantNameOverride: string
73
+ }) => {
69
74
  const removeAction = defineDocumentFieldAction({
70
75
  name: `Remove ${experimentNameOverride}`,
71
76
  useAction: (props) =>
@@ -76,6 +81,7 @@ const newActions = ({
76
81
  inputId,
77
82
  experimentNameOverride,
78
83
  experimentId,
84
+ variantNameOverride,
79
85
  }),
80
86
  })
81
87
  const addAction = defineDocumentFieldAction({
@@ -90,27 +96,43 @@ const newActions = ({
90
96
  experimentId,
91
97
  }),
92
98
  })
93
- if (active) {
94
- return removeAction
95
- }
96
- return addAction
99
+ return active ? removeAction : addAction
97
100
  }
98
101
 
99
102
  export const ExperimentField = (
100
- props: ObjectFieldProps & {experimentNameOverride: string; experimentId: string},
103
+ props: ObjectFieldProps & {
104
+ experimentNameOverride: string
105
+ experimentId: string
106
+ variantNameOverride: string
107
+ },
101
108
  ) => {
102
109
  const {onChange} = props.inputProps
103
- const {inputId, experimentNameOverride, experimentId} = props
110
+ const {inputId, experimentNameOverride, experimentId, variantNameOverride} = props
104
111
  const active = props.value?.active as boolean | undefined
105
112
 
106
- const oldActions = props.actions || []
113
+ const actionProps = useMemo(
114
+ () => ({
115
+ onChange,
116
+ inputId,
117
+ active,
118
+ experimentNameOverride,
119
+ experimentId,
120
+ variantNameOverride,
121
+ }),
122
+ [onChange, inputId, active, experimentNameOverride, experimentId, variantNameOverride],
123
+ )
107
124
 
108
- const withActionProps = {
109
- ...props,
110
- actions: [
111
- newActions({onChange, inputId, active, experimentNameOverride, experimentId}),
112
- ...oldActions,
113
- ],
114
- }
125
+ const memoizedActions = useMemo(() => {
126
+ const oldActions = props.actions || []
127
+ return [createActions(actionProps), ...oldActions]
128
+ }, [actionProps, props.actions])
129
+
130
+ const withActionProps = useMemo(
131
+ () => ({
132
+ ...props,
133
+ actions: memoizedActions,
134
+ }),
135
+ [props, memoizedActions],
136
+ )
115
137
  return props.renderDefault(withActionProps)
116
138
  }
@@ -2,6 +2,7 @@ import {Card, Text} from '@sanity/ui'
2
2
  import {FormEvent, useCallback, useMemo} from 'react'
3
3
  import {
4
4
  FormPatch,
5
+ getPublishedId,
5
6
  PatchEvent,
6
7
  set,
7
8
  StringInputProps,
@@ -27,13 +28,13 @@ export const ExperimentInput = (
27
28
  const {experiments} = useExperimentContext()
28
29
 
29
30
  const id = useFormValue(['_id']) as string
30
- const aditionalChangePath = useMemo(
31
- () => [...props.path.slice(0, -1), props.variantNameOverride],
31
+ const additionalChangePath = useMemo(
32
+ () => [...props.path.slice(0, -1), `${props.variantNameOverride}s`],
32
33
  [props.variantNameOverride, props.path],
33
34
  )
34
- const subValues = useFormValue(aditionalChangePath)
35
+ const subValues = useFormValue(additionalChangePath)
35
36
 
36
- const {patch} = useDocumentOperation(id.replace('drafts.', ''), props.schemaType.name)
37
+ const {patch} = useDocumentOperation(getPublishedId(id), props.schemaType.name)
37
38
 
38
39
  const handleChange = useCallback(
39
40
  (
@@ -51,12 +52,12 @@ export const ExperimentInput = (
51
52
 
52
53
  if (subValues) {
53
54
  const patchEvent = {
54
- unset: [aditionalChangePath.join('.')],
55
+ unset: [additionalChangePath.join('.')],
55
56
  }
56
57
  patch.execute([patchEvent])
57
58
  }
58
59
  },
59
- [patch, subValues, aditionalChangePath],
60
+ [patch, subValues, additionalChangePath],
60
61
  )
61
62
 
62
63
  if (!experiments.length)
@@ -44,6 +44,7 @@ const createExperimentType = ({
44
44
  {...props}
45
45
  experimentId={experimentId}
46
46
  experimentNameOverride={experimentNameOverride}
47
+ variantNameOverride={variantNameOverride}
47
48
  />
48
49
  ),
49
50
  },
@@ -0,0 +1,38 @@
1
+ import {createContext, useContext, useMemo, useState} from 'react'
2
+ import {ObjectInputProps} from 'sanity'
3
+
4
+ import {GrowthbookContextProps, GrowthbookExperimentFieldPluginConfig} from '../types'
5
+ import {Secrets} from './Secrets'
6
+
7
+ export const GROWTHBOOK_CONFIG_DEFAULT = {
8
+ baseUrl: 'https://api.growthbook.io/api/v1',
9
+ }
10
+
11
+ export const GrowthbookContext = createContext<GrowthbookContextProps>({
12
+ setSecret: () => undefined,
13
+ secret: undefined,
14
+ })
15
+
16
+ export function useGrowthbookContext() {
17
+ return useContext(GrowthbookContext)
18
+ }
19
+
20
+ type GrowthbookProps = ObjectInputProps & {
21
+ growthbookFieldPluginConfig: GrowthbookExperimentFieldPluginConfig
22
+ }
23
+
24
+ export function GrowthbookProvider(props: GrowthbookProps) {
25
+ const {growthbookFieldPluginConfig} = props
26
+ const [secret, setSecret] = useState<string | undefined>()
27
+
28
+ const context = useMemo(
29
+ () => ({...growthbookFieldPluginConfig, secret, setSecret}),
30
+ [growthbookFieldPluginConfig, secret, setSecret],
31
+ )
32
+
33
+ return (
34
+ <GrowthbookContext.Provider value={context}>
35
+ <Secrets {...props} />
36
+ </GrowthbookContext.Provider>
37
+ )
38
+ }
@@ -2,18 +2,20 @@ import {SettingsView, useSecrets} from '@sanity/studio-secrets'
2
2
  import {useEffect, useState} from 'react'
3
3
  import {ObjectInputProps} from 'sanity'
4
4
 
5
- import {useExperimentContext} from './ExperimentContext'
5
+ import {useGrowthbookContext} from './GrowthbookContext'
6
6
 
7
- const pluginConfigKeys = [
7
+ export const namespace = 'growthbook'
8
+
9
+ export const pluginConfigKeys = [
8
10
  {
9
11
  key: 'apiKey',
10
12
  title: 'Your secret API key',
11
13
  },
12
14
  ]
13
15
 
14
- export const Secrets = (props: ObjectInputProps, namespace: string) => {
16
+ export const Secrets = (props: ObjectInputProps) => {
15
17
  const {secrets, loading} = useSecrets(namespace) as {secrets: {apiKey: string}; loading: boolean}
16
- const {setSecret} = useExperimentContext()
18
+ const {setSecret} = useGrowthbookContext()
17
19
  const [showSettings, setShowSettings] = useState<boolean>(false)
18
20
 
19
21
  useEffect(() => {
@@ -32,7 +34,7 @@ export const Secrets = (props: ObjectInputProps, namespace: string) => {
32
34
  return (
33
35
  <>
34
36
  <SettingsView
35
- title={`${namespace} api key`}
37
+ title={'Growthbook secret'}
36
38
  namespace={namespace}
37
39
  keys={pluginConfigKeys}
38
40
  onClose={() => {
@@ -0,0 +1,54 @@
1
+ import {definePlugin, isObjectInputProps} from 'sanity'
2
+
3
+ import {fieldLevelExperiments as baseFieldLevelExperiments} from '../fieldExperiments'
4
+ import {flattenSchemaType} from '../utils/flattenSchemaType'
5
+ import {GROWTHBOOK_CONFIG_DEFAULT, GrowthbookProvider} from './Components/GrowthbookContext'
6
+ import {GrowthbookExperimentFieldPluginConfig} from './types'
7
+ import {getExperiments} from './utils'
8
+
9
+ export const fieldLevelExperiments = definePlugin<GrowthbookExperimentFieldPluginConfig>(
10
+ (config) => {
11
+ const pluginConfig = {...GROWTHBOOK_CONFIG_DEFAULT, ...config}
12
+ const {fields, environment, project, convertBooleans, baseUrl, tags} = pluginConfig
13
+ return {
14
+ name: 'sanity-growthbook-personalistaion-plugin-field-level-experiments',
15
+ plugins: [
16
+ baseFieldLevelExperiments({
17
+ fields,
18
+ experiments: (client) =>
19
+ getExperiments({client, environment, baseUrl, project, convertBooleans, tags}),
20
+ }),
21
+ ],
22
+
23
+ form: {
24
+ components: {
25
+ input: (props) => {
26
+ const isRootInput = props.id === 'root' && isObjectInputProps(props)
27
+
28
+ if (!isRootInput) {
29
+ return props.renderDefault(props)
30
+ }
31
+
32
+ const flatFieldTypeNames = flattenSchemaType(props.schemaType).map(
33
+ (field) => field.type.name,
34
+ )
35
+
36
+ const hasExperiment = flatFieldTypeNames.some((name) => name.startsWith('experiment'))
37
+
38
+ if (!hasExperiment) {
39
+ return props.renderDefault(props)
40
+ }
41
+
42
+ const providerProps = {
43
+ ...props,
44
+ growthbookFieldPluginConfig: {
45
+ ...pluginConfig,
46
+ },
47
+ }
48
+ return GrowthbookProvider(providerProps)
49
+ },
50
+ },
51
+ },
52
+ }
53
+ },
54
+ )
@@ -0,0 +1,15 @@
1
+ import {FieldDefinition} from 'sanity'
2
+
3
+ export type GrowthbookExperimentFieldPluginConfig = {
4
+ fields: (string | FieldDefinition)[]
5
+ environment: string
6
+ baseUrl?: string
7
+ project?: string
8
+ convertBooleans?: boolean
9
+ tags?: string[]
10
+ }
11
+
12
+ export type GrowthbookContextProps = {
13
+ setSecret: (secret: string | undefined) => void
14
+ secret: string | undefined
15
+ }
@@ -0,0 +1,94 @@
1
+ import {SanityClient} from 'sanity'
2
+
3
+ import {ExperimentType, GrowthbookFeature, VariantType} from '../types'
4
+ import {namespace, pluginConfigKeys} from './Components/Secrets'
5
+ import {GrowthbookExperimentFieldPluginConfig} from './types'
6
+
7
+ const getBooleanConversion = (value: string) => {
8
+ // control is false
9
+ if (value === 'true') {
10
+ return 'variant'
11
+ } else if (value === 'false') {
12
+ return 'control'
13
+ }
14
+ return value
15
+ }
16
+
17
+ export const getExperiments = async ({
18
+ client,
19
+ environment,
20
+ baseUrl,
21
+ project,
22
+ convertBooleans,
23
+ tags,
24
+ }: Omit<GrowthbookExperimentFieldPluginConfig, 'fields' | 'baseUrl'> & {
25
+ client: SanityClient
26
+ baseUrl: string
27
+ }): Promise<ExperimentType[]> => {
28
+ const query = `*[_id == 'secrets.${namespace}'][0].secrets.${pluginConfigKeys[0].key}`
29
+
30
+ const secret = await client.fetch(query) // secret is stored in the content lake using @sanity/studio-secrets
31
+ if (!secret) return []
32
+
33
+ const featureExperiments: ExperimentType[] = []
34
+ let hasMore = true
35
+ let offset = 0
36
+ const url = new URL(`${baseUrl}/features`)
37
+ if (project) {
38
+ url.searchParams.set('projectId', project)
39
+ }
40
+
41
+ while (hasMore) {
42
+ url.searchParams.set('offset', offset.toString())
43
+ const response = await fetch(url, {
44
+ headers: {
45
+ Authorization: `Bearer ${secret}`,
46
+ },
47
+ })
48
+
49
+ const {features, hasMore: responseHasMore, nextOffset} = await response.json()
50
+
51
+ hasMore = responseHasMore
52
+ offset = nextOffset
53
+ if (!features) continue
54
+
55
+ features.forEach((feature: GrowthbookFeature) => {
56
+ if (feature.archived) {
57
+ return undefined
58
+ }
59
+ if (tags && feature.tags && !feature.tags.some((tag) => tags.includes(tag))) {
60
+ return undefined
61
+ }
62
+
63
+ const experiments = feature.environments[environment]?.rules.filter(
64
+ (experiment) => experiment.type === 'experiment-ref' || experiment.type === 'experiment',
65
+ )
66
+
67
+ if (!experiments) {
68
+ return undefined
69
+ }
70
+
71
+ const variations: VariantType[] = []
72
+ const uniqueValues = new Set<string>()
73
+
74
+ experiments.forEach((experiment) => {
75
+ experiment?.variations.forEach((variant) => {
76
+ const value = convertBooleans ? getBooleanConversion(variant.value) : variant.value
77
+ if (!uniqueValues.has(value)) {
78
+ uniqueValues.add(value)
79
+ variations.push({
80
+ id: value,
81
+ label: value,
82
+ })
83
+ }
84
+ })
85
+ })
86
+ const value = {id: feature.id, label: feature.id, variants: variations}
87
+
88
+ featureExperiments.push(value)
89
+ return undefined
90
+ })
91
+ }
92
+ const sortedFeatureExperiments = featureExperiments.sort((a, b) => a.id.localeCompare(b.id))
93
+ return sortedFeatureExperiments
94
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './fieldExperiments'
2
- export * from './launchDarklyExperiments'
3
2
  export * from './types'
4
3
  export * from './utils/flattenSchemaType'