Skip to content

Commit 2ed82a6

Browse files
committed
[ENHANCEMENT]: Support Dashboards links for internal and external naviagtion
Signed-off-by: Prakhar JAIN <prakhar29jain@gmail.com>
1 parent d8aa1e9 commit 2ed82a6

13 files changed

Lines changed: 594 additions & 15 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { useState, ReactElement } from 'react';
15+
import {
16+
Button,
17+
Stack,
18+
Box,
19+
Typography,
20+
IconButton,
21+
Table,
22+
TableBody,
23+
TableCell,
24+
TableContainer,
25+
TableHead,
26+
TableRow,
27+
Collapse,
28+
} from '@mui/material';
29+
import AddIcon from 'mdi-material-ui/Plus';
30+
import TrashIcon from 'mdi-material-ui/TrashCan';
31+
import ArrowUp from 'mdi-material-ui/ArrowUp';
32+
import ArrowDown from 'mdi-material-ui/ArrowDown';
33+
import PencilIcon from 'mdi-material-ui/Pencil';
34+
import ChevronUp from 'mdi-material-ui/ChevronUp';
35+
import { Link } from '@perses-dev/core';
36+
import { useImmer } from 'use-immer';
37+
import { InfoTooltip, LinkEditorForm } from '@perses-dev/components';
38+
import { useDiscardChangesConfirmationDialog } from '../../context';
39+
40+
export interface DashboardLinksEditorProps {
41+
links: Link[];
42+
onChange: (links: Link[]) => void;
43+
onCancel: () => void;
44+
}
45+
46+
const DEFAULT_LINK: Link = {
47+
url: '',
48+
name: '',
49+
tooltip: '',
50+
renderVariables: true,
51+
targetBlank: true,
52+
};
53+
54+
export function DashboardLinksEditor({
55+
links: initialLinks,
56+
onChange,
57+
onCancel,
58+
}: DashboardLinksEditorProps): ReactElement {
59+
const [links, setLinks] = useImmer(initialLinks);
60+
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
61+
62+
const { openDiscardChangesConfirmationDialog, closeDiscardChangesConfirmationDialog } =
63+
useDiscardChangesConfirmationDialog();
64+
65+
const handleCancel = (): void => {
66+
if (JSON.stringify(initialLinks) !== JSON.stringify(links)) {
67+
openDiscardChangesConfirmationDialog({
68+
onDiscardChanges: () => {
69+
closeDiscardChangesConfirmationDialog();
70+
onCancel();
71+
},
72+
onCancel: closeDiscardChangesConfirmationDialog,
73+
});
74+
} else {
75+
onCancel();
76+
}
77+
};
78+
79+
const handleAdd = (): void => {
80+
setLinks((draft) => {
81+
draft.push({ ...DEFAULT_LINK });
82+
});
83+
setExpandedIndex(links.length);
84+
};
85+
86+
const handleRemove = (index: number): void => {
87+
setLinks((draft) => {
88+
draft.splice(index, 1);
89+
});
90+
if (expandedIndex === index) {
91+
setExpandedIndex(null);
92+
} else if (expandedIndex !== null && expandedIndex > index) {
93+
setExpandedIndex(expandedIndex - 1);
94+
}
95+
};
96+
97+
const handleMoveUp = (index: number): void => {
98+
if (index === 0) return;
99+
setLinks((draft) => {
100+
const temp = draft[index - 1];
101+
if (temp && draft[index]) {
102+
draft[index - 1] = draft[index]!;
103+
draft[index] = temp;
104+
}
105+
});
106+
if (expandedIndex === index) {
107+
setExpandedIndex(index - 1);
108+
} else if (expandedIndex === index - 1) {
109+
setExpandedIndex(index);
110+
}
111+
};
112+
113+
const handleMoveDown = (index: number): void => {
114+
if (index >= links.length - 1) return;
115+
setLinks((draft) => {
116+
const temp = draft[index + 1];
117+
if (temp && draft[index]) {
118+
draft[index + 1] = draft[index]!;
119+
draft[index] = temp;
120+
}
121+
});
122+
if (expandedIndex === index) {
123+
setExpandedIndex(index + 1);
124+
} else if (expandedIndex === index + 1) {
125+
setExpandedIndex(index);
126+
}
127+
};
128+
129+
const handleUpdateLink = (index: number, link: Link): void => {
130+
setLinks((draft) => {
131+
draft[index] = link;
132+
});
133+
};
134+
135+
const isValid = links.every((link) => link.url.trim().length > 0);
136+
137+
return (
138+
<>
139+
<Box
140+
sx={{
141+
display: 'flex',
142+
alignItems: 'center',
143+
padding: (theme) => theme.spacing(1, 2),
144+
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
145+
}}
146+
>
147+
<Typography variant="h2">Edit Dashboard Links</Typography>
148+
<Stack direction="row" spacing={1} marginLeft="auto">
149+
<Button disabled={!isValid} variant="contained" onClick={() => onChange(links)}>
150+
Apply
151+
</Button>
152+
<Button color="secondary" variant="outlined" onClick={handleCancel}>
153+
Cancel
154+
</Button>
155+
</Stack>
156+
</Box>
157+
<Box padding={2} sx={{ overflowY: 'scroll' }}>
158+
<Stack spacing={2}>
159+
<TableContainer>
160+
<Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }} aria-label="table of dashboard links">
161+
<TableHead>
162+
<TableRow>
163+
<TableCell>Name</TableCell>
164+
<TableCell>URL</TableCell>
165+
<TableCell width={80}>New Tab</TableCell>
166+
<TableCell align="right" width={180}>
167+
Actions
168+
</TableCell>
169+
</TableRow>
170+
</TableHead>
171+
<TableBody>
172+
{links.map((link, index) => (
173+
<LinkTableRow
174+
key={index}
175+
link={link}
176+
index={index}
177+
isFirst={index === 0}
178+
isLast={index === links.length - 1}
179+
isExpanded={expandedIndex === index}
180+
onToggleExpand={() => setExpandedIndex(expandedIndex === index ? null : index)}
181+
onUpdate={(updatedLink) => handleUpdateLink(index, updatedLink)}
182+
onRemove={() => handleRemove(index)}
183+
onMoveUp={() => handleMoveUp(index)}
184+
onMoveDown={() => handleMoveDown(index)}
185+
/>
186+
))}
187+
</TableBody>
188+
</Table>
189+
</TableContainer>
190+
{links.length === 0 && (
191+
<Typography variant="body1" color="text.secondary" fontStyle="italic" textAlign="center" py={4}>
192+
No links defined. Click &apos;Add Link&apos; to create one.
193+
</Typography>
194+
)}
195+
<Button variant="contained" startIcon={<AddIcon />} onClick={handleAdd} sx={{ alignSelf: 'flex-start' }}>
196+
Add Link
197+
</Button>
198+
</Stack>
199+
</Box>
200+
</>
201+
);
202+
}
203+
204+
interface LinkTableRowProps {
205+
link: Link;
206+
index: number;
207+
isFirst: boolean;
208+
isLast: boolean;
209+
isExpanded: boolean;
210+
onToggleExpand: () => void;
211+
onUpdate: (link: Link) => void;
212+
onRemove: () => void;
213+
onMoveUp: () => void;
214+
onMoveDown: () => void;
215+
}
216+
217+
function LinkTableRow({
218+
link,
219+
index,
220+
isFirst,
221+
isLast,
222+
isExpanded,
223+
onToggleExpand,
224+
onUpdate,
225+
onRemove,
226+
onMoveUp,
227+
onMoveDown,
228+
}: LinkTableRowProps): ReactElement {
229+
const displayName = link.name?.trim() || `Link ${index + 1}`;
230+
const hasError = link.url.trim().length === 0;
231+
232+
return (
233+
<>
234+
<TableRow>
235+
<TableCell component="th" scope="row" sx={{ fontWeight: 'bold', overflow: 'hidden', textOverflow: 'ellipsis' }}>
236+
{displayName}
237+
</TableCell>
238+
<TableCell
239+
sx={{
240+
overflow: 'hidden',
241+
textOverflow: 'ellipsis',
242+
whiteSpace: 'nowrap',
243+
color: hasError ? 'error.main' : undefined,
244+
}}
245+
>
246+
<InfoTooltip description={link.url} enterDelay={100}>
247+
{link.url || '(no URL)'}
248+
</InfoTooltip>
249+
</TableCell>
250+
<TableCell>{link.targetBlank ? 'Yes' : 'No'}</TableCell>
251+
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
252+
<IconButton onClick={onMoveUp} disabled={isFirst} aria-label="Move link up">
253+
<ArrowUp />
254+
</IconButton>
255+
<IconButton onClick={onMoveDown} disabled={isLast} aria-label="Move link down">
256+
<ArrowDown />
257+
</IconButton>
258+
<IconButton onClick={onToggleExpand} aria-label={isExpanded ? 'Collapse link editor' : 'Edit link'}>
259+
{isExpanded ? <ChevronUp /> : <PencilIcon />}
260+
</IconButton>
261+
<IconButton onClick={onRemove} aria-label="Remove link">
262+
<TrashIcon />
263+
</IconButton>
264+
</TableCell>
265+
</TableRow>
266+
<TableRow>
267+
<TableCell colSpan={4} sx={{ paddingBottom: 0, paddingTop: 0, border: isExpanded ? undefined : 'none' }}>
268+
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
269+
<Box sx={{ margin: 2 }}>
270+
<LinkEditorForm
271+
mode="modalEmbedded"
272+
url={{
273+
value: link.url,
274+
label: 'URL',
275+
error: { hasError: hasError, helperText: hasError ? 'URL is required' : undefined },
276+
placeholder: 'https://example.com/dashboard?var=$variable',
277+
onChange: (url) => onUpdate({ ...link, url }),
278+
}}
279+
name={{
280+
value: link.name ?? '',
281+
label: 'Display Name',
282+
onChange: (name) => onUpdate({ ...link, name }),
283+
}}
284+
tooltip={{
285+
value: link.tooltip ?? '',
286+
label: 'Tooltip',
287+
onChange: (tooltip) => onUpdate({ ...link, tooltip }),
288+
}}
289+
renderVariables={{
290+
value: link.renderVariables ?? true,
291+
label: 'Replace variables in URL',
292+
onChange: (renderVariables) => onUpdate({ ...link, renderVariables }),
293+
}}
294+
newTabOpen={{
295+
value: link.targetBlank ?? true,
296+
label: 'Open in new tab',
297+
onChange: (targetBlank) => onUpdate({ ...link, targetBlank }),
298+
}}
299+
/>
300+
</Box>
301+
</Collapse>
302+
</TableCell>
303+
</TableRow>
304+
</>
305+
);
306+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright The Perses Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
import { ReactElement, useState } from 'react';
15+
import { Button, ButtonProps } from '@mui/material';
16+
import PencilIcon from 'mdi-material-ui/PencilOutline';
17+
import { Drawer, InfoTooltip } from '@perses-dev/components';
18+
import { Link } from '@perses-dev/core';
19+
import { TOOLTIP_TEXT, editButtonStyle } from '../../constants';
20+
import { useDashboardLinks, useDashboardLinksActions } from '../../context';
21+
import { DashboardLinksEditor } from './DashboardLinksEditor';
22+
23+
export interface EditDashboardLinksButtonProps extends Pick<ButtonProps, 'fullWidth'> {
24+
/**
25+
* The variant to use to display the button.
26+
*/
27+
variant?: 'text' | 'outlined';
28+
29+
/**
30+
* The color to use to display the button.
31+
*/
32+
color?: 'primary' | 'secondary';
33+
34+
/**
35+
* The label used inside the button.
36+
*/
37+
label?: string;
38+
}
39+
40+
export function EditDashboardLinksButton({
41+
variant = 'text',
42+
label = 'Links',
43+
color = 'primary',
44+
fullWidth,
45+
}: EditDashboardLinksButtonProps): ReactElement {
46+
const [isLinksEditorOpen, setIsLinksEditorOpen] = useState(false);
47+
const links = useDashboardLinks();
48+
const { setLinks } = useDashboardLinksActions();
49+
50+
const openLinksEditor = (): void => {
51+
setIsLinksEditorOpen(true);
52+
};
53+
54+
const closeLinksEditor = (): void => {
55+
setIsLinksEditorOpen(false);
56+
};
57+
58+
return (
59+
<>
60+
<InfoTooltip description={TOOLTIP_TEXT.editLinks}>
61+
<Button
62+
startIcon={<PencilIcon />}
63+
onClick={openLinksEditor}
64+
aria-label={TOOLTIP_TEXT.editLinks}
65+
variant={variant}
66+
color={color}
67+
fullWidth={fullWidth}
68+
sx={editButtonStyle}
69+
>
70+
{label}
71+
</Button>
72+
</InfoTooltip>
73+
<Drawer
74+
isOpen={isLinksEditorOpen}
75+
onClose={closeLinksEditor}
76+
PaperProps={{ sx: { width: '50%' } }}
77+
data-testid="dashboard-links-editor"
78+
>
79+
<DashboardLinksEditor
80+
links={links}
81+
onCancel={closeLinksEditor}
82+
onChange={(updatedLinks: Link[]) => {
83+
setLinks?.(updatedLinks);
84+
setIsLinksEditorOpen(false);
85+
}}
86+
/>
87+
</Drawer>
88+
</>
89+
);
90+
}

0 commit comments

Comments
 (0)