refactor: Show template versions as timeline (#4800)

This commit is contained in:
Bruno Quaresma 2022-10-31 13:38:07 -03:00 committed by GitHub
parent cc655672eb
commit 46e0953876
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 218 additions and 218 deletions

View File

@ -94,7 +94,7 @@ func displayTemplateVersions(activeVersionID uuid.UUID, templateVersions ...code
rows[i] = templateVersionRow{
Name: templateVersion.Name,
CreatedAt: templateVersion.CreatedAt,
CreatedBy: templateVersion.CreatedByName,
CreatedBy: templateVersion.CreatedBy.Username,
Status: strings.Title(string(templateVersion.Job.Status)),
Active: activeStatus,
}

View File

@ -35,7 +35,7 @@ func TestTemplateVersions(t *testing.T) {
require.NoError(t, <-errC)
pty.ExpectMatch(version.Name)
pty.ExpectMatch(version.CreatedByName)
pty.ExpectMatch(version.CreatedBy.Username)
pty.ExpectMatch("Active")
})
}

View File

@ -327,7 +327,7 @@ CREATE TABLE template_versions (
name character varying(64) NOT NULL,
readme character varying(1048576) NOT NULL,
job_id uuid NOT NULL,
created_by uuid
created_by uuid NOT NULL
);
CREATE TABLE templates (

View File

@ -0,0 +1 @@
ALTER TABLE template_versions ALTER COLUMN created_by DROP NOT NULL;

View File

@ -0,0 +1,4 @@
BEGIN;
ALTER TABLE template_versions ALTER COLUMN created_by SET NOT NULL;
UPDATE template_versions SET created_by = '00000000-0000-0000-0000-000000000000'::uuid WHERE created_by IS NULL;
COMMIT;

View File

@ -600,7 +600,7 @@ type TemplateVersion struct {
Name string `db:"name" json:"name"`
Readme string `db:"readme" json:"readme"`
JobID uuid.UUID `db:"job_id" json:"job_id"`
CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
}
type User struct {

View File

@ -3634,7 +3634,7 @@ type InsertTemplateVersionParams struct {
Name string `db:"name" json:"name"`
Readme string `db:"readme" json:"readme"`
JobID uuid.UUID `db:"job_id" json:"job_id"`
CreatedBy uuid.NullUUID `db:"created_by" json:"created_by"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
}
func (q *sqlQuerier) InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) (TemplateVersion, error) {

View File

@ -658,10 +658,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO
Name: namesgenerator.GetRandomName(1),
Readme: "",
JobID: job.ID,
CreatedBy: uuid.NullUUID{
UUID: opts.userID,
Valid: true,
},
CreatedBy: opts.userID,
})
if err != nil {
return xerrors.Errorf("insert template version: %w", err)

View File

@ -1,7 +1,6 @@
package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
@ -43,16 +42,16 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) {
return
}
createdByName, err := getUsernameByUserID(ctx, api.Database, templateVersion.CreatedBy)
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching creator name.",
Message: "Internal error on fetching user.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), createdByName))
httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), user))
}
func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Request) {
@ -523,15 +522,15 @@ func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Reque
})
return err
}
createdByName, err := getUsernameByUserID(ctx, store, version.CreatedBy)
user, err := store.GetUserByID(ctx, version.CreatedBy)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching creator name.",
Message: "Internal error on fetching user.",
Detail: err.Error(),
})
return err
}
apiVersions = append(apiVersions, convertTemplateVersion(version, convertProvisionerJob(job), createdByName))
apiVersions = append(apiVersions, convertTemplateVersion(version, convertProvisionerJob(job), user))
}
return nil
@ -581,16 +580,16 @@ func (api *API) templateVersionByName(rw http.ResponseWriter, r *http.Request) {
return
}
createdByName, err := getUsernameByUserID(ctx, api.Database, templateVersion.CreatedBy)
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching creator name.",
Message: "Internal error on fetching user.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), createdByName))
httpapi.Write(ctx, rw, http.StatusOK, convertTemplateVersion(templateVersion, convertProvisionerJob(job), user))
}
func (api *API) patchActiveTemplateVersion(rw http.ResponseWriter, r *http.Request) {
@ -841,10 +840,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
Name: req.Name,
Readme: "",
JobID: provisionerJob.ID,
CreatedBy: uuid.NullUUID{
UUID: apiKey.UserID,
Valid: true,
},
CreatedBy: apiKey.UserID,
})
if err != nil {
return xerrors.Errorf("insert template version: %w", err)
@ -859,16 +855,16 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
}
aReq.New = templateVersion
createdByName, err := getUsernameByUserID(ctx, api.Database, templateVersion.CreatedBy)
user, err := api.Database.GetUserByID(ctx, templateVersion.CreatedBy)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching creator name.",
Message: "Internal error on fetching user.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusCreated, convertTemplateVersion(templateVersion, convertProvisionerJob(provisionerJob), createdByName))
httpapi.Write(ctx, rw, http.StatusCreated, convertTemplateVersion(templateVersion, convertProvisionerJob(provisionerJob), user))
}
// templateVersionResources returns the workspace agent resources associated
@ -926,18 +922,17 @@ func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) {
api.provisionerJobLogs(rw, r, job)
}
func getUsernameByUserID(ctx context.Context, db database.Store, userID uuid.NullUUID) (string, error) {
if !userID.Valid {
return "", nil
func convertTemplateVersion(version database.TemplateVersion, job codersdk.ProvisionerJob, user database.User) codersdk.TemplateVersion {
createdBy := codersdk.User{
ID: user.ID,
Username: user.Username,
Email: user.Email,
CreatedAt: user.CreatedAt,
Status: codersdk.UserStatus(user.Status),
Roles: []codersdk.Role{},
AvatarURL: user.AvatarURL.String,
}
user, err := db.GetUserByID(ctx, userID.UUID)
if err != nil {
return "", err
}
return user.Username, nil
}
func convertTemplateVersion(version database.TemplateVersion, job codersdk.ProvisionerJob, createdByName string) codersdk.TemplateVersion {
return codersdk.TemplateVersion{
ID: version.ID,
TemplateID: &version.TemplateID.UUID,
@ -947,7 +942,6 @@ func convertTemplateVersion(version database.TemplateVersion, job codersdk.Provi
Name: version.Name,
Job: job,
Readme: version.Readme,
CreatedByID: version.CreatedBy.UUID,
CreatedByName: createdByName,
CreatedBy: createdBy,
}
}

View File

@ -21,8 +21,7 @@ type TemplateVersion struct {
Name string `json:"name"`
Job ProvisionerJob `json:"job"`
Readme string `json:"readme"`
CreatedByID uuid.UUID `json:"created_by_id"`
CreatedByName string `json:"created_by_name"`
CreatedBy User `json:"created_by"`
}
// TemplateVersion returns a template version by ID.

View File

@ -277,7 +277,7 @@ func Test_diff(t *testing.T) {
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{3},
Name: "rust",
CreatedBy: uuid.NullUUID{UUID: uuid.UUID{4}, Valid: true},
CreatedBy: uuid.UUID{4},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
@ -296,11 +296,11 @@ func Test_diff(t *testing.T) {
UpdatedAt: time.Now(),
OrganizationID: uuid.UUID{3},
Name: "rust",
CreatedBy: uuid.NullUUID{UUID: uuid.UUID{4}, Valid: true},
CreatedBy: uuid.UUID{4},
},
exp: audit.Map{
"id": audit.OldNew{Old: "", New: uuid.UUID{1}.String()},
"created_by": audit.OldNew{Old: "null", New: uuid.UUID{4}.String()},
"created_by": audit.OldNew{Old: "", New: uuid.UUID{4}.String()},
"name": audit.OldNew{Old: "", New: "rust"},
},
},

View File

@ -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

View File

@ -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",
},
}))

View File

@ -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>

View File

@ -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>
)
})}
</>
)
}

View File

@ -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

View File

@ -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,
},
}))

View File

@ -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>

View File

@ -1,3 +1,4 @@
{
"deleteSuccess": "Template successfully deleted."
"deleteSuccess": "Template successfully deleted.",
"createdVersion": "created the version"
}

View File

@ -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>

View File

@ -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 = {