@sanity/personalization-plugin 2.0.0 → 2.1.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.0.0",
3
+ "version": "2.1.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
 
@@ -16,6 +16,8 @@ export const CONFIG_DEFAULT = {
16
16
  export const ExperimentContext = createContext<ExperimentContextProps>({
17
17
  ...CONFIG_DEFAULT,
18
18
  experiments: [],
19
+ setSecret: () => undefined,
20
+ secret: undefined,
19
21
  })
20
22
 
21
23
  export function useExperimentContext() {
@@ -28,6 +30,7 @@ type ExperimentProps = ObjectInputProps & {
28
30
 
29
31
  export function ExperimentProvider(props: ExperimentProps) {
30
32
  const {experimentFieldPluginConfig} = props
33
+ const [secret, setSecret] = useState<string | undefined>()
31
34
 
32
35
  const client = useClient({apiVersion: experimentFieldPluginConfig.apiVersion})
33
36
  const workspace = useWorkspace()
@@ -39,17 +42,17 @@ export function ExperimentProvider(props: ExperimentProps) {
39
42
  // eslint-disable-next-line require-await
40
43
  async () => {
41
44
  if (typeof experimentFieldPluginConfig.experiments === 'function') {
42
- return experimentFieldPluginConfig.experiments(client)
45
+ return experimentFieldPluginConfig.experiments(client, secret)
43
46
  }
44
47
  return experimentFieldPluginConfig.experiments
45
48
  },
46
- [workspace],
49
+ [workspace, secret],
47
50
  {equal},
48
51
  )
49
52
 
50
53
  const context = useMemo(
51
- () => ({...experimentFieldPluginConfig, experiments}),
52
- [experimentFieldPluginConfig, experiments],
54
+ () => ({...experimentFieldPluginConfig, experiments, secret, setSecret}),
55
+ [experimentFieldPluginConfig, experiments, secret, setSecret],
53
56
  )
54
57
 
55
58
  return (
@@ -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
+ }
@@ -20,7 +20,9 @@ const createFieldType = ({
20
20
  field,
21
21
  }: {
22
22
  field: string | FieldDefinition
23
- experiments: ExperimentType[] | ((client: SanityClient) => Promise<ExperimentType[]>)
23
+ experiments:
24
+ | ExperimentType[]
25
+ | ((client: SanityClient, secret?: string) => Promise<ExperimentType[]>)
24
26
  }) => {
25
27
  const typeName = typeof field === `string` ? field : field.name
26
28
  const usedName = String(typeName[0]).toUpperCase() + String(typeName).slice(1)
@@ -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
  }
26
29
 
@@ -33,6 +36,8 @@ export type VariantPreviewProps = Omit<PreviewProps, 'SchemaType'> & {
33
36
 
34
37
  export type ExperimentContextProps = Required<FieldPluginConfig> & {
35
38
  experiments: ExperimentType[]
39
+ setSecret: Dispatch<SetStateAction<string | undefined>>
40
+ secret: string | undefined
36
41
  }
37
42
 
38
43
  export type ArrayInputProps = ArrayOfObjectsInputProps & {
@@ -58,3 +63,179 @@ export type ExperimentGeneric<T> = {
58
63
  } & VariantGeneric<T>
59
64
  >
60
65
  }
66
+
67
+ export type GrowthbookExperiment = {
68
+ id: string
69
+ dateCreated: string
70
+ dateUpdated: string
71
+ name: string
72
+ project: string
73
+ hypothesis: string
74
+ description: string
75
+ tags: [string]
76
+ owner: string
77
+ archived: boolean
78
+ status: string
79
+ autoRefresh: boolean
80
+ hashAttribute: string
81
+ fallbackAttribute: string
82
+ hashVersion: number
83
+ disableStickyBucketing: boolean
84
+ bucketVersion: number
85
+ minBucketVersion: number
86
+ variations: [
87
+ {
88
+ variationId: string
89
+ key: string
90
+ name: string
91
+ description: string
92
+ screenshots: [string]
93
+ },
94
+ ]
95
+ phases: [
96
+ {
97
+ name: string
98
+ dateStarted: string
99
+ dateEnded: string
100
+ reasonForStopping: string
101
+ seed: string
102
+ coverage: 0
103
+ trafficSplit: [
104
+ {
105
+ variationId: string
106
+ weight: 0
107
+ },
108
+ ]
109
+ namespace: {
110
+ namespaceId: string
111
+ range: []
112
+ }
113
+ targetingCondition: string
114
+ savedGroupTargeting: [
115
+ {
116
+ matchType: string
117
+ savedGroups: [string]
118
+ },
119
+ ]
120
+ },
121
+ ]
122
+ settings: {
123
+ datasourceId: string
124
+ assignmentQueryId: string
125
+ experimentId: string
126
+ segmentId: string
127
+ queryFilter: string
128
+ inProgressConversions: string
129
+ attributionModel: string
130
+ statsEngine: string
131
+ regressionAdjustmentEnabled: boolean
132
+ goals: [
133
+ {
134
+ metricId: string
135
+ overrides: {
136
+ delayHours: 0
137
+ windowHours: 0
138
+ window: string
139
+ winRiskThreshold: 0
140
+ loseRiskThreshold: 0
141
+ }
142
+ },
143
+ ]
144
+ secondaryMetrics: [
145
+ {
146
+ metricId: string
147
+ overrides: {
148
+ delayHours: 0
149
+ windowHours: 0
150
+ window: string
151
+ winRiskThreshold: 0
152
+ loseRiskThreshold: 0
153
+ }
154
+ },
155
+ ]
156
+ guardrails: [
157
+ {
158
+ metricId: string
159
+ overrides: {
160
+ delayHours: 0
161
+ windowHours: 0
162
+ window: string
163
+ winRiskThreshold: 0
164
+ loseRiskThreshold: 0
165
+ }
166
+ },
167
+ ]
168
+ activationMetric: {
169
+ metricId: string
170
+ overrides: {
171
+ delayHours: 0
172
+ windowHours: 0
173
+ window: string
174
+ winRiskThreshold: 0
175
+ loseRiskThreshold: 0
176
+ }
177
+ }
178
+ }
179
+ resultSummary: {
180
+ status: string
181
+ winner: string
182
+ conclusions: string
183
+ releasedVariationId: string
184
+ excludeFromPayload: boolean
185
+ }
186
+ }
187
+
188
+ export type GrowthbookFeature = {
189
+ id: string
190
+ dateCreated: string
191
+ dateUpdated: string
192
+ archived: boolean
193
+ description: string
194
+ owner: string
195
+ project: string
196
+ valueType: string
197
+ defaultValue: string
198
+ tags: string[]
199
+ environments: {
200
+ [key: string]: {
201
+ enabled: boolean
202
+ defaultValue: string
203
+ rules: {
204
+ description: string
205
+ condition: string
206
+ savedGroupTargeting: {matchType: string; savedGroups: string[]}[]
207
+ id: string
208
+ enabled: boolean
209
+ type: string
210
+ value: string
211
+ variations: {value: string; variationId: string}[]
212
+ }[]
213
+ definition: string
214
+ draft: {
215
+ enabled: boolean
216
+ defaultValue: string
217
+ rules: {
218
+ description: string
219
+ condition: string
220
+ savedGroupTargeting: {matchType: string; savedGroups: string[]}[]
221
+ id: string
222
+ enabled: boolean
223
+ type: string
224
+ value: string
225
+ variations: {value: string; variationId: string}[]
226
+ }[]
227
+ definition: string
228
+ }
229
+ }
230
+ }
231
+ prerequisites: {
232
+ parentId: string
233
+ parentCondition: string
234
+ }[]
235
+ revision: {
236
+ version: number
237
+ comment: string
238
+ date: string
239
+ publishedBy: string
240
+ }
241
+ }
@@ -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
+ }