From 7bd039e91ba821f016e561ba0ab31392f14c1f88 Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 18 Mar 2022 23:58:50 +0000 Subject: [PATCH 1/4] feat(site): add ActionCell component Summary: This is a direct follow-up to #484 and a next step in porting over the AuditLog with appropriate refactoring. This isn't a direct port; we used to have a map of icons on the FE, but we want to no loner split the logic between FE and BE. If we want icons at a later time point, we can return an icon identifier in the response, or we can simplify the view and gain real estate by omitting the icons. At the time of this commit, I prefer omitting the icons. Details: - Port over ActionCell from v1, sans icons - Add tests and stories for ActionCell Impact: This change does not have any user-facing impact yet, because the ActionCell is not yet rendered in the product. This enables an incremental approach to migrating in the FE of the Audit Log, which is still waiting on the BE port. Relations: - This commit relates to #472, but does not finish it. - This commit should not merged until after #484 because it's based from it --- .../AuditLog/ActionCell.stories.tsx | 15 ++++++ .../components/AuditLog/ActionCell.test.tsx | 54 +++++++++++++++++++ site/src/components/AuditLog/ActionCell.tsx | 31 +++++++++++ 3 files changed, 100 insertions(+) create mode 100644 site/src/components/AuditLog/ActionCell.stories.tsx create mode 100644 site/src/components/AuditLog/ActionCell.test.tsx create mode 100644 site/src/components/AuditLog/ActionCell.tsx diff --git a/site/src/components/AuditLog/ActionCell.stories.tsx b/site/src/components/AuditLog/ActionCell.stories.tsx new file mode 100644 index 0000000000000..44c88fb3d8c93 --- /dev/null +++ b/site/src/components/AuditLog/ActionCell.stories.tsx @@ -0,0 +1,15 @@ +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 Example = Template.bind({}) +Example.args = { + action: "create", +} diff --git a/site/src/components/AuditLog/ActionCell.test.tsx b/site/src/components/AuditLog/ActionCell.test.tsx new file mode 100644 index 0000000000000..3c41c4010ffaf --- /dev/null +++ b/site/src/components/AuditLog/ActionCell.test.tsx @@ -0,0 +1,54 @@ +import { ActionCell, ActionCellProps } from "./ActionCell" +import React from "react" +import { render, screen } from "@testing-library/react" + +namespace Helpers { + export const Component: React.FC = (props) => +} + +describe("ActionCellProps", () => { + it.each<[ActionCellProps, boolean]>([ + [{ action: "Create" }, false], + [{ action: "" }, true], + ])(`validate(%p) throws: %p`, (props, throws) => { + const validate = () => { + ActionCellProps.validate(props) + } + + if (throws) { + expect(validate).toThrowError() + } else { + expect(validate).not.toThrowError() + } + }) +}) + +describe("ActionCell", () => { + it("renders the action", () => { + // Given + const props: ActionCellProps = { + action: "Create", + } + + // When + render() + + // Then + expect(screen.getByText(props.action)).toBeDefined() + }) + + it("throws when action is an empty string", () => { + // Given + const props: ActionCellProps = { + action: "", + } + + // When + const shouldThrow = () => { + render() + } + + // Then + expect(shouldThrow).toThrowError() + }) +}) diff --git a/site/src/components/AuditLog/ActionCell.tsx b/site/src/components/AuditLog/ActionCell.tsx new file mode 100644 index 0000000000000..b9eb707abc593 --- /dev/null +++ b/site/src/components/AuditLog/ActionCell.tsx @@ -0,0 +1,31 @@ +import Box from "@material-ui/core/Box" +import Typography from "@material-ui/core/Typography" +import React from "react" + +export interface ActionCellProps { + action: string +} +export namespace ActionCellProps { + /** + * validate that the received props are valid + * + * @throws Error if invalid + */ + export const validate = (props: ActionCellProps): void => { + if (!props.action.trim()) { + throw new Error(`invalid action: '${props.action}'`) + } + } +} + +export const ActionCell: React.FC = ({ action }) => { + ActionCellProps.validate({ action }) + + return ( + + + {action} + + + ) +} From f77de26e0c6468e2bf97864121e8c815102f93b0 Mon Sep 17 00:00:00 2001 From: G r e y Date: Sat, 19 Mar 2022 10:05:38 +0000 Subject: [PATCH 2/4] fixup! feat(site): add ActionCell component --- site/src/components/AuditLog/ActionCell.test.tsx | 13 +++++++------ site/src/components/AuditLog/ActionCell.tsx | 14 ++++++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/site/src/components/AuditLog/ActionCell.test.tsx b/site/src/components/AuditLog/ActionCell.test.tsx index 3c41c4010ffaf..94a6ac0417a8a 100644 --- a/site/src/components/AuditLog/ActionCell.test.tsx +++ b/site/src/components/AuditLog/ActionCell.test.tsx @@ -7,18 +7,19 @@ namespace Helpers { } describe("ActionCellProps", () => { - it.each<[ActionCellProps, boolean]>([ - [{ action: "Create" }, false], - [{ action: "" }, true], - ])(`validate(%p) throws: %p`, (props, throws) => { + it.each<[ActionCellProps, ActionCellProps, boolean]>([ + [{ action: "Create" }, { action: "Create" }, false], + [{ action: " Create " }, { action: "Create" }, false], + [{ action: "" }, { action: "" }, true], + ])(`validate(%p) throws: %p`, (props, expected, throws) => { const validate = () => { - ActionCellProps.validate(props) + return ActionCellProps.validate(props) } if (throws) { expect(validate).toThrowError() } else { - expect(validate).not.toThrowError() + expect(validate()).toStrictEqual(expected) } }) }) diff --git a/site/src/components/AuditLog/ActionCell.tsx b/site/src/components/AuditLog/ActionCell.tsx index b9eb707abc593..f35627e763574 100644 --- a/site/src/components/AuditLog/ActionCell.tsx +++ b/site/src/components/AuditLog/ActionCell.tsx @@ -11,15 +11,21 @@ export namespace ActionCellProps { * * @throws Error if invalid */ - export const validate = (props: ActionCellProps): void => { - if (!props.action.trim()) { + export const validate = (props: ActionCellProps): ActionCellProps => { + const sanitizedAction = props.action.trim() + + if (!sanitizedAction) { throw new Error(`invalid action: '${props.action}'`) } + + return { + action: sanitizedAction, + } } } -export const ActionCell: React.FC = ({ action }) => { - ActionCellProps.validate({ action }) +export const ActionCell: React.FC = (props) => { + const { action } = ActionCellProps.validate(props) return ( From 606de09b1e901f9a67e8ba36753517dd18b2aa16 Mon Sep 17 00:00:00 2001 From: G r e y Date: Sat, 19 Mar 2022 10:09:00 +0000 Subject: [PATCH 3/4] comment code --- site/src/components/AuditLog/ActionCell.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/site/src/components/AuditLog/ActionCell.tsx b/site/src/components/AuditLog/ActionCell.tsx index f35627e763574..08f76b4fde5af 100644 --- a/site/src/components/AuditLog/ActionCell.tsx +++ b/site/src/components/AuditLog/ActionCell.tsx @@ -24,6 +24,14 @@ export namespace ActionCellProps { } } +/** + * 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 } = ActionCellProps.validate(props) From d35ec5e76267e5a455a22bff2a2395be92a66aa9 Mon Sep 17 00:00:00 2001 From: G r e y Date: Sat, 19 Mar 2022 22:03:03 +0000 Subject: [PATCH 4/4] Merge "StatusCell" with "ActionCell" The old AuditLog had bad use of screen real estate. This change merges the old status cell (that had a large chip) with the action cell. These two cells seem related (wanting to see the action and whether or not it was successful in one spot). NTS: squash this message in before merge --- .../AuditLog/ActionCell.stories.tsx | 11 +++- .../components/AuditLog/ActionCell.test.tsx | 55 ++++++++++++++++--- site/src/components/AuditLog/ActionCell.tsx | 29 +++++++++- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/site/src/components/AuditLog/ActionCell.stories.tsx b/site/src/components/AuditLog/ActionCell.stories.tsx index 44c88fb3d8c93..7134ef5f76a81 100644 --- a/site/src/components/AuditLog/ActionCell.stories.tsx +++ b/site/src/components/AuditLog/ActionCell.stories.tsx @@ -9,7 +9,14 @@ export default { const Template: Story = (args) => -export const Example = Template.bind({}) -Example.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 index 94a6ac0417a8a..7a840e6bcc216 100644 --- a/site/src/components/AuditLog/ActionCell.test.tsx +++ b/site/src/components/AuditLog/ActionCell.test.tsx @@ -1,16 +1,20 @@ -import { ActionCell, ActionCellProps } from "./ActionCell" +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" }, { action: "Create" }, false], - [{ action: " Create " }, { action: "Create" }, false], - [{ action: "" }, { action: "" }, true], + [{ 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) @@ -22,14 +26,25 @@ describe("ActionCellProps", () => { 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: ActionCellProps = { - action: "Create", - } + const props = Helpers.Props // When render() @@ -37,10 +52,10 @@ describe("ActionCell", () => { // Then expect(screen.getByText(props.action)).toBeDefined() }) - it("throws when action is an empty string", () => { // Given const props: ActionCellProps = { + ...Helpers.Props, action: "", } @@ -52,4 +67,28 @@ describe("ActionCell", () => { // 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 index 08f76b4fde5af..6d7a620cbc9cc 100644 --- a/site/src/components/AuditLog/ActionCell.tsx +++ b/site/src/components/AuditLog/ActionCell.tsx @@ -1,9 +1,16 @@ 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 { /** @@ -20,10 +27,21 @@ export namespace ActionCellProps { 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. @@ -33,13 +51,18 @@ export namespace ActionCellProps { * Some common actions are CRUD, Open, signing in etc. */ export const ActionCell: React.FC = (props) => { - const { action } = ActionCellProps.validate(props) + const { action, statusCode } = ActionCellProps.validate(props) + const isSuccess = ActionCellProps.isSuccessStatus(statusCode) + const styles = useStyles(isSuccess) return ( - - + + {action} + + {isSuccess ? LANGUAGE.statusCodeSuccess : LANGUAGE.statusCodeFail} + ) }