Skip to content

Commit

Permalink
Allow configuration of column order (#4118)
Browse files Browse the repository at this point in the history
In the lookout UI, allow the user to change the order of the columns of the jobs table through a drag-and-drop interface.

This change replaces the existing column selector with a new column configuration dialog which extends the current functionality of the column selector with the ability to drag and drop columns into the user's desired order.

The existing functionality which has been preserved is:
	- Hiding and showing columns (via a checkbox)
	- Adding annotation key columns
	- Removing and editing annotation key columns which have been added
	- Indicates when columns are for annotation keys

The new column configuration dialog has the following additional functionalities:
	- Hides columns which are configured to be pinned (i.e. the selector column)
	- Hides columns which are grouped, and explains this to the user
	- When the input to add an annotation column is not visible on the screen, it displays a chip indicating its presence. Clicking on this chip will scroll to the input
	- Validates annotation key columns on input, making sure they have no leading or trailing whitespace, and that there is not already a column for the same annotation key

This change also makes the buttons in the job action bar more visually consistent - using primary colours for actions and secondary colours for configuration changes.
  • Loading branch information
mauriceyap authored Jan 6, 2025
1 parent e611e13 commit 6e5c1eb
Show file tree
Hide file tree
Showing 17 changed files with 1,216 additions and 434 deletions.
3 changes: 3 additions & 0 deletions internal/lookout/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"fmt": "eslint './src/**/*.{js,ts,tsx}' --max-warnings 0 --fix"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@emotion/react": "^11.13.5",
"@emotion/styled": "^11.13.5",
"@fortawesome/fontawesome-common-types": "^6.7.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState } from "react"

import { AddCircle } from "@mui/icons-material"
import { FormControl, FormHelperText, IconButton, InputAdornment, InputLabel, OutlinedInput } from "@mui/material"

const INPUT_LABEL_TEXT = "Annotation key"
const INPUT_ID = "annotation-key"

export interface AddAnnotationColumnInputProps {
onCreate: (annotationKey: string) => void
existingAnnotationColumnKeysSet: Set<string>
}

export const AddAnnotationColumnInput = ({
onCreate,
existingAnnotationColumnKeysSet,
}: AddAnnotationColumnInputProps) => {
const [value, setValue] = useState("")

const isValueFilled = value.length !== 0

const valueHasNoLeadingTrailingWhitespace = value.trim() === value
const valueIsNew = !existingAnnotationColumnKeysSet.has(value)
const isValueValid = [valueHasNoLeadingTrailingWhitespace, valueIsNew].every(Boolean)

const handleCreate = () => {
onCreate(value)
setValue("")
}

return (
<FormControl fullWidth size="small" margin="normal" error={!isValueValid}>
<InputLabel htmlFor={INPUT_ID}>{INPUT_LABEL_TEXT}</InputLabel>
<OutlinedInput
id={INPUT_ID}
label={INPUT_LABEL_TEXT}
value={value}
onChange={({ target }) => {
setValue(target.value)
}}
onKeyDown={(event) => {
if (event.key === "Enter" && isValueFilled && isValueValid) {
handleCreate()
event.currentTarget.blur()
event.preventDefault()
}
}}
endAdornment={
isValueFilled && isValueValid ? (
<InputAdornment position="end">
<IconButton
aria-label={`Add a column for annotation '${value}'`}
edge="end"
onClick={handleCreate}
title={`Add a column for annotation '${value}'`}
>
<AddCircle />
</IconButton>
</InputAdornment>
) : undefined
}
/>
{!valueHasNoLeadingTrailingWhitespace && (
<FormHelperText>The annotation key must not have leading or trailing whitespace.</FormHelperText>
)}
{!valueIsNew && <FormHelperText>A column for this annotation key already exists.</FormHelperText>}
</FormControl>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useState } from "react"

import { Check } from "@mui/icons-material"
import { FormControl, FormHelperText, IconButton, InputAdornment, InputLabel, OutlinedInput } from "@mui/material"

const INPUT_LABEL_TEXT = "Annotation key"
const INPUT_ID = "annotation-key"

export interface EditAnnotationColumnInputProps {
onEdit: (annotationKey: string) => void
currentAnnotationKey: string
existingAnnotationColumnKeys: Set<string>
}

export const EditAnnotationColumnInput = ({
onEdit,
existingAnnotationColumnKeys,
currentAnnotationKey,
}: EditAnnotationColumnInputProps) => {
const [value, setValue] = useState(currentAnnotationKey)

const isValueFilled = value.length !== 0

const valueHasNoLeadingTrailingWhitespace = value.trim() === value
const valueIsUnique = !existingAnnotationColumnKeys.has(value) || value === currentAnnotationKey
const isValueValid = [valueHasNoLeadingTrailingWhitespace, valueIsUnique].every(Boolean)

const handleEdit = () => {
onEdit(value)
}

return (
<FormControl fullWidth size="small" margin="normal" error={!isValueValid}>
<InputLabel htmlFor={INPUT_ID}>{INPUT_LABEL_TEXT}</InputLabel>
<OutlinedInput
id={INPUT_ID}
label={INPUT_LABEL_TEXT}
value={value}
onChange={({ target }) => {
setValue(target.value)
}}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter" && isValueFilled && isValueValid) {
handleEdit()
}
}}
endAdornment={
isValueFilled && isValueValid ? (
<InputAdornment position="end">
<IconButton
aria-label={`Change this column's annotation to '${value}'`}
edge="end"
onClick={handleEdit}
title={`Change this column's annotation to '${value}'`}
>
<Check />
</IconButton>
</InputAdornment>
) : undefined
}
/>
{!valueHasNoLeadingTrailingWhitespace && (
<FormHelperText>The annotation key must not have leading or trailing whitespace.</FormHelperText>
)}
{!valueIsUnique && <FormHelperText>A column for this annotation key already exists.</FormHelperText>}
</FormControl>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useState } from "react"

import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Cancel, Delete, DragHandle, Edit } from "@mui/icons-material"
import {
Checkbox,
IconButton,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Stack,
styled,
} from "@mui/material"

import { EditAnnotationColumnInput } from "./EditAnnotationColumnInput"
import { SPACING } from "../../../styling/spacing"
import {
AnnotationColumnId,
fromAnnotationColId,
getColumnMetadata,
JobTableColumn,
toColId,
} from "../../../utils/jobsTableColumns"

const GrabListItemIcon = styled(ListItemIcon)({
cursor: "grab",
touchAction: "none",
})

const EditAnnotationColumnInputContainer = styled(Stack)({
width: "100%",
})

export interface OrderableColumnListItemProps {
column: JobTableColumn
isVisible: boolean
onToggleVisibility: () => void
removeAnnotationColumn: () => void
editAnnotationColumn: (annotationKey: string) => void
existingAnnotationColumnKeysSet: Set<string>
}

export const OrderableColumnListItem = ({
column,
isVisible,
onToggleVisibility,
removeAnnotationColumn,
editAnnotationColumn,
existingAnnotationColumnKeysSet,
}: OrderableColumnListItemProps) => {
const colId = toColId(column.id)
const colMetadata = getColumnMetadata(column)
const colIsAnnotation = colMetadata.annotation ?? false
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: colId,
})

const [isEditing, setIsEditing] = useState(false)

return (
<ListItem
key={colId}
disablePadding
dense
style={{ transform: CSS.Transform.toString(transform), transition }}
secondaryAction={
colIsAnnotation && !isEditing ? (
<>
<IconButton
data-no-dnd
title="Edit column"
onClick={() => {
setIsEditing(true)
}}
>
<Edit />
</IconButton>
<IconButton data-no-dnd edge="end" title="Delete column" onClick={removeAnnotationColumn}>
<Delete />
</IconButton>
</>
) : undefined
}
ref={setNodeRef}
{...attributes}
{...listeners}
>
<GrabListItemIcon>
<DragHandle />
</GrabListItemIcon>
{colIsAnnotation && isEditing ? (
<EditAnnotationColumnInputContainer data-no-dnd spacing={SPACING.xs} direction="row">
<EditAnnotationColumnInput
onEdit={(annotationKey) => {
editAnnotationColumn(annotationKey)
setIsEditing(false)
}}
currentAnnotationKey={fromAnnotationColId(colId as AnnotationColumnId)}
existingAnnotationColumnKeys={existingAnnotationColumnKeysSet}
/>
<div>
<IconButton onClick={() => setIsEditing(false)} title="Cancel changes">
<Cancel />
</IconButton>
</div>
</EditAnnotationColumnInputContainer>
) : (
<ListItemButton onClick={onToggleVisibility} dense disabled={!column.enableHiding} tabIndex={2} data-no-dnd>
<ListItemIcon>
<Checkbox
edge="start"
checked={isVisible}
tabIndex={-1}
disableRipple
inputProps={{ "aria-labelledby": colId }}
size="small"
/>
</ListItemIcon>
<ListItemText
id={colId}
primary={colMetadata.displayName}
secondary={colIsAnnotation ? "Annotation" : undefined}
/>
</ListItemButton>
)}
</ListItem>
)
}
Loading

0 comments on commit 6e5c1eb

Please sign in to comment.