Browse Source

feat(dashbords): Implement widget new design part 5 (#25296)

Priscila Oliveira 3 years ago
parent
commit
3189a2ec37

+ 4 - 14
static/app/routes.tsx

@@ -1184,15 +1184,6 @@ function routes() {
               }
               component={errorHandler(LazyLoad)}
             />
-            <Route
-              path="widget/new/"
-              componentPromise={() =>
-                import(
-                  /* webpackChunkName: "WidgetNew" */ 'app/views/dashboardsV2/widget/new'
-                )
-              }
-              component={errorHandler(LazyLoad)}
-            />
           </Route>
 
           <Route
@@ -1868,16 +1859,15 @@ function routes() {
           <Route
             path="/organizations/:orgId/dashboards/:dashboardId/"
             componentPromise={() =>
-              import(
-                /* webpackChunkName: "DashboardsV2Container" */ 'app/views/dashboardsV2'
-              )
+              import(/* webpackChunkName: "DashboardsV2" */ 'app/views/dashboardsV2')
             }
             component={errorHandler(LazyLoad)}
           >
-            <IndexRoute
+            <Route
+              path="widget/new/"
               componentPromise={() =>
                 import(
-                  /* webpackChunkName: "DashboardDetail" */ 'app/views/dashboardsV2/detail'
+                  /* webpackChunkName: "WidgetNew" */ 'app/views/dashboardsV2/widget/new'
                 )
               }
               component={errorHandler(LazyLoad)}

+ 4 - 3
static/app/views/dashboardsV2/addWidget.tsx

@@ -8,7 +8,7 @@ import {IconAdd} from 'app/icons';
 import {t} from 'app/locale';
 import {Organization} from 'app/types';
 
-import {DisplayType} from './types';
+import {DashboardDetails, DisplayType} from './types';
 import WidgetWrapper from './widgetWrapper';
 
 export const ADD_WIDGET_BUTTON_DRAG_ID = 'add-widget-button';
@@ -24,9 +24,10 @@ type Props = {
   onClick: () => void;
   orgFeatures: Organization['features'];
   orgSlug: Organization['slug'];
+  dashboardId: DashboardDetails['id'];
 };
 
-function AddWidget({onClick, orgFeatures, orgSlug}: Props) {
+function AddWidget({onClick, orgFeatures, orgSlug, dashboardId}: Props) {
   const {setNodeRef, transform} = useSortable({
     disabled: true,
     id: ADD_WIDGET_BUTTON_DRAG_ID,
@@ -58,7 +59,7 @@ function AddWidget({onClick, orgFeatures, orgSlug}: Props) {
         <InnerWrapper>
           <ButtonBar gap={1}>
             <Button
-              to={`/organizations/${orgSlug}/dashboards/widget/new/?dataSet=metrics`}
+              to={`/organizations/${orgSlug}/dashboards/${dashboardId}/widget/new/?dataSet=metrics`}
             >
               {t('Add metrics widget')}
             </Button>

+ 3 - 0
static/app/views/dashboardsV2/dashboard.tsx

@@ -19,6 +19,7 @@ type Props = {
   api: Client;
   organization: Organization;
   dashboard: DashboardDetails;
+  paramDashboardId: string;
   selection: GlobalSelection;
   isEditing: boolean;
   /**
@@ -122,6 +123,7 @@ class Dashboard extends React.Component<Props> {
       onUpdate,
       dashboard: {widgets},
       organization,
+      paramDashboardId,
     } = this.props;
 
     const items = this.getWidgetIds();
@@ -148,6 +150,7 @@ class Dashboard extends React.Component<Props> {
             {widgets.map((widget, index) => this.renderWidget(widget, index))}
             {isEditing && (
               <AddWidget
+                dashboardId={paramDashboardId}
                 orgSlug={organization.slug}
                 orgFeatures={organization.features}
                 onClick={this.handleStartAdd}

+ 130 - 58
static/app/views/dashboardsV2/detail.tsx

@@ -1,7 +1,6 @@
 import React from 'react';
 import {browserHistory, PlainRoute, WithRouterProps} from 'react-router';
 import styled from '@emotion/styled';
-import {Location} from 'history';
 import isEqual from 'lodash/isEqual';
 
 import {
@@ -12,9 +11,11 @@ import {
 import {addSuccessMessage} from 'app/actionCreators/indicator';
 import {Client} from 'app/api';
 import NotFound from 'app/components/errors/notFound';
+import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
 import LoadingIndicator from 'app/components/loadingIndicator';
 import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
 import {t} from 'app/locale';
+import {PageContent} from 'app/styles/organization';
 import space from 'app/styles/space';
 import {Organization} from 'app/types';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
@@ -50,6 +51,7 @@ class DashboardDetail extends React.Component<Props, State> {
 
   componentDidMount() {
     const {route, router} = this.props;
+    this.checkStateRoute();
     router.setRouteLeaveHook(route, this.onRouteLeave);
     window.addEventListener('beforeunload', this.onUnload);
   }
@@ -58,6 +60,37 @@ class DashboardDetail extends React.Component<Props, State> {
     window.removeEventListener('beforeunload', this.onUnload);
   }
 
+  checkStateRoute() {
+    if (this.isWidgetBuilderRouter && !this.isEditing) {
+      const {router, organization, params} = this.props;
+      const {dashboardId} = params;
+      router.replace(`/organizations/${organization.slug}/dashboards/${dashboardId}/`);
+    }
+  }
+
+  updateRouteAfterSavingWidget() {
+    if (this.isWidgetBuilderRouter) {
+      const {router, organization, params} = this.props;
+      const {dashboardId} = params;
+      router.replace(`/organizations/${organization.slug}/dashboards/${dashboardId}/`);
+    }
+  }
+
+  get isEditing() {
+    const {dashboardState} = this.state;
+    return ['edit', 'create', 'pending_delete'].includes(dashboardState);
+  }
+
+  get isWidgetBuilderRouter() {
+    const {location, params, organization} = this.props;
+    const {dashboardId} = params;
+
+    return (
+      location.pathname ===
+      `/organizations/${organization.slug}/dashboards/${dashboardId}/widget/new/`
+    );
+  }
+
   onEdit = (dashboard: State['modifiedDashboard']) => () => {
     if (!dashboard) {
       return;
@@ -75,16 +108,7 @@ class DashboardDetail extends React.Component<Props, State> {
     });
   };
 
-  onRouteLeave = (nextLocation?: Location) => {
-    const {organization} = this.props;
-
-    if (
-      nextLocation?.pathname ===
-      `/organizations/${organization.slug}/dashboards/widget/new/`
-    ) {
-      return undefined;
-    }
-
+  onRouteLeave = () => {
     if (!['view', 'pending_delete'].includes(this.state.dashboardState)) {
       return UNSAVED_MESSAGE;
     }
@@ -269,6 +293,7 @@ class DashboardDetail extends React.Component<Props, State> {
 
   onWidgetChange = (widgets: Widget[]) => {
     const {modifiedDashboard} = this.state;
+
     if (modifiedDashboard === null) {
       return;
     }
@@ -290,11 +315,34 @@ class DashboardDetail extends React.Component<Props, State> {
     });
   };
 
-  render() {
-    const {api, location, params, organization} = this.props;
-    const {modifiedDashboard, dashboardState} = this.state;
+  onSaveWidget = (widgets: Widget[]) => {
+    const {modifiedDashboard} = this.state;
 
-    const isEditing = ['edit', 'create', 'pending_delete'].includes(dashboardState);
+    if (modifiedDashboard === null) {
+      return;
+    }
+
+    this.setState(
+      (state: State) => ({
+        ...state,
+        modifiedDashboard: {
+          ...state.modifiedDashboard!,
+          widgets,
+        },
+      }),
+      this.updateRouteAfterSavingWidget
+    );
+  };
+
+  renderDetails({
+    dashboard,
+    dashboards,
+    reloadData,
+    error,
+  }: Parameters<OrgDashboards['props']['children']>[0]) {
+    const {organization, params} = this.props;
+    const {modifiedDashboard, dashboardState} = this.state;
+    const {dashboardId} = params;
 
     return (
       <GlobalSelectionHeader
@@ -308,52 +356,76 @@ class DashboardDetail extends React.Component<Props, State> {
           },
         }}
       >
-        <OrgDashboards
-          api={api}
-          location={location}
-          params={params}
-          organization={organization}
-        >
-          {({dashboard, dashboards, error, reloadData}) => {
-            return (
-              <React.Fragment>
-                <StyledPageHeader>
-                  <DashboardTitle
-                    dashboard={modifiedDashboard || dashboard}
-                    onUpdate={this.setModifiedDashboard}
-                    isEditing={isEditing}
-                  />
-                  <Controls
-                    organization={organization}
-                    dashboards={dashboards}
-                    dashboard={dashboard}
-                    onEdit={this.onEdit(dashboard)}
-                    onCreate={this.onCreate}
-                    onCancel={this.onCancel}
-                    onCommit={this.onCommit({dashboard, reloadData})}
-                    onDelete={this.onDelete(dashboard)}
-                    dashboardState={dashboardState}
-                  />
-                </StyledPageHeader>
-                {error ? (
-                  <NotFound />
-                ) : dashboard ? (
-                  <Dashboard
-                    dashboard={modifiedDashboard || dashboard}
-                    organization={organization}
-                    isEditing={isEditing}
-                    onUpdate={this.onWidgetChange}
-                  />
-                ) : (
-                  <LoadingIndicator />
-                )}
-              </React.Fragment>
-            );
-          }}
-        </OrgDashboards>
+        <PageContent>
+          <LightWeightNoProjectMessage organization={organization}>
+            <StyledPageHeader>
+              <DashboardTitle
+                dashboard={modifiedDashboard || dashboard}
+                onUpdate={this.setModifiedDashboard}
+                isEditing={this.isEditing}
+              />
+              <Controls
+                organization={organization}
+                dashboards={dashboards}
+                dashboard={dashboard}
+                onEdit={this.onEdit(dashboard)}
+                onCreate={this.onCreate}
+                onCancel={this.onCancel}
+                onCommit={this.onCommit({dashboard, reloadData})}
+                onDelete={this.onDelete(dashboard)}
+                dashboardState={dashboardState}
+              />
+            </StyledPageHeader>
+            {error ? (
+              <NotFound />
+            ) : dashboard ? (
+              <Dashboard
+                dashboard={modifiedDashboard || dashboard}
+                paramDashboardId={dashboardId}
+                organization={organization}
+                isEditing={this.isEditing}
+                onUpdate={this.onWidgetChange}
+              />
+            ) : (
+              <LoadingIndicator />
+            )}
+          </LightWeightNoProjectMessage>
+        </PageContent>
       </GlobalSelectionHeader>
     );
   }
+
+  renderWidgetBuilder(dashboard: DashboardDetails | null) {
+    const {children} = this.props;
+    const {modifiedDashboard} = this.state;
+
+    return React.isValidElement(children)
+      ? React.cloneElement(children, {
+          dashboard: modifiedDashboard || dashboard,
+          onSave: this.onSaveWidget,
+        })
+      : children;
+  }
+
+  render() {
+    const {api, location, params, organization} = this.props;
+
+    return (
+      <OrgDashboards
+        api={api}
+        location={location}
+        params={params}
+        organization={organization}
+      >
+        {({dashboard, dashboards, error, reloadData}) => {
+          if (this.isEditing && this.isWidgetBuilderRouter) {
+            return this.renderWidgetBuilder(dashboard);
+          }
+          return this.renderDetails({dashboard, dashboards, error, reloadData});
+        }}
+      </OrgDashboards>
+    );
+  }
 }
 
 const StyledPageHeader = styled('div')`

+ 6 - 3
static/app/views/dashboardsV2/index.tsx

@@ -1,21 +1,24 @@
 import React from 'react';
+import {RouteComponentProps} from 'react-router';
 
 import Feature from 'app/components/acl/feature';
 import {Organization} from 'app/types';
 import withOrganization from 'app/utils/withOrganization';
 
-type Props = {
+import Detail from './detail';
+
+type Props = RouteComponentProps<{orgId: string; dashboardId: string}, {}> & {
   organization: Organization;
   children: React.ReactNode;
 };
 
 class DashboardsV2Container extends React.Component<Props> {
   render() {
-    const {organization, children} = this.props;
+    const {organization, ...props} = this.props;
 
     return (
       <Feature features={['dashboards-basic']} organization={organization}>
-        {children}
+        <Detail {...props} organization={organization} />
       </Feature>
     );
   }

+ 7 - 15
static/app/views/dashboardsV2/orgDashboards.tsx

@@ -6,10 +6,8 @@ import isEqual from 'lodash/isEqual';
 import {Client} from 'app/api';
 import AsyncComponent from 'app/components/asyncComponent';
 import NotFound from 'app/components/errors/notFound';
-import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
 import SentryDocumentTitle from 'app/components/sentryDocumentTitle';
 import {t} from 'app/locale';
-import {PageContent} from 'app/styles/organization';
 import {Organization} from 'app/types';
 import {trackAnalyticsEvent} from 'app/utils/analytics';
 
@@ -100,21 +98,15 @@ class OrgDashboards extends AsyncComponent<Props, State> {
   }
 
   renderBody() {
-    const {organization, children} = this.props;
+    const {children} = this.props;
     const {selectedDashboard, error} = this.state;
 
-    return (
-      <PageContent>
-        <LightWeightNoProjectMessage organization={organization}>
-          {children({
-            error,
-            dashboard: selectedDashboard,
-            dashboards: this.getDashboards(),
-            reloadData: this.reloadData.bind(this),
-          })}
-        </LightWeightNoProjectMessage>
-      </PageContent>
-    );
+    return children({
+      error,
+      dashboard: selectedDashboard,
+      dashboards: this.getDashboards(),
+      reloadData: this.reloadData.bind(this),
+    });
   }
 
   renderError(error: Error) {

+ 66 - 5
static/app/views/dashboardsV2/widget/eventWidget/index.tsx

@@ -1,8 +1,11 @@
 import React from 'react';
 import styled from '@emotion/styled';
 import cloneDeep from 'lodash/cloneDeep';
+import pick from 'lodash/pick';
 import set from 'lodash/set';
 
+import {validateWidget} from 'app/actionCreators/dashboards';
+import {addSuccessMessage} from 'app/actionCreators/indicator';
 import WidgetQueryFields from 'app/components/dashboards/widgetQueryFields';
 import SelectControl from 'app/components/forms/selectControl';
 import * as Layout from 'app/components/layouts/thirds';
@@ -17,7 +20,12 @@ import withGlobalSelection from 'app/utils/withGlobalSelection';
 import withOrganization from 'app/utils/withOrganization';
 import withTags from 'app/utils/withTags';
 import AsyncView from 'app/views/asyncView';
-import {DisplayType, Widget, WidgetQuery} from 'app/views/dashboardsV2/types';
+import {
+  DashboardDetails,
+  DisplayType,
+  Widget,
+  WidgetQuery,
+} from 'app/views/dashboardsV2/types';
 import WidgetCard from 'app/views/dashboardsV2/widgetCard';
 import {generateFieldOptions} from 'app/views/eventsV2/utils';
 
@@ -29,6 +37,7 @@ import Header from '../header';
 import {DataSet, displayTypes} from '../utils';
 
 import Queries from './queries';
+import {mapErrors, normalizeQueries} from './utils';
 
 const newQuery = {
   name: '',
@@ -42,6 +51,9 @@ type Props = AsyncView['props'] & {
   onChangeDataSet: (dataSet: DataSet) => void;
   selection: GlobalSelection;
   tags: TagCollection;
+  dashboard: DashboardDetails;
+  onSave: (widgets: Widget[]) => void;
+  widget?: Widget;
 };
 
 type State = AsyncView['state'] & {
@@ -49,6 +61,7 @@ type State = AsyncView['state'] & {
   displayType: DisplayType;
   interval: string;
   queries: Widget['queries'];
+  widgetErrors?: Record<string, any>;
 };
 
 class EventWidget extends AsyncView<Props, State> {
@@ -62,15 +75,34 @@ class EventWidget extends AsyncView<Props, State> {
     };
   }
 
+  getFirstQueryError(key: string) {
+    const {widgetErrors} = this.state;
+
+    if (!widgetErrors) {
+      return undefined;
+    }
+
+    return widgetErrors.find(queryError => !!queryError?.[key]);
+  }
+
   handleFieldChange = <F extends keyof State>(field: F, value: State[F]) => {
-    this.setState(state => ({...state, [field]: value}));
+    this.setState(state => {
+      const newState = cloneDeep(state);
+      set(newState, field, value);
+
+      if (field === 'displayType') {
+        set(newState, 'queries', normalizeQueries(value as DisplayType, state.queries));
+      }
+
+      return {...newState, widgetErrors: undefined};
+    });
   };
 
   handleRemoveQuery = (index: number) => {
     this.setState(state => {
       const newState = cloneDeep(state);
       newState.queries.splice(index, index + 1);
-      return newState;
+      return {...newState, widgetErrors: undefined};
     });
   };
 
@@ -86,13 +118,38 @@ class EventWidget extends AsyncView<Props, State> {
     this.setState(state => {
       const newState = cloneDeep(state);
       set(newState, `queries.${index}`, query);
-      return newState;
+      return {...newState, widgetErrors: undefined};
     });
   };
 
+  handleSave = async (event: React.FormEvent) => {
+    event.preventDefault();
+
+    const {organization, onSave, dashboard} = this.props;
+    this.setState({loading: true});
+    try {
+      const widgetData: Widget = pick(this.state, [
+        'title',
+        'displayType',
+        'interval',
+        'queries',
+      ]);
+
+      await validateWidget(this.api, organization.slug, widgetData);
+
+      onSave([...dashboard.widgets, widgetData]);
+      addSuccessMessage(t('Added widget.'));
+    } catch (err) {
+      const widgetErrors = mapErrors(err?.responseJSON ?? {}, {});
+      this.setState({widgetErrors});
+    } finally {
+      this.setState({loading: false});
+    }
+  };
+
   renderBody() {
     const {organization, onChangeDataSet, selection, tags} = this.props;
-    const {title, displayType, queries, interval} = this.state;
+    const {title, displayType, queries, interval, widgetErrors} = this.state;
     const orgSlug = organization.slug;
 
     function fieldOptions(measurementKeys: string[]) {
@@ -120,6 +177,7 @@ class EventWidget extends AsyncView<Props, State> {
             orgSlug={orgSlug}
             title={title}
             onChangeTitle={newTitle => this.handleFieldChange('title', newTitle)}
+            onSave={this.handleSave}
           />
           <Layout.Body>
             <BuildSteps>
@@ -140,6 +198,7 @@ class EventWidget extends AsyncView<Props, State> {
                     onChange={(option: {label: string; value: DisplayType}) => {
                       this.handleFieldChange('displayType', option.value);
                     }}
+                    error={widgetErrors?.displayType}
                   />
                   <WidgetCard
                     api={this.api}
@@ -172,6 +231,7 @@ class EventWidget extends AsyncView<Props, State> {
                   onRemoveQuery={this.handleRemoveQuery}
                   onAddQuery={this.handleAddQuery}
                   onChangeQuery={this.handleChangeQuery}
+                  errors={widgetErrors?.queries}
                 />
               </BuildStep>
               <Measurements>
@@ -181,6 +241,7 @@ class EventWidget extends AsyncView<Props, State> {
                   const buildStepContent = (
                     <WidgetQueryFields
                       style={{padding: 0}}
+                      errors={this.getFirstQueryError('fields')}
                       displayType={displayType}
                       fieldOptions={amendedFieldOptions}
                       fields={queries[0].fields}

+ 9 - 1
static/app/views/dashboardsV2/widget/eventWidget/queries.tsx

@@ -21,6 +21,7 @@ type Props = {
   onRemoveQuery: (index: number) => void;
   onAddQuery: () => void;
   onChangeQuery: (queryIndex: number, queries: WidgetQuery) => void;
+  errors?: Array<Record<string, any>>;
 };
 
 function Queries({
@@ -31,6 +32,7 @@ function Queries({
   onRemoveQuery,
   onAddQuery,
   onChangeQuery,
+  errors,
 }: Props) {
   function handleFieldChange(queryIndex: number, field: keyof WidgetQuery) {
     const widgetQuery = queries[queryIndex];
@@ -64,7 +66,13 @@ function Queries({
         const displayDeleteButton = queries.length > 1;
         const displayLegendAlias = !hideLegendAlias;
         return (
-          <StyledField key={queryIndex} inline={false} flexibleControlStateSize stacked>
+          <StyledField
+            key={queryIndex}
+            inline={false}
+            flexibleControlStateSize
+            stacked
+            error={errors?.[queryIndex].conditions}
+          >
             <Fields
               displayDeleteButton={displayDeleteButton}
               displayLegendAlias={displayLegendAlias}

+ 131 - 0
static/app/views/dashboardsV2/widget/eventWidget/utils.tsx

@@ -0,0 +1,131 @@
+import isEqual from 'lodash/isEqual';
+
+import {
+  aggregateOutputType,
+  isAggregateField,
+  isLegalYAxisType,
+} from 'app/utils/discover/fields';
+import {Widget} from 'app/views/dashboardsV2/types';
+
+import {DisplayType} from '../utils';
+
+type ValidationError = {
+  [key: string]: string[] | ValidationError[] | ValidationError;
+};
+
+type FlatValidationError = {
+  [key: string]: string | FlatValidationError[] | FlatValidationError;
+};
+
+export function mapErrors(
+  data: ValidationError,
+  update: FlatValidationError
+): FlatValidationError {
+  Object.keys(data).forEach((key: string) => {
+    const value = data[key];
+    // Recurse into nested objects.
+    if (Array.isArray(value) && typeof value[0] === 'string') {
+      update[key] = value[0];
+      return;
+    } else if (Array.isArray(value) && typeof value[0] === 'object') {
+      update[key] = (value as ValidationError[]).map(item => mapErrors(item, {}));
+    } else {
+      update[key] = mapErrors(value as ValidationError, {});
+    }
+  });
+
+  return update;
+}
+
+export function normalizeQueries(
+  displayType: DisplayType,
+  queries: Widget['queries']
+): Widget['queries'] {
+  const isTimeseriesChart = [
+    DisplayType.LINE,
+    DisplayType.AREA,
+    DisplayType.STACKED_AREA,
+    DisplayType.BAR,
+  ].includes(displayType);
+
+  if (
+    [DisplayType.TABLE, DisplayType.WORLD_MAP, DisplayType.BIG_NUMBER].includes(
+      displayType
+    )
+  ) {
+    // Some display types may only support at most 1 query.
+    queries = queries.slice(0, 1);
+  } else if (isTimeseriesChart) {
+    // Timeseries charts supports at most 3 queries.
+    queries = queries.slice(0, 3);
+  }
+
+  if (displayType === DisplayType.TABLE) {
+    return queries;
+  }
+
+  // Filter out non-aggregate fields
+  queries = queries.map(query => {
+    let fields = query.fields.filter(isAggregateField);
+
+    if (isTimeseriesChart || displayType === DisplayType.WORLD_MAP) {
+      // Filter out fields that will not generate numeric output types
+      fields = fields.filter(field => isLegalYAxisType(aggregateOutputType(field)));
+    }
+
+    if (isTimeseriesChart && fields.length && fields.length > 3) {
+      // Timeseries charts supports at most 3 fields.
+      fields = fields.slice(0, 3);
+    }
+
+    return {
+      ...query,
+      fields: fields.length ? fields : ['count()'],
+    };
+  });
+
+  if (isTimeseriesChart) {
+    // For timeseries widget, all queries must share identical set of fields.
+
+    const referenceFields = [...queries[0].fields];
+
+    queryLoop: for (const query of queries) {
+      if (referenceFields.length >= 3) {
+        break;
+      }
+
+      if (isEqual(referenceFields, query.fields)) {
+        continue;
+      }
+
+      for (const field of query.fields) {
+        if (referenceFields.length >= 3) {
+          break queryLoop;
+        }
+
+        if (!referenceFields.includes(field)) {
+          referenceFields.push(field);
+        }
+      }
+    }
+
+    queries = queries.map(query => {
+      return {
+        ...query,
+        fields: referenceFields,
+      };
+    });
+  }
+
+  if ([DisplayType.WORLD_MAP, DisplayType.BIG_NUMBER].includes(displayType)) {
+    // For world map chart, cap fields of the queries to only one field.
+    queries = queries.map(query => {
+      return {
+        ...query,
+        fields: query.fields.slice(0, 1),
+      };
+    });
+  }
+
+  return queries;
+}

+ 5 - 2
static/app/views/dashboardsV2/widget/header.tsx

@@ -11,9 +11,10 @@ type Props = {
   title: string;
   orgSlug: string;
   onChangeTitle: (title: string) => void;
+  onSave: (event: React.MouseEvent) => void;
 };
 
-function Header({title, orgSlug, onChangeTitle}: Props) {
+function Header({title, orgSlug, onChangeTitle, onSave}: Props) {
   return (
     <Layout.Header>
       <Layout.HeaderContent>
@@ -46,7 +47,9 @@ function Header({title, orgSlug, onChangeTitle}: Props) {
           >
             {t('Give Feedback')}
           </Button>
-          <Button priority="primary">{t('Save Widget')}</Button>
+          <Button priority="primary" onClick={onSave}>
+            {t('Save Widget')}
+          </Button>
         </ButtonBar>
       </Layout.HeaderActions>
     </Layout.Header>

Some files were not shown because too many files changed in this diff