@sanity/personalization-plugin 2.1.0 → 2.2.0-growthbook.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/personalization-plugin",
3
- "version": "2.1.0",
3
+ "version": "2.2.0-growthbook.1",
4
4
  "description": "Plugin to help with personalization, a/b testing when using Sanity",
5
5
  "keywords": [
6
6
  "sanity",
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@sanity/incompatible-plugin": "^1.0.4",
48
+ "@sanity/studio-secrets": "^3.0.0",
48
49
  "@sanity/ui": "^2.8.19",
49
50
  "@sanity/uuid": "^3.0.2",
50
51
  "fast-deep-equal": "^3.1.3",
@@ -1,5 +1,5 @@
1
1
  import equal from 'fast-deep-equal'
2
- import {createContext, useContext, useMemo} from 'react'
2
+ import {createContext, useContext, useMemo, useState} from 'react'
3
3
  import {type ObjectInputProps, useClient, useWorkspace} from 'sanity'
4
4
  import {suspend} from 'suspend-react'
5
5
 
@@ -21,6 +21,8 @@ export const CONFIG_DEFAULT = {
21
21
  export const ExperimentContext = createContext<ExperimentContextProps>({
22
22
  ...CONFIG_DEFAULT,
23
23
  experiments: [],
24
+ setSecret: () => undefined,
25
+ secret: undefined,
24
26
  })
25
27
 
26
28
  export function useExperimentContext() {
@@ -33,6 +35,7 @@ type ExperimentProps = ObjectInputProps & {
33
35
 
34
36
  export function ExperimentProvider(props: ExperimentProps) {
35
37
  const {experimentFieldPluginConfig} = props
38
+ const [secret, setSecret] = useState<string | undefined>()
36
39
 
37
40
  const client = useClient({apiVersion: experimentFieldPluginConfig.apiVersion})
38
41
  const workspace = useWorkspace()
@@ -44,17 +47,17 @@ export function ExperimentProvider(props: ExperimentProps) {
44
47
  // eslint-disable-next-line require-await
45
48
  async () => {
46
49
  if (typeof experimentFieldPluginConfig.experiments === 'function') {
47
- return experimentFieldPluginConfig.experiments(client)
50
+ return experimentFieldPluginConfig.experiments(client, secret)
48
51
  }
49
52
  return experimentFieldPluginConfig.experiments
50
53
  },
51
- [workspace],
54
+ [workspace, secret],
52
55
  {equal},
53
56
  )
54
57
 
55
58
  const context = useMemo(
56
- () => ({...experimentFieldPluginConfig, experiments}),
57
- [experimentFieldPluginConfig, experiments],
59
+ () => ({...experimentFieldPluginConfig, experiments, secret, setSecret}),
60
+ [experimentFieldPluginConfig, experiments, secret, setSecret],
58
61
  )
59
62
 
60
63
  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,22 @@ 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
+
46
+ const handleClearAction = useCallback(() => {
40
47
  const activeId = ['active']
41
- return set(!active, activeId)
42
- }
43
- const patchClearEvent = () => {
44
48
  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
- }
49
+ const variants = [`${variantNameOverride}s`]
50
+ onChange([set(!active, activeId), unset(experiment), unset(variants)])
51
+ }, [onChange, active, experimentId, variantNameOverride])
52
+
53
53
  return {
54
54
  title: `Remove ${experimentNameOverride}`,
55
55
  type: 'action',
@@ -59,13 +59,19 @@ const useRemoveExperimentAction = (
59
59
  }
60
60
  }
61
61
 
62
- const newActions = ({
62
+ const createActions = ({
63
63
  onChange,
64
64
  inputId,
65
65
  active,
66
66
  experimentNameOverride,
67
67
  experimentId,
68
- }: PatchStuff & {active?: boolean; experimentNameOverride: string; experimentId: string}) => {
68
+ variantNameOverride,
69
+ }: PatchStuff & {
70
+ active?: boolean
71
+ experimentNameOverride: string
72
+ experimentId: string
73
+ variantNameOverride: string
74
+ }) => {
69
75
  const removeAction = defineDocumentFieldAction({
70
76
  name: `Remove ${experimentNameOverride}`,
71
77
  useAction: (props) =>
@@ -76,6 +82,7 @@ const newActions = ({
76
82
  inputId,
77
83
  experimentNameOverride,
78
84
  experimentId,
85
+ variantNameOverride,
79
86
  }),
80
87
  })
81
88
  const addAction = defineDocumentFieldAction({
@@ -90,27 +97,44 @@ const newActions = ({
90
97
  experimentId,
91
98
  }),
92
99
  })
93
- if (active) {
94
- return removeAction
95
- }
96
- return addAction
100
+ return active ? removeAction : addAction
97
101
  }
98
102
 
99
103
  export const ExperimentField = (
100
- props: ObjectFieldProps & {experimentNameOverride: string; experimentId: string},
104
+ props: ObjectFieldProps & {
105
+ experimentNameOverride: string
106
+ experimentId: string
107
+ variantNameOverride: string
108
+ },
101
109
  ) => {
102
110
  const {onChange} = props.inputProps
103
- const {inputId, experimentNameOverride, experimentId} = props
111
+ const {inputId, experimentNameOverride, experimentId, variantNameOverride} = props
104
112
  const active = props.value?.active as boolean | undefined
105
113
 
106
- const oldActions = props.actions || []
114
+ const actionProps = useMemo(
115
+ () => ({
116
+ onChange,
117
+ inputId,
118
+ active,
119
+ experimentNameOverride,
120
+ experimentId,
121
+ variantNameOverride,
122
+ }),
123
+ [onChange, inputId, active, experimentNameOverride, experimentId, variantNameOverride],
124
+ )
125
+
126
+ const memoizedActions = useMemo(() => {
127
+ const oldActions = props.actions || []
128
+ return [createActions(actionProps), ...oldActions]
129
+ }, [actionProps, props.actions])
130
+
131
+ const withActionProps = useMemo(
132
+ () => ({
133
+ ...props,
134
+ actions: memoizedActions,
135
+ }),
136
+ [props, memoizedActions],
137
+ )
107
138
 
108
- const withActionProps = {
109
- ...props,
110
- actions: [
111
- newActions({onChange, inputId, active, experimentNameOverride, experimentId}),
112
- ...oldActions,
113
- ],
114
- }
115
139
  return props.renderDefault(withActionProps)
116
140
  }
@@ -25,11 +25,11 @@ export const ExperimentInput = (props: StringInputProps & {variantNameOverride:
25
25
 
26
26
  const id = useFormValue(['_id']) as string
27
27
  const aditionalChangePath = useMemo(
28
- () => [...props.path.slice(0, -1), props.variantNameOverride],
28
+ () => [...props.path.slice(0, -1), `${props.variantNameOverride}s`],
29
29
  [props.variantNameOverride, props.path],
30
30
  )
31
- const subValues = useFormValue(aditionalChangePath)
32
31
 
32
+ const subValues = useFormValue(aditionalChangePath)
33
33
  const {patch} = useDocumentOperation(id.replace('drafts.', ''), props.schemaType.name)
34
34
 
35
35
  const handleChange = useCallback(
@@ -0,0 +1,47 @@
1
+ import {SettingsView, useSecrets} from '@sanity/studio-secrets'
2
+ import {useEffect, useState} from 'react'
3
+ import {ObjectInputProps} from 'sanity'
4
+
5
+ import {useExperimentContext} from './ExperimentContext'
6
+
7
+ const namespace = 'growthbook'
8
+
9
+ const pluginConfigKeys = [
10
+ {
11
+ key: 'apiKey',
12
+ title: 'Your secret API key',
13
+ },
14
+ ]
15
+
16
+ export const Secrets = (props: ObjectInputProps) => {
17
+ const {secrets, loading} = useSecrets(namespace) as {secrets: {apiKey: string}; loading: boolean}
18
+ const {setSecret} = useExperimentContext()
19
+ const [showSettings, setShowSettings] = useState<boolean>(false)
20
+
21
+ useEffect(() => {
22
+ if (loading) return undefined
23
+ if (!secrets && !loading) {
24
+ setSecret(undefined)
25
+ return setShowSettings(true)
26
+ }
27
+ setSecret(secrets.apiKey)
28
+ return setShowSettings(false)
29
+ }, [secrets, loading, setSecret])
30
+
31
+ if (!showSettings) {
32
+ return props.renderDefault(props)
33
+ }
34
+ return (
35
+ <>
36
+ <SettingsView
37
+ title={'Growthbook secret'}
38
+ namespace={namespace}
39
+ keys={pluginConfigKeys}
40
+ onClose={() => {
41
+ setShowSettings(false)
42
+ }}
43
+ />
44
+ {props.renderDefault(props)}
45
+ </>
46
+ )
47
+ }
@@ -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,51 @@
1
+ import {definePlugin, FieldDefinition, isObjectInputProps} from 'sanity'
2
+
3
+ import {Secrets} from './components/Secrets'
4
+ import {fieldLevelExperiments} from './fieldExperiments'
5
+ import {flattenSchemaType} from './utils/flattenSchemaType'
6
+ import {getExperiments} from './utils/growthbook'
7
+
8
+ export type GrowthbookABConfig = {
9
+ fields: (string | FieldDefinition)[]
10
+ environment: string
11
+ baseUrl?: string
12
+ project?: string
13
+ convertBooleans?: boolean
14
+ }
15
+
16
+ export const growthbookFieldLevel = definePlugin<GrowthbookABConfig>((config) => {
17
+ const {fields, environment, project, convertBooleans, baseUrl} = config
18
+ return {
19
+ name: 'sanity-growthbook-personalistaion-plugin-field-level-experiments',
20
+ plugins: [
21
+ fieldLevelExperiments({
22
+ fields,
23
+ experiments: (client) =>
24
+ getExperiments({client, environment, baseUrl, project, convertBooleans}),
25
+ }),
26
+ ],
27
+
28
+ form: {
29
+ components: {
30
+ input: (props) => {
31
+ const isRootInput = props.id === 'root' && isObjectInputProps(props)
32
+
33
+ if (!isRootInput) {
34
+ return props.renderDefault(props)
35
+ }
36
+
37
+ const flatFieldTypeNames = flattenSchemaType(props.schemaType).map(
38
+ (field) => field.type.name,
39
+ )
40
+
41
+ const hasExperiment = flatFieldTypeNames.some((name) => name.startsWith('experiment'))
42
+
43
+ if (!hasExperiment) {
44
+ return props.renderDefault(props)
45
+ }
46
+ return Secrets(props)
47
+ },
48
+ },
49
+ },
50
+ }
51
+ })
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './fieldExperiments'
2
+ export * from './growthbookFieldExperiments'
2
3
  export * from './types'
3
4
  export * from './utils/flattenSchemaType'
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {Dispatch, SetStateAction} from 'react'
1
2
  import {
2
3
  ArrayOfObjectsInputProps,
3
4
  FieldDefinition,
@@ -20,7 +21,9 @@ export type ExperimentType = {
20
21
 
21
22
  export type FieldPluginConfig = {
22
23
  fields: (string | FieldDefinition)[]
23
- experiments: ExperimentType[] | ((client: SanityClient) => Promise<ExperimentType[]>)
24
+ experiments:
25
+ | ExperimentType[]
26
+ | ((client: SanityClient, secret?: string) => Promise<ExperimentType[]>)
24
27
  apiVersion?: string
25
28
  experimentNameOverride?: string
26
29
  variantNameOverride?: string
@@ -37,6 +40,8 @@ export type VariantPreviewProps = Omit<PreviewProps, 'SchemaType'> & {
37
40
 
38
41
  export type ExperimentContextProps = Required<FieldPluginConfig> & {
39
42
  experiments: ExperimentType[]
43
+ setSecret: Dispatch<SetStateAction<string | undefined>>
44
+ secret: string | undefined
40
45
  }
41
46
 
42
47
  export type ArrayInputProps = ArrayOfObjectsInputProps & {
@@ -67,3 +72,179 @@ export type ExperimentGeneric<T> = {
67
72
  | T
68
73
  | undefined
69
74
  }
75
+
76
+ export type GrowthbookExperiment = {
77
+ id: string
78
+ dateCreated: string
79
+ dateUpdated: string
80
+ name: string
81
+ project: string
82
+ hypothesis: string
83
+ description: string
84
+ tags: [string]
85
+ owner: string
86
+ archived: boolean
87
+ status: string
88
+ autoRefresh: boolean
89
+ hashAttribute: string
90
+ fallbackAttribute: string
91
+ hashVersion: number
92
+ disableStickyBucketing: boolean
93
+ bucketVersion: number
94
+ minBucketVersion: number
95
+ variations: [
96
+ {
97
+ variationId: string
98
+ key: string
99
+ name: string
100
+ description: string
101
+ screenshots: [string]
102
+ },
103
+ ]
104
+ phases: [
105
+ {
106
+ name: string
107
+ dateStarted: string
108
+ dateEnded: string
109
+ reasonForStopping: string
110
+ seed: string
111
+ coverage: 0
112
+ trafficSplit: [
113
+ {
114
+ variationId: string
115
+ weight: 0
116
+ },
117
+ ]
118
+ namespace: {
119
+ namespaceId: string
120
+ range: []
121
+ }
122
+ targetingCondition: string
123
+ savedGroupTargeting: [
124
+ {
125
+ matchType: string
126
+ savedGroups: [string]
127
+ },
128
+ ]
129
+ },
130
+ ]
131
+ settings: {
132
+ datasourceId: string
133
+ assignmentQueryId: string
134
+ experimentId: string
135
+ segmentId: string
136
+ queryFilter: string
137
+ inProgressConversions: string
138
+ attributionModel: string
139
+ statsEngine: string
140
+ regressionAdjustmentEnabled: boolean
141
+ goals: [
142
+ {
143
+ metricId: string
144
+ overrides: {
145
+ delayHours: 0
146
+ windowHours: 0
147
+ window: string
148
+ winRiskThreshold: 0
149
+ loseRiskThreshold: 0
150
+ }
151
+ },
152
+ ]
153
+ secondaryMetrics: [
154
+ {
155
+ metricId: string
156
+ overrides: {
157
+ delayHours: 0
158
+ windowHours: 0
159
+ window: string
160
+ winRiskThreshold: 0
161
+ loseRiskThreshold: 0
162
+ }
163
+ },
164
+ ]
165
+ guardrails: [
166
+ {
167
+ metricId: string
168
+ overrides: {
169
+ delayHours: 0
170
+ windowHours: 0
171
+ window: string
172
+ winRiskThreshold: 0
173
+ loseRiskThreshold: 0
174
+ }
175
+ },
176
+ ]
177
+ activationMetric: {
178
+ metricId: string
179
+ overrides: {
180
+ delayHours: 0
181
+ windowHours: 0
182
+ window: string
183
+ winRiskThreshold: 0
184
+ loseRiskThreshold: 0
185
+ }
186
+ }
187
+ }
188
+ resultSummary: {
189
+ status: string
190
+ winner: string
191
+ conclusions: string
192
+ releasedVariationId: string
193
+ excludeFromPayload: boolean
194
+ }
195
+ }
196
+
197
+ export type GrowthbookFeature = {
198
+ id: string
199
+ dateCreated: string
200
+ dateUpdated: string
201
+ archived: boolean
202
+ description: string
203
+ owner: string
204
+ project: string
205
+ valueType: string
206
+ defaultValue: string
207
+ tags: string[]
208
+ environments: {
209
+ [key: string]: {
210
+ enabled: boolean
211
+ defaultValue: string
212
+ rules: {
213
+ description: string
214
+ condition: string
215
+ savedGroupTargeting: {matchType: string; savedGroups: string[]}[]
216
+ id: string
217
+ enabled: boolean
218
+ type: string
219
+ value: string
220
+ variations: {value: string; variationId: string}[]
221
+ }[]
222
+ definition: string
223
+ draft: {
224
+ enabled: boolean
225
+ defaultValue: string
226
+ rules: {
227
+ description: string
228
+ condition: string
229
+ savedGroupTargeting: {matchType: string; savedGroups: string[]}[]
230
+ id: string
231
+ enabled: boolean
232
+ type: string
233
+ value: string
234
+ variations: {value: string; variationId: string}[]
235
+ }[]
236
+ definition: string
237
+ }
238
+ }
239
+ }
240
+ prerequisites: {
241
+ parentId: string
242
+ parentCondition: string
243
+ }[]
244
+ revision: {
245
+ version: number
246
+ comment: string
247
+ date: string
248
+ publishedBy: string
249
+ }
250
+ }
@@ -0,0 +1,78 @@
1
+ import {SanityClient} from 'sanity'
2
+
3
+ import {GrowthbookABConfig} from '../growthbookFieldExperiments'
4
+ import {ExperimentType, GrowthbookFeature, VariantType} from '../types'
5
+
6
+ const getBooleanConversion = (value: string) => {
7
+ // this way or the other way around?
8
+ if (value === 'true') {
9
+ return 'variant'
10
+ } else if (value === 'false') {
11
+ return 'control'
12
+ }
13
+ return value
14
+ }
15
+
16
+ export const getExperiments = async ({
17
+ client,
18
+ environment,
19
+ baseUrl,
20
+ project,
21
+ convertBooleans,
22
+ }: Omit<GrowthbookABConfig, 'fields'> & {client: SanityClient}): Promise<ExperimentType[]> => {
23
+ const query = `*[_id == 'secrets.growthbook'][0].secrets.apiKey`
24
+
25
+ const secret = await client.fetch(query) // secret is stored in the content lake using @sanity/studio-secrets
26
+ if (!secret) return []
27
+
28
+ const featureExperiments: ExperimentType[] = []
29
+ let hasMore = true
30
+ let offset = 0
31
+ const url = new URL(baseUrl ?? 'https://api.growthbook.io/api/v1/features')
32
+ if (project) {
33
+ url.searchParams.set('projectId', project)
34
+ }
35
+
36
+ while (hasMore) {
37
+ url.searchParams.set('offset', offset.toString())
38
+ const response = await fetch(url, {
39
+ headers: {
40
+ Authorization: `Bearer ${secret}`,
41
+ },
42
+ })
43
+
44
+ const {features, hasMore: responseHasMore, nextOffset} = await response.json()
45
+
46
+ hasMore = responseHasMore
47
+ offset = nextOffset
48
+ if (!features) continue
49
+
50
+ features.forEach((feature: GrowthbookFeature) => {
51
+ if (feature.archived) {
52
+ return undefined
53
+ }
54
+ const experiments = feature.environments[environment]?.rules.filter(
55
+ (experiment) => experiment.type === 'experiment-ref' || experiment.type === 'experiment',
56
+ )
57
+
58
+ if (!experiments) {
59
+ return undefined
60
+ }
61
+
62
+ const variations = new Set<VariantType>()
63
+ experiments.forEach((experiment) => {
64
+ experiment?.variations.forEach((variant) => {
65
+ variations.add({
66
+ id: convertBooleans ? getBooleanConversion(variant.value) : variant.value,
67
+ label: convertBooleans ? getBooleanConversion(variant.value) : variant.value,
68
+ })
69
+ })
70
+ })
71
+ const value = {id: feature.id, label: feature.id, variants: [...variations]}
72
+
73
+ featureExperiments.push(value)
74
+ return undefined
75
+ })
76
+ }
77
+ return featureExperiments
78
+ }