From fbc60fb519628398ec40e1ff99ebe26dc3c11a20 Mon Sep 17 00:00:00 2001 From: iso9000t <119494473+iso9000t@users.noreply.github.com> Date: Thu, 30 Jan 2025 17:33:18 +0400 Subject: [PATCH] EPMRPP-98425 || Duplicate dashboard on current project --- app/src/common/urls.js | 7 +- .../controllers/dashboard/actionCreators.js | 12 +++ app/src/controllers/dashboard/constants.js | 2 + app/src/controllers/dashboard/index.js | 2 + app/src/controllers/dashboard/reducer.js | 3 + app/src/controllers/dashboard/sagas.js | 38 +++++++ .../dashboardTable/dashboardTable.jsx | 20 +++- .../dashboardTable/dashboardTable.scss | 102 ++++++++++++++++-- .../dashboardTable/dashboardTableColumns.jsx | 76 ++++++++++++- .../inside/dashboardPage/dashboardPage.jsx | 23 ++++ .../modals/addEditModal/addEditModal.jsx | 73 ++++++++++--- 11 files changed, 331 insertions(+), 27 deletions(-) diff --git a/app/src/common/urls.js b/app/src/common/urls.js index 40eb9817ea..3a14721b24 100644 --- a/app/src/common/urls.js +++ b/app/src/common/urls.js @@ -41,6 +41,8 @@ export const URLS = { dashboard: (activeProject, id) => `${urlBase}${activeProject}/dashboard/${id}`, dashboards: (activeProject, params) => `${urlBase}${activeProject}/dashboard${getQueryParams({ ...params })}`, + dashboardConfig: (activeProject, id) => `${urlBase}${activeProject}/dashboard/${id}/config`, + dashboardPreconfigured: (activeProject) => `${urlBase}${activeProject}/dashboard/preconfigured`, widget: (activeProject, widgetId = '') => `${urlBase}${activeProject}/widget/${widgetId}`, widgetMultilevel: (activeProject, widgetId, params) => @@ -306,7 +308,10 @@ export const URLS = { btsIntegrationPostTicket: (projectId, integrationId) => `${urlBase}bts/${projectId}/${integrationId}/ticket`, btsTicket: (activeProject, issueId, btsProject, btsUrl) => - `${urlBase}bts/${activeProject}/ticket/${issueId}${getQueryParams({ btsProject, btsUrl })}`, + `${urlBase}bts/${activeProject}/ticket/${issueId}${getQueryParams({ + btsProject, + btsUrl, + })}`, runUniqueErrorAnalysis: (activeProject) => `${urlBase}${activeProject}/launch/cluster`, clusterByLaunchId: (activeProject, launchId, query) => `${urlBase}${activeProject}/launch/cluster/${launchId}${getQueryParams(query)}`, diff --git a/app/src/controllers/dashboard/actionCreators.js b/app/src/controllers/dashboard/actionCreators.js index be5a1111ca..cb9b6a52ac 100644 --- a/app/src/controllers/dashboard/actionCreators.js +++ b/app/src/controllers/dashboard/actionCreators.js @@ -30,6 +30,8 @@ import { UPDATE_DASHBOARD, UPDATE_DASHBOARD_SUCCESS, UPDATE_DASHBOARD_WIDGETS, + DUPLICATE_DASHBOARD, + DUPLICATE_DASHBOARD_SUCCESS, } from './constants'; export const fetchDashboardsAction = (params) => ({ @@ -51,6 +53,16 @@ export const addDashboardSuccessAction = (item) => ({ payload: item, }); +export const duplicateDashboardAction = (item) => ({ + type: DUPLICATE_DASHBOARD, + payload: item, +}); + +export const duplicateDashboardSuccessAction = (item) => ({ + type: DUPLICATE_DASHBOARD_SUCCESS, + payload: item, +}); + export const updateDashboardAction = (item) => ({ type: UPDATE_DASHBOARD, payload: item, diff --git a/app/src/controllers/dashboard/constants.js b/app/src/controllers/dashboard/constants.js index 1a95b9f146..e315e10a86 100644 --- a/app/src/controllers/dashboard/constants.js +++ b/app/src/controllers/dashboard/constants.js @@ -36,6 +36,8 @@ export const ADD_DASHBOARD_SUCCESS = 'addDashboardSuccess'; export const UPDATE_DASHBOARD = 'updateDashboard'; export const UPDATE_DASHBOARD_WIDGETS = 'updateDashboardWidgets'; export const UPDATE_DASHBOARD_SUCCESS = 'updateDashboardSuccess'; +export const DUPLICATE_DASHBOARD = 'duplicateDashboard'; +export const DUPLICATE_DASHBOARD_SUCCESS = 'duplicateDashboardSuccess'; export const REMOVE_DASHBOARD = 'removeDashboard'; export const REMOVE_DASHBOARD_SUCCESS = 'removeDashboardSuccess'; export const CHANGE_VISIBILITY_TYPE = 'changeVisibilityType'; diff --git a/app/src/controllers/dashboard/index.js b/app/src/controllers/dashboard/index.js index 110954062a..3a8f173f70 100644 --- a/app/src/controllers/dashboard/index.js +++ b/app/src/controllers/dashboard/index.js @@ -24,6 +24,8 @@ export { updateDashboardWidgetsAction, toggleFullScreenModeAction, changeFullScreenModeAction, + duplicateDashboardAction, + duplicateDashboardSuccessAction, } from './actionCreators'; export { dashboardReducer } from './reducer'; export { diff --git a/app/src/controllers/dashboard/reducer.js b/app/src/controllers/dashboard/reducer.js index ec06894c04..ec3a31e429 100644 --- a/app/src/controllers/dashboard/reducer.js +++ b/app/src/controllers/dashboard/reducer.js @@ -36,6 +36,7 @@ import { REMOVE_DASHBOARD_SUCCESS, TOGGLE_FULL_SCREEN_MODE, UPDATE_DASHBOARD_SUCCESS, + DUPLICATE_DASHBOARD_SUCCESS, } from './constants'; const dashboardsReducer = (state = INITIAL_STATE.dashboards, { type = '', payload = {} }) => { @@ -46,6 +47,8 @@ const dashboardsReducer = (state = INITIAL_STATE.dashboards, { type = '', payloa return state.map((item) => (item.id === payload.id ? payload : item)); case REMOVE_DASHBOARD_SUCCESS: return state.filter((item) => item.id !== payload); + case DUPLICATE_DASHBOARD_SUCCESS: + return [...state, payload]; default: return state; } diff --git a/app/src/controllers/dashboard/sagas.js b/app/src/controllers/dashboard/sagas.js index b589d4bdc9..998636c864 100644 --- a/app/src/controllers/dashboard/sagas.js +++ b/app/src/controllers/dashboard/sagas.js @@ -47,6 +47,7 @@ import { REMOVE_DASHBOARD_SUCCESS, INCREASE_TOTAL_DASHBOARDS_LOCALLY, DECREASE_TOTAL_DASHBOARDS_LOCALLY, + DUPLICATE_DASHBOARD, } from './constants'; import { dashboardItemsSelector, querySelector } from './selectors'; import { @@ -131,6 +132,42 @@ function* addDashboard({ payload: dashboard }) { }); } +function* duplicateDashboard({ payload: dashboard }) { + const activeProject = yield select(activeProjectSelector); + try { + const config = yield call(fetch, URLS.dashboardConfig(activeProject, dashboard.id)); + + const result = yield call(fetch, URLS.dashboardPreconfigured(activeProject), { + method: 'post', + data: { + name: dashboard.name, + description: dashboard.description, + config, + }, + }); + + yield put( + addDashboardSuccessAction({ + id: result.id, + name: dashboard.name, + description: dashboard.description, + owner: dashboard.owner, + widgets: [], // or config.widgets if available + }), + ); + + yield put( + showNotification({ + message: 'Dashboard successfully duplicated', + type: NOTIFICATION_TYPES.SUCCESS, + }), + ); + yield put(hideModalAction()); + } catch (error) { + yield put(showDefaultErrorNotification(error)); + } +} + function* updateDashboard({ payload: dashboard }) { const activeProject = yield select(activeProjectSelector); const { name, description, id } = dashboard; @@ -205,5 +242,6 @@ export function* dashboardSagas() { yield takeEvery(REMOVE_DASHBOARD, removeDashboard), yield takeEvery(CHANGE_VISIBILITY_TYPE, changeVisibilityType), yield takeEvery(REMOVE_DASHBOARD_SUCCESS, redirectAfterDelete), + yield takeEvery(DUPLICATE_DASHBOARD, duplicateDashboard), ]); } diff --git a/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.jsx b/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.jsx index 5e104cf434..3042c52a69 100644 --- a/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.jsx +++ b/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.jsx @@ -28,6 +28,7 @@ import { OwnerColumn, EditColumn, DeleteColumn, + DuplicateColumn, } from './dashboardTableColumns'; import styles from './dashboardTable.scss'; @@ -45,6 +46,10 @@ const messages = defineMessages({ id: 'DashboardTable.owner', defaultMessage: 'Owner', }, + duplicate: { + id: 'DashboardTable.duplicate', + defaultMessage: 'Duplicate', + }, edit: { id: 'DashboardTable.edit', defaultMessage: 'Edit', @@ -64,6 +69,7 @@ export class DashboardTable extends Component { intl: PropTypes.object.isRequired, onDeleteItem: PropTypes.func, onEditItem: PropTypes.func, + onDuplicate: PropTypes.func, onAddItem: PropTypes.func, projectId: PropTypes.string, dashboardItems: PropTypes.array, @@ -74,6 +80,7 @@ export class DashboardTable extends Component { static defaultProps = { onDeleteItem: () => {}, onEditItem: () => {}, + onDuplicate: () => {}, onAddItem: () => {}, projectId: '', dashboardItems: [], @@ -82,7 +89,7 @@ export class DashboardTable extends Component { }; getTableColumns() { - const { onDeleteItem, onEditItem, intl, projectId } = this.props; + const { onDeleteItem, onEditItem, onDuplicate, intl, projectId } = this.props; return [ { @@ -111,6 +118,17 @@ export class DashboardTable extends Component { formatter: (value) => value.owner, component: OwnerColumn, }, + { + title: { + full: intl.formatMessage(messages.duplicate), + short: intl.formatMessage(messages.duplicate), + }, + component: DuplicateColumn, + customProps: { + onDuplicate, + }, + align: ALIGN_CENTER, + }, { title: { full: intl.formatMessage(messages.edit), diff --git a/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.scss b/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.scss index c2a541e013..6dc356a6da 100644 --- a/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.scss +++ b/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTable.scss @@ -57,8 +57,21 @@ } .icon-holder { - border-left: 1px solid $COLOR--gray-91; min-height: 20px; + display: flex; + align-items: center; + justify-content: center; + + &.no-border { + border-left: none; + } +} + +.edit-cell, +.delete-cell { + .icon-holder { + border-left: 1px solid $COLOR--gray-91; + } } .dashboard-table { @@ -66,6 +79,87 @@ padding-bottom: 15px; } +.with-button { + i { + cursor: pointer; + } +} + +.duplicate-dropdown { + position: relative; + display: flex; + align-items: center; + padding-right: 15px; + cursor: pointer; + color: $COLOR--gray-60; + + &:hover { + color: $COLOR--gray-47; + } +} + +.duplicate-icon { + display: flex; + justify-content: center; + align-items: center; + height: 20px; + width: 20px; + + svg { + height: 30px; + width: 30px; + stroke-width: 1.5; + } +} + +.arrow { + position: absolute; + right: 2px; + top: 50%; + margin-top: -1px; + width: 0; + height: 0; + border-top: 4px solid currentColor; + border-right: 4px solid transparent; + border-left: 4px solid transparent; + box-sizing: border-box; + transition: transform 200ms linear; + + &.opened { + transform: rotate(180deg); + } +} + +.hamburger-menu { + position: absolute; + top: 100%; + right: -25px; + z-index: $Z-INDEX-POPUP; + margin-top: 4px; + background: $COLOR--white-two; + border: 1px solid $COLOR--gray-91; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + border-radius: 4px; + min-width: max-content; + white-space: nowrap; + display: none; + + &.shown { + display: block; + } +} + +.dropdown-item { + padding: 8px 15px; + font-size: 13px; + color: $COLOR--charcoal-grey; + text-align: left; + + &:hover { + background: $COLOR--tealish-hover; + } +} + @media (max-width: $SCREEN_SM_MAX) { .name, .description, @@ -82,9 +176,3 @@ } } } - -.with-button { - i { - cursor: pointer; - } -} diff --git a/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTableColumns.jsx b/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTableColumns.jsx index 28f4cf9fe1..12aa94b670 100644 --- a/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTableColumns.jsx +++ b/app/src/pages/inside/dashboardPage/dashboardList/dashboardTable/dashboardTableColumns.jsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import classNames from 'classnames/bind'; import PropTypes from 'prop-types'; import track from 'react-tracking'; @@ -22,6 +22,8 @@ import { Icon } from 'components/main/icon'; import { PROJECT_DASHBOARD_ITEM_PAGE } from 'controllers/pages'; import { NavLink } from 'components/main/navLink'; import { DASHBOARD_EVENTS } from 'analyticsEvents/dashboardsPageEvents'; +import Parser from 'html-react-parser'; +import IconDuplicate from 'common/img/duplicate-inline.svg'; import styles from './dashboardTable.scss'; const cx = classNames.bind(styles); @@ -77,6 +79,70 @@ OwnerColumn.defaultProps = { className: '', }; +export const DuplicateColumn = track()( + ({ value, customProps, className, tracking: { trackEvent } }) => { + const [opened, setOpened] = useState(false); + + useEffect(() => { + const handleOutsideClick = (e) => { + if (!e.target.closest(`.${cx('duplicate-dropdown')}`) && opened) { + setOpened(false); + } + }; + + document.addEventListener('click', handleOutsideClick); + return () => document.removeEventListener('click', handleOutsideClick); + }, [opened]); + + const handleDuplicate = () => { + const { id } = value; + trackEvent(DASHBOARD_EVENTS.clickOnIconDashboard('duplicate', id)); + customProps.onDuplicate(value); + setOpened(false); + }; + + const handleCopyConfig = () => { + // TODO: Copy configuration functionality will be added later + setOpened(false); + }; + + return ( +
+
+
setOpened(!opened)}> +
+ {Parser(IconDuplicate.replace('stroke="#999999"', 'stroke="currentColor"'))} +
+ + {opened && ( +
+
+ Duplicate +
+
+ Copy dashboard configuration to clipboard +
+
+ )} +
+
+
+ ); + }, +); + +DuplicateColumn.propTypes = { + value: PropTypes.object, + customProps: PropTypes.object, + className: PropTypes.string, +}; + +DuplicateColumn.defaultProps = { + value: {}, + customProps: {}, + className: '', +}; + export const EditColumn = track()(({ value, customProps, className, tracking: { trackEvent } }) => { const { onEdit } = customProps; const { id } = value; @@ -87,8 +153,10 @@ export const EditColumn = track()(({ value, customProps, className, tracking: { }; return ( -
- +
+
+ +
); }); @@ -112,7 +180,7 @@ export const DeleteColumn = track()( }; return ( -
+
diff --git a/app/src/pages/inside/dashboardPage/dashboardPage.jsx b/app/src/pages/inside/dashboardPage/dashboardPage.jsx index a0015de7fb..b03bc3e681 100644 --- a/app/src/pages/inside/dashboardPage/dashboardPage.jsx +++ b/app/src/pages/inside/dashboardPage/dashboardPage.jsx @@ -26,6 +26,7 @@ import { deleteDashboardAction, updateDashboardAction, addDashboardAction, + duplicateDashboardAction, dashboardItemsSelector, dashboardGridTypeSelector, DASHBOARDS_TABLE_VIEW, @@ -90,6 +91,7 @@ const messages = defineMessages({ deleteDashboard: deleteDashboardAction, editDashboard: updateDashboardAction, addDashboard: addDashboardAction, + duplicateDashboard: duplicateDashboardAction, }, ) @withFilter() @@ -122,6 +124,7 @@ export class DashboardPage extends Component { pageSize: PropTypes.number, onChangePage: PropTypes.func, onChangePageSize: PropTypes.func, + duplicateDashboard: PropTypes.func, }; static defaultProps = { @@ -129,6 +132,7 @@ export class DashboardPage extends Component { deleteDashboard: () => {}, editDashboard: () => {}, addDashboard: () => {}, + duplicateDashboard: () => {}, userInfo: {}, filter: '', dashboardItems: [], @@ -176,6 +180,24 @@ export class DashboardPage extends Component { }); }; + onDuplicateDashboardItem = (item) => { + const { showModal, duplicateDashboard } = this.props; + + const duplicateItem = { + ...item, + name: `${item.name}_copy`, + }; + + showModal({ + id: 'dashboardAddEditModal', + data: { + dashboardItem: duplicateItem, + onSubmit: duplicateDashboard, + type: 'duplicate', + }, + }); + }; + onEditDashboardItem = (item) => { const { showModal, editDashboard } = this.props; @@ -253,6 +275,7 @@ export class DashboardPage extends Component { userInfo={userInfo} loading={loading} onDeleteItem={this.onDeleteDashboardItem} + onDuplicate={this.onDuplicateDashboardItem} onEditItem={this.onEditDashboardItem} onAddItem={this.onAddDashboardItem} filter={filter} diff --git a/app/src/pages/inside/dashboardPage/modals/addEditModal/addEditModal.jsx b/app/src/pages/inside/dashboardPage/modals/addEditModal/addEditModal.jsx index e4b275d88a..3d2f5fd380 100644 --- a/app/src/pages/inside/dashboardPage/modals/addEditModal/addEditModal.jsx +++ b/app/src/pages/inside/dashboardPage/modals/addEditModal/addEditModal.jsx @@ -68,6 +68,14 @@ const messages = defineMessages({ id: 'DashboardForm.modalCancelButtonText', defaultMessage: 'Cancel', }, + duplicateModalTitle: { + id: 'DashboardForm.duplicateModalTitle', + defaultMessage: 'Duplicate Dashboard', + }, + duplicateModalSubmitButtonText: { + id: 'DashboardForm.duplicateModalSubmitButtonText', + defaultMessage: 'Duplicate', + }, }); const LABEL_WIDTH = 90; @@ -92,12 +100,12 @@ const createDashboardNameValidator = (dashboardItems, dashboardItem) => }) export class AddEditModal extends Component { static propTypes = { + intl: PropTypes.object.isRequired, data: PropTypes.shape({ dashboardItem: PropTypes.object, onSubmit: PropTypes.func, type: PropTypes.string, }), - intl: PropTypes.object.isRequired, initialize: PropTypes.func, dirty: PropTypes.bool.isRequired, handleSubmit: PropTypes.func, @@ -122,11 +130,18 @@ export class AddEditModal extends Component { } getCloseConfirmationConfig = () => { - if (!this.props.dirty) { + const { + dirty, + data: { type }, + intl, + } = this.props; + + if (!dirty || type === 'duplicate') { return null; } + return { - confirmationWarning: this.props.intl.formatMessage(COMMON_LOCALE_KEYS.CLOSE_MODAL_WARNING), + confirmationWarning: intl.formatMessage(COMMON_LOCALE_KEYS.CLOSE_MODAL_WARNING), }; }; @@ -139,15 +154,29 @@ export class AddEditModal extends Component { const dashboardId = dashboardItem?.id; - if (dirty) { + if (type === 'duplicate' || dirty) { const isChangedDescription = item.description !== this.props.data.dashboardItem?.description; - const dashboardEvent = - type === 'edit' - ? DASHBOARD_EVENTS.clickOnButtonUpdateInModalEditDashboard( - dashboardId, - isChangedDescription, - ) - : DASHBOARD_EVENTS.clickOnButtonInModalAddNewDashboard(dashboardId, isChangedDescription); + let dashboardEvent; + + switch (type) { + case 'edit': + dashboardEvent = DASHBOARD_EVENTS.clickOnButtonUpdateInModalEditDashboard( + dashboardId, + isChangedDescription, + ); + break; + case 'duplicate': + dashboardEvent = DASHBOARD_EVENTS.clickOnButtonInModalAddNewDashboard( + dashboardId, + isChangedDescription, + ); + break; + default: + dashboardEvent = DASHBOARD_EVENTS.clickOnButtonInModalAddNewDashboard( + dashboardId, + isChangedDescription, + ); + } trackEvent(dashboardEvent); this.props.data.onSubmit(item); @@ -162,8 +191,24 @@ export class AddEditModal extends Component { handleSubmit, data: { type }, } = this.props; - const submitText = intl.formatMessage(messages[`${type}ModalSubmitButtonText`]); - const title = intl.formatMessage(messages[`${type}ModalTitle`]); + + let title; + let submitText; + + switch (type) { + case 'edit': + title = intl.formatMessage(messages.editModalTitle); + submitText = intl.formatMessage(messages.editModalSubmitButtonText); + break; + case 'duplicate': + title = intl.formatMessage(messages.duplicateModalTitle); + submitText = intl.formatMessage(messages.duplicateModalSubmitButtonText); + break; + default: + title = intl.formatMessage(messages.addModalTitle); + submitText = intl.formatMessage(messages.addModalSubmitButtonText); + } + const cancelText = intl.formatMessage(messages.modalCancelButtonText); return ( @@ -180,7 +225,7 @@ export class AddEditModal extends Component { }} closeConfirmation={this.getCloseConfirmationConfig()} > -
event.preventDefault()} className={cx('add-dashboard-form')}> +