Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a691e63

Browse files
authored
Add edit and remove actions to link in RTE (#9864)
Add edit and remove actions to link in RTE
1 parent 79033eb commit a691e63

File tree

9 files changed

+208
-57
lines changed

9 files changed

+208
-57
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"dependencies": {
5858
"@babel/runtime": "^7.12.5",
5959
"@matrix-org/analytics-events": "^0.3.0",
60-
"@matrix-org/matrix-wysiwyg": "^0.13.0",
60+
"@matrix-org/matrix-wysiwyg": "^0.14.0",
6161
"@matrix-org/react-sdk-module-api": "^0.0.3",
6262
"@sentry/browser": "^7.0.0",
6363
"@sentry/tracing": "^7.0.0",

res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,32 @@ limitations under the License.
1616

1717
.mx_LinkModal {
1818
padding: $spacing-32;
19-
20-
.mx_Dialog_content {
21-
margin-top: 30px;
22-
margin-bottom: 42px;
23-
}
19+
max-width: 600px;
20+
height: 341px;
21+
box-sizing: border-box;
22+
display: flex;
23+
flex-direction: column;
2424

2525
.mx_LinkModal_content {
2626
display: flex;
2727
flex-direction: column;
28+
flex: 1;
29+
gap: $spacing-8;
30+
margin-top: 7px;
31+
32+
.mx_LinkModal_Field {
33+
flex: initial;
34+
height: 40px;
35+
}
36+
37+
.mx_LinkModal_buttons {
38+
display: flex;
39+
flex: 1;
40+
align-items: flex-end;
41+
42+
.mx_Dialog_buttons {
43+
display: inline-block;
44+
}
45+
}
2846
}
2947
}

src/components/views/elements/Field.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
262262

263263
this.inputRef = inputRef || React.createRef();
264264

265-
inputProps.placeholder = inputProps.placeholder || inputProps.label;
265+
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
266266
inputProps.id = this.id; // this overwrites the id from props
267267

268268
inputProps.onFocus = this.onFocus;

src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
120120
<Button
121121
isActive={actionStates.link === "reversed"}
122122
label={_td("Link")}
123-
onClick={() => openLinkModal(composer, composerContext)}
123+
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
124124
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
125125
/>
126126
</div>

src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,28 @@ limitations under the License.
1717
import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
1818
import React, { ChangeEvent, useState } from "react";
1919

20-
import { _td } from "../../../../../languageHandler";
20+
import { _t } from "../../../../../languageHandler";
2121
import Modal from "../../../../../Modal";
22-
import QuestionDialog from "../../../dialogs/QuestionDialog";
2322
import Field from "../../../elements/Field";
2423
import { ComposerContextState } from "../ComposerContext";
2524
import { isSelectionEmpty, setSelection } from "../utils/selection";
25+
import BaseDialog from "../../../dialogs/BaseDialog";
26+
import DialogButtons from "../../../elements/DialogButtons";
2627

27-
export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) {
28+
export function openLinkModal(
29+
composer: FormattingFunctions,
30+
composerContext: ComposerContextState,
31+
isEditing: boolean,
32+
) {
2833
const modal = Modal.createDialog(
2934
LinkModal,
30-
{ composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() },
35+
{
36+
composerContext,
37+
composer,
38+
onClose: () => modal.close(),
39+
isTextEnabled: isSelectionEmpty(),
40+
isEditing,
41+
},
3142
"mx_CompoundDialog",
3243
false,
3344
true,
@@ -43,48 +54,86 @@ interface LinkModalProps {
4354
isTextEnabled: boolean;
4455
onClose: () => void;
4556
composerContext: ComposerContextState;
57+
isEditing: boolean;
4658
}
4759

48-
export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) {
49-
const [fields, setFields] = useState({ text: "", link: "" });
50-
const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link);
60+
export function LinkModal({ composer, isTextEnabled, onClose, composerContext, isEditing }: LinkModalProps) {
61+
const [hasLinkChanged, setHasLinkChanged] = useState(false);
62+
const [fields, setFields] = useState({ text: "", link: isEditing ? composer.getLink() : "" });
63+
const hasText = !isEditing && isTextEnabled;
64+
const isSaveDisabled = !hasLinkChanged || (hasText && isEmpty(fields.text)) || isEmpty(fields.link);
5165

5266
return (
53-
<QuestionDialog
67+
<BaseDialog
5468
className="mx_LinkModal"
55-
title={_td("Create a link")}
56-
button={_td("Save")}
57-
buttonDisabled={isSaveDisabled}
58-
hasCancelButton={true}
59-
onFinished={async (isClickOnSave: boolean) => {
60-
if (isClickOnSave) {
69+
title={isEditing ? _t("Edit link") : _t("Create a link")}
70+
hasCancel={true}
71+
onFinished={onClose}
72+
>
73+
<form
74+
className="mx_LinkModal_content"
75+
onSubmit={async (evt) => {
76+
evt.preventDefault();
77+
evt.stopPropagation();
78+
79+
onClose();
80+
81+
// When submitting is done when pressing enter when the link field has the focus,
82+
// The link field is getting back the focus (due to react-focus-lock)
83+
// So we are waiting that the focus stuff is done to play with the composer selection
84+
await new Promise((resolve) => setTimeout(resolve, 0));
85+
6186
await setSelection(composerContext.selection);
6287
composer.link(fields.link, isTextEnabled ? fields.text : undefined);
63-
}
64-
onClose();
65-
}}
66-
description={
67-
<div className="mx_LinkModal_content">
68-
{isTextEnabled && (
69-
<Field
70-
autoFocus={true}
71-
label={_td("Text")}
72-
value={fields.text}
73-
onChange={(e: ChangeEvent<HTMLInputElement>) =>
74-
setFields((fields) => ({ ...fields, text: e.target.value }))
75-
}
76-
/>
77-
)}
88+
}}
89+
>
90+
{hasText && (
7891
<Field
79-
autoFocus={!isTextEnabled}
80-
label={_td("Link")}
81-
value={fields.link}
92+
required={true}
93+
autoFocus={true}
94+
label={_t("Text")}
95+
value={fields.text}
96+
className="mx_LinkModal_Field"
97+
placeholder=""
8298
onChange={(e: ChangeEvent<HTMLInputElement>) =>
83-
setFields((fields) => ({ ...fields, link: e.target.value }))
99+
setFields((fields) => ({ ...fields, text: e.target.value }))
84100
}
85101
/>
102+
)}
103+
<Field
104+
required={true}
105+
autoFocus={!hasText}
106+
label={_t("Link")}
107+
value={fields.link}
108+
className="mx_LinkModal_Field"
109+
placeholder=""
110+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
111+
setFields((fields) => ({ ...fields, link: e.target.value }));
112+
setHasLinkChanged(true);
113+
}}
114+
/>
115+
116+
<div className="mx_LinkModal_buttons">
117+
{isEditing && (
118+
<button
119+
type="button"
120+
className="danger"
121+
onClick={() => {
122+
composer.removeLinks();
123+
onClose();
124+
}}
125+
>
126+
{_t("Remove")}
127+
</button>
128+
)}
129+
<DialogButtons
130+
primaryButton={_t("Save")}
131+
primaryDisabled={isSaveDisabled}
132+
primaryIsSubmit={true}
133+
onCancel={onClose}
134+
/>
86135
</div>
87-
}
88-
/>
136+
</form>
137+
</BaseDialog>
89138
);
90139
}

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,6 +2136,7 @@
21362136
"Underline": "Underline",
21372137
"Code": "Code",
21382138
"Link": "Link",
2139+
"Edit link": "Edit link",
21392140
"Create a link": "Create a link",
21402141
"Text": "Text",
21412142
"Message Actions": "Message Actions",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React from "react";
18+
import { render, screen } from "@testing-library/react";
19+
20+
import Field from "../../../../src/components/views/elements/Field";
21+
22+
describe("Field", () => {
23+
describe("Placeholder", () => {
24+
it("Should display a placeholder", async () => {
25+
// When
26+
const { rerender } = render(<Field value="" placeholder="my placeholder" />);
27+
28+
// Then
29+
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my placeholder");
30+
31+
// When
32+
rerender(<Field value="" placeholder="" />);
33+
34+
// Then
35+
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "");
36+
});
37+
38+
it("Should display label as placeholder", async () => {
39+
// When
40+
render(<Field value="" label="my label" />);
41+
42+
// Then
43+
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my label");
44+
});
45+
46+
it("Should not display a placeholder", async () => {
47+
// When
48+
render(<Field value="" />);
49+
50+
// Then
51+
expect(screen.getByRole("textbox")).not.toHaveAttribute("placeholder", "my placeholder");
52+
});
53+
});
54+
});

test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import { SubSelection } from "../../../../../../src/components/views/rooms/wysiw
2727
describe("LinkModal", () => {
2828
const formattingFunctions = {
2929
link: jest.fn(),
30+
removeLinks: jest.fn(),
31+
getLink: jest.fn().mockReturnValue("my initial content"),
3032
} as unknown as FormattingFunctions;
3133
const defaultValue: SubSelection = {
3234
focusNode: null,
@@ -35,13 +37,14 @@ describe("LinkModal", () => {
3537
anchorOffset: 4,
3638
};
3739

38-
const customRender = (isTextEnabled: boolean, onClose: () => void) => {
40+
const customRender = (isTextEnabled: boolean, onClose: () => void, isEditing = false) => {
3941
return render(
4042
<LinkModal
4143
composer={formattingFunctions}
4244
isTextEnabled={isTextEnabled}
4345
onClose={onClose}
4446
composerContext={{ selection: defaultValue }}
47+
isEditing={isEditing}
4548
/>,
4649
);
4750
};
@@ -75,13 +78,13 @@ describe("LinkModal", () => {
7578
// When
7679
jest.useFakeTimers();
7780
screen.getByText("Save").click();
81+
jest.runAllTimers();
7882

7983
// Then
80-
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
81-
await waitFor(() => expect(onClose).toBeCalledTimes(1));
82-
83-
// When
84-
jest.runAllTimers();
84+
await waitFor(() => {
85+
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
86+
expect(onClose).toBeCalledTimes(1);
87+
});
8588

8689
// Then
8790
expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined);
@@ -118,15 +121,41 @@ describe("LinkModal", () => {
118121
// When
119122
jest.useFakeTimers();
120123
screen.getByText("Save").click();
124+
jest.runAllTimers();
121125

122126
// Then
123-
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
124-
await waitFor(() => expect(onClose).toBeCalledTimes(1));
127+
await waitFor(() => {
128+
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
129+
expect(onClose).toBeCalledTimes(1);
130+
});
125131

132+
// Then
133+
expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t");
134+
});
135+
136+
it("Should remove the link", async () => {
126137
// When
127-
jest.runAllTimers();
138+
const onClose = jest.fn();
139+
customRender(true, onClose, true);
140+
await userEvent.click(screen.getByText("Remove"));
128141

129142
// Then
130-
expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t");
143+
expect(formattingFunctions.removeLinks).toHaveBeenCalledTimes(1);
144+
expect(onClose).toBeCalledTimes(1);
145+
});
146+
147+
it("Should display the link in editing", async () => {
148+
// When
149+
customRender(true, jest.fn(), true);
150+
151+
// Then
152+
expect(screen.getByLabelText("Link")).toContainHTML("my initial content");
153+
expect(screen.getByText("Save")).toBeDisabled();
154+
155+
// When
156+
await userEvent.type(screen.getByLabelText("Link"), "l");
157+
158+
// Then
159+
await waitFor(() => expect(screen.getByText("Save")).toBeEnabled());
131160
});
132161
});

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,10 +1525,10 @@
15251525
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6"
15261526
integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA==
15271527

1528-
"@matrix-org/matrix-wysiwyg@^0.13.0":
1529-
version "0.13.0"
1530-
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.13.0.tgz#e643df4e13cdc5dbf9285740bc0ce2aef9873c16"
1531-
integrity sha512-MCeTj4hkl0snjlygd1v+mEEOgaN6agyjAVjJEbvEvP/BaYaDiPEXMTDaRQrcUt3OIY53UNhm1DDEn4yPTn83Jg==
1528+
"@matrix-org/matrix-wysiwyg@^0.14.0":
1529+
version "0.14.0"
1530+
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.14.0.tgz#359fabf5af403b3f128fe6ede3bff9754a9e18c4"
1531+
integrity sha512-iSwIR7kS/zwAzy/8S5cUMv2aceoJl/vIGhqmY9hSU0gVyzmsyaVnx00uNMvVDBUFiiPT2gonN8R3+dxg58TPaQ==
15321532

15331533
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
15341534
version "3.2.14"

0 commit comments

Comments
 (0)