diff --git a/site/src/components/AuditLog/ActionCell.stories.tsx b/site/src/components/AuditLog/ActionCell.stories.tsx new file mode 100644 index 0000000000000..7134ef5f76a81 --- /dev/null +++ b/site/src/components/AuditLog/ActionCell.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { ActionCell, ActionCellProps } from "./ActionCell" + +export default { + title: "AuditLog/Cells/ActionCell", + component: ActionCell, +} as ComponentMeta + +const Template: Story = (args) => + +export const Success = Template.bind({}) +Success.args = { + action: "create", + statusCode: 200, +} + +export const Failure = Template.bind({}) +Failure.args = { + action: "create", + statusCode: 500, +} diff --git a/site/src/components/AuditLog/ActionCell.test.tsx b/site/src/components/AuditLog/ActionCell.test.tsx new file mode 100644 index 0000000000000..7a840e6bcc216 --- /dev/null +++ b/site/src/components/AuditLog/ActionCell.test.tsx @@ -0,0 +1,94 @@ +import { ActionCell, ActionCellProps, LANGUAGE } from "./ActionCell" +import React from "react" +import { render, screen } from "@testing-library/react" + +namespace Helpers { + export const Props: ActionCellProps = { + action: "create", + statusCode: 200, + } + export const Component: React.FC = (props) => +} + +describe("ActionCellProps", () => { + it.each<[ActionCellProps, ActionCellProps, boolean]>([ + [{ action: "Create", statusCode: 200 }, { action: "Create", statusCode: 200 }, false], + [{ action: " Create ", statusCode: 400 }, { action: "Create", statusCode: 400 }, false], + [{ action: "", statusCode: 200 }, { action: "", statusCode: 200 }, true], + ])(`validate(%p) throws: %p`, (props, expected, throws) => { + const validate = () => { + return ActionCellProps.validate(props) + } + + if (throws) { + expect(validate).toThrowError() + } else { + expect(validate()).toStrictEqual(expected) + } + }) + it.each<[number, boolean]>([ + // success cases + [200, true], + [201, true], + [302, true], + // failure cases + [400, false], + [404, false], + [500, false], + ])(`isSuccessStatus(%p) returns %p`, (statusCode, expected) => { + expect(ActionCellProps.isSuccessStatus(statusCode)).toBe(expected) + }) +}) + +describe("ActionCell", () => { + // action cases + it("renders the action", () => { + // Given + const props = Helpers.Props + + // When + render() + + // Then + expect(screen.getByText(props.action)).toBeDefined() + }) + it("throws when action is an empty string", () => { + // Given + const props: ActionCellProps = { + ...Helpers.Props, + action: "", + } + + // When + const shouldThrow = () => { + render() + } + + // Then + expect(shouldThrow).toThrowError() + }) + + // statusCode cases + it.each<[number, string]>([ + // Success cases + [200, LANGUAGE.statusCodeSuccess], + [201, LANGUAGE.statusCodeSuccess], + [302, LANGUAGE.statusCodeSuccess], + // Failure cases + [400, LANGUAGE.statusCodeFail], + [404, LANGUAGE.statusCodeFail], + [500, LANGUAGE.statusCodeFail], + ])("renders %p when statusCode is %p", (statusCode, expected) => { + // Given + const props: ActionCellProps = { + ...Helpers.Props, + statusCode, + } + + // When + render() + + // Then + expect(screen.getByText(expected)).toBeDefined() + }) +}) diff --git a/site/src/components/AuditLog/ActionCell.tsx b/site/src/components/AuditLog/ActionCell.tsx new file mode 100644 index 0000000000000..6d7a620cbc9cc --- /dev/null +++ b/site/src/components/AuditLog/ActionCell.tsx @@ -0,0 +1,68 @@ +import Box from "@material-ui/core/Box" +import Typography from "@material-ui/core/Typography" +import makeStyles from "@material-ui/core/styles/makeStyles" +import React from "react" + +export const LANGUAGE = { + statusCodeFail: "failure", + statusCodeSuccess: "success", +} + +export interface ActionCellProps { + action: string + statusCode: number +} +export namespace ActionCellProps { + /** + * validate that the received props are valid + * + * @throws Error if invalid + */ + export const validate = (props: ActionCellProps): ActionCellProps => { + const sanitizedAction = props.action.trim() + + if (!sanitizedAction) { + throw new Error(`invalid action: '${props.action}'`) + } + + return { + action: sanitizedAction, + statusCode: props.statusCode, + } + } + + export const isSuccessStatus = (statusCode: ActionCellProps["statusCode"]): boolean => { + return statusCode >= 100 && statusCode < 400 + } +} + +const useStyles = makeStyles((theme) => ({ + statusText: (isSuccess: boolean) => ({ + color: isSuccess ? theme.palette.primary.main : theme.palette.error.main, + }), +})) + +/** + * ActionCell is a single cell in an audit log table row that contains + * information about an action that was taken on a resource. + * + * @remarks + * + * Some common actions are CRUD, Open, signing in etc. + */ +export const ActionCell: React.FC = (props) => { + const { action, statusCode } = ActionCellProps.validate(props) + const isSuccess = ActionCellProps.isSuccessStatus(statusCode) + const styles = useStyles(isSuccess) + + return ( + + + {action} + + + {isSuccess ? LANGUAGE.statusCodeSuccess : LANGUAGE.statusCodeFail} + + + ) +}