From 6dfaa5fcc40a8a7bf012aa936465e425f38e5a3b Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 May 2024 15:12:18 +0100 Subject: [PATCH 01/23] WIP edit in place component --- .../EditInPlace/EditInPlace.module.css | 127 +++++++++++ .../EditInPlace/EditInPlace.stories.tsx | 93 ++++++++ .../Form/Controls/EditInPlace/EditInPlace.tsx | 205 ++++++++++++++++++ .../Form/Controls/EditInPlace/index.ts | 17 ++ 4 files changed, 442 insertions(+) create mode 100644 src/components/Form/Controls/EditInPlace/EditInPlace.module.css create mode 100644 src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx create mode 100644 src/components/Form/Controls/EditInPlace/EditInPlace.tsx create mode 100644 src/components/Form/Controls/EditInPlace/index.ts diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.module.css b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css new file mode 100644 index 00000000..5a5e85b8 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css @@ -0,0 +1,127 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.container { + min-height: 110px; +} + +.label { + font-size: 15px; + font-weight: 500; + margin-bottom: 4px; +} + +.controls { + display: grid; + grid-template-columns: 1fr auto; + gap: 15px; +} + +.buttonGroup { + display: none; + position: relative; + top: 5px; + grid-template-columns: 1fr 1fr; + gap: 7px; +} + +.button { + display: flex; + width: 36px; + height: 36px; + border: 1px solid var(--cpd-color-border-interactive-secondary); + border-radius: 20px; + background-color: transparent; + text-align: center; +} + +.primaryButton { + background-color: var(--cpd-color-bg-action-primary-rest); + border-color: var(--cpd-color-bg-action-primary-rest); + svg { + color: var(--cpd-color-icon-on-solid-primary); + } +} + +.primaryButtonDisabled { + background-color: var(--cpd-color-bg-subtle-primary); + border-color: var(--cpd-color-bg-subtle-primary); + svg { + color: var(--cpd-color-icon-disabled); + } +} + +.button svg { + width: 24px; + height: 24px; + margin: auto; +} + +.control { + width: 100%; +} + +.container:focus-within .buttonGroup { + display: inline-grid; +} + +.containerError .control { + border-color: var(--cpd-color-border-critical-primary); +} + +.containerError .label { + color: var(--cpd-color-text-critical-primary); +} + +.captionLine { + margin-top: var(--cpd-space-2x); +} + +.captionIcon { + display: inline-block; + vertical-align: middle; + width: 20px; + height: 20px; + margin-right: var(--cpd-space-2x); +} + +.captionIconError { + color: var(--cpd-color-icon-critical-primary); +} + +.captionIconSaved { + background-color: var(--cpd-color-icon-success-primary); + border-radius: 20px; + text-align: center; + margin-right: var(--cpd-space-2x); + + svg { + color: var(--cpd-color-icon-on-solid-primary); + } +} + +.captionText { + vertical-align: middle; + font-size: 13px; +} + +.captionTextError { + color: var(--cpd-color-text-critical-primary); +} + +.captionTextSaved { + color: var(--cpd-color-text-success-primary); +} diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx new file mode 100644 index 00000000..8a6a579c --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx @@ -0,0 +1,93 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import { EditInPlace } from "./"; +import { Meta, StoryObj } from "@storybook/react"; + +type Props = { invalid?: boolean } & React.ComponentProps; + +export default { + title: "Form/Controls/EditInPlace", + component: EditInPlace, + tags: ["autodocs"], + parameters: { + controls: { + include: [ + "onChange", + "onSave", + "onCancel", + "value", + "valueIsChanged", + "error", + "savedLabel", + "saveButtonLabel", + "cancelButtonLabel", + ], + }, + }, + argTypes: { + label: { + type: "string", + }, + value: { + type: "string", + }, + valueIsChanged: { + type: "boolean", + }, + onChange: { + action: "changed", + }, + onSave: { + action: "saved", + }, + onCancel: { + action: "cancelled", + }, + error: { + type: "string", + }, + savedLabel: { + type: "string", + }, + saveButtonLabel: { + type: "string", + }, + cancelButtonLabel: { + type: "string", + }, + }, + render: ({ ...restArgs }) => , + args: { + label: "Label", + value: "", + saveButtonLabel: "Save", + cancelButtonLabel: "Cancel", + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Empty: Story = { + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/file/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?type=design&node-id=4335-2016&mode=design&t=BvxRca0YDRaq20IR-0", + }, + }, +}; diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx new file mode 100644 index 00000000..6d536cb9 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -0,0 +1,205 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classnames from "classnames"; +import React, { forwardRef, useCallback } from "react"; +import styles from "./EditInPlace.module.css"; +import { TextInput } from "../Text"; +import useId from "../../../../utils/useId"; + +import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg"; +import CancelIcon from "@vector-im/compound-design-tokens/icons/close.svg"; +import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg"; + +type Props = { + /** + * The label for the control + */ + label: string; + + /** + * The CSS class name. + */ + className?: string; + + /** + * The content of the text box + */ + value: string; + + /** + * Whether the value has been changed from its initial value. If false, + * the save button will be disabled. + * Default: false + */ + valueIsChanged?: boolean; + + /** + * Event handler for when the user has edited the value in the text box + * (but not yet saved/confirmed it) + */ + onChange: (value: string) => void; + + /** + * Callback for when the user confirms the change + */ + onSave: () => Promise; + + /** + * Calback for when the user wishes to cancel the change + */ + onCancel: () => void; + + /** + * Error message to be displayed below the box. If supplied, will disable the + * save button. + */ + error?: string; + + /** + * Label to be displayed by the green check at the bottom. Will only be displayed + * for 2 seconds after the onSave callback promise resolves successfully. + */ + savedLabel?: string; + + /** + * The label for the save button + */ + saveButtonLabel?: string; + + /** + * The label for the cancel button + */ + cancelButtonLabel?: string; +} & React.ComponentProps; + +/** + * A text box with save/cancel buttons that appear when the field is active + */ +export const EditInPlace = forwardRef( + function EditInPlace( + { + className, + valueIsChanged, + onSave, + onCancel, + saveButtonLabel, + cancelButtonLabel, + error, + savedLabel, + ...props + }, + ref, + ) { + const id = useId(); + const labelId = useId(); + const classes = classnames(styles.container, className, { + [styles.containerError]: Boolean(error), + }); + + const saveDisabled = Boolean(error) || !valueIsChanged; + + const [showSaved, setShowSaved] = React.useState(false); + + const onSaveButonClicked = useCallback(async () => { + if (saveDisabled) return; + + try { + onSave(); + setShowSaved(true); + setTimeout(() => { + setShowSaved(false); + }, 2000); + } catch (e) { + // We don't really need to do anything here, we just don't want to display the + // 'saved' label, obviously. The user of the component can update the error to + // show what failed. + } + }, []); + + return ( +
+
+ {props.label} +
+
+ +
+
+ +
+
+ +
+
+
+ {error && ( +
+ + + {error} + +
+ )} + {savedLabel && showSaved && ( +
+
+ +
+ + {savedLabel} + +
+ )} +
+ ); + }, +); diff --git a/src/components/Form/Controls/EditInPlace/index.ts b/src/components/Form/Controls/EditInPlace/index.ts new file mode 100644 index 00000000..7d6ffb37 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { EditInPlace } from "./EditInPlace"; From a1a062f1b38f33a4c17510553b30a8d208b99d6e Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 May 2024 16:27:51 +0100 Subject: [PATCH 02/23] lint styles --- .../EditInPlace/EditInPlace.module.css | 54 ++++++++++--------- .../Form/Controls/EditInPlace/EditInPlace.tsx | 28 +++++----- 2 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.module.css b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css index 5a5e85b8..e36fc23e 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.module.css +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css @@ -15,13 +15,13 @@ limitations under the License. */ .container { - min-height: 110px; + min-block-size: 110px; } .label { font-size: 15px; font-weight: 500; - margin-bottom: 4px; + margin-block-end: 4px; } .controls { @@ -30,98 +30,100 @@ limitations under the License. gap: 15px; } -.buttonGroup { +.button-group { display: none; position: relative; - top: 5px; + inset-block-start: 5px; grid-template-columns: 1fr 1fr; gap: 7px; } .button { display: flex; - width: 36px; - height: 36px; + inline-size: 36px; + block-size: 36px; border: 1px solid var(--cpd-color-border-interactive-secondary); border-radius: 20px; background-color: transparent; text-align: center; } -.primaryButton { +.primary-button { background-color: var(--cpd-color-bg-action-primary-rest); border-color: var(--cpd-color-bg-action-primary-rest); + svg { color: var(--cpd-color-icon-on-solid-primary); } } -.primaryButtonDisabled { +.primary-button-disabled { background-color: var(--cpd-color-bg-subtle-primary); border-color: var(--cpd-color-bg-subtle-primary); + svg { color: var(--cpd-color-icon-disabled); } } .button svg { - width: 24px; - height: 24px; + inline-size: 24px; + block-size: 24px; margin: auto; } .control { - width: 100%; + inline-size: 100%; } -.container:focus-within .buttonGroup { +.container:focus-within .button-group { display: inline-grid; } -.containerError .control { +.container-error .control { border-color: var(--cpd-color-border-critical-primary); } -.containerError .label { +.container-error .label { color: var(--cpd-color-text-critical-primary); } -.captionLine { - margin-top: var(--cpd-space-2x); +.caption-line { + margin-block-start: var(--cpd-space-2x); } -.captionIcon { +.caption-icon { display: inline-block; vertical-align: middle; - width: 20px; - height: 20px; - margin-right: var(--cpd-space-2x); + inline-size: 20px; + block-size: 20px; + margin-inline-end: var(--cpd-space-2x); } -.captionIconError { +.caption-icon-error { color: var(--cpd-color-icon-critical-primary); } -.captionIconSaved { +.caption-icon-saved { background-color: var(--cpd-color-icon-success-primary); border-radius: 20px; text-align: center; - margin-right: var(--cpd-space-2x); + margin-inline-end: var(--cpd-space-2x); svg { color: var(--cpd-color-icon-on-solid-primary); } } -.captionText { +.caption-text { vertical-align: middle; font-size: 13px; } -.captionTextError { +.caption-text-error { color: var(--cpd-color-text-critical-primary); } -.captionTextSaved { +.caption-text-saved { color: var(--cpd-color-text-success-primary); } diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx index 6d536cb9..6a557854 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -107,7 +107,7 @@ export const EditInPlace = forwardRef( const id = useId(); const labelId = useId(); const classes = classnames(styles.container, className, { - [styles.containerError]: Boolean(error), + [styles["container-error"]]: Boolean(error), }); const saveDisabled = Boolean(error) || !valueIsChanged; @@ -137,11 +137,11 @@ export const EditInPlace = forwardRef(
-
+
(
{error && ( -
+
{error} @@ -180,19 +180,19 @@ export const EditInPlace = forwardRef(
)} {savedLabel && showSaved && ( -
+
{savedLabel} From c7a8d7853a2963151c9327e28532dd940b0f4bdb Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 May 2024 17:07:19 +0100 Subject: [PATCH 03/23] Add screenshot --- ...ntrols-EditInPlace-Empty-1-chromium-linux.png | Bin 0 -> 5234 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..63caa4fdd2abed1c78bf28465afe765ae8feedb8 GIT binary patch literal 5234 zcmeI0?Nd`{8pfZyW;030JPgvSD~Yh>$uZ#R6WGXY%-Ul5d7^v8fNIlKhG zL66ArKgQ?QYL(0uCAFY`)u8S9oi?OsQfNRYG*(_<#=Lg2HsJcw5B(m~;&l8kX{q-- z%pW+^4E@2Sa&L1HA+x;8(@XulJ<8`Bmgf`xbanApKh7SERivIDZ0PARtiLd`9h1Ch zOpj&_HY1$ zcA3;jX4@H&ZSaIH8!8JuJAYNxnMy90?K;#bLIO}5h4VcV=ogU4X0zi|Y!{Qsq*)oc z9Jjs;6xjqP5SFpuy1NT{7_4S%aWPh8Y*8;QTgyo_R1dGTtdz9zmP1vfKdf6Z5LwCu z2RcL3(jnq>b$2)BDCQrE|<&UaQMbPv&K79+=|6w@8B3p zR%Q!x^C6SZBAG0eB+8q)G)r%*d}q$#AkzJ(;%NK~#uL8OWHvI<-`Wnt{JarSGd6>aTl zFCqe@0${TWYEQ9i#MuAKk&&oW!OTq zXj7Q&Dt$dI0VAb}&BjRiGAyWD@XG`|z7B(djB`P7-5W1B!q1n!%OI0mITx$O%lM{M z--k4vDvq)sY0cBWzGR32pdk+t;^!C7wB2O}EO$#J5^RnH$K$F~Ix>0s!tBr7Tm$0e zQ#a-!4QYO^5Ke4iKxk$z+0hXxKc3agi4L{2DIU%ANJmFw+>OwX=30M6a&9~Q6$w<(5=$`|N-<};{hc;=s<{3oH?jg_r@ahOB&=6*uZpP2LfTDEDd?^)L zt0$^|s$--wDw`eFUcO8utGe*PcHmSifASH3IdA%btGhc(8rXPvq$0STEfZO8y^ew- z`PW4KItCgb1f^0-PdKdo%8Zn4vNUNDn9@___Imh*o$171wu(zE(lKE};27L;KXm$n zCbOz~uhv~3M1;_PZbC~D;MB2ba@(+p5g{ty56_TYu;klsiQBgrVHUZ%guJLthmrdA z(_?IE2D{)2@oza{hMP25v8wl-E&;GPAi9I$Gic$BNufmg5rW`nBndi2?8z!t+=3TF^LfQPsM|JS*++1(9anLKtiFbV#N2qMo%hx0$acI!WaT0m9+ literal 0 HcmV?d00001 From 9a9b10a716c9ba157b597bc4bba6ac230e9d7b91 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 May 2024 17:40:13 +0100 Subject: [PATCH 04/23] Use real buttons --- src/components/Form/Controls/EditInPlace/EditInPlace.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx index 6a557854..0ffc4376 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -138,8 +138,7 @@ export const EditInPlace = forwardRef(
-
( aria-disabled={saveDisabled} > -
-
+
+
{error && ( From 972919ec2dd1c05a72ddafc919a38a9c9248dea9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 May 2024 17:45:54 +0100 Subject: [PATCH 05/23] Clear timeout on unmount --- .../Form/Controls/EditInPlace/EditInPlace.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx index 0ffc4376..0c125019 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import classnames from "classnames"; -import React, { forwardRef, useCallback } from "react"; +import React, { forwardRef, useCallback, useRef } from "react"; import styles from "./EditInPlace.module.css"; import { TextInput } from "../Text"; import useId from "../../../../utils/useId"; @@ -114,13 +114,21 @@ export const EditInPlace = forwardRef( const [showSaved, setShowSaved] = React.useState(false); + const hideTimer = useRef(null); + + React.useEffect(() => { + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; + }); + const onSaveButonClicked = useCallback(async () => { if (saveDisabled) return; try { onSave(); setShowSaved(true); - setTimeout(() => { + hideTimer.current = setTimeout(() => { setShowSaved(false); }, 2000); } catch (e) { From 66245100a755cc713fd59932e32a237998cedfa6 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 May 2024 18:03:45 +0100 Subject: [PATCH 06/23] Basic test --- .../Controls/EditInPlace/EditInPlace.test.tsx | 45 ++++++++++++ .../__snapshots__/EditInPlace.test.tsx.snap | 73 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx create mode 100644 src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx new file mode 100644 index 00000000..f3fb0a2a --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx @@ -0,0 +1,45 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect } from "vitest"; +import React from "react"; +import { render } from "@testing-library/react"; + +import { EditInPlace } from "./EditInPlace"; +import { Root, Field, Control } from "@radix-ui/react-form"; + +describe("PasswordControl", () => { + const EditInPlaceTest = () => ( + + + + {}} + onSave={() => Promise.resolve()} + onCancel={() => {}} + /> + + + + ); + + it("renders", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap new file mode 100644 index 00000000..84dbd07b --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap @@ -0,0 +1,73 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PasswordControl > renders 1`] = ` + +
+
+
+
+ Edit Me +
+
+ +
+ + +
+
+
+
+
+
+`; From 48566616d2d6ee6770d511c9327c7ac36056f49f Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 May 2024 13:03:31 +0100 Subject: [PATCH 07/23] Add tests and also change the inital value behaviour as I think this is probably a bit more natural. --- .../EditInPlace/EditInPlace.stories.tsx | 6 +- .../Controls/EditInPlace/EditInPlace.test.tsx | 110 +++++++++++++++++- .../Form/Controls/EditInPlace/EditInPlace.tsx | 31 ++--- 3 files changed, 126 insertions(+), 21 deletions(-) diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx index 8a6a579c..d681a66e 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx @@ -32,7 +32,7 @@ export default { "onSave", "onCancel", "value", - "valueIsChanged", + "initialValue", "error", "savedLabel", "saveButtonLabel", @@ -47,8 +47,8 @@ export default { value: { type: "string", }, - valueIsChanged: { - type: "boolean", + initialValue: { + type: "string", }, onChange: { action: "changed", diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx index f3fb0a2a..e377ddfd 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx @@ -14,32 +14,132 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import React from "react"; -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { EditInPlace } from "./EditInPlace"; import { Root, Field, Control } from "@radix-ui/react-form"; +import { userEvent } from "@storybook/test"; +import { afterEach } from "node:test"; +import { act } from "react-dom/test-utils"; + +type EditInPlaceTestProps = { + error?: string; + value?: string; + onSave?: () => Promise; +}; describe("PasswordControl", () => { - const EditInPlaceTest = () => ( + const EditInPlaceTest = (props: EditInPlaceTestProps) => ( {}} - onSave={() => Promise.resolve()} + onSave={props.onSave ?? (() => Promise.resolve())} onCancel={() => {}} + saveButtonLabel="Save" + cancelButtonLabel="Cancel" + savedLabel={"Saved"} + initialValue={"Edit this text"} /> ); + afterEach(() => { + vi.useRealTimers(); + }); + it("renders", () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); + + it("field is valid if no error specified", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).toBeValid(); + }); + + it("renders error icon and text if error provided", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).toBeInvalid(); + expect(input).toHaveAttribute("aria-errormessage"); + + const errorText = screen.getByText("Missing Left Falangey"); + expect(errorText).toBeInTheDocument(); + + expect(errorText.id).toEqual(input.getAttribute("aria-errormessage")); + }); + + it("should disable save button if there's an error", () => { + render(); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).toBeDisabled(); + }); + + it("disables save button when value not changed", () => { + render(); + + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("enables save button when value is changed", () => { + render(); + + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("calls save callback on save button click", async () => { + const onSave = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalled(); + }); + + it("displays saved label for 2 seconds after save", async () => { + vi.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(screen.getByText("Saved")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1900); + }); + + expect(screen.queryByText("Saved")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("does not called onSave if cancel pressed", async () => { + const onSave = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onSave).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx index 0c125019..999140f1 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -41,11 +41,10 @@ type Props = { value: string; /** - * Whether the value has been changed from its initial value. If false, - * the save button will be disabled. - * Default: false + * The 'initial' value of the field. If the current value is equal to this, the save + * button will be disabled (as it will be considered not to have changed) */ - valueIsChanged?: boolean; + initialValue: string; /** * Event handler for when the user has edited the value in the text box @@ -78,7 +77,7 @@ type Props = { /** * The label for the save button */ - saveButtonLabel?: string; + saveButtonLabel: string; /** * The label for the cancel button @@ -93,7 +92,7 @@ export const EditInPlace = forwardRef( function EditInPlace( { className, - valueIsChanged, + initialValue, onSave, onCancel, saveButtonLabel, @@ -106,11 +105,12 @@ export const EditInPlace = forwardRef( ) { const id = useId(); const labelId = useId(); + const errorTextId = useId(); const classes = classnames(styles.container, className, { [styles["container-error"]]: Boolean(error), }); - const saveDisabled = Boolean(error) || !valueIsChanged; + const saveDisabled = Boolean(error) || props.value === initialValue; const [showSaved, setShowSaved] = React.useState(false); @@ -120,11 +120,9 @@ export const EditInPlace = forwardRef( return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; - }); + }, []); const onSaveButonClicked = useCallback(async () => { - if (saveDisabled) return; - try { onSave(); setShowSaved(true); @@ -136,7 +134,7 @@ export const EditInPlace = forwardRef( // 'saved' label, obviously. The user of the component can update the error to // show what failed. } - }, []); + }, [setShowSaved, onSave, hideTimer]); return (
@@ -144,7 +142,13 @@ export const EditInPlace = forwardRef( {props.label}
- +
@@ -177,6 +181,7 @@ export const EditInPlace = forwardRef( )} /> Date: Thu, 16 May 2024 13:06:02 +0100 Subject: [PATCH 08/23] Update snapshot --- .../EditInPlace/__snapshots__/EditInPlace.test.tsx.snap | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap index 84dbd07b..4990a968 100644 --- a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap +++ b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap @@ -18,6 +18,7 @@ exports[`PasswordControl > renders 1`] = ` class="_controls_980156" > renders 1`] = ` > diff --git a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap index 4990a968..38290959 100644 --- a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap +++ b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap @@ -32,8 +32,7 @@ exports[`PasswordControl > renders 1`] = ` + +
+
+
+
+ + +`; + exports[`PasswordControl > renders 1`] = `
From 897cabbe2e70ab86d8658eee5127181632f3e884 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 30 May 2024 13:08:46 +0100 Subject: [PATCH 23/23] Don't need that screenshot --- ...ancel-buttons-correctly-1-chromium-darwin.png | Bin 8316 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-darwin.png diff --git a/playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-darwin.png b/playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-darwin.png deleted file mode 100644 index b82bd85e08f11737f8f69b4de5f9e786e3c50da7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8316 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