feat: dashboard lock feature (#3880)

* feat: dashboard lock feature

* feat: update API method and minor ui updates

* feat: update API and author logic

* feat: update permissions for author role

* feat: use strings and remove console logs
This commit is contained in:
Yunus M 2023-11-03 17:27:09 +05:30 committed by GitHub
parent 8371670512
commit 0906886e9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 265 additions and 84 deletions

View File

@ -20,5 +20,7 @@
"variable_updated_successfully": "Variable updated successfully",
"error_while_updating_variable": "Error while updating variable",
"dashboard_has_been_updated": "Dashboard has been updated",
"do_you_want_to_refresh_the_dashboard": "Do you want to refresh the dashboard?"
"do_you_want_to_refresh_the_dashboard": "Do you want to refresh the dashboard?",
"locked_dashboard_delete_tooltip_admin_author": "Dashboard is locked. Please unlock the dashboard to enable delete.",
"locked_dashboard_delete_tooltip_editor": "Dashboard is locked. Please contact admin to delete the dashboard."
}

View File

@ -0,0 +1,11 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
interface LockDashboardProps {
uuid: string;
}
const lockDashboard = (props: LockDashboardProps): Promise<AxiosResponse> =>
axios.put(`/dashboards/${props.uuid}/lock`);
export default lockDashboard;

View File

@ -0,0 +1,11 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
interface UnlockDashboardProps {
uuid: string;
}
const unlockDashboard = (props: UnlockDashboardProps): Promise<AxiosResponse> =>
axios.put(`/dashboards/${props.uuid}/unlock`);
export default unlockDashboard;

View File

@ -11,8 +11,10 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
import { headerMenuList } from './config';
import { EditMenuAction, ViewMenuAction } from './config';
import GridCard from './GridCard';
import {
Button,
@ -32,10 +34,11 @@ function GraphLayout({
layouts,
setLayouts,
setSelectedDashboard,
isDashboardLocked,
} = useDashboard();
const { t } = useTranslation(['dashboard']);
const { featureResponse, role } = useSelector<AppState, AppReducer>(
const { featureResponse, role, user } = useSelector<AppState, AppReducer>(
(state) => state.app,
);
@ -45,9 +48,20 @@ function GraphLayout({
const { notifications } = useNotifications();
let permissions: ComponentTypes[] = ['save_layout', 'add_panel'];
if (isDashboardLocked) {
permissions = ['edit_locked_dashboard', 'add_panel_locked_dashboard'];
}
const userRole: ROLES | null =
selectedDashboard?.created_by === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: role;
const [saveLayoutPermission, addPanelPermission] = useComponentPermission(
['save_layout', 'add_panel'],
role,
permissions,
userRole,
);
const onSaveHandler = (): void => {
@ -83,35 +97,41 @@ function GraphLayout({
});
};
const widgetActions = !isDashboardLocked
? [...ViewMenuAction, ...EditMenuAction]
: [...ViewMenuAction];
return (
<>
<ButtonContainer>
{saveLayoutPermission && (
<Button
loading={updateDashboardMutation.isLoading}
onClick={onSaveHandler}
icon={<SaveFilled />}
disabled={updateDashboardMutation.isLoading}
>
{t('dashboard:save_layout')}
</Button>
)}
{!isDashboardLocked && (
<ButtonContainer>
{saveLayoutPermission && (
<Button
loading={updateDashboardMutation.isLoading}
onClick={onSaveHandler}
icon={<SaveFilled />}
disabled={updateDashboardMutation.isLoading}
>
{t('dashboard:save_layout')}
</Button>
)}
{addPanelPermission && (
<Button onClick={onAddPanelHandler} icon={<PlusOutlined />}>
{t('dashboard:add_panel')}
</Button>
)}
</ButtonContainer>
{addPanelPermission && (
<Button onClick={onAddPanelHandler} icon={<PlusOutlined />}>
{t('dashboard:add_panel')}
</Button>
)}
</ButtonContainer>
)}
<ReactGridLayout
cols={12}
rowHeight={100}
autoSize
width={100}
isDraggable={addPanelPermission}
isDroppable={addPanelPermission}
isResizable={addPanelPermission}
isDraggable={!isDashboardLocked && addPanelPermission}
isDroppable={!isDashboardLocked && addPanelPermission}
isResizable={!isDashboardLocked && addPanelPermission}
allowOverlap={false}
onLayoutChange={setLayouts}
draggableHandle=".drag-handle"
@ -122,12 +142,20 @@ function GraphLayout({
const currentWidget = (widgets || [])?.find((e) => e.id === id);
return (
<CardContainer isDarkMode={isDarkMode} key={id} data-grid={layout}>
<Card $panelType={currentWidget?.panelTypes || PANEL_TYPES.TIME_SERIES}>
<CardContainer
className={isDashboardLocked ? '' : 'enable-resize'}
isDarkMode={isDarkMode}
key={id}
data-grid={layout}
>
<Card
className="grid-item"
$panelType={currentWidget?.panelTypes || PANEL_TYPES.TIME_SERIES}
>
<GridCard
widget={currentWidget || ({ id, query: {} } as Widgets)}
name={currentWidget?.id || ''}
headerMenuList={headerMenuList}
headerMenuList={widgetActions}
/>
</Card>
</CardContainer>

View File

@ -1,13 +1,16 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
export const headerMenuList = [
MenuItemKeys.View,
export const ViewMenuAction = [MenuItemKeys.View];
export const EditMenuAction = [
MenuItemKeys.Clone,
MenuItemKeys.Delete,
MenuItemKeys.Edit,
];
export const headerMenuList = [...ViewMenuAction];
export const EMPTY_WIDGET_LAYOUT = {
i: PANEL_TYPES.EMPTY_WIDGET,
w: 6,

View File

@ -34,29 +34,31 @@ interface Props {
export const CardContainer = styled.div<Props>`
overflow: auto;
:hover {
.react-resizable-handle {
position: absolute;
width: 20px;
height: 20px;
bottom: 0;
right: 0;
background-position: bottom right;
padding: 0 3px 3px 0;
background-repeat: no-repeat;
background-origin: content-box;
box-sizing: border-box;
cursor: se-resize;
&.enable-resize {
:hover {
.react-resizable-handle {
position: absolute;
width: 20px;
height: 20px;
bottom: 0;
right: 0;
background-position: bottom right;
padding: 0 3px 3px 0;
background-repeat: no-repeat;
background-origin: content-box;
box-sizing: border-box;
cursor: se-resize;
${({ isDarkMode }): StyledCSS => {
const uri = `data:image/svg+xml,%3Csvg viewBox='0 0 6 6' style='background-color:%23ffffff00' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' xml:space='preserve' x='0px' y='0px' width='6px' height='6px'%0A%3E%3Cg opacity='0.302'%3E%3Cpath d='M 6 6 L 0 6 L 0 4.2 L 4 4.2 L 4.2 4.2 L 4.2 0 L 6 0 L 6 6 L 6 6 Z' fill='${
isDarkMode ? 'white' : 'grey'
}'/%3E%3C/g%3E%3C/svg%3E`;
${({ isDarkMode }): StyledCSS => {
const uri = `data:image/svg+xml,%3Csvg viewBox='0 0 6 6' style='background-color:%23ffffff00' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' xml:space='preserve' x='0px' y='0px' width='6px' height='6px'%0A%3E%3Cg opacity='0.302'%3E%3Cpath d='M 6 6 L 0 6 L 0 4.2 L 4 4.2 L 4.2 4.2 L 4.2 0 L 6 0 L 6 6 L 6 6 Z' fill='${
isDarkMode ? 'white' : 'grey'
}'/%3E%3C/g%3E%3C/svg%3E`;
return css`
background-image: ${(): string => `url("${uri}")`};
`;
}}
return css`
background-image: ${(): string => `url("${uri}")`};
`;
}}
}
}
}
`;

View File

@ -1,18 +1,27 @@
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Modal } from 'antd';
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { Modal, Tooltip } from 'antd';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { Data } from '../index';
import { TableLinkText } from './styles';
function DeleteButton({ id }: Data): JSX.Element {
function DeleteButton({ id, createdBy, isLocked }: Data): JSX.Element {
const [modal, contextHolder] = Modal.useModal();
const { role, user } = useSelector<AppState, AppReducer>((state) => state.app);
const isAuthor = user?.email === createdBy;
const queryClient = useQueryClient();
const { t } = useTranslation(['dashboard']);
const deleteDashboardMutation = useDeleteDashboard(id);
const openConfirmationDialog = useCallback((): void => {
@ -32,11 +41,33 @@ function DeleteButton({ id }: Data): JSX.Element {
});
}, [modal, deleteDashboardMutation, queryClient]);
const getDeleteTooltipContent = (): string => {
if (isLocked) {
if (role === USER_ROLES.ADMIN || isAuthor) {
return t('dashboard:locked_dashboard_delete_tooltip_admin_author');
}
return t('dashboard:locked_dashboard_delete_tooltip_editor');
}
return '';
};
return (
<>
<TableLinkText type="danger" onClick={openConfirmationDialog}>
Delete
</TableLinkText>
<Tooltip placement="left" title={getDeleteTooltipContent()}>
<TableLinkText
type="danger"
onClick={(): void => {
if (!isLocked) {
openConfirmationDialog();
}
}}
disabled={isLocked}
>
<DeleteOutlined /> Delete
</TableLinkText>
</Tooltip>
{contextHolder}
</>
@ -55,6 +86,7 @@ function Wrapper(props: Data): JSX.Element {
tags,
createdBy,
lastUpdatedBy,
isLocked,
} = props;
return (
@ -69,6 +101,7 @@ function Wrapper(props: Data): JSX.Element {
tags,
createdBy,
lastUpdatedBy,
isLocked,
}}
/>
);

View File

@ -1,3 +1,4 @@
import { LockFilled } from '@ant-design/icons';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { generatePath } from 'react-router-dom';
@ -6,9 +7,9 @@ import { Data } from '..';
import { TableLinkText } from './styles';
function Name(name: Data['name'], data: Data): JSX.Element {
const onClickHandler = (): void => {
const { id: DashboardId } = data;
const { id: DashboardId, isLocked } = data;
const onClickHandler = (): void => {
history.push(
generatePath(ROUTES.DASHBOARD, {
dashboardId: DashboardId,
@ -16,7 +17,11 @@ function Name(name: Data['name'], data: Data): JSX.Element {
);
};
return <TableLinkText onClick={onClickHandler}>{name}</TableLinkText>;
return (
<TableLinkText onClick={onClickHandler}>
{isLocked && <LockFilled />} {name}
</TableLinkText>
);
}
export default Name;

View File

@ -130,7 +130,7 @@ function ListOfAllDashboard(): JSX.Element {
dataIndex: 'description',
},
{
title: 'Tags (can be multiple)',
title: 'Tags',
dataIndex: 'tags',
width: 50,
render: (value): JSX.Element => <LabelColumn labels={value} />,
@ -159,6 +159,7 @@ function ListOfAllDashboard(): JSX.Element {
tags: e.data.tags || [],
key: e.uuid,
createdBy: e.created_by,
isLocked: !!e.isLocked || false,
lastUpdatedBy: e.updated_by,
refetchDashboardList,
})) || [];
@ -342,6 +343,7 @@ export interface Data {
createdAt: string;
lastUpdatedTime: string;
lastUpdatedBy: string;
isLocked: boolean;
id: string;
}

View File

@ -3,11 +3,13 @@ import styled from 'styled-components';
export const Container = styled.div`
display: flex;
gap: 0.6rem;
justify-content: right;
gap: 8px;
`;
export const Card = styled(CardComponent)`
min-height: 10vh;
min-width: 120px;
overflow-y: auto;
cursor: pointer;

View File

@ -1,10 +1,7 @@
import Input from 'components/Input';
import { ChangeEvent, Dispatch, SetStateAction, useCallback } from 'react';
function NameOfTheDashboard({
setName,
name,
}: NameOfTheDashboardProps): JSX.Element {
function DashboardName({ setName, name }: DashboardNameProps): JSX.Element {
const onChangeHandler = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
@ -22,9 +19,9 @@ function NameOfTheDashboard({
);
}
interface NameOfTheDashboardProps {
interface DashboardNameProps {
name: string;
setName: Dispatch<SetStateAction<string>>;
}
export default NameOfTheDashboard;
export default DashboardName;

View File

@ -18,7 +18,7 @@ function SettingsDrawer(): JSX.Element {
return (
<>
<Button type="dashed" onClick={showDrawer}>
<Button type="dashed" onClick={showDrawer} style={{ width: '100%' }}>
<SettingOutlined /> Configure
</Button>
<DrawerContainer

View File

@ -1,5 +1,5 @@
import { ShareAltOutlined } from '@ant-design/icons';
import { Button, Card, Col, Row, Space, Tag, Typography } from 'antd';
import { LockFilled, ShareAltOutlined, UnlockFilled } from '@ant-design/icons';
import { Button, Card, Col, Row, Space, Tag, Tooltip, Typography } from 'antd';
import useComponentPermission from 'hooks/useComponentPermission';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useState } from 'react';
@ -7,13 +7,18 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import DashboardVariableSelection from '../DashboardVariablesSelection';
import SettingsDrawer from './SettingsDrawer';
import ShareModal from './ShareModal';
function DescriptionOfDashboard(): JSX.Element {
const { selectedDashboard } = useDashboard();
function DashboardDescription(): JSX.Element {
const {
selectedDashboard,
isDashboardLocked,
handleDashboardLockToggle,
} = useDashboard();
const selectedData = selectedDashboard?.data;
const { title, tags, description } = selectedData || {};
@ -21,18 +26,33 @@ function DescriptionOfDashboard(): JSX.Element {
const [isJSONModalVisible, isIsJSONModalVisible] = useState<boolean>(false);
const { t } = useTranslation('common');
const { role } = useSelector<AppState, AppReducer>((state) => state.app);
const { user, role } = useSelector<AppState, AppReducer>((state) => state.app);
const [editDashboard] = useComponentPermission(['edit_dashboard'], role);
let isAuthor = false;
if (selectedDashboard && user && user.email) {
isAuthor = selectedDashboard?.created_by === user?.email;
}
const onToggleHandler = (): void => {
isIsJSONModalVisible((state) => !state);
};
const handleLockDashboardToggle = (): void => {
handleDashboardLockToggle(!isDashboardLocked);
};
return (
<Card>
<Row>
<Col flex={1}>
<Typography.Title level={4} style={{ padding: 0, margin: 0 }}>
{isDashboardLocked && (
<Tooltip title="Dashboard Locked" placement="top">
<LockFilled /> &nbsp;
</Tooltip>
)}
{title}
</Typography.Title>
<Typography>{description}</Typography>
@ -55,7 +75,7 @@ function DescriptionOfDashboard(): JSX.Element {
)}
<Space direction="vertical">
{editDashboard && <SettingsDrawer />}
{!isDashboardLocked && editDashboard && <SettingsDrawer />}
<Button
style={{ width: '100%' }}
type="dashed"
@ -64,6 +84,21 @@ function DescriptionOfDashboard(): JSX.Element {
>
{t('share')}
</Button>
{(isAuthor || role === USER_ROLES.ADMIN) && (
<Tooltip
placement="left"
title={isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
>
<Button
style={{ width: '100%' }}
type="dashed"
onClick={handleLockDashboardToggle}
icon={isDashboardLocked ? <LockFilled /> : <UnlockFilled />}
>
{isDashboardLocked ? 'Unlock' : 'Lock'}
</Button>
</Tooltip>
)}
</Space>
</Col>
</Row>
@ -71,4 +106,4 @@ function DescriptionOfDashboard(): JSX.Element {
);
}
export default DescriptionOfDashboard;
export default DashboardDescription;

View File

@ -1,4 +1,4 @@
import Description from './DescriptionOfDashboard';
import Description from './DashboardDescription';
import GridGraphs from './GridGraphs';
function NewDashboard(): JSX.Element {

View File

@ -1,9 +1,12 @@
import { Modal } from 'antd';
import get from 'api/dashboard/get';
import lockDashboardApi from 'api/dashboard/lockDashboard';
import unlockDashboardApi from 'api/dashboard/unlockDashboard';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { getMinMax } from 'container/TopNav/AutoRefresh/config';
import dayjs, { Dayjs } from 'dayjs';
import useAxiosError from 'hooks/useAxiosError';
import useTabVisibility from 'hooks/useTabFocus';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import {
@ -17,7 +20,7 @@ import {
} from 'react';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { useQuery, UseQueryResult } from 'react-query';
import { useMutation, useQuery, UseQueryResult } from 'react-query';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router-dom';
import { Dispatch } from 'redux';
@ -32,7 +35,9 @@ import { IDashboardContext } from './types';
const DashboardContext = createContext<IDashboardContext>({
isDashboardSliderOpen: false,
isDashboardLocked: false,
handleToggleDashboardSlider: () => {},
handleDashboardLockToggle: () => {},
dashboardResponse: {} as UseQueryResult<Dashboard, unknown>,
selectedDashboard: {} as Dashboard,
dashboardId: '',
@ -50,6 +55,9 @@ export function DashboardProvider({
children,
}: PropsWithChildren): JSX.Element {
const [isDashboardSliderOpen, setIsDashboardSlider] = useState<boolean>(false);
const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false);
const isDashboardPage = useRouteMatch<Props>({
path: ROUTES.DASHBOARD,
exact: true,
@ -99,6 +107,8 @@ export function DashboardProvider({
onSuccess: (data) => {
const updatedDate = dayjs(data.updated_at);
setIsDashboardLocked(data?.isLocked || false);
// on first render
if (updatedTimeRef.current === null) {
setSelectedDashboard(data);
@ -179,10 +189,39 @@ export function DashboardProvider({
setIsDashboardSlider(value);
};
const handleError = useAxiosError();
const { mutate: lockDashboard } = useMutation(lockDashboardApi, {
onSuccess: () => {
setIsDashboardSlider(false);
setIsDashboardLocked(true);
},
onError: handleError,
});
const { mutate: unlockDashboard } = useMutation(unlockDashboardApi, {
onSuccess: () => {
setIsDashboardLocked(false);
},
onError: handleError,
});
const handleDashboardLockToggle = async (value: boolean): Promise<void> => {
if (selectedDashboard) {
if (value) {
lockDashboard(selectedDashboard);
} else {
unlockDashboard(selectedDashboard);
}
}
};
const value: IDashboardContext = useMemo(
() => ({
isDashboardSliderOpen,
isDashboardLocked,
handleToggleDashboardSlider,
handleDashboardLockToggle,
dashboardResponse,
selectedDashboard,
dashboardId,
@ -191,8 +230,10 @@ export function DashboardProvider({
setSelectedDashboard,
updatedTimeRef,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isDashboardSliderOpen,
isDashboardLocked,
dashboardResponse,
selectedDashboard,
dashboardId,

View File

@ -5,7 +5,9 @@ import { Dashboard } from 'types/api/dashboard/getAll';
export interface IDashboardContext {
isDashboardSliderOpen: boolean;
isDashboardLocked: boolean;
handleToggleDashboardSlider: (value: boolean) => void;
handleDashboardLockToggle: (value: boolean) => void;
dashboardResponse: UseQueryResult<Dashboard, unknown>;
selectedDashboard: Dashboard | undefined;
dashboardId: string;

View File

@ -45,6 +45,7 @@ export interface Dashboard {
created_by: string;
updated_by: string;
data: DashboardData;
isLocked?: boolean;
}
export interface DashboardData {

View File

@ -1,11 +1,13 @@
export type ADMIN = 'ADMIN';
export type VIEWER = 'VIEWER';
export type EDITOR = 'EDITOR';
export type AUTHOR = 'AUTHOR';
export type ROLES = ADMIN | VIEWER | EDITOR;
export type ROLES = ADMIN | VIEWER | EDITOR | AUTHOR;
export const USER_ROLES = {
ADMIN: 'ADMIN',
VIEWER: 'VIEWER',
EDITOR: 'EDITOR',
AUTHOR: 'AUTHOR',
};

View File

@ -18,7 +18,9 @@ export type ComponentTypes =
| 'new_alert_action'
| 'edit_widget'
| 'add_panel'
| 'page_pipelines';
| 'page_pipelines'
| 'edit_locked_dashboard'
| 'add_panel_locked_dashboard';
export const componentPermission: Record<ComponentTypes, ROLES[]> = {
current_org_settings: ['ADMIN'],
@ -30,14 +32,16 @@ export const componentPermission: Record<ComponentTypes, ROLES[]> = {
add_new_channel: ['ADMIN'],
set_retention_period: ['ADMIN'],
action: ['ADMIN', 'EDITOR'],
save_layout: ['ADMIN', 'EDITOR'],
edit_dashboard: ['ADMIN', 'EDITOR'],
delete_widget: ['ADMIN', 'EDITOR'],
save_layout: ['ADMIN', 'EDITOR', 'AUTHOR'],
edit_dashboard: ['ADMIN', 'EDITOR', 'AUTHOR'],
delete_widget: ['ADMIN', 'EDITOR', 'AUTHOR'],
new_dashboard: ['ADMIN', 'EDITOR'],
new_alert_action: ['ADMIN'],
edit_widget: ['ADMIN', 'EDITOR'],
add_panel: ['ADMIN', 'EDITOR'],
add_panel: ['ADMIN', 'EDITOR', 'AUTHOR'],
page_pipelines: ['ADMIN', 'EDITOR'],
edit_locked_dashboard: ['ADMIN', 'AUTHOR'],
add_panel_locked_dashboard: ['ADMIN', 'AUTHOR'],
};
export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {