Skip to content

Commit b7e2eef

Browse files
authored
Add Job tagging to UI (#2837)
* Add job tagging to UI Signed-off-by: sharpd <davidsharp7@gmail.com> * update reducers Signed-off-by: sharpd <davidsharp7@gmail.com> * minor tweaks based on feedback Signed-off-by: sharpd <davidsharp7@gmail.com> * remove uneeded code Signed-off-by: sharpd <davidsharp7@gmail.com> * lint and remove uneeded code Signed-off-by: sharpd <davidsharp7@gmail.com> * update tag dialog to use label switch Signed-off-by: sharpd <davidsharp7@gmail.com> --------- Signed-off-by: sharpd <davidsharp7@gmail.com>
1 parent 4b93764 commit b7e2eef

9 files changed

Lines changed: 439 additions & 4 deletions

File tree

web/src/__tests__/reducers/jobs.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('jobs reducer', () => {
1818
jobs: jobs as Job[]
1919
}
2020
} as IJobsAction
21-
expect(jobsReducer(initialState, action)).toStrictEqual({ isLoading: false, result: jobs, totalCount: 13, init: true, deletedJobName: '' })
21+
expect(jobsReducer(initialState, action)).toStrictEqual({ isLoading: false, result: jobs, totalCount: 13, init: true, deletedJobName: '', jobTags:[] })
2222
})
2323
})
2424

web/src/components/jobs/JobDetailPage.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { connect } from 'react-redux'
1919
import {
2020
deleteJob,
2121
dialogToggle,
22+
fetchJobTags,
2223
fetchRuns,
2324
resetJobs,
2425
resetRuns,
@@ -34,6 +35,7 @@ import { useTheme } from '@emotion/react'
3435
import CloseIcon from '@mui/icons-material/Close'
3536
import Dialog from '../Dialog'
3637
import IconButton from '@mui/material/IconButton'
38+
import JobTags from './JobTags'
3739
import MqEmpty from '../core/empty/MqEmpty'
3840
import MqStatus from '../core/status/MqStatus'
3941
import MqText from '../core/text/MqText'
@@ -48,6 +50,7 @@ interface DispatchProps {
4850
deleteJob: typeof deleteJob
4951
dialogToggle: typeof dialogToggle
5052
setTabIndex: typeof setTabIndex
53+
fetchJobTags: typeof fetchJobTags
5154
}
5255

5356
type IProps = {
@@ -57,6 +60,7 @@ type IProps = {
5760
runsLoading: boolean
5861
display: IState['display']
5962
tabIndex: IState['lineage']['tabIndex']
63+
jobTags: string[]
6064
} & DispatchProps
6165

6266
const JobDetailPage: FunctionComponent<IProps> = (props) => {
@@ -73,6 +77,8 @@ const JobDetailPage: FunctionComponent<IProps> = (props) => {
7377
runsLoading,
7478
tabIndex,
7579
setTabIndex,
80+
jobTags,
81+
fetchJobTags,
7682
} = props
7783
const navigate = useNavigate()
7884
const [_, setSearchParams] = useSearchParams()
@@ -84,6 +90,7 @@ const JobDetailPage: FunctionComponent<IProps> = (props) => {
8490

8591
useEffect(() => {
8692
fetchRuns(job.name, job.namespace)
93+
fetchJobTags(job.namespace, job.name)
8794
}, [job.name])
8895

8996
useEffect(() => {
@@ -100,7 +107,7 @@ const JobDetailPage: FunctionComponent<IProps> = (props) => {
100107
}
101108
}, [])
102109

103-
if (runsLoading) {
110+
if (runsLoading || jobs.isLoading) {
104111
return (
105112
<Box display={'flex'} justifyContent={'center'} mt={2}>
106113
<CircularProgress color='primary' />
@@ -242,6 +249,7 @@ const JobDetailPage: FunctionComponent<IProps> = (props) => {
242249
</Grid>
243250
</Grid>
244251
<Divider sx={{ my: 1 }} />
252+
<JobTags jobTags={jobTags} jobName={job.name} namespace={job.namespace} />
245253
<Box
246254
mb={2}
247255
display={'flex'}
@@ -274,6 +282,7 @@ const mapStateToProps = (state: IState) => ({
274282
display: state.display,
275283
jobs: state.jobs,
276284
tabIndex: state.lineage.tabIndex,
285+
jobTags: state.jobs.jobTags,
277286
})
278287

279288
const mapDispatchToProps = (dispatch: Redux.Dispatch) =>
@@ -285,6 +294,7 @@ const mapDispatchToProps = (dispatch: Redux.Dispatch) =>
285294
deleteJob: deleteJob,
286295
dialogToggle: dialogToggle,
287296
setTabIndex: setTabIndex,
297+
fetchJobTags: fetchJobTags,
288298
},
289299
dispatch
290300
)
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
// Copyright 2018-2024 contributors to the Marquez project
2+
// SPDX-License-Identifier: Apache-2.0
3+
import * as Redux from 'redux'
4+
import {
5+
Autocomplete,
6+
AutocompleteChangeDetails,
7+
AutocompleteChangeReason,
8+
Checkbox,
9+
TextField,
10+
} from '@mui/material'
11+
import { Box, createTheme } from '@mui/material'
12+
import { IState } from '../../store/reducers'
13+
import { Tag } from '../../types/api'
14+
import { addJobTag, addTags, deleteJobTag } from '../../store/actionCreators'
15+
import { bindActionCreators } from 'redux'
16+
import { connect, useSelector } from 'react-redux'
17+
import { useTheme } from '@emotion/react'
18+
import Button from '@mui/material/Button'
19+
import CheckBoxIcon from '@mui/icons-material/CheckBox'
20+
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'
21+
import Chip from '@mui/material/Chip'
22+
import Dialog from '@mui/material/Dialog'
23+
import DialogActions from '@mui/material/DialogActions'
24+
import DialogContent from '@mui/material/DialogContent'
25+
import LocalOfferIcon from '@mui/icons-material/LocalOffer'
26+
import MQText from '../core/text/MqText'
27+
import MQTooltip from '../core/tooltip/MQTooltip'
28+
import React, { ChangeEvent, useState } from 'react'
29+
import Snackbar from '@mui/material/Snackbar'
30+
31+
interface JobTagsProps {
32+
namespace: string
33+
jobName: string
34+
jobTags: string[]
35+
}
36+
37+
interface DispatchProps {
38+
deleteJobTag: typeof deleteJobTag
39+
addJobTag: typeof addJobTag
40+
addTags: typeof addTags
41+
}
42+
43+
type IProps = JobTagsProps & DispatchProps
44+
45+
const JobTags: React.FC<IProps> = (props) => {
46+
const { namespace, jobName, jobTags, deleteJobTag, addJobTag, addTags } = props
47+
48+
const [listTag, setListTag] = useState('')
49+
const [openTagDesc, setOpenTagDesc] = useState(false)
50+
const [tagDescription, setTagDescription] = useState('')
51+
const [selectedTags, setSelectedTags] = useState<string[]>(jobTags)
52+
53+
const handleButtonClick = () => {
54+
setOpenTagDesc(true)
55+
}
56+
57+
const [snackbarOpen, setSnackbarOpen] = useState(false)
58+
const theme = createTheme(useTheme())
59+
60+
const handleTagDescClose = () => {
61+
setOpenTagDesc(false)
62+
setListTag('')
63+
setTagDescription('')
64+
}
65+
66+
const handleTagDescChange = (_event: ChangeEvent<HTMLInputElement>, value: string) => {
67+
const selectedTagData = tagData.find((tag) => tag.name === value)
68+
setListTag(value)
69+
setTagDescription(selectedTagData ? selectedTagData.description : '')
70+
}
71+
72+
const handleDescriptionChange = (event: ChangeEvent<HTMLInputElement>) => {
73+
setTagDescription(event.target.value)
74+
}
75+
76+
const tagData = useSelector((state: IState) =>
77+
state.tags.tags.sort((a, b) => a.name.localeCompare(b.name))
78+
)
79+
80+
const handleTagChange = (
81+
_event: React.SyntheticEvent,
82+
_value: string[],
83+
_reason: AutocompleteChangeReason,
84+
details?: AutocompleteChangeDetails<string> | undefined
85+
) => {
86+
if (details && _reason === 'removeOption') {
87+
const newTag = details.option
88+
const newSelectedTags = selectedTags.filter((tag) => newTag !== tag)
89+
setSelectedTags(newSelectedTags)
90+
deleteJobTag(namespace, jobName, newTag)
91+
} else if (details && !selectedTags.includes(details.option)) {
92+
const newTag = details.option
93+
const newSelectedTags = [...selectedTags, newTag]
94+
setSelectedTags(newSelectedTags)
95+
addJobTag(namespace, jobName, newTag)
96+
}
97+
}
98+
99+
const handleDelete = (deletedTag: string) => {
100+
const newSelectedTags = selectedTags.filter((tag) => deletedTag !== tag)
101+
102+
setSelectedTags(newSelectedTags)
103+
104+
deleteJobTag(namespace, jobName, deletedTag)
105+
}
106+
107+
const addTag = () => {
108+
addTags(listTag, tagDescription)
109+
setSnackbarOpen(true)
110+
setOpenTagDesc(false)
111+
setListTag('')
112+
setTagDescription('')
113+
}
114+
115+
const formatTags = (tags: string[], tag_desc: Tag[]) => {
116+
return tags.map((tag, index) => {
117+
const tagDescription = tag_desc.find((tagItem) => tagItem.name === tag)
118+
const tooltipTitle = tagDescription?.description || 'No Tag Description'
119+
return (
120+
<MQTooltip title={tooltipTitle} key={tag}>
121+
<Chip
122+
color={'primary'}
123+
variant='outlined'
124+
label={tag}
125+
size='small'
126+
onDelete={() => handleDelete(tag)}
127+
style={{
128+
display: 'row',
129+
marginLeft: index === 0 ? theme.spacing(0) : theme.spacing(1),
130+
}}
131+
/>
132+
</MQTooltip>
133+
)
134+
})
135+
}
136+
137+
return (
138+
<>
139+
<Snackbar
140+
open={snackbarOpen}
141+
autoHideDuration={1000}
142+
style={{ zIndex: theme.zIndex.snackbar }}
143+
onClose={() => setSnackbarOpen(false)}
144+
message={'Tag updated.'}
145+
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
146+
/>
147+
<Box display={'flex'} alignItems='center' justifyContent='center' width={'100%'}>
148+
<MQTooltip title='Edit a Tag' key='edit-tag'>
149+
<Button
150+
variant='outlined'
151+
onClick={handleButtonClick}
152+
color='primary'
153+
sx={{ marginRight: '8px' }}
154+
style={{ paddingTop: '6.75px', paddingBottom: '6.75px' }}
155+
startIcon={<LocalOfferIcon />}
156+
>
157+
Edit Tag
158+
</Button>
159+
</MQTooltip>
160+
<Autocomplete
161+
multiple
162+
disableCloseOnSelect
163+
id='dataset-tags'
164+
sx={{ flex: 1, width: 'auto' }}
165+
limitTags={6}
166+
autoHighlight
167+
disableClearable
168+
disablePortal
169+
options={tagData.map((option) => option.name)}
170+
value={selectedTags}
171+
onChange={handleTagChange}
172+
renderTags={(value: string[]) => formatTags(value, tagData)}
173+
renderOption={(props, option, { selected }) => (
174+
<li {...props}>
175+
<Checkbox
176+
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
177+
checkedIcon={<CheckBoxIcon fontSize='small' />}
178+
style={{ marginRight: 4 }}
179+
checked={selected}
180+
/>
181+
<div>
182+
<MQText bold>{option}</MQText>
183+
<MQText subdued overflowHidden>
184+
{tagData.find((tagItem) => tagItem.name === option)?.description || ''}
185+
</MQText>
186+
</div>
187+
</li>
188+
)}
189+
renderInput={(params) => (
190+
<TextField
191+
variant={'outlined'}
192+
{...params}
193+
placeholder={selectedTags.length > 0 ? '' : 'Search Tags'}
194+
InputProps={{
195+
...params.InputProps,
196+
}}
197+
InputLabelProps={{
198+
shrink: true,
199+
}}
200+
size='small'
201+
/>
202+
)}
203+
/>
204+
</Box>
205+
<Dialog
206+
PaperProps={{
207+
sx: { backgroundColor: theme.palette.background.default, backgroundImage: 'none' },
208+
}}
209+
open={openTagDesc}
210+
fullWidth
211+
maxWidth='sm'
212+
onKeyDown={(event) => {
213+
if (event.key === 'Escape') {
214+
handleTagDescClose()
215+
}
216+
}}
217+
>
218+
<DialogContent>
219+
<MQText label sx={{ fontSize: '1.25rem' }} bottomMargin>
220+
Select a Tag to change
221+
</MQText>
222+
<MQText label sx={{ fontSize: '0.85rem' }}>Tag</MQText>
223+
<Autocomplete
224+
options={tagData.map((option) => option.name)}
225+
autoSelect
226+
freeSolo
227+
fullWidth
228+
autoFocus
229+
forcePopupIcon
230+
onChange={handleTagDescChange}
231+
renderInput={(params) => (
232+
<TextField
233+
{...params}
234+
placeholder={'Search for a Tag...or enter a new one.'}
235+
autoFocus
236+
margin='dense'
237+
id='tag'
238+
variant='outlined'
239+
InputLabelProps={{
240+
...params.InputProps,
241+
shrink: false,
242+
}}
243+
/>
244+
)}
245+
/>
246+
<MQText label sx={{ fontSize: '0.85rem' }} bottomMargin>
247+
Description
248+
</MQText>
249+
<TextField
250+
multiline
251+
id='tag-description'
252+
name='tag-description'
253+
fullWidth
254+
variant='outlined'
255+
placeholder={''}
256+
onChange={handleDescriptionChange}
257+
rows={6}
258+
value={tagDescription}
259+
InputProps={{
260+
style: { padding: '12px 16px' },
261+
}}
262+
InputLabelProps={{
263+
shrink: false,
264+
}}
265+
/>
266+
</DialogContent>
267+
<DialogActions>
268+
<Button color='primary' onClick={addTag} disabled={listTag === ''}>
269+
Submit
270+
</Button>
271+
<Button color='primary' onClick={handleTagDescClose}>
272+
Cancel
273+
</Button>
274+
</DialogActions>
275+
</Dialog>
276+
</>
277+
)
278+
}
279+
280+
const mapDispatchToProps = (dispatch: Redux.Dispatch) =>
281+
bindActionCreators(
282+
{
283+
deleteJobTag: deleteJobTag,
284+
addJobTag: addJobTag,
285+
addTags: addTags,
286+
},
287+
dispatch
288+
)
289+
290+
export default connect(null, mapDispatchToProps)(JobTags)

web/src/store/actionCreators/actionTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export const FETCH_JOBS_SUCCESS = 'FETCH_JOBS_SUCCESS'
2424
export const RESET_JOBS = 'RESET_JOBS'
2525
export const DELETE_JOB = 'DELETE_JOB'
2626
export const DELETE_JOB_SUCCESS = 'DELETE_JOB_SUCCESS'
27+
export const FETCH_JOB_TAGS = 'FETCH_JOB_TAGS'
28+
export const FETCH_JOB_TAGS_SUCCESS = 'FETCH_JOB_TAGS_SUCCESS'
29+
export const ADD_JOB_TAG = 'ADD_JOB_TAG'
30+
export const ADD_JOB_TAG_SUCCESS = 'ADD_JOB_TAG_SUCCESS'
31+
export const DELETE_JOB_TAG = 'DELETE_JOB_TAG'
32+
export const DELETE_JOB_TAG_SUCCESS = 'DELETE_JOB_TAG_SUCCESS'
2733

2834
// datasets
2935
export const FETCH_DATASETS = 'FETCH_DATASETS'

0 commit comments

Comments
 (0)