Browse Source

feat(project-creation): Add experimentation to frontend (#49551)

The experiment logging and exposure on frontend happens using
useExperiment hook so I'm modifying the eligibility method to also be a
hook. Replacing all the callsites with the new hook and adding testing.
Athena Moghaddam 1 year ago
parent
commit
bedc4d9688

+ 8 - 4
static/app/components/noProjectMessage.tsx

@@ -5,7 +5,7 @@ import {Button} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import NoProjectEmptyState from 'sentry/components/illustrations/NoProjectEmptyState';
 import * as Layout from 'sentry/components/layouts/thirds';
-import {canCreateProject} from 'sentry/components/projects/utils';
+import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess';
 import {t} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
@@ -26,7 +26,7 @@ function NoProjectMessage({
   const {projects, initiallyLoaded: projectsLoaded} = useProjects();
 
   const orgSlug = organization.slug;
-  const canCreate = canCreateProject(organization);
+  const {canCreateProject} = useProjectCreationAccess(organization);
   const canJoinTeam = organization.access.includes('team:read');
 
   const {isSuperuser} = ConfigStore.get('user');
@@ -58,8 +58,12 @@ function NoProjectMessage({
 
   const createProjectAction = (
     <Button
-      title={canCreate ? undefined : t('You do not have permission to create a project.')}
-      disabled={!canCreate}
+      title={
+        canCreateProject
+          ? undefined
+          : t('You do not have permission to create a project.')
+      }
+      disabled={!canCreateProject}
       priority={orgHasProjects ? 'default' : 'primary'}
       to={`/organizations/${orgSlug}/projects/new/`}
     >

+ 79 - 0
static/app/components/projects/useProjectCreationAccess.spec.jsx

@@ -0,0 +1,79 @@
+const {useProjectCreationAccess} = require('./useProjectCreationAccess');
+
+import {reactHooks} from 'sentry-test/reactTestingLibrary';
+
+import * as useExperiment from 'sentry/utils/useExperiment';
+
+describe('ProjectCreationAccess', function () {
+  it('passes project creation eligibility for project admin', function () {
+    const organization = TestStubs.Organization();
+    const {result} = reactHooks.renderHook(useProjectCreationAccess, {
+      initialProps: organization,
+    });
+    expect(result.current.canCreateProject).toBeTruthy();
+  });
+
+  it('fails project creation eligibility for org members', function () {
+    const organization = TestStubs.Organization({
+      access: ['org:read', 'team:read', 'project:read'],
+    });
+    const {result} = reactHooks.renderHook(useProjectCreationAccess, {
+      initialProps: organization,
+    });
+    expect(result.current.canCreateProject).toBeFalsy();
+  });
+
+  it('passes if org is part of experiment and member has no access', function () {
+    const organization = TestStubs.Organization({
+      access: ['org:read', 'team:read', 'project:read'],
+      features: ['organizations:team-project-creation-all'],
+      experiments: [{ProjectCreationForAllExperiment: 1}],
+    });
+
+    jest.spyOn(useExperiment, 'useExperiment').mockReturnValue({
+      experimentAssignment: 1,
+      logExperiment: jest.fn(),
+    });
+
+    const {result} = reactHooks.renderHook(useProjectCreationAccess, {
+      initialProps: organization,
+    });
+    expect(result.current.canCreateProject).toBeTruthy();
+  });
+
+  it('fails if org is not part of experiment and member has no access', function () {
+    const organization = TestStubs.Organization({
+      access: ['org:read', 'team:read', 'project:read'],
+      features: ['organizations:team-project-creation-all'],
+      experiments: [{ProjectCreationForAllExperiment: 0}],
+    });
+
+    jest.spyOn(useExperiment, 'useExperiment').mockReturnValue({
+      experimentAssignment: 0,
+      logExperiment: jest.fn(),
+    });
+
+    const {result} = reactHooks.renderHook(useProjectCreationAccess, {
+      initialProps: organization,
+    });
+    expect(result.current.canCreateProject).toBeFalsy();
+  });
+
+  it('fails if org does not have the feature regardress of experiment value', function () {
+    const organization = TestStubs.Organization({
+      access: ['org:read', 'team:read', 'project:read'],
+      features: [],
+      experiments: [{ProjectCreationForAllExperiment: 1}],
+    });
+
+    jest.spyOn(useExperiment, 'useExperiment').mockReturnValue({
+      experimentAssignment: 1,
+      logExperiment: jest.fn(),
+    });
+
+    const {result} = reactHooks.renderHook(useProjectCreationAccess, {
+      initialProps: organization,
+    });
+    expect(result.current.canCreateProject).toBeFalsy();
+  });
+});

+ 38 - 0
static/app/components/projects/useProjectCreationAccess.tsx

@@ -0,0 +1,38 @@
+import {useMemo} from 'react';
+
+import {unassignedValue} from 'sentry/data/experimentConfig';
+import {Organization} from 'sentry/types';
+import {useExperiment} from 'sentry/utils/useExperiment';
+
+/**
+ * Used to determine if viewer can see project creation button
+ */
+export function useProjectCreationAccess(organization: Organization) {
+  const {experimentAssignment, logExperiment} = useExperiment(
+    'ProjectCreationForAllExperiment',
+    {
+      logExperimentOnMount: false,
+    }
+  );
+
+  const canCreateProject = useMemo(() => {
+    if (
+      organization.access.includes('project:admin') ||
+      organization.access.includes('project:write')
+    ) {
+      return true;
+    }
+
+    if (!organization.features.includes('organizations:team-project-creation-all')) {
+      return false;
+    }
+
+    if (experimentAssignment === unassignedValue) {
+      return false;
+    }
+
+    logExperiment();
+    return experimentAssignment === 1;
+  }, [organization, experimentAssignment, logExperiment]);
+  return {canCreateProject};
+}

+ 0 - 15
static/app/components/projects/utils.spec.jsx

@@ -1,15 +0,0 @@
-const {canCreateProject} = require('./utils');
-
-describe('ProjectUtils', function () {
-  it('passes project creation eligibility for project admin', function () {
-    const org = TestStubs.Organization();
-    expect(canCreateProject(org)).toBeTruthy();
-  });
-
-  it('fails project creation eligibility for org members', function () {
-    const org = TestStubs.Organization({
-      access: ['org:read', 'team:read', 'project:read'],
-    });
-    expect(canCreateProject(org)).toBeFalsy();
-  });
-});

+ 0 - 11
static/app/components/projects/utils.tsx

@@ -1,11 +0,0 @@
-import {Organization} from 'sentry/types';
-
-/**
- * Used to determine if viewer can see project creation button
- */
-export function canCreateProject(organization: Organization): boolean {
-  return (
-    organization.access.includes('project:admin') ||
-    organization.access.includes('project:write')
-  );
-}

+ 6 - 0
static/app/data/experimentConfig.tsx

@@ -24,6 +24,12 @@ export const experimentList = [
     parameter: 'exposed',
     assignments: [0, 1],
   },
+  {
+    key: 'ProjectCreationForAllExperiment',
+    type: ExperimentType.Organization,
+    parameter: 'exposed',
+    assignments: [0, 1],
+  },
 ] as const;
 
 export const experimentConfig = experimentList.reduce(

+ 3 - 3
static/app/views/projectInstall/createProject.tsx

@@ -13,7 +13,7 @@ import * as Layout from 'sentry/components/layouts/thirds';
 import ExternalLink from 'sentry/components/links/externalLink';
 import {SUPPORTED_LANGUAGES} from 'sentry/components/onboarding/frameworkSuggestionModal';
 import PlatformPicker, {Platform} from 'sentry/components/platformPicker';
-import {canCreateProject} from 'sentry/components/projects/utils';
+import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess';
 import TeamSelector from 'sentry/components/teamSelector';
 import {IconAdd} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
@@ -207,11 +207,11 @@ function CreateProject() {
   }
 
   const {shouldCreateCustomRule, conditions} = alertRuleConfig || {};
-
+  const {canCreateProject} = useProjectCreationAccess(organization);
   const canSubmitForm =
     !inFlight &&
     team &&
-    canCreateProject(organization) &&
+    canCreateProject &&
     projectName !== '' &&
     (!shouldCreateCustomRule || conditions?.every?.(condition => condition.value));
 

+ 4 - 5
static/app/views/projectsDashboard/index.tsx

@@ -15,7 +15,7 @@ import LoadingError from 'sentry/components/loadingError';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import NoProjectMessage from 'sentry/components/noProjectMessage';
 import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
-import {canCreateProject} from 'sentry/components/projects/utils';
+import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess';
 import SearchBar from 'sentry/components/searchBar';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
@@ -87,6 +87,7 @@ function Dashboard({teams, organization, loadingTeams, error, router, location}:
     []
   );
 
+  const {canCreateProject} = useProjectCreationAccess(organization);
   if (loadingTeams) {
     return <LoadingIndicator />;
   }
@@ -94,8 +95,6 @@ function Dashboard({teams, organization, loadingTeams, error, router, location}:
   if (error) {
     return <LoadingError message={t('An error occurred while fetching your projects')} />;
   }
-
-  const canCreateProjects = canCreateProject(organization);
   const canJoinTeam = organization.access.includes('team:read');
 
   const selectedTeams = getTeamParams(location ? location.query.team : '');
@@ -163,9 +162,9 @@ function Dashboard({teams, organization, loadingTeams, error, router, location}:
             <Button
               size="sm"
               priority="primary"
-              disabled={!canCreateProjects}
+              disabled={!canCreateProject}
               title={
-                !canCreateProjects
+                !canCreateProject
                   ? t('You do not have permission to create projects')
                   : undefined
               }

+ 3 - 3
static/app/views/replays/list/replayOnboardingPanel.tsx

@@ -10,7 +10,7 @@ import ButtonBar from 'sentry/components/buttonBar';
 import HookOrDefault from 'sentry/components/hookOrDefault';
 import ExternalLink from 'sentry/components/links/externalLink';
 import OnboardingPanel from 'sentry/components/onboardingPanel';
-import {canCreateProject} from 'sentry/components/projects/utils';
+import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess';
 import {Tooltip} from 'sentry/components/tooltip';
 import {replayPlatforms} from 'sentry/data/platformCategories';
 import {IconInfo} from 'sentry/icons';
@@ -44,7 +44,7 @@ export default function ReplayOnboardingPanel() {
   const pageFilters = usePageFilters();
   const projects = useProjects();
   const organization = useOrganization();
-  const canCreateProjects = canCreateProject(organization);
+  const {canCreateProject} = useProjectCreationAccess(organization);
 
   const selectedProjects = projects.projects.filter(p =>
     pageFilters.selection.projects.includes(Number(p.id))
@@ -67,7 +67,7 @@ export default function ReplayOnboardingPanel() {
   // disable "setup" if the current selected pageFilters are not supported
   const primaryActionDisabled =
     primaryAction === 'create'
-      ? !canCreateProjects
+      ? !canCreateProject
       : allSelectedProjectsUnsupported && hasSelectedProjects;
 
   const breakpoints = preferences.collapsed

+ 4 - 6
static/app/views/settings/organizationProjects/createProjectButton.tsx

@@ -1,5 +1,5 @@
 import {Button} from 'sentry/components/button';
-import {canCreateProject} from 'sentry/components/projects/utils';
+import {useProjectCreationAccess} from 'sentry/components/projects/useProjectCreationAccess';
 import {IconAdd} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {Organization} from 'sentry/types';
@@ -9,17 +9,15 @@ type Props = {
 };
 
 export default function CreateProjectButton({organization}: Props) {
-  const canCreateProjects = canCreateProject(organization);
+  const {canCreateProject} = useProjectCreationAccess(organization);
 
   return (
     <Button
       priority="primary"
       size="sm"
-      disabled={!canCreateProjects}
+      disabled={!canCreateProject}
       title={
-        !canCreateProjects
-          ? t('You do not have permission to create projects')
-          : undefined
+        !canCreateProject ? t('You do not have permission to create projects') : undefined
       }
       to={`/organizations/${organization.slug}/projects/new/`}
       icon={<IconAdd size="xs" isCircled />}