diff --git a/apps/amplitude-experiment/README.md b/apps/amplitude-experiment/README.md index d37848a73..412840a14 100644 --- a/apps/amplitude-experiment/README.md +++ b/apps/amplitude-experiment/README.md @@ -12,7 +12,7 @@ Run yarn install && yarn start ``` -To test locally, create an app definition that points to `localhost:3000` for the App URL and register the `App configuration screen`, `Entry sidebar`, and `Entry editor` locations. +To test locally, create an app definition that points to `localhost:5173` for the App URL and register the `App configuration screen`, `Entry sidebar`, and `Entry editor` locations. ## [Privacy Policy](https://amplitude.com/privacy) diff --git a/apps/amplitude-experiment/package.json b/apps/amplitude-experiment/package.json index 7a76a561c..c45ec4934 100644 --- a/apps/amplitude-experiment/package.json +++ b/apps/amplitude-experiment/package.json @@ -2,6 +2,10 @@ "name": "amplitude-contentful", "version": "1.0.6", "private": true, + "engines": { + "node": ">=18.0.0 < 20.0.0", + "npm": ">= 9.0.0 < 11.0.0" + }, "dependencies": { "@contentful/app-sdk": "^4.29.1", "@contentful/f36-components": "4.70.0", diff --git a/apps/amplitude-experiment/src/contexts/ExperimentContext.tsx b/apps/amplitude-experiment/src/contexts/ExperimentContext.tsx index fd79e551d..baead27e1 100644 --- a/apps/amplitude-experiment/src/contexts/ExperimentContext.tsx +++ b/apps/amplitude-experiment/src/contexts/ExperimentContext.tsx @@ -1,6 +1,6 @@ -import { createContext, useEffect, useMemo, useState } from "react"; -import { AmplitudeExperimentApi } from "../utils/amplitude"; -import { useSDK } from "@contentful/react-apps-toolkit"; +import { createContext, useEffect, useMemo, useState } from 'react'; +import { AmplitudeExperimentApi } from '../utils/amplitude'; +import { useSDK } from '@contentful/react-apps-toolkit'; export interface Variant { [key: string]: string; @@ -15,6 +15,7 @@ export interface TargetSegmentCondition { type: string; values: Array; } + export interface TargetSegment { bucketingKey: string; conditions: Array; @@ -24,9 +25,9 @@ export interface TargetSegment { } export declare enum ExperimentDecision { - ROLLOUT = "rollout", - ROLLBACK = "rollback", - CONTINUE_RUNNING = "continue-running", + ROLLOUT = 'rollout', + ROLLBACK = 'rollback', + CONTINUE_RUNNING = 'continue-running', } export interface Experiment { @@ -53,8 +54,31 @@ export interface Experiment { rolloutWeights: RolloutWeights; } +export interface Flag { + bucketingKey: string; + bucketingSalt: string; + bucketingUnit: string; + decision: ExperimentDecision | null; + deleted: boolean; + description: string; + evaluationMode: string; + enabled: boolean; + id: string; + projectId: string; + deployments: Array; + key: string; + name: string; + variants: Array; + targetSegments: Array; + rolloutPercentage: number; + stickyBucketing: boolean; + tags: Array; + rolledOutVariant: string | null; // variant key + rolloutWeights: RolloutWeights; +} + interface ExperimentContextProps { - experiments: Array; + experiments: Array; loading: boolean; amplitudeExperimentApi: AmplitudeExperimentApi | null; } @@ -65,40 +89,26 @@ export const ExperimentContext = createContext({ amplitudeExperimentApi: null, }); -export const ExperimentProvider = ({ - children, -}: { - children: React.ReactElement; -}) => { +export const ExperimentProvider = ({ children }: { children: React.ReactElement }) => { const sdk = useSDK(); - const [experiments, setExperiments] = useState([]); + const [experiments, setExperiments] = useState<(Experiment | Flag)[]>([]); const [loading, setLoading] = useState(false); const amplitudeExperimentApi = useMemo( - () => - new AmplitudeExperimentApi( - sdk.parameters.installation.managementApiKey, - sdk.parameters.installation.datacenter - ), - [ - sdk.parameters.installation.managementApiKey, - sdk.parameters.installation.datacenter, - ] + () => new AmplitudeExperimentApi(sdk.parameters.installation.managementApiKey, sdk.parameters.installation.datacenter), + [sdk.parameters.installation.managementApiKey, sdk.parameters.installation.datacenter] ); useEffect(() => { const fetchExperiments = async () => { setLoading(true); - const experiments = await amplitudeExperimentApi.listAllExperiments(); - return experiments; + const experiments = await amplitudeExperimentApi.listAllResources(true); + const flags = await amplitudeExperimentApi.listAllResources(); + return [...experiments, ...flags]; }; fetchExperiments().then((experiments) => { setExperiments(experiments); setLoading(false); }); }, [amplitudeExperimentApi]); - return ( - - {children} - - ); + return {children}; }; diff --git a/apps/amplitude-experiment/src/locations/EntryEditor.tsx b/apps/amplitude-experiment/src/locations/EntryEditor.tsx index 033bc76b6..6de7bb35e 100644 --- a/apps/amplitude-experiment/src/locations/EntryEditor.tsx +++ b/apps/amplitude-experiment/src/locations/EntryEditor.tsx @@ -1,4 +1,4 @@ -import { EditorAppSDK } from "@contentful/app-sdk"; +import { EditorAppSDK } from '@contentful/app-sdk'; import { Autocomplete, @@ -13,42 +13,21 @@ import { FormControl, Heading, MenuItem, - Note, Popover, + Note, + Popover, SectionHeading, - Stack -} from "@contentful/f36-components"; -import { useSDK } from "@contentful/react-apps-toolkit"; -import { - ContentTypeProps, - EntryProps, - KeyValueMap, - MetaSysProps, -} from "contentful-management"; -import { cloneDeep } from "lodash"; -import get from "lodash/get"; -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; -import { ContentTypesContext } from "../contexts/ContentTypesContext"; -import { - Experiment, - ExperimentContext -} from "../contexts/ExperimentContext"; + Stack, +} from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { ContentTypeProps, EntryProps, KeyValueMap, MetaSysProps } from 'contentful-management'; +import { cloneDeep } from 'lodash'; +import get from 'lodash/get'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { ContentTypesContext } from '../contexts/ContentTypesContext'; +import { Experiment, ExperimentContext, Flag } from '../contexts/ExperimentContext'; import useInterval from '../utils/use-interval'; -const PopoverWrapper = ({ - children, - buttonText, - buttonProps, -}: { - children: JSX.Element; - buttonText: string; - buttonProps?: { [key: string]: string }; -}) => { +const PopoverWrapper = ({ children, buttonText, buttonProps }: { children: JSX.Element; buttonText: string; buttonProps?: { [key: string]: string } }) => { const [modalShown, setModalShown] = useState(false); return ( @@ -65,30 +44,19 @@ const PopoverWrapper = ({ ); }; -const ContentTypeField = ({ - variantName, - setVariants, -}: { - variantName: string; - setVariants: (variants: EntryProps[] | undefined) => void; -}) => { +const ContentTypeField = ({ variantName, setVariants }: { variantName: string; setVariants: (variants: EntryProps[] | undefined) => void }) => { const sdk = useSDK(); const { contentTypes } = useContext(ContentTypesContext); const [filteredItems, setFilteredItems] = React.useState(contentTypes); const handleInputValueChange = (value: string) => { - const newFilteredItems = contentTypes.filter((item) => - item.name.toLowerCase().includes(value.toLowerCase()) - ); + const newFilteredItems = contentTypes.filter((item) => item.name.toLowerCase().includes(value.toLowerCase())); setFilteredItems(newFilteredItems); }; - const handleChangeVariant = ( - variantName: string, - metaSysPropsId?: string - ) => { + const handleChangeVariant = (variantName: string, metaSysPropsId?: string) => { if (!metaSysPropsId) { - throw new Error("Missing prop id"); + throw new Error('Missing prop id'); } const values = sdk.entry.fields.variants.getValue() ?? []; const meta = sdk.entry.fields.meta.getValue() ?? {}; @@ -102,9 +70,9 @@ const ContentTypeField = ({ ...values, { sys: { - type: "Link", + type: 'Link', id: metaSysPropsId, - linkType: "Entry", + linkType: 'Entry', }, }, ]; @@ -113,10 +81,7 @@ const ContentTypeField = ({ sdk.entry.fields.variants.setValue(newVariants); }; - const handleSelectItem = async ( - item: ContentTypeProps, - variantName: string - ) => { + const handleSelectItem = async (item: ContentTypeProps, variantName: string) => { const data = await sdk.navigator.openNewEntry(item.sys.id, { slideIn: true, }); @@ -145,37 +110,24 @@ const ContentTypeField = ({ <> - - Contently Content Type - + Contently Content Type - handleSelectItem(item, variantName) - } + onSelectItem={(item: ContentTypeProps) => handleSelectItem(item, variantName)} itemToString={(item) => item.name} renderItem={(item) => `${item.name} (${item.sys.id})`} /> - ); }; -const VariantPlaceholder = ({ - variantName, - setVariants, -}: { - variantName: string; - setVariants: (variants: EntryProps[] | undefined) => void; -}) => { +const VariantPlaceholder = ({ variantName, setVariants }: { variantName: string; setVariants: (variants: EntryProps[] | undefined) => void }) => { return ( @@ -187,11 +139,11 @@ const VariantPlaceholder = ({ }; const VariantsField = ({ - variantNames, - variantEntries, - setVariantEntries, - flagKey, -}: { + variantNames, + variantEntries, + setVariantEntries, + flagKey, + }: { variantNames?: Array; variantEntries: EntryProps[] | undefined; setVariantEntries: (variants: EntryProps[] | undefined) => void; @@ -204,39 +156,20 @@ const VariantsField = ({ <> Variants {flagKey && ( - + {variantNames?.map((variantName) => { const variant = variantEntries?.find((entry) => { const entryId = meta[variantName]; return entry.sys.id === entryId; }); if (variant) { - return ( - - ); + return ; } - return ( - - ); + return ; })} )} - {!flagKey && ( - - Select a flag key to add variants - - )} + {!flagKey && Select a flag key to add variants} ); }; @@ -250,19 +183,17 @@ interface VariantEntity { } const EntryCardWrapper = ({ - variant, - setVariants, - meta, -}: { + variant, + setVariants, + meta, + }: { variant: EntryProps; setVariants: (variants: EntryProps[] | undefined) => void; meta: KeyValueMap; }): JSX.Element => { const sdk = useSDK(); const { contentTypes } = useContext(ContentTypesContext); - const [entryData, setEntryData] = useState< - EntryProps | undefined - >(undefined); + const [entryData, setEntryData] = useState | undefined>(undefined); const fetchEntry = useCallback( async (id: string, contentTypes: ContentTypeProps[]) => { @@ -270,32 +201,18 @@ const EntryCardWrapper = ({ return undefined; } const entry = await sdk.cma.entry.get({ entryId: id }); - const contentTypeId = get(entry, ["sys", "contentType", "sys", "id"]); - const contentType = contentTypes.find( - (contentType) => contentType.sys.id === contentTypeId - ); + const contentTypeId = get(entry, ['sys', 'contentType', 'sys', 'id']); + const contentType = contentTypes.find((contentType) => contentType.sys.id === contentTypeId); if (!contentType) { // things are still loading return undefined; } const displayField = contentType.displayField; - const descriptionFieldType = contentType.fields - .filter((field) => field.id !== displayField) - .find((field) => field.type === "Text"); - - const description = descriptionFieldType - ? get( - entry, - ["fields", descriptionFieldType.id, sdk.locales.default], - "" - ) - : ""; - const title = get( - entry, - ["fields", displayField, sdk.locales.default], - "Untitled" - ); + const descriptionFieldType = contentType.fields.filter((field) => field.id !== displayField).find((field) => field.type === 'Text'); + + const description = descriptionFieldType ? get(entry, ['fields', descriptionFieldType.id, sdk.locales.default], '') : ''; + const title = get(entry, ['fields', displayField, sdk.locales.default], 'Untitled'); const status = getEntryStatus(entry.sys); const variantEntry = Object.entries(meta ?? {}).find(([_, value]) => { return value === variant.sys.id; @@ -332,9 +249,7 @@ const EntryCardWrapper = ({ } sdk.entry.fields.meta.setValue(newMeta); - const filteredVariants = values.filter( - (v: EntryProps) => v.sys.id !== variant.sys.id - ); + const filteredVariants = values.filter((v: EntryProps) => v.sys.id !== variant.sys.id); setVariants(filteredVariants); sdk.entry.fields.variants.setValue(filteredVariants); }; @@ -345,27 +260,22 @@ const EntryCardWrapper = ({ const getEntryStatus = (sys: MetaSysProps) => { if (sys.archivedVersion) { - return "archived"; + return 'archived'; } else if (sys.publishedVersion) { if (sys.version > sys.publishedVersion + 1) { - return "changed"; + return 'changed'; } else { - return "published"; + return 'published'; } } else { - return "draft"; + return 'draft'; } }; return ( {entryData?.fields?.variantName} - + void; - setExperiment: (experiment?: Experiment) => void; - experiment?: Experiment; - experiments: Experiment[]; + setExperiment: (experiment?: Experiment | Flag) => void; + experiment?: Experiment | Flag; + experiments: (Experiment | Flag)[]; }) => { const sdk = useSDK(); const { loading } = useContext(ExperimentContext); - const [filteredItems, setFilteredItems] = useState(experiments); + const [filteredItems, setFilteredItems] = useState<(Experiment | Flag)[]>(experiments); - const handleSelectItem = (experiment: Experiment) => { + const handleSelectItem = (experiment: Experiment | Flag) => { resetFields(); const { key } = experiment; sdk.entry.fields.experimentId.setValue(key); @@ -421,19 +331,17 @@ const FlagKeyField = ({ const handleInputValueChange = (value: string) => { // This time, we tell the component to compare the property "name" to the inputValue - const newFilteredItems = experiments.filter((item) => - item.name.toLowerCase().includes(value.toLowerCase()) - ); + const newFilteredItems = experiments.filter((item) => item.name.toLowerCase().includes(value.toLowerCase())); setFilteredItems(newFilteredItems); - if (value === "") { + if (value === '') { resetFields(); } }; return ( - + - Flag Key + Flag/Experiment Name `${item.name} (${item.id})`} isLoading={loading} /> - - This is in the overview card or defined at the top of the - experiment/flag. - + This is in the overview card or defined at the top of the flag/experiment. {experiment && ( {experiment.name} - - {experiment.enabled ? "Active" : "Inactive"} + + {experiment.enabled ? 'Active' : 'Inactive'} - } - > + }> Flag key: {experiment.key} - {experiment.description && ( - Description: {experiment.description} - )} - Variants: {experiment.variants.map((v) => v.key).join(", ")} + {experiment.description && Description: {experiment.description}} + Variants: {experiment.variants.map((v) => v.key).join(', ')} Evaluation mode: {experiment.evaluationMode} )} @@ -476,33 +375,38 @@ const FlagKeyField = ({ const Entry = () => { const sdk = useSDK(); - const [experiment, setExperiment] = useState( - sdk.entry.fields.experiment.getValue() - ); + const [experiment, setExperiment] = useState(sdk.entry.fields.experiment.getValue()); const { amplitudeExperimentApi } = useContext(ExperimentContext); - const variantNames = useMemo( - () => experiment?.variants.map((variant) => variant.key), - [experiment] - ); + const variantNames = useMemo(() => experiment?.variants.map((variant) => variant.key), [experiment]); // Update experiment if there is an experiment update on Amplitude side useInterval(() => { if (experiment && amplitudeExperimentApi) { - amplitudeExperimentApi - .getExperimentDetails(experiment.id) - .then((experiment) => { - setExperiment(experiment); - sdk.entry.fields.experimentId.setValue(experiment.key); - sdk.entry.fields.experiment.setValue(experiment); - }) - .catch(() => {}); + // Check if it's an experiment or a flag + if ('experimentType' in experiment) { + amplitudeExperimentApi + .getResourceDetails(experiment.id, true) + .then((experiment) => { + setExperiment(experiment); + sdk.entry.fields.experimentId.setValue(experiment.key); + sdk.entry.fields.experiment.setValue(experiment); + }) + .catch(() => {}); + } else { + amplitudeExperimentApi + .getResourceDetails(experiment.id) + .then((flag) => { + setExperiment(flag); + sdk.entry.fields.experimentId.setValue(flag.key); + sdk.entry.fields.experiment.setValue(flag); + }) + .catch(() => {}); + } } }, 5000); - const [variants, setVariants] = React.useState( - sdk.entry.fields.variants.getValue() - ); + const [variants, setVariants] = React.useState(sdk.entry.fields.variants.getValue()); const { experiments } = useContext(ExperimentContext); @@ -510,8 +414,7 @@ const Entry = () => { const valueChangeHandler = (experiment?: Experiment) => { setExperiment(experiment); }; - const detachValueChangeHandler = - sdk.entry.fields.experiment.onValueChanged(valueChangeHandler); + const detachValueChangeHandler = sdk.entry.fields.experiment.onValueChanged(valueChangeHandler); return detachValueChangeHandler; }, [sdk.entry.fields.experiment]); @@ -519,22 +422,11 @@ const Entry = () => { return ( + margin: '50px', + }}>
- - + +
); diff --git a/apps/amplitude-experiment/src/locations/Sidebar.tsx b/apps/amplitude-experiment/src/locations/Sidebar.tsx index 5fabe502b..e73395d07 100644 --- a/apps/amplitude-experiment/src/locations/Sidebar.tsx +++ b/apps/amplitude-experiment/src/locations/Sidebar.tsx @@ -1,36 +1,29 @@ -import { SidebarAppSDK } from "@contentful/app-sdk"; -import { Button, Paragraph, Stack } from "@contentful/f36-components"; -import { useSDK } from "@contentful/react-apps-toolkit"; -import { Experiment } from "../contexts/ExperimentContext"; -import { useEffect, useState } from "react"; -import { Datacenter } from "../utils/amplitude"; +import { SidebarAppSDK } from '@contentful/app-sdk'; +import { Button, Paragraph, Stack } from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { Experiment } from '../contexts/ExperimentContext'; +import { useEffect, useState } from 'react'; +import { Datacenter } from '../utils/amplitude'; const Sidebar = () => { const sdk = useSDK(); - const [experiment, setExperiment] = useState( - undefined - ); + const [experiment, setExperiment] = useState(undefined); useEffect(() => { const valueChangeHandler = (experiment?: Experiment) => { setExperiment(experiment); }; - const detachValueChangeHandler = - sdk.entry.fields.experiment.onValueChanged(valueChangeHandler); + const detachValueChangeHandler = sdk.entry.fields.experiment.onValueChanged(valueChangeHandler); return detachValueChangeHandler; }, [sdk.entry.fields.experiment]); const { orgId, datacenter } = sdk.parameters.installation; - const getExperimentDetailsUrl = - (orgId: string, datacenter: Datacenter) => (projectId: string, flagId: string) => { - const baseUrl = datacenter === "US" ? - 'https://app.amplitude.com' : - 'https://app.eu.amplitude.com'; - return `${baseUrl}/experiment/${orgId}/${projectId}/config/${flagId}`; - } - + const getExperimentDetailsUrl = (orgId: string, datacenter: Datacenter) => (projectId: string, flagId: string) => { + const baseUrl = datacenter === 'US' ? 'https://app.amplitude.com' : 'https://app.eu.amplitude.com'; + return `${baseUrl}/experiment/${orgId}/${projectId}/config/${flagId}`; + }; if (!experiment) { return No experiment configured yet.; @@ -43,13 +36,9 @@ const Sidebar = () => { as="a" isFullWidth isDisabled={!orgId} - href={getExperimentDetailsUrl(orgId, datacenter)( - experiment.projectId, - experiment.id - )} + href={getExperimentDetailsUrl(orgId, datacenter)(experiment.projectId, experiment.id)} onClick={() => {}} - target="_blank" - > + target="_blank"> View/Edit in Amplitude
); diff --git a/apps/amplitude-experiment/src/utils/amplitude.ts b/apps/amplitude-experiment/src/utils/amplitude.ts index 7ad06dc77..29e34f2ec 100644 --- a/apps/amplitude-experiment/src/utils/amplitude.ts +++ b/apps/amplitude-experiment/src/utils/amplitude.ts @@ -1,10 +1,10 @@ -import { Experiment } from "../contexts/ExperimentContext"; +import { Experiment, Flag } from '../contexts/ExperimentContext'; -const US_BASE_URL = "https://experiment.amplitude.com/api/1"; -const EU_BASE_URL = "https://experiment.eu.amplitude.com/api/1"; +const US_BASE_URL = 'https://experiment.amplitude.com/api/1'; +const EU_BASE_URL = 'https://experiment.eu.amplitude.com/api/1'; const LIMIT = 1000; -export type Datacenter = "US" | "EU"; +export type Datacenter = 'US' | 'EU'; export class AmplitudeExperimentApi { token: string; @@ -12,7 +12,7 @@ export class AmplitudeExperimentApi { constructor(token: string, datacenter: Datacenter) { this.token = token; - this.baseUrl = datacenter === "US" ? US_BASE_URL : EU_BASE_URL; + this.baseUrl = datacenter === 'US' ? US_BASE_URL : EU_BASE_URL; } fetchWithAuth(url: string) { return fetch(url, { @@ -22,34 +22,27 @@ export class AmplitudeExperimentApi { }); } - async listAllExperiments() { + async listAllResources(isExperiment?: boolean) { let cursor; - const allExperiments: Array = []; - - const { experiments, nextCursor } = await this.listExperiments(); - cursor = nextCursor; - allExperiments.push(...experiments); - - while (cursor) { - const res = await this.listExperiments(cursor); - let nCursor = res.nextCursor, nExperiments = res.experiments; - allExperiments.push(...nExperiments); - cursor = nCursor; - } - return allExperiments; + const allResources: Array = []; + + do { + const { experiments, flags, nextCursor } = await this.listResources(cursor, isExperiment); + const resources = isExperiment ? experiments : flags; + allResources.push(...resources); + cursor = nextCursor; + } while (cursor); + + return allResources; } - async listExperiments(cursor?: string) { - const response = await this.fetchWithAuth( - `${this.baseUrl}/experiments?limit=${LIMIT}${cursor ? "&cursor=" + cursor : ''}` - ); + async listResources(cursor?: string, isExperiment?: boolean) { + const response = await this.fetchWithAuth(`${this.baseUrl}/${isExperiment ? 'experiments' : 'flags'}?limit=${LIMIT}${cursor ? '&cursor=' + cursor : ''}`); return response.json(); } - async getExperimentDetails(experimentId: string) { - const response = await this.fetchWithAuth( - `${this.baseUrl}/experiments/${experimentId}` - ); + async getResourceDetails(id: string, isExperiment?: boolean) { + const response = await this.fetchWithAuth(`${this.baseUrl}/${isExperiment ? 'experiments' : 'flags'}/${id}`); return response.json(); } }