@sanity/personalization-plugin 2.2.1 → 2.3.0-growthbook.2

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.2.1",
3
+ "version": "2.3.0-growthbook.2",
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,6 +50,7 @@
45
50
  },
46
51
  "dependencies": {
47
52
  "@sanity/incompatible-plugin": "^1.0.4",
53
+ "@sanity/studio-secrets": "^3.0.0",
48
54
  "@sanity/ui": "^2.8.19",
49
55
  "@sanity/uuid": "^3.0.2",
50
56
  "fast-deep-equal": "^3.1.3",
@@ -47,7 +47,7 @@ const useRemoveExperimentAction = (
47
47
  const experiment = [experimentId]
48
48
  const variants = [`${variantNameOverride}s`]
49
49
  onChange([set(!active, activeId), unset(experiment), unset(variants)])
50
- }, [onChange, active, experimentId, experimentNameOverride, variantNameOverride])
50
+ }, [onChange, active, experimentId, variantNameOverride])
51
51
 
52
52
  return {
53
53
  title: `Remove ${experimentNameOverride}`,
@@ -96,10 +96,7 @@ const createActions = ({
96
96
  experimentId,
97
97
  }),
98
98
  })
99
- if (active) {
100
- return removeAction
101
- }
102
- return addAction
99
+ return active ? removeAction : addAction
103
100
  }
104
101
 
105
102
  export const ExperimentField = (
@@ -0,0 +1,38 @@
1
+ import {createContext, useContext, useMemo, useState} from 'react'
2
+ import {ObjectInputProps} from 'sanity'
3
+
4
+ import {GrowthbookABConfig, GrowthbookContextProps} 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: GrowthbookABConfig
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
+ }
@@ -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 {useGrowthbookContext} from './GrowthbookContext'
6
+
7
+ export const namespace = 'growthbook'
8
+
9
+ export 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} = useGrowthbookContext()
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
+ }
@@ -0,0 +1,52 @@
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 {GrowthbookABConfig} from './types'
7
+ import {getExperiments} from './utils'
8
+
9
+ export const fieldLevelExperiments = definePlugin<GrowthbookABConfig>((config) => {
10
+ const pluginConfig = {...GROWTHBOOK_CONFIG_DEFAULT, ...config}
11
+ const {fields, environment, project, convertBooleans, baseUrl, tags} = pluginConfig
12
+ return {
13
+ name: 'sanity-growthbook-personalistaion-plugin-field-level-experiments',
14
+ plugins: [
15
+ baseFieldLevelExperiments({
16
+ fields,
17
+ experiments: (client) =>
18
+ getExperiments({client, environment, baseUrl, project, convertBooleans, tags}),
19
+ }),
20
+ ],
21
+
22
+ form: {
23
+ components: {
24
+ input: (props) => {
25
+ const isRootInput = props.id === 'root' && isObjectInputProps(props)
26
+
27
+ if (!isRootInput) {
28
+ return props.renderDefault(props)
29
+ }
30
+
31
+ const flatFieldTypeNames = flattenSchemaType(props.schemaType).map(
32
+ (field) => field.type.name,
33
+ )
34
+
35
+ const hasExperiment = flatFieldTypeNames.some((name) => name.startsWith('experiment'))
36
+
37
+ if (!hasExperiment) {
38
+ return props.renderDefault(props)
39
+ }
40
+
41
+ const providerProps = {
42
+ ...props,
43
+ growthbookFieldPluginConfig: {
44
+ ...pluginConfig,
45
+ },
46
+ }
47
+ return GrowthbookProvider(providerProps)
48
+ },
49
+ },
50
+ },
51
+ }
52
+ })
@@ -0,0 +1,15 @@
1
+ import {FieldDefinition} from 'sanity'
2
+
3
+ export type GrowthbookABConfig = {
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 {GrowthbookABConfig} 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<GrowthbookABConfig, '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/types.ts CHANGED
@@ -20,7 +20,9 @@ export type ExperimentType = {
20
20
 
21
21
  export type FieldPluginConfig = {
22
22
  fields: (string | FieldDefinition)[]
23
- experiments: ExperimentType[] | ((client: SanityClient) => Promise<ExperimentType[]>)
23
+ experiments:
24
+ | ExperimentType[]
25
+ | ((client: SanityClient, secret?: string) => Promise<ExperimentType[]>)
24
26
  apiVersion?: string
25
27
  experimentNameOverride?: string
26
28
  variantNameOverride?: string
@@ -67,3 +69,179 @@ export type ExperimentGeneric<T> = {
67
69
  | T
68
70
  | undefined
69
71
  }
72
+
73
+ export type GrowthbookExperiment = {
74
+ id: string
75
+ dateCreated: string
76
+ dateUpdated: string
77
+ name: string
78
+ project: string
79
+ hypothesis: string
80
+ description: string
81
+ tags: [string]
82
+ owner: string
83
+ archived: boolean
84
+ status: string
85
+ autoRefresh: boolean
86
+ hashAttribute: string
87
+ fallbackAttribute: string
88
+ hashVersion: number
89
+ disableStickyBucketing: boolean
90
+ bucketVersion: number
91
+ minBucketVersion: number
92
+ variations: [
93
+ {
94
+ variationId: string
95
+ key: string
96
+ name: string
97
+ description: string
98
+ screenshots: [string]
99
+ },
100
+ ]
101
+ phases: [
102
+ {
103
+ name: string
104
+ dateStarted: string
105
+ dateEnded: string
106
+ reasonForStopping: string
107
+ seed: string
108
+ coverage: 0
109
+ trafficSplit: [
110
+ {
111
+ variationId: string
112
+ weight: 0
113
+ },
114
+ ]
115
+ namespace: {
116
+ namespaceId: string
117
+ range: []
118
+ }
119
+ targetingCondition: string
120
+ savedGroupTargeting: [
121
+ {
122
+ matchType: string
123
+ savedGroups: [string]
124
+ },
125
+ ]
126
+ },
127
+ ]
128
+ settings: {
129
+ datasourceId: string
130
+ assignmentQueryId: string
131
+ experimentId: string
132
+ segmentId: string
133
+ queryFilter: string
134
+ inProgressConversions: string
135
+ attributionModel: string
136
+ statsEngine: string
137
+ regressionAdjustmentEnabled: boolean
138
+ goals: [
139
+ {
140
+ metricId: string
141
+ overrides: {
142
+ delayHours: 0
143
+ windowHours: 0
144
+ window: string
145
+ winRiskThreshold: 0
146
+ loseRiskThreshold: 0
147
+ }
148
+ },
149
+ ]
150
+ secondaryMetrics: [
151
+ {
152
+ metricId: string
153
+ overrides: {
154
+ delayHours: 0
155
+ windowHours: 0
156
+ window: string
157
+ winRiskThreshold: 0
158
+ loseRiskThreshold: 0
159
+ }
160
+ },
161
+ ]
162
+ guardrails: [
163
+ {
164
+ metricId: string
165
+ overrides: {
166
+ delayHours: 0
167
+ windowHours: 0
168
+ window: string
169
+ winRiskThreshold: 0
170
+ loseRiskThreshold: 0
171
+ }
172
+ },
173
+ ]
174
+ activationMetric: {
175
+ metricId: string
176
+ overrides: {
177
+ delayHours: 0
178
+ windowHours: 0
179
+ window: string
180
+ winRiskThreshold: 0
181
+ loseRiskThreshold: 0
182
+ }
183
+ }
184
+ }
185
+ resultSummary: {
186
+ status: string
187
+ winner: string
188
+ conclusions: string
189
+ releasedVariationId: string
190
+ excludeFromPayload: boolean
191
+ }
192
+ }
193
+
194
+ export type GrowthbookFeature = {
195
+ id: string
196
+ dateCreated: string
197
+ dateUpdated: string
198
+ archived: boolean
199
+ description: string
200
+ owner: string
201
+ project: string
202
+ valueType: string
203
+ defaultValue: string
204
+ tags: string[]
205
+ environments: {
206
+ [key: string]: {
207
+ enabled: boolean
208
+ defaultValue: string
209
+ rules: {
210
+ description: string
211
+ condition: string
212
+ savedGroupTargeting: {matchType: string; savedGroups: string[]}[]
213
+ id: string
214
+ enabled: boolean
215
+ type: string
216
+ value: string
217
+ variations: {value: string; variationId: string}[]
218
+ }[]
219
+ definition: string
220
+ draft: {
221
+ enabled: boolean
222
+ defaultValue: string
223
+ rules: {
224
+ description: string
225
+ condition: string
226
+ savedGroupTargeting: {matchType: string; savedGroups: string[]}[]
227
+ id: string
228
+ enabled: boolean
229
+ type: string
230
+ value: string
231
+ variations: {value: string; variationId: string}[]
232
+ }[]
233
+ definition: string
234
+ }
235
+ }
236
+ }
237
+ prerequisites: {
238
+ parentId: string
239
+ parentCondition: string
240
+ }[]
241
+ revision: {
242
+ version: number
243
+ comment: string
244
+ date: string
245
+ publishedBy: string
246
+ }
247
+ }