Просмотр исходного кода

feat(dashboards): Add Edit Access Selector to Dashbaords (#79427)

#79826 

- Add Edit Access Selector Button to the UI, and hook up the dashboard
detail endpoints to update Edit permissions.

<img width="1280" alt="Screenshot 2024-10-28 at 10 54 39 AM"
src="https://github.com/user-attachments/assets/eff9249d-4fad-4356-b009-45c0b7bd1ec1">

<img height="200" alt="Screenshot 2024-10-28 at 10 55 31 AM"
src="https://github.com/user-attachments/assets/7bfe68c4-eb8d-4fe3-afcc-74f92b76ff2e">

<img height="200" alt="Screenshot 2024-10-28 at 10 55 44 AM"
src="https://github.com/user-attachments/assets/f11c7d00-33f4-4b84-bbfd-8b17b1204418">

<br>
<br>

- Disables the ‘Edit Dashboard’ and ‘Add Widget’ buttons when the user
does not have perms to Edit the Dashboard.

<img width="1252" alt="Screenshot 2024-10-28 at 10 59 30 AM"
src="https://github.com/user-attachments/assets/75d16c5a-9ab9-4f0f-a5ce-059168de60a6">

---------

Co-authored-by: harshithadurai <harshi.durai@esentry.io>
Co-authored-by: Nar Saynorath <nar.saynorath@sentry.io>
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Harshitha Durai 4 месяцев назад
Родитель
Сommit
f8951b0e31

+ 26 - 4
static/app/actionCreators/dashboards.tsx

@@ -42,8 +42,18 @@ export function createDashboard(
   newDashboard: DashboardDetails,
   duplicate?: boolean
 ): Promise<DashboardDetails> {
-  const {title, widgets, projects, environment, period, start, end, filters, utc} =
-    newDashboard;
+  const {
+    title,
+    widgets,
+    projects,
+    environment,
+    period,
+    start,
+    end,
+    filters,
+    utc,
+    permissions,
+  } = newDashboard;
 
   const promise: Promise<DashboardDetails> = api.requestPromise(
     `/organizations/${orgSlug}/dashboards/`,
@@ -60,6 +70,7 @@ export function createDashboard(
         end,
         filters,
         utc,
+        permissions,
       },
       query: {
         project: projects,
@@ -127,8 +138,18 @@ export function updateDashboard(
   orgId: string,
   dashboard: DashboardDetails
 ): Promise<DashboardDetails> {
-  const {title, widgets, projects, environment, period, start, end, filters, utc} =
-    dashboard;
+  const {
+    title,
+    widgets,
+    projects,
+    environment,
+    period,
+    start,
+    end,
+    filters,
+    utc,
+    permissions,
+  } = dashboard;
   const data = {
     title,
     widgets: widgets.map(widget => omit(widget, ['tempId'])),
@@ -139,6 +160,7 @@ export function updateDashboard(
     end,
     filters,
     utc,
+    permissions,
   };
 
   const promise: Promise<DashboardDetails> = api.requestPromise(

+ 1 - 1
static/app/components/menuListItem.tsx

@@ -412,7 +412,7 @@ const ContentWrap = styled('div')<{
     `}
 `;
 
-const LeadingItems = styled('div')<{
+export const LeadingItems = styled('div')<{
   disabled: boolean;
   size: Props['size'];
   spanFullHeight: boolean;

+ 22 - 4
static/app/views/dashboards/controls.tsx

@@ -16,15 +16,18 @@ import type {Organization} from 'sentry/types/organization';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import {hasCustomMetrics} from 'sentry/utils/metrics/features';
 import useOrganization from 'sentry/utils/useOrganization';
+import {useUser} from 'sentry/utils/useUser';
 import {AddWidgetButton} from 'sentry/views/dashboards/addWidget';
+import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector';
 import {DataSet} from 'sentry/views/dashboards/widgetBuilder/utils';
 
-import {UNSAVED_FILTERS_MESSAGE} from './detail';
+import {checkUserHasEditAccess, UNSAVED_FILTERS_MESSAGE} from './detail';
 import exportDashboard from './exportDashboard';
-import type {DashboardListItem} from './types';
+import type {DashboardDetails, DashboardListItem, DashboardPermissions} from './types';
 import {DashboardState, MAX_WIDGETS} from './types';
 
 type Props = {
+  dashboard: DashboardDetails;
   dashboardState: DashboardState;
   dashboards: DashboardListItem[];
   onAddWidget: (dataset: DataSet) => void;
@@ -35,13 +38,16 @@ type Props = {
   organization: Organization;
   widgetLimitReached: boolean;
   hasUnsavedFilters?: boolean;
+  onChangeEditAccess?: (newDashboardPermissions: DashboardPermissions) => void;
 };
 
 function Controls({
   dashboardState,
+  dashboard,
   dashboards,
   hasUnsavedFilters,
   widgetLimitReached,
+  onChangeEditAccess,
   onEdit,
   onCommit,
   onDelete,
@@ -64,6 +70,7 @@ function Controls({
   }
 
   const organization = useOrganization();
+  const currentUser = useUser();
 
   if ([DashboardState.EDIT, DashboardState.PENDING_DELETE].includes(dashboardState)) {
     return (
@@ -138,6 +145,11 @@ function Controls({
     ? DataSet.ERRORS
     : DataSet.EVENTS;
 
+  let hasEditAccess = true;
+  if (organization.features.includes('dashboards-edit-access')) {
+    hasEditAccess = checkUserHasEditAccess(dashboard, currentUser, organization);
+  }
+
   return (
     <StyledButtonBar gap={1} key="controls">
       <DashboardEditFeature>
@@ -158,6 +170,12 @@ function Controls({
                 {t('Export Dashboard')}
               </Button>
             </Feature>
+            <Feature features="dashboards-edit-access">
+              <EditAccessSelector
+                dashboard={dashboard}
+                onChangeEditAccess={onChangeEditAccess}
+              />
+            </Feature>
             <Button
               data-test-id="dashboard-edit"
               onClick={e => {
@@ -165,7 +183,7 @@ function Controls({
                 onEdit();
               }}
               icon={<IconEdit />}
-              disabled={!hasFeature || hasUnsavedFilters}
+              disabled={!hasFeature || hasUnsavedFilters || !hasEditAccess}
               title={hasUnsavedFilters && UNSAVED_FILTERS_MESSAGE}
               priority="default"
               size="sm"
@@ -192,7 +210,7 @@ function Controls({
                     data-test-id="add-widget-library"
                     priority="primary"
                     size="sm"
-                    disabled={widgetLimitReached}
+                    disabled={widgetLimitReached || !hasEditAccess}
                     icon={<IconAdd isCircled />}
                     onClick={() => {
                       trackAnalytics('dashboards_views.widget_library.opened', {

+ 212 - 1
static/app/views/dashboards/detail.spec.tsx

@@ -4,6 +4,7 @@ import {OrganizationFixture} from 'sentry-fixture/organization';
 import {ProjectFixture} from 'sentry-fixture/project';
 import {ReleaseFixture} from 'sentry-fixture/release';
 import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture';
+import {UserFixture} from 'sentry-fixture/user';
 import {WidgetFixture} from 'sentry-fixture/widget';
 
 import {initializeOrg} from 'sentry-test/initializeOrg';
@@ -17,10 +18,13 @@ import {
 } from 'sentry-test/reactTestingLibrary';
 
 import * as modals from 'sentry/actionCreators/modal';
+import ConfigStore from 'sentry/stores/configStore';
+import PageFiltersStore from 'sentry/stores/pageFiltersStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {browserHistory} from 'sentry/utils/browserHistory';
 import CreateDashboard from 'sentry/views/dashboards/create';
 import {handleUpdateDashboardSplit} from 'sentry/views/dashboards/detail';
+import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector';
 import * as types from 'sentry/views/dashboards/types';
 import ViewEditDashboard from 'sentry/views/dashboards/view';
 import {OrganizationContext} from 'sentry/views/organizationContext';
@@ -224,6 +228,15 @@ describe('Dashboards > Detail', function () {
           location: LocationFixture(),
         },
       });
+      PageFiltersStore.init();
+      PageFiltersStore.onInitializeUrlState(
+        {
+          projects: [],
+          environments: [],
+          datetime: {start: null, end: null, period: '14d', utc: null},
+        },
+        new Set()
+      );
       widgets = [
         WidgetFixture({
           queries: [
@@ -312,6 +325,7 @@ describe('Dashboards > Detail', function () {
           id: '1',
           title: 'Custom Errors',
           filters: {},
+          createdBy: UserFixture({id: '1'}),
         }),
       });
       mockPut = MockApiClient.addMockResponse({
@@ -590,7 +604,7 @@ describe('Dashboards > Detail', function () {
         {router: initialData.router}
       );
       expect(await screen.findByText('All Releases')).toBeInTheDocument();
-      expect(mockReleases).toHaveBeenCalledTimes(1);
+      expect(mockReleases).toHaveBeenCalledTimes(2); // Called once when PageFiltersStore is initialized
     });
 
     it('hides add widget option', async function () {
@@ -1632,6 +1646,203 @@ describe('Dashboards > Detail', function () {
       expect(screen.getByRole('option', {name: 'search-result'})).toBeInTheDocument();
     });
 
+    it('renders edit access selector', async function () {
+      render(
+        <EditAccessSelector
+          dashboard={DashboardFixture([], {id: '1', title: 'Custom Errors'})}
+          onChangeEditAccess={jest.fn()}
+        />,
+        {
+          router: initialData.router,
+          organization: {
+            features: ['dashboards-edit-access'],
+            ...initialData.organization,
+          },
+        }
+      );
+
+      await userEvent.click(await screen.findByText('Edit Access:'));
+      expect(screen.getByText('Creator')).toBeInTheDocument();
+      expect(screen.getByText('Everyone')).toBeInTheDocument();
+    });
+
+    it('creates and updates new permissions for dashboard with no edit perms initialized', async function () {
+      const mockPUT = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        method: 'PUT',
+        body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
+      });
+
+      render(
+        <ViewEditDashboard
+          {...RouteComponentPropsFixture()}
+          organization={{
+            ...initialData.organization,
+            features: ['dashboards-edit-access', ...initialData.organization.features],
+          }}
+          params={{orgId: 'org-slug', dashboardId: '1'}}
+          router={initialData.router}
+          location={initialData.router.location}
+        >
+          {null}
+        </ViewEditDashboard>,
+        {
+          router: initialData.router,
+          organization: {
+            features: ['dashboards-edit-access', ...initialData.organization.features],
+          },
+        }
+      );
+      await userEvent.click(await screen.findByText('Edit Access:'));
+
+      // deselects 'Everyone' so only creator has edit access
+      expect(await screen.findByText('Everyone')).toBeEnabled();
+      expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+        'aria-selected',
+        'true'
+      );
+      await userEvent.click(screen.getByRole('option', {name: 'Everyone'}));
+      expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+        'aria-selected',
+        'false'
+      );
+
+      // clicks out of dropdown to trigger onClose()
+      await userEvent.click(await screen.findByText('Edit Access:'));
+
+      await waitFor(() => {
+        expect(mockPUT).toHaveBeenCalledTimes(1);
+        expect(mockPUT).toHaveBeenCalledWith(
+          '/organizations/org-slug/dashboards/1/',
+          expect.objectContaining({
+            data: expect.objectContaining({
+              permissions: {isCreatorOnlyEditable: true},
+            }),
+          })
+        );
+      });
+    });
+
+    it('creator can update permissions for dashboard', async function () {
+      const mockPUT = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        method: 'PUT',
+        body: DashboardFixture([], {id: '1', title: 'Custom Errors'}),
+      });
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        body: DashboardFixture([], {
+          id: '1',
+          title: 'Custom Errors',
+          createdBy: UserFixture({id: '781629'}),
+          permissions: {isCreatorOnlyEditable: true},
+        }),
+      });
+
+      const currentUser = UserFixture({id: '781629'});
+      ConfigStore.set('user', currentUser);
+
+      render(
+        <ViewEditDashboard
+          {...RouteComponentPropsFixture()}
+          organization={{
+            ...initialData.organization,
+            features: ['dashboards-edit-access', ...initialData.organization.features],
+          }}
+          params={{orgId: 'org-slug', dashboardId: '1'}}
+          router={initialData.router}
+          location={initialData.router.location}
+        >
+          {null}
+        </ViewEditDashboard>,
+        {
+          router: initialData.router,
+          organization: {
+            features: ['dashboards-edit-access', ...initialData.organization.features],
+          },
+        }
+      );
+      await userEvent.click(await screen.findByText('Edit Access:'));
+
+      // selects 'Everyone' so everyone has edit access
+      expect(await screen.findByText('Everyone')).toBeEnabled();
+      expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+        'aria-selected',
+        'false'
+      );
+      await userEvent.click(screen.getByRole('option', {name: 'Everyone'}));
+      expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+        'aria-selected',
+        'true'
+      );
+
+      // clicks out of dropdown to trigger onClose()
+      await userEvent.click(await screen.findByText('Edit Access:'));
+
+      await waitFor(() => {
+        expect(mockPUT).toHaveBeenCalledTimes(1);
+        expect(mockPUT).toHaveBeenCalledWith(
+          '/organizations/org-slug/dashboards/1/',
+          expect.objectContaining({
+            data: expect.objectContaining({
+              permissions: {isCreatorOnlyEditable: false},
+            }),
+          })
+        );
+      });
+    });
+
+    it('disables edit dashboard and add widget button if user cannot edit dashboard', async function () {
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/',
+        body: [
+          DashboardFixture([], {
+            id: '1',
+            title: 'Custom Errors',
+            createdBy: UserFixture({id: '238900'}),
+            permissions: {isCreatorOnlyEditable: true},
+          }),
+        ],
+      });
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        body: DashboardFixture([], {
+          id: '1',
+          title: 'Custom Errors',
+          createdBy: UserFixture({id: '238900'}),
+          permissions: {isCreatorOnlyEditable: true},
+        }),
+      });
+
+      const currentUser = UserFixture({id: '781629'});
+      ConfigStore.set('user', currentUser);
+
+      render(
+        <ViewEditDashboard
+          {...RouteComponentPropsFixture()}
+          organization={{
+            ...initialData.organization,
+            features: ['dashboards-edit-access', ...initialData.organization.features],
+          }}
+          params={{orgId: 'org-slug', dashboardId: '1'}}
+          router={initialData.router}
+          location={initialData.router.location}
+        >
+          {null}
+        </ViewEditDashboard>,
+        {
+          router: initialData.router,
+          organization: {
+            features: ['dashboards-edit-access', ...initialData.organization.features],
+          },
+        }
+      );
+
+      await screen.findByText('Edit Access:');
+      expect(screen.getByRole('button', {name: 'Edit Dashboard'})).toBeDisabled();
+      expect(screen.getByRole('button', {name: 'Add Widget'})).toBeDisabled();
+    });
+
     describe('discover split', function () {
       it('calls the dashboard callbacks with the correct widgetType for discover split', function () {
         const widget = {

+ 42 - 1
static/app/views/dashboards/detail.tsx

@@ -1,5 +1,6 @@
 import {cloneElement, Component, Fragment, isValidElement} from 'react';
 import styled from '@emotion/styled';
+import type {User} from '@sentry/types';
 import isEqual from 'lodash/isEqual';
 import isEqualWith from 'lodash/isEqualWith';
 import omit from 'lodash/omit';
@@ -77,6 +78,7 @@ import type {
   DashboardDetails,
   DashboardFilters,
   DashboardListItem,
+  DashboardPermissions,
   Widget,
 } from './types';
 import {
@@ -167,6 +169,23 @@ export function handleUpdateDashboardSplit({
   }
 }
 
+/* Checks if current user has permissions to edit dashboard */
+export function checkUserHasEditAccess(
+  dashboard: DashboardDetails,
+  currentUser: User,
+  organization: Organization
+): boolean {
+  if (
+    !organization.features.includes('dashboards-edit-access') ||
+    !dashboard.permissions
+  ) {
+    return true;
+  }
+  return dashboard.permissions.isCreatorOnlyEditable
+    ? dashboard.createdBy?.id === currentUser.id
+    : !dashboard.permissions.isCreatorOnlyEditable;
+}
+
 class DashboardDetail extends Component<Props, State> {
   state: State = {
     dashboardState: this.props.initialState,
@@ -594,6 +613,25 @@ class DashboardDetail extends Component<Props, State> {
     );
   };
 
+  /* Handles POST request for Edit Access Selector Changes */
+  onChangeEditAccess = (newDashboardPermissions: DashboardPermissions) => {
+    const {dashboard, api, organization} = this.props;
+
+    const dashboardCopy = cloneDashboard(dashboard);
+    dashboardCopy.permissions = newDashboardPermissions;
+
+    updateDashboard(api, organization.slug, dashboardCopy).then(
+      (newDashboard: DashboardDetails) => {
+        addSuccessMessage(t('Dashboard Edit Access updated.'));
+        this.props.onDashboardUpdate?.(newDashboard);
+        this.setState({
+          modifiedDashboard: null,
+        });
+        return newDashboard;
+      }
+    );
+  };
+
   onCommit = () => {
     const {api, organization, location, dashboard, onDashboardUpdate} = this.props;
     const {modifiedDashboard, dashboardState} = this.state;
@@ -757,7 +795,6 @@ class DashboardDetail extends Component<Props, State> {
     const {organization, dashboard, dashboards, params, router, location} = this.props;
     const {modifiedDashboard, dashboardState, widgetLimitReached} = this.state;
     const {dashboardId} = params;
-
     return (
       <PageFiltersContainer
         disablePersistence
@@ -785,10 +822,12 @@ class DashboardDetail extends Component<Props, State> {
                   <Controls
                     organization={organization}
                     dashboards={dashboards}
+                    dashboard={dashboard}
                     onEdit={this.onEdit}
                     onCancel={this.onCancel}
                     onCommit={this.onCommit}
                     onAddWidget={this.onAddWidget}
+                    onChangeEditAccess={this.onChangeEditAccess}
                     onDelete={this.onDelete(dashboard)}
                     dashboardState={dashboardState}
                     widgetLimitReached={widgetLimitReached}
@@ -927,12 +966,14 @@ class DashboardDetail extends Component<Props, State> {
                       <Controls
                         organization={organization}
                         dashboards={dashboards}
+                        dashboard={dashboard}
                         hasUnsavedFilters={hasUnsavedFilters}
                         onEdit={this.onEdit}
                         onCancel={this.onCancel}
                         onCommit={this.onCommit}
                         onAddWidget={this.onAddWidget}
                         onDelete={this.onDelete(dashboard)}
+                        onChangeEditAccess={this.onChangeEditAccess}
                         dashboardState={dashboardState}
                         widgetLimitReached={widgetLimitReached}
                       />

+ 169 - 0
static/app/views/dashboards/editAccessSelector.spec.tsx

@@ -0,0 +1,169 @@
+import {DashboardFixture} from 'sentry-fixture/dashboard';
+import {LocationFixture} from 'sentry-fixture/locationFixture';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {UserFixture} from 'sentry-fixture/user';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import ConfigStore from 'sentry/stores/configStore';
+
+import EditAccessSelector from './editAccessSelector';
+
+function renderTestComponent(
+  initialData,
+  mockDashboard = DashboardFixture([], {
+    id: '1',
+    title: 'test dashboard 2',
+    createdBy: UserFixture({id: '35478'}),
+  })
+) {
+  render(
+    <EditAccessSelector dashboard={mockDashboard} onChangeEditAccess={jest.fn()} />,
+    {
+      router: initialData.router,
+      organization: {
+        user: UserFixture({id: '1'}),
+        features: ['dashboards-edit-access', 'dashboards-edit'],
+        ...initialData.organization,
+      },
+    }
+  );
+}
+
+describe('When EditAccessSelector is rendered', () => {
+  let initialData;
+  const organization = OrganizationFixture({});
+  beforeEach(() => {
+    initialData = initializeOrg({
+      organization,
+      router: {
+        location: LocationFixture(),
+      },
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      body: [
+        {
+          ...DashboardFixture([], {
+            id: 'default-overview',
+            title: 'Default',
+          }),
+        },
+        {
+          ...DashboardFixture([], {
+            id: '1',
+            title: 'test dashboard 2',
+            createdBy: UserFixture({id: '35478'}),
+          }),
+        },
+      ],
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/1/',
+      body: DashboardFixture([], {
+        id: '1',
+        title: 'Custom Errors',
+        filters: {},
+      }),
+    });
+  });
+
+  afterEach(() => {
+    MockApiClient.clearMockResponses();
+    jest.clearAllMocks();
+  });
+
+  it('renders with creator and everyone options', async function () {
+    renderTestComponent(initialData);
+
+    await userEvent.click(await screen.findByText('Edit Access:'));
+    expect(screen.getByText('Creator')).toBeInTheDocument();
+    expect(screen.getByText('Everyone')).toBeInTheDocument();
+  });
+
+  it('renders All badge when dashboards has no perms defined', async function () {
+    renderTestComponent(initialData);
+    await userEvent.click(await screen.findByText('Edit Access:'));
+    expect(screen.getByText('All')).toBeInTheDocument();
+  });
+
+  it('renders All badge when perms is set to everyone', async function () {
+    const mockDashboard = DashboardFixture([], {
+      id: '1',
+      createdBy: UserFixture({id: '1'}),
+      title: 'Custom Errors',
+      permissions: {isCreatorOnlyEditable: false}, // set to false
+    });
+    renderTestComponent(initialData, mockDashboard);
+    await screen.findByText('Edit Access:');
+    expect(screen.getByText('All')).toBeInTheDocument();
+  });
+
+  it('renders All badge when everyone is selected', async function () {
+    const mockDashboard = DashboardFixture([], {
+      id: '1',
+      createdBy: UserFixture({id: '1'}),
+      title: 'Custom Errors',
+      permissions: {isCreatorOnlyEditable: true}, // set to false
+    });
+    renderTestComponent(initialData, mockDashboard);
+    await userEvent.click(await screen.findByText('Edit Access:'));
+
+    expect(screen.queryByText('All')).not.toBeInTheDocument();
+
+    // Select everyone
+    expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+      'aria-selected',
+      'false'
+    );
+    await userEvent.click(screen.getByRole('option', {name: 'Everyone'}));
+    expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+
+    expect(screen.getByText('All')).toBeInTheDocument();
+  });
+
+  it('renders User badge when creator-only is selected', async function () {
+    const currentUser = UserFixture({id: '781629', name: 'John Doe'});
+    ConfigStore.set('user', currentUser);
+
+    const mockDashboard = DashboardFixture([], {
+      id: '1',
+      createdBy: UserFixture({id: '1', name: 'Lorem Ipsum'}),
+      title: 'Custom Errors',
+      permissions: {isCreatorOnlyEditable: true}, // set to true
+    });
+    renderTestComponent(initialData, mockDashboard);
+    await screen.findByText('Edit Access:');
+    expect(screen.getByText('LI')).toBeInTheDocument(); // dashboard owner's initials
+    expect(screen.queryByText('All')).not.toBeInTheDocument();
+  });
+
+  it('disables dropdown options when current user is not dashboard creator', async function () {
+    const currentUser = UserFixture({id: '781629'});
+    ConfigStore.set('user', currentUser);
+
+    renderTestComponent(initialData);
+    await userEvent.click(await screen.findByText('Edit Access:'));
+
+    // Everyone option should be disabled
+    expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+    await userEvent.click(screen.getByRole('option', {name: 'Everyone'}));
+    expect(await screen.findByRole('option', {name: 'Everyone'})).toHaveAttribute(
+      'aria-selected',
+      'true'
+    );
+  });
+
+  // [WIP] (Teams based access)
+  it('renders all teams', async function () {});
+  it('selects all teams when everyone is selected', async function () {});
+  it('retains team selection on re-opening selector', async function () {});
+  it('makes a post request with success message when different teams are selected', async function () {});
+});

+ 180 - 0
static/app/views/dashboards/editAccessSelector.tsx

@@ -0,0 +1,180 @@
+import {useState} from 'react';
+import styled from '@emotion/styled';
+import isEqual from 'lodash/isEqual';
+
+import AvatarList from 'sentry/components/avatar/avatarList';
+import Badge from 'sentry/components/badge/badge';
+import Checkbox from 'sentry/components/checkbox';
+import {CompactSelect} from 'sentry/components/compactSelect';
+import {CheckWrap} from 'sentry/components/compactSelect/styles';
+import UserBadge from 'sentry/components/idBadge/userBadge';
+import {InnerWrap, LeadingItems} from 'sentry/components/menuListItem';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t, tct} from 'sentry/locale';
+import type {User} from 'sentry/types/user';
+import {defined} from 'sentry/utils';
+import {useTeamsById} from 'sentry/utils/useTeamsById';
+import {useUser} from 'sentry/utils/useUser';
+import type {DashboardDetails, DashboardPermissions} from 'sentry/views/dashboards/types';
+
+interface EditAccessSelectorProps {
+  dashboard: DashboardDetails;
+  onChangeEditAccess?: (newDashboardPermissions: DashboardPermissions) => void;
+}
+
+/**
+ * Dropdown multiselect button to enable selective Dashboard editing access to
+ * specific users and teams
+ */
+function EditAccessSelector({dashboard, onChangeEditAccess}: EditAccessSelectorProps) {
+  const currentUser: User = useUser();
+  const dashboardCreator: User | undefined = dashboard.createdBy;
+  const {teams} = useTeamsById();
+  const teamIds: string[] = Object.values(teams).map(team => team.id);
+  const [selectedOptions, setselectedOptions] = useState<string[]>(
+    dashboard.permissions?.isCreatorOnlyEditable
+      ? ['_creator']
+      : ['_everyone', '_creator', ...teamIds]
+  );
+  // Dashboard creator option in the dropdown
+  const makeCreatorOption = () => ({
+    value: '_creator',
+    label: (
+      <UserBadge
+        avatarSize={18}
+        user={dashboardCreator}
+        displayName={
+          <StyledDisplayName>
+            {dashboardCreator?.id === currentUser.id
+              ? tct('You ([email])', {email: currentUser.email})
+              : dashboardCreator?.email ||
+                tct('You ([email])', {email: currentUser.email})}
+          </StyledDisplayName>
+        }
+        displayEmail={t('Creator')}
+      />
+    ),
+    textValue: `creator_${currentUser.email}`,
+    disabled: dashboardCreator?.id !== currentUser.id,
+    // Creator option is always disabled
+    leadingItems: <Checkbox size="sm" checked disabled />,
+    hideCheck: true,
+  });
+
+  // Single team option in the dropdown [WIP]
+  // const makeTeamOption = (team: Team) => ({
+  //   value: team.id,
+  //   label: `#${team.slug}`,
+  //   leadingItems: <TeamAvatar team={team} size={18} />,
+  // });
+
+  // Avatars/Badges in the Edit Selector Button
+  const triggerAvatars =
+    selectedOptions.includes('_everyone') || !dashboardCreator ? (
+      <StyledBadge key="_all" text={'All'} />
+    ) : (
+      <StyledAvatarList key="avatar-list" users={[dashboardCreator]} avatarSize={25} />
+    );
+
+  const dropdownOptions = [
+    makeCreatorOption(),
+    {
+      value: '_everyone_section',
+      options: [
+        {
+          value: '_everyone',
+          label: t('Everyone'),
+          disabled: dashboardCreator?.id !== currentUser.id,
+        },
+      ],
+    },
+    // [WIP: Selective edit access to teams]
+    // {
+    //   value: '_teams',
+    //   label: t('Teams'),
+    //   options: teams.map(makeTeamOption),
+    //   showToggleAllButton: true,
+    //   disabled: true,
+    // },
+  ];
+
+  // Handles state change when dropdown options are selected
+  const onSelectOptions = newSelectedOptions => {
+    const newSelectedValues = newSelectedOptions.map(
+      (option: {value: string}) => option.value
+    );
+    if (newSelectedValues.includes('_everyone')) {
+      setselectedOptions(['_everyone', '_creator', ...teamIds]);
+    } else if (!newSelectedValues.includes('_everyone')) {
+      setselectedOptions(['_creator']);
+    }
+  };
+
+  // Creates or modifies permissions object based on the options selected
+  function getDashboardPermissions() {
+    return {
+      isCreatorOnlyEditable: !selectedOptions.includes('_everyone'),
+    };
+  }
+
+  const dropdownMenu = (
+    <StyledCompactSelect
+      size="sm"
+      onChange={newSelectedOptions => {
+        onSelectOptions(newSelectedOptions);
+      }}
+      onClose={() => {
+        const isDefaultState =
+          !defined(dashboard.permissions) && selectedOptions.includes('_everyone');
+        const newDashboardPermissions = getDashboardPermissions();
+        if (!isDefaultState && !isEqual(newDashboardPermissions, dashboard.permissions)) {
+          onChangeEditAccess?.(newDashboardPermissions);
+        }
+      }}
+      multiple
+      searchable
+      options={dropdownOptions}
+      value={selectedOptions}
+      triggerLabel={[t('Edit Access:'), triggerAvatars]}
+      searchPlaceholder={t('Search Teams')}
+    />
+  );
+
+  return dashboardCreator?.id !== currentUser.id ? (
+    <Tooltip title={t('Only Dashboard Creator may change Edit Access')}>
+      {dropdownMenu}
+    </Tooltip>
+  ) : (
+    dropdownMenu
+  );
+}
+
+export default EditAccessSelector;
+
+const StyledCompactSelect = styled(CompactSelect)`
+  ${InnerWrap} {
+    align-items: center;
+  }
+
+  ${LeadingItems} {
+    margin-top: 0;
+  }
+
+  ${CheckWrap} {
+    padding-bottom: 0;
+  }
+`;
+
+const StyledDisplayName = styled('div')`
+  font-weight: normal;
+`;
+
+const StyledAvatarList = styled(AvatarList)`
+  margin-left: 10px;
+`;
+
+const StyledBadge = styled(Badge)`
+  color: ${p => p.theme.white};
+  background: ${p => p.theme.purple300};
+  margin-right: 3px;
+`;

+ 5 - 0
static/app/views/dashboards/types.tsx

@@ -111,6 +111,10 @@ export type WidgetPreview = {
   layout: WidgetLayout | null;
 };
 
+export type DashboardPermissions = {
+  isCreatorOnlyEditable: boolean;
+};
+
 /**
  * The response shape from dashboard list endpoint
  */
@@ -145,6 +149,7 @@ export type DashboardDetails = {
   end?: string;
   environment?: string[];
   period?: string;
+  permissions?: DashboardPermissions;
   start?: string;
   utc?: boolean;
 };