@sanity/personalization-plugin 2.2.0-growthbook.1 → 2.2.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.2.0-growthbook.1",
3
+ "version": "2.2.0",
4
4
  "description": "Plugin to help with personalization, a/b testing when using Sanity",
5
5
  "keywords": [
6
6
  "sanity",
@@ -45,7 +45,6 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@sanity/incompatible-plugin": "^1.0.4",
48
- "@sanity/studio-secrets": "^3.0.0",
49
48
  "@sanity/ui": "^2.8.19",
50
49
  "@sanity/uuid": "^3.0.2",
51
50
  "fast-deep-equal": "^3.1.3",
@@ -79,7 +78,7 @@
79
78
  "typescript": "^5.7.3"
80
79
  },
81
80
  "peerDependencies": {
82
- "react": "^18",
81
+ "react": "^18 || ^19",
83
82
  "sanity": "^3"
84
83
  },
85
84
  "engines": {
@@ -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()
@@ -47,17 +44,17 @@ export function ExperimentProvider(props: ExperimentProps) {
47
44
  // eslint-disable-next-line require-await
48
45
  async () => {
49
46
  if (typeof experimentFieldPluginConfig.experiments === 'function') {
50
- return experimentFieldPluginConfig.experiments(client, secret)
47
+ return experimentFieldPluginConfig.experiments(client)
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,5 +1,4 @@
1
1
  import {CloseIcon} from '@sanity/icons'
2
- import {useCallback, useMemo} from 'react'
3
2
  import {GiSoapExperiment} from 'react-icons/gi'
4
3
  import {
5
4
  defineDocumentFieldAction,
@@ -19,9 +18,9 @@ const useAddExperimentAction = (
19
18
  ): DocumentFieldActionItem => {
20
19
  const {onChange, active, experimentNameOverride} = props
21
20
 
22
- const handleAddAction = useCallback(() => {
21
+ const handleAddAction = () => {
23
22
  onChange([set(!active, ['active'])])
24
- }, [onChange, active])
23
+ }
25
24
 
26
25
  return {
27
26
  title: `Add ${experimentNameOverride}`,
@@ -34,22 +33,23 @@ const useAddExperimentAction = (
34
33
 
35
34
  const useRemoveExperimentAction = (
36
35
  props: DocumentFieldActionProps &
37
- PatchStuff & {
38
- experimentNameOverride: string
39
- experimentId: string
40
- active: boolean
41
- variantNameOverride: string
42
- },
36
+ PatchStuff & {experimentNameOverride: string; experimentId: string; active: boolean},
43
37
  ): DocumentFieldActionItem => {
44
- const {onChange, active, experimentId, experimentNameOverride, variantNameOverride} = props
45
-
46
- const handleClearAction = useCallback(() => {
38
+ const {onChange, active, experimentId, experimentNameOverride} = props
39
+ const patchActiveFalseEvent = () => {
47
40
  const activeId = ['active']
41
+ return set(!active, activeId)
42
+ }
43
+ const patchClearEvent = () => {
48
44
  const experiment = [experimentId]
49
- const variants = [`${variantNameOverride}s`]
50
- onChange([set(!active, activeId), unset(experiment), unset(variants)])
51
- }, [onChange, active, experimentId, variantNameOverride])
52
-
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
+ }
53
53
  return {
54
54
  title: `Remove ${experimentNameOverride}`,
55
55
  type: 'action',
@@ -59,19 +59,13 @@ const useRemoveExperimentAction = (
59
59
  }
60
60
  }
61
61
 
62
- const createActions = ({
62
+ const newActions = ({
63
63
  onChange,
64
64
  inputId,
65
65
  active,
66
66
  experimentNameOverride,
67
67
  experimentId,
68
- variantNameOverride,
69
- }: PatchStuff & {
70
- active?: boolean
71
- experimentNameOverride: string
72
- experimentId: string
73
- variantNameOverride: string
74
- }) => {
68
+ }: PatchStuff & {active?: boolean; experimentNameOverride: string; experimentId: string}) => {
75
69
  const removeAction = defineDocumentFieldAction({
76
70
  name: `Remove ${experimentNameOverride}`,
77
71
  useAction: (props) =>
@@ -82,7 +76,6 @@ const createActions = ({
82
76
  inputId,
83
77
  experimentNameOverride,
84
78
  experimentId,
85
- variantNameOverride,
86
79
  }),
87
80
  })
88
81
  const addAction = defineDocumentFieldAction({
@@ -97,44 +90,27 @@ const createActions = ({
97
90
  experimentId,
98
91
  }),
99
92
  })
100
- return active ? removeAction : addAction
93
+ if (active) {
94
+ return removeAction
95
+ }
96
+ return addAction
101
97
  }
102
98
 
103
99
  export const ExperimentField = (
104
- props: ObjectFieldProps & {
105
- experimentNameOverride: string
106
- experimentId: string
107
- variantNameOverride: string
108
- },
100
+ props: ObjectFieldProps & {experimentNameOverride: string; experimentId: string},
109
101
  ) => {
110
102
  const {onChange} = props.inputProps
111
- const {inputId, experimentNameOverride, experimentId, variantNameOverride} = props
103
+ const {inputId, experimentNameOverride, experimentId} = props
112
104
  const active = props.value?.active as boolean | undefined
113
105
 
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
- )
106
+ const oldActions = props.actions || []
138
107
 
108
+ const withActionProps = {
109
+ ...props,
110
+ actions: [
111
+ newActions({onChange, inputId, active, experimentNameOverride, experimentId}),
112
+ ...oldActions,
113
+ ],
114
+ }
139
115
  return props.renderDefault(withActionProps)
140
116
  }
@@ -1,3 +1,4 @@
1
+ import {Card, Text} from '@sanity/ui'
1
2
  import {FormEvent, useCallback, useMemo} from 'react'
2
3
  import {
3
4
  FormPatch,
@@ -20,16 +21,18 @@ const formatlistOptions = (experiments: ExperimentType[]): SelectOption[] =>
20
21
  value: experiment.id,
21
22
  }))
22
23
 
23
- export const ExperimentInput = (props: StringInputProps & {variantNameOverride: string}) => {
24
+ export const ExperimentInput = (
25
+ props: StringInputProps & {variantNameOverride: string; experimentNameOverride: string},
26
+ ) => {
24
27
  const {experiments} = useExperimentContext()
25
28
 
26
29
  const id = useFormValue(['_id']) as string
27
30
  const aditionalChangePath = useMemo(
28
- () => [...props.path.slice(0, -1), `${props.variantNameOverride}s`],
31
+ () => [...props.path.slice(0, -1), props.variantNameOverride],
29
32
  [props.variantNameOverride, props.path],
30
33
  )
31
-
32
34
  const subValues = useFormValue(aditionalChangePath)
35
+
33
36
  const {patch} = useDocumentOperation(id.replace('drafts.', ''), props.schemaType.name)
34
37
 
35
38
  const handleChange = useCallback(
@@ -56,7 +59,14 @@ export const ExperimentInput = (props: StringInputProps & {variantNameOverride:
56
59
  [patch, subValues, aditionalChangePath],
57
60
  )
58
61
 
59
- if (!experiments.length) return <></>
62
+ if (!experiments.length)
63
+ return (
64
+ <Card padding={[3, 3, 4]} radius={2} shadow={1} tone="caution">
65
+ <Text align="center" size={[2, 2, 3]}>
66
+ There are no defined {props.experimentNameOverride}s
67
+ </Text>
68
+ </Card>
69
+ )
60
70
 
61
71
  return (
62
72
  <Select {...props} listOptions={formatlistOptions(experiments)} handleChange={handleChange} />
@@ -44,7 +44,6 @@ const createExperimentType = ({
44
44
  {...props}
45
45
  experimentId={experimentId}
46
46
  experimentNameOverride={experimentNameOverride}
47
- variantNameOverride={variantNameOverride}
48
47
  />
49
48
  ),
50
49
  },
@@ -70,7 +69,11 @@ const createExperimentType = ({
70
69
  type: 'string',
71
70
  components: {
72
71
  input: (props) => (
73
- <ExperimentInput {...props} variantNameOverride={variantNameOverride} />
72
+ <ExperimentInput
73
+ {...props}
74
+ experimentNameOverride={experimentNameOverride}
75
+ variantNameOverride={variantNameOverride}
76
+ />
74
77
  ),
75
78
  },
76
79
  hidden: ({parent}) => {
package/src/index.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './fieldExperiments'
2
- export * from './growthbookFieldExperiments'
3
2
  export * from './types'
4
3
  export * from './utils/flattenSchemaType'
package/src/types.ts CHANGED
@@ -1,4 +1,3 @@
1
- import {Dispatch, SetStateAction} from 'react'
2
1
  import {
3
2
  ArrayOfObjectsInputProps,
4
3
  FieldDefinition,
@@ -21,9 +20,7 @@ export type ExperimentType = {
21
20
 
22
21
  export type FieldPluginConfig = {
23
22
  fields: (string | FieldDefinition)[]
24
- experiments:
25
- | ExperimentType[]
26
- | ((client: SanityClient, secret?: string) => Promise<ExperimentType[]>)
23
+ experiments: ExperimentType[] | ((client: SanityClient) => Promise<ExperimentType[]>)
27
24
  apiVersion?: string
28
25
  experimentNameOverride?: string
29
26
  variantNameOverride?: string
@@ -40,8 +37,6 @@ export type VariantPreviewProps = Omit<PreviewProps, 'SchemaType'> & {
40
37
 
41
38
  export type ExperimentContextProps = Required<FieldPluginConfig> & {
42
39
  experiments: ExperimentType[]
43
- setSecret: Dispatch<SetStateAction<string | undefined>>
44
- secret: string | undefined
45
40
  }
46
41
 
47
42
  export type ArrayInputProps = ArrayOfObjectsInputProps & {
@@ -72,179 +67,3 @@ export type ExperimentGeneric<T> = {
72
67
  | T
73
68
  | undefined
74
69
  }
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
- }
@@ -1,47 +0,0 @@
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
- }
@@ -1,51 +0,0 @@
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
- })
@@ -1,78 +0,0 @@
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
- }