mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor: Show template versions as timeline (#4800)
This commit is contained in:
@@ -656,8 +656,7 @@ export interface TemplateVersion {
|
||||
readonly name: string
|
||||
readonly job: ProvisionerJob
|
||||
readonly readme: string
|
||||
readonly created_by_id: string
|
||||
readonly created_by_name: string
|
||||
readonly created_by: User
|
||||
}
|
||||
|
||||
// From codersdk/templates.go
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import formatRelative from "date-fns/formatRelative"
|
||||
import { FC } from "react"
|
||||
|
||||
export interface BuildDateRow {
|
||||
date: Date
|
||||
}
|
||||
|
||||
export const BuildDateRow: FC<BuildDateRow> = ({ date }) => {
|
||||
const styles = useStyles()
|
||||
// We only want the message related to the date since the time is displayed
|
||||
// inside of the build row
|
||||
const displayDate = formatRelative(date, new Date()).split("at")[0]
|
||||
|
||||
return (
|
||||
<TableRow className={styles.buildDateRow}>
|
||||
<TableCell
|
||||
className={styles.buildDateCell}
|
||||
title={date.toLocaleDateString()}
|
||||
>
|
||||
{displayDate}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
buildDateRow: {
|
||||
background: theme.palette.background.paper,
|
||||
|
||||
"&:not(:first-child) td": {
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
},
|
||||
|
||||
buildDateCell: {
|
||||
padding: `${theme.spacing(1, 4)} !important`,
|
||||
background: `${theme.palette.background.paperLight} !important`,
|
||||
fontSize: 12,
|
||||
position: "relative",
|
||||
color: theme.palette.text.secondary,
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
}))
|
||||
@@ -4,73 +4,37 @@ import TableBody from "@material-ui/core/TableBody"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableContainer from "@material-ui/core/TableContainer"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import { FC, Fragment } from "react"
|
||||
import { Timeline } from "components/Timeline/Timeline"
|
||||
import { FC } from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { EmptyState } from "../EmptyState/EmptyState"
|
||||
import { TableLoader } from "../TableLoader/TableLoader"
|
||||
import { BuildDateRow } from "./BuildDateRow"
|
||||
import { BuildRow } from "./BuildRow"
|
||||
|
||||
export const Language = {
|
||||
emptyMessage: "No builds found",
|
||||
inProgressLabel: "In progress",
|
||||
actionLabel: "Action",
|
||||
durationLabel: "Duration",
|
||||
startedAtLabel: "Started at",
|
||||
statusLabel: "Status",
|
||||
}
|
||||
|
||||
export interface BuildsTableProps {
|
||||
builds?: TypesGen.WorkspaceBuild[]
|
||||
}
|
||||
|
||||
const groupBuildsByDate = (builds?: TypesGen.WorkspaceBuild[]) => {
|
||||
const buildsByDate: Record<string, TypesGen.WorkspaceBuild[]> = {}
|
||||
|
||||
if (!builds) {
|
||||
return
|
||||
}
|
||||
|
||||
builds.forEach((build) => {
|
||||
const dateKey = new Date(build.created_at).toDateString()
|
||||
|
||||
// Unsure why this is here but we probably need to fix it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- see above
|
||||
if (buildsByDate[dateKey]) {
|
||||
buildsByDate[dateKey].push(build)
|
||||
} else {
|
||||
buildsByDate[dateKey] = [build]
|
||||
}
|
||||
})
|
||||
|
||||
return buildsByDate
|
||||
}
|
||||
|
||||
export const BuildsTable: FC<React.PropsWithChildren<BuildsTableProps>> = ({
|
||||
builds,
|
||||
}) => {
|
||||
const isLoading = !builds
|
||||
const buildsByDate = groupBuildsByDate(builds)
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table data-testid="builds-table" aria-describedby="builds table">
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader />}
|
||||
|
||||
{buildsByDate &&
|
||||
Object.keys(buildsByDate).map((dateStr) => {
|
||||
const builds = buildsByDate[dateStr]
|
||||
|
||||
return (
|
||||
<Fragment key={dateStr}>
|
||||
<BuildDateRow date={new Date(dateStr)} />
|
||||
{builds.map((build) => (
|
||||
<BuildRow key={build.id} build={build} />
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{builds ? (
|
||||
<Timeline
|
||||
items={builds}
|
||||
getDate={(build) => new Date(build.created_at)}
|
||||
row={(build) => <BuildRow key={build.id} build={build} />}
|
||||
/>
|
||||
) : (
|
||||
<TableLoader />
|
||||
)}
|
||||
|
||||
{builds && builds.length === 0 && (
|
||||
<TableRow>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { TimelineDateRow } from "components/Timeline/TimelineDateRow"
|
||||
import { Fragment } from "react"
|
||||
|
||||
type GetDateFn<TData> = (data: TData) => Date
|
||||
|
||||
const groupByDate = <TData,>(
|
||||
items: TData[],
|
||||
getDate: GetDateFn<TData>,
|
||||
): Record<string, TData[]> => {
|
||||
const itemsByDate: Record<string, TData[]> = {}
|
||||
|
||||
items.forEach((item) => {
|
||||
const dateKey = getDate(item).toDateString()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Is not a guarantee a value is defined when access it dynamically
|
||||
if (itemsByDate[dateKey]) {
|
||||
itemsByDate[dateKey].push(item)
|
||||
} else {
|
||||
itemsByDate[dateKey] = [item]
|
||||
}
|
||||
})
|
||||
|
||||
return itemsByDate
|
||||
}
|
||||
|
||||
export interface TimelineProps<TData> {
|
||||
items: TData[]
|
||||
getDate: GetDateFn<TData>
|
||||
row: (item: TData) => JSX.Element
|
||||
}
|
||||
|
||||
export const Timeline = <TData,>({
|
||||
items,
|
||||
getDate,
|
||||
row,
|
||||
}: TimelineProps<TData>): JSX.Element => {
|
||||
const itemsByDate = groupByDate(items, getDate)
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(itemsByDate).map((dateStr) => {
|
||||
const items = itemsByDate[dateStr]
|
||||
|
||||
return (
|
||||
<Fragment key={dateStr}>
|
||||
<TimelineDateRow date={new Date(dateStr)} />
|
||||
{items.map(row)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -4,11 +4,11 @@ import TableRow from "@material-ui/core/TableRow"
|
||||
import formatRelative from "date-fns/formatRelative"
|
||||
import { FC } from "react"
|
||||
|
||||
export interface TableDateRow {
|
||||
export interface TimelineDateRow {
|
||||
date: Date
|
||||
}
|
||||
|
||||
export const TableDateRow: FC<TableDateRow> = ({ date }) => {
|
||||
export const TimelineDateRow: FC<TimelineDateRow> = ({ date }) => {
|
||||
const styles = useStyles()
|
||||
// We only want the message related to the date since the time is displayed
|
||||
// inside of the build row
|
||||
@@ -0,0 +1,88 @@
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import { TemplateVersion } from "api/typesGenerated"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { UserAvatar } from "components/UserAvatar/UserAvatar"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export interface VersionRowProps {
|
||||
version: TemplateVersion
|
||||
}
|
||||
|
||||
export const VersionRow: React.FC<VersionRowProps> = ({ version }) => {
|
||||
const styles = useStyles()
|
||||
const { t } = useTranslation("templatePage")
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={styles.versionRow}
|
||||
data-testid={`version-${version.id}`}
|
||||
>
|
||||
<TableCell className={styles.versionCell}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
className={styles.versionWrapper}
|
||||
>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<UserAvatar
|
||||
username={version.created_by.username}
|
||||
avatarURL={version.created_by.avatar_url}
|
||||
/>
|
||||
<Stack
|
||||
className={styles.versionSummary}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
>
|
||||
<span>
|
||||
<strong>{version.created_by.username}</strong>{" "}
|
||||
{t("createdVersion")} <strong>{version.name}</strong>
|
||||
</span>
|
||||
|
||||
<span className={styles.versionTime}>
|
||||
{new Date(version.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
versionRow: {
|
||||
"&:not(:last-child) td:before": {
|
||||
position: "absolute",
|
||||
top: 20,
|
||||
left: 50,
|
||||
display: "block",
|
||||
content: "''",
|
||||
height: "100%",
|
||||
width: 2,
|
||||
background: theme.palette.divider,
|
||||
},
|
||||
},
|
||||
|
||||
versionWrapper: {
|
||||
padding: theme.spacing(2, 4),
|
||||
},
|
||||
|
||||
versionCell: {
|
||||
padding: "0 !important",
|
||||
position: "relative",
|
||||
borderBottom: 0,
|
||||
},
|
||||
|
||||
versionSummary: {
|
||||
...theme.typography.body1,
|
||||
fontFamily: "inherit",
|
||||
},
|
||||
|
||||
versionTime: {
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
},
|
||||
}))
|
||||
@@ -1,16 +1,15 @@
|
||||
import Box from "@material-ui/core/Box"
|
||||
import { Theme } from "@material-ui/core/styles"
|
||||
import Table from "@material-ui/core/Table"
|
||||
import TableBody from "@material-ui/core/TableBody"
|
||||
import TableCell from "@material-ui/core/TableCell"
|
||||
import TableContainer from "@material-ui/core/TableContainer"
|
||||
import TableHead from "@material-ui/core/TableHead"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import useTheme from "@material-ui/styles/useTheme"
|
||||
import { Timeline } from "components/Timeline/Timeline"
|
||||
import { FC } from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { EmptyState } from "../EmptyState/EmptyState"
|
||||
import { TableLoader } from "../TableLoader/TableLoader"
|
||||
import { VersionRow } from "./VersionRow"
|
||||
|
||||
export const Language = {
|
||||
emptyMessage: "No versions found",
|
||||
@@ -26,45 +25,21 @@ export interface VersionsTableProps {
|
||||
export const VersionsTable: FC<React.PropsWithChildren<VersionsTableProps>> = ({
|
||||
versions,
|
||||
}) => {
|
||||
const isLoading = !versions
|
||||
const theme: Theme = useTheme()
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table data-testid="versions-table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="30%">{Language.nameLabel}</TableCell>
|
||||
<TableCell width="30%">{Language.createdAtLabel}</TableCell>
|
||||
<TableCell width="40%">{Language.createdByLabel}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader />}
|
||||
{versions &&
|
||||
versions
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((version) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={version.id}
|
||||
data-testid={`version-${version.id}`}
|
||||
>
|
||||
<TableCell>{version.name}</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{new Date(version.created_at).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span style={{ color: theme.palette.text.secondary }}>
|
||||
{version.created_by_name}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{versions ? (
|
||||
<Timeline
|
||||
items={versions.slice().reverse()}
|
||||
getDate={(version) => new Date(version.created_at)}
|
||||
row={(version) => (
|
||||
<VersionRow version={version} key={version.id} />
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<TableLoader />
|
||||
)}
|
||||
|
||||
{versions && versions.length === 0 && (
|
||||
<TableRow>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"deleteSuccess": "Template successfully deleted."
|
||||
"deleteSuccess": "Template successfully deleted.",
|
||||
"createdVersion": "created the version"
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
|
||||
import { SearchBarWithFilter } from "components/SearchBarWithFilter/SearchBarWithFilter"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { TableDateRow } from "components/TableDateRow/TableDateRow"
|
||||
import { TableLoader } from "components/TableLoader/TableLoader"
|
||||
import { Timeline } from "components/Timeline/Timeline"
|
||||
import { AuditHelpTooltip } from "components/Tooltips"
|
||||
import { FC, Fragment } from "react"
|
||||
import { FC } from "react"
|
||||
import { PaginationMachineRef } from "xServices/pagination/paginationXService"
|
||||
|
||||
export const Language = {
|
||||
@@ -37,27 +37,6 @@ const presetFilters = [
|
||||
{ query: "resource_type:user action:delete", name: "Deleted users" },
|
||||
]
|
||||
|
||||
const groupAuditLogsByDate = (auditLogs?: AuditLog[]) => {
|
||||
const auditLogsByDate: Record<string, AuditLog[]> = {}
|
||||
|
||||
if (!auditLogs) {
|
||||
return
|
||||
}
|
||||
|
||||
auditLogs.forEach((auditLog) => {
|
||||
const dateKey = new Date(auditLog.time).toDateString()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TODO look into this
|
||||
if (auditLogsByDate[dateKey]) {
|
||||
auditLogsByDate[dateKey].push(auditLog)
|
||||
} else {
|
||||
auditLogsByDate[dateKey] = [auditLog]
|
||||
}
|
||||
})
|
||||
|
||||
return auditLogsByDate
|
||||
}
|
||||
|
||||
export interface AuditPageViewProps {
|
||||
auditLogs?: AuditLog[]
|
||||
count?: number
|
||||
@@ -75,7 +54,6 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
}) => {
|
||||
const isLoading = auditLogs === undefined || count === undefined
|
||||
const isEmpty = !isLoading && auditLogs.length === 0
|
||||
const auditLogsByDate = groupAuditLogsByDate(auditLogs)
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
@@ -101,19 +79,13 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader />}
|
||||
|
||||
{auditLogsByDate &&
|
||||
Object.keys(auditLogsByDate).map((dateStr) => {
|
||||
const auditLogs = auditLogsByDate[dateStr]
|
||||
|
||||
return (
|
||||
<Fragment key={dateStr}>
|
||||
<TableDateRow date={new Date(dateStr)} />
|
||||
{auditLogs.map((log) => (
|
||||
<AuditLogRow key={log.id} auditLog={log} />
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{auditLogs && (
|
||||
<Timeline
|
||||
items={auditLogs}
|
||||
getDate={(log) => new Date(log.time)}
|
||||
row={(log) => <AuditLogRow key={log.id} auditLog={log} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<TableRow>
|
||||
|
||||
@@ -175,8 +175,7 @@ name:Template test
|
||||
You can add instructions here
|
||||
|
||||
[Some link info](https://coder.com)`,
|
||||
created_by_id: "test-creator-id",
|
||||
created_by_name: "test_creator",
|
||||
created_by: MockUser,
|
||||
}
|
||||
|
||||
export const MockTemplate: TypesGen.Template = {
|
||||
|
||||
Reference in New Issue
Block a user