Add edit and remove actions to link in RTE (#9864)

Add edit and remove actions to link in RTE
This commit is contained in:
Florian Duros 2023-01-11 11:10:55 +01:00 committed by GitHub
parent 79033eb034
commit a691e634b0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 209 additions and 58 deletions

View file

@ -57,7 +57,7 @@
"dependencies": { "dependencies": {
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.3.0", "@matrix-org/analytics-events": "^0.3.0",
"@matrix-org/matrix-wysiwyg": "^0.13.0", "@matrix-org/matrix-wysiwyg": "^0.14.0",
"@matrix-org/react-sdk-module-api": "^0.0.3", "@matrix-org/react-sdk-module-api": "^0.0.3",
"@sentry/browser": "^7.0.0", "@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0", "@sentry/tracing": "^7.0.0",

View file

@ -16,14 +16,32 @@ limitations under the License.
.mx_LinkModal { .mx_LinkModal {
padding: $spacing-32; padding: $spacing-32;
max-width: 600px;
.mx_Dialog_content { height: 341px;
margin-top: 30px; box-sizing: border-box;
margin-bottom: 42px; display: flex;
} flex-direction: column;
.mx_LinkModal_content { .mx_LinkModal_content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
gap: $spacing-8;
margin-top: 7px;
.mx_LinkModal_Field {
flex: initial;
height: 40px;
}
.mx_LinkModal_buttons {
display: flex;
flex: 1;
align-items: flex-end;
.mx_Dialog_buttons {
display: inline-block;
}
}
} }
} }

View file

@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
this.inputRef = inputRef || React.createRef(); this.inputRef = inputRef || React.createRef();
inputProps.placeholder = inputProps.placeholder || inputProps.label; inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
inputProps.id = this.id; // this overwrites the id from props inputProps.id = this.id; // this overwrites the id from props
inputProps.onFocus = this.onFocus; inputProps.onFocus = this.onFocus;

View file

@ -120,7 +120,7 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
<Button <Button
isActive={actionStates.link === "reversed"} isActive={actionStates.link === "reversed"}
label={_td("Link")} label={_td("Link")}
onClick={() => openLinkModal(composer, composerContext)} onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
icon={<LinkIcon className="mx_FormattingButtons_Icon" />} icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
/> />
</div> </div>

View file

@ -17,17 +17,28 @@ limitations under the License.
import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
import React, { ChangeEvent, useState } from "react"; import React, { ChangeEvent, useState } from "react";
import { _td } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import QuestionDialog from "../../../dialogs/QuestionDialog";
import Field from "../../../elements/Field"; import Field from "../../../elements/Field";
import { ComposerContextState } from "../ComposerContext"; import { ComposerContextState } from "../ComposerContext";
import { isSelectionEmpty, setSelection } from "../utils/selection"; import { isSelectionEmpty, setSelection } from "../utils/selection";
import BaseDialog from "../../../dialogs/BaseDialog";
import DialogButtons from "../../../elements/DialogButtons";
export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) { export function openLinkModal(
composer: FormattingFunctions,
composerContext: ComposerContextState,
isEditing: boolean,
) {
const modal = Modal.createDialog( const modal = Modal.createDialog(
LinkModal, LinkModal,
{ composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() }, {
composerContext,
composer,
onClose: () => modal.close(),
isTextEnabled: isSelectionEmpty(),
isEditing,
},
"mx_CompoundDialog", "mx_CompoundDialog",
false, false,
true, true,
@ -43,48 +54,86 @@ interface LinkModalProps {
isTextEnabled: boolean; isTextEnabled: boolean;
onClose: () => void; onClose: () => void;
composerContext: ComposerContextState; composerContext: ComposerContextState;
isEditing: boolean;
} }
export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) { export function LinkModal({ composer, isTextEnabled, onClose, composerContext, isEditing }: LinkModalProps) {
const [fields, setFields] = useState({ text: "", link: "" }); const [hasLinkChanged, setHasLinkChanged] = useState(false);
const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link); const [fields, setFields] = useState({ text: "", link: isEditing ? composer.getLink() : "" });
const hasText = !isEditing && isTextEnabled;
const isSaveDisabled = !hasLinkChanged || (hasText && isEmpty(fields.text)) || isEmpty(fields.link);
return ( return (
<QuestionDialog <BaseDialog
className="mx_LinkModal" className="mx_LinkModal"
title={_td("Create a link")} title={isEditing ? _t("Edit link") : _t("Create a link")}
button={_td("Save")} hasCancel={true}
buttonDisabled={isSaveDisabled} onFinished={onClose}
hasCancelButton={true} >
onFinished={async (isClickOnSave: boolean) => { <form
if (isClickOnSave) { className="mx_LinkModal_content"
onSubmit={async (evt) => {
evt.preventDefault();
evt.stopPropagation();
onClose();
// When submitting is done when pressing enter when the link field has the focus,
// The link field is getting back the focus (due to react-focus-lock)
// So we are waiting that the focus stuff is done to play with the composer selection
await new Promise((resolve) => setTimeout(resolve, 0));
await setSelection(composerContext.selection); await setSelection(composerContext.selection);
composer.link(fields.link, isTextEnabled ? fields.text : undefined); composer.link(fields.link, isTextEnabled ? fields.text : undefined);
} }}
onClose(); >
}} {hasText && (
description={
<div className="mx_LinkModal_content">
{isTextEnabled && (
<Field
autoFocus={true}
label={_td("Text")}
value={fields.text}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, text: e.target.value }))
}
/>
)}
<Field <Field
autoFocus={!isTextEnabled} required={true}
label={_td("Link")} autoFocus={true}
value={fields.link} label={_t("Text")}
value={fields.text}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, link: e.target.value })) setFields((fields) => ({ ...fields, text: e.target.value }))
} }
/> />
)}
<Field
required={true}
autoFocus={!hasText}
label={_t("Link")}
value={fields.link}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setFields((fields) => ({ ...fields, link: e.target.value }));
setHasLinkChanged(true);
}}
/>
<div className="mx_LinkModal_buttons">
{isEditing && (
<button
type="button"
className="danger"
onClick={() => {
composer.removeLinks();
onClose();
}}
>
{_t("Remove")}
</button>
)}
<DialogButtons
primaryButton={_t("Save")}
primaryDisabled={isSaveDisabled}
primaryIsSubmit={true}
onCancel={onClose}
/>
</div> </div>
} </form>
/> </BaseDialog>
); );
} }

View file

@ -2136,6 +2136,7 @@
"Underline": "Underline", "Underline": "Underline",
"Code": "Code", "Code": "Code",
"Link": "Link", "Link": "Link",
"Edit link": "Edit link",
"Create a link": "Create a link", "Create a link": "Create a link",
"Text": "Text", "Text": "Text",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",

View file

@ -0,0 +1,54 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
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 { render, screen } from "@testing-library/react";
import Field from "../../../../src/components/views/elements/Field";
describe("Field", () => {
describe("Placeholder", () => {
it("Should display a placeholder", async () => {
// When
const { rerender } = render(<Field value="" placeholder="my placeholder" />);
// Then
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my placeholder");
// When
rerender(<Field value="" placeholder="" />);
// Then
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "");
});
it("Should display label as placeholder", async () => {
// When
render(<Field value="" label="my label" />);
// Then
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my label");
});
it("Should not display a placeholder", async () => {
// When
render(<Field value="" />);
// Then
expect(screen.getByRole("textbox")).not.toHaveAttribute("placeholder", "my placeholder");
});
});
});

View file

@ -27,6 +27,8 @@ import { SubSelection } from "../../../../../../src/components/views/rooms/wysiw
describe("LinkModal", () => { describe("LinkModal", () => {
const formattingFunctions = { const formattingFunctions = {
link: jest.fn(), link: jest.fn(),
removeLinks: jest.fn(),
getLink: jest.fn().mockReturnValue("my initial content"),
} as unknown as FormattingFunctions; } as unknown as FormattingFunctions;
const defaultValue: SubSelection = { const defaultValue: SubSelection = {
focusNode: null, focusNode: null,
@ -35,13 +37,14 @@ describe("LinkModal", () => {
anchorOffset: 4, anchorOffset: 4,
}; };
const customRender = (isTextEnabled: boolean, onClose: () => void) => { const customRender = (isTextEnabled: boolean, onClose: () => void, isEditing = false) => {
return render( return render(
<LinkModal <LinkModal
composer={formattingFunctions} composer={formattingFunctions}
isTextEnabled={isTextEnabled} isTextEnabled={isTextEnabled}
onClose={onClose} onClose={onClose}
composerContext={{ selection: defaultValue }} composerContext={{ selection: defaultValue }}
isEditing={isEditing}
/>, />,
); );
}; };
@ -75,13 +78,13 @@ describe("LinkModal", () => {
// When // When
jest.useFakeTimers(); jest.useFakeTimers();
screen.getByText("Save").click(); screen.getByText("Save").click();
jest.runAllTimers();
// Then // Then
expect(selectionSpy).toHaveBeenCalledWith(defaultValue); await waitFor(() => {
await waitFor(() => expect(onClose).toBeCalledTimes(1)); expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
expect(onClose).toBeCalledTimes(1);
// When });
jest.runAllTimers();
// Then // Then
expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined); expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined);
@ -118,15 +121,41 @@ describe("LinkModal", () => {
// When // When
jest.useFakeTimers(); jest.useFakeTimers();
screen.getByText("Save").click(); screen.getByText("Save").click();
jest.runAllTimers();
// Then // Then
expect(selectionSpy).toHaveBeenCalledWith(defaultValue); await waitFor(() => {
await waitFor(() => expect(onClose).toBeCalledTimes(1)); expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
expect(onClose).toBeCalledTimes(1);
// When });
jest.runAllTimers();
// Then // Then
expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t"); expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t");
}); });
it("Should remove the link", async () => {
// When
const onClose = jest.fn();
customRender(true, onClose, true);
await userEvent.click(screen.getByText("Remove"));
// Then
expect(formattingFunctions.removeLinks).toHaveBeenCalledTimes(1);
expect(onClose).toBeCalledTimes(1);
});
it("Should display the link in editing", async () => {
// When
customRender(true, jest.fn(), true);
// Then
expect(screen.getByLabelText("Link")).toContainHTML("my initial content");
expect(screen.getByText("Save")).toBeDisabled();
// When
await userEvent.type(screen.getByLabelText("Link"), "l");
// Then
await waitFor(() => expect(screen.getByText("Save")).toBeEnabled());
});
}); });

View file

@ -1525,10 +1525,10 @@
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6"
integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA== integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA==
"@matrix-org/matrix-wysiwyg@^0.13.0": "@matrix-org/matrix-wysiwyg@^0.14.0":
version "0.13.0" version "0.14.0"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.13.0.tgz#e643df4e13cdc5dbf9285740bc0ce2aef9873c16" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.14.0.tgz#359fabf5af403b3f128fe6ede3bff9754a9e18c4"
integrity sha512-MCeTj4hkl0snjlygd1v+mEEOgaN6agyjAVjJEbvEvP/BaYaDiPEXMTDaRQrcUt3OIY53UNhm1DDEn4yPTn83Jg== integrity sha512-iSwIR7kS/zwAzy/8S5cUMv2aceoJl/vIGhqmY9hSU0gVyzmsyaVnx00uNMvVDBUFiiPT2gonN8R3+dxg58TPaQ==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14" version "3.2.14"