Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add validation to the link form #15633

Merged
merged 15 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/framework/architecture/ui-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,11 +599,16 @@ import loupe from '@ckeditor/ckeditor5-core/theme/icons/loupe.svg';

{@snippet framework/ui/ui-input}

There are also inputs in the CKEditor 5 UI library. There are a few use cases to put inputs inside a main toolbar, but you also can add them to balloon panels.
The CKEditor 5 UI library contains a few input elements. Usually, they are used in dropdowns and balloon panels, but you can also use them in a main toolbar.

To create them, use the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabeledFieldView`} class, which takes two parameters:

* an instance of the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#locale `locale`} class,
* a helper function, depending on the type of field you want to create.

### Text

You can use the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabaledFieldView`} class to instantiate an input. It takes two parameters: {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#locale `locale`} and a helper function. Pass the {@link module:ui/labeledfield/utils#createLabeledInputText `createLabeledInputText()`} helper function to create a text input.
To create a text field, pass the {@link module:ui/labeledfield/utils#createLabeledInputText `createLabeledInputText()`} helper function as the second parameter to the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabeledFieldView`} class.

```js
import { createLabeledInputText, LabeledFieldView } from '@ckeditor/ckeditor5-ui';
Expand All @@ -620,7 +625,7 @@ document.getElementById( 'input-text' ).append( textInput.element );

### Number

You can use the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabaledFieldView`} class to instantiate an input. It takes two parameters: {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#locale `locale`} and a helper function. Pass the {@link module:ui/labeledfield/utils#createLabeledInputNumber `createLabeledInputNumber()`} helper function to create a number input.
To create a number field, pass the {@link module:ui/labeledfield/utils#createLabeledInputNumber `createLabeledInputNumber()`} helper function as the second parameter to the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabeledFieldView`} class.

```js
import { createLabeledInputNumber, LabeledFieldView } from '@ckeditor/ckeditor5-ui';
Expand Down
18 changes: 17 additions & 1 deletion docs/updating/update-to-41.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ Among other changes, some icons have been moved around the project. Observe thes

The following icons were moved to the `@ckeditor/ckeditor5-core` package: `browse-files`, `bulletedlist`, `codeblock`, `color-palette`, `heading1`, `heading2`, `heading3`, `heading4`, `heading5`, `heading6`, `horizontalline`, `html`, `indent`, `next-arrow`, `numberedlist`, `outdent`, `previous-arrow`, `redo`, `table`,`todolist`, `undo`.


### Exports renaming

Some exports names were changed due to possibility of name conflicts:
Expand All @@ -149,3 +148,20 @@ Some exports names were changed due to possibility of name conflicts:
The code we distribute in our npm packages uses the [ES Module syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) (for example `import X from 'y'`). Until now it was not fully compliant with the standard and the packages were not properly marked as ES module. In some cases this resulted in bundlers (like Vite) and other tools (such as Vitest) failing to build or run the projects containing CKEditor 5. It required workarounds in their configuration.

In this release we fix these issues, meaning that our packages are now fully ESM compliant and these workarounds are no longer needed.

### Added validation to the URL field in the Link form

Until now, the form for adding a URL to the selected text accepted an empty value, leaving the `href` empty. We believe this is undesirable in most cases, so we've added a validation to prevent adding a link if the field is empty.

However, if for some reason you want to allow empty links, you can do so using the new `allowCreatingEmptyLinks` configuration option we've added to the `link` plugin.

```diff
ClassicEditor
.create( editorElement, {
link: {
+ allowCreatingEmptyLinks: true
}
} )
.then( ... )
.catch( ... );
```
20 changes: 20 additions & 0 deletions packages/ckeditor5-link/src/linkconfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ export interface LinkConfig {
*/
defaultProtocol?: string;

/**
* When set to `true`, the form will accept an empty value in the URL field, creating a link with an empty `href` (`<a href="">`).
*
* ```ts
* ClassicEditor
* .create( editorElement, {
* link: {
* allowCreatingEmptyLinks: true
* }
* } )
* .then( ... )
* .catch( ... );
* ```
*
* **NOTE:** This option only adds form validation. If a link with an empty `href` is loaded into the editor, it will be left as-is.
*
* @default false
*/
allowCreatingEmptyLinks?: boolean;

/**
* When set to `true`, the `target="blank"` and `rel="noopener noreferrer"` attributes are automatically added to all external links
* in the editor. "External links" are all links in the editor content starting with `http`, `https`, or `//`.
Expand Down
1 change: 1 addition & 0 deletions packages/ckeditor5-link/src/linkediting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export default class LinkEditing extends Plugin {
super( editor );

editor.config.define( 'link', {
allowCreatingEmptyLinks: false,
addTargetToExternalLinks: false
} );
}
Expand Down
12 changes: 11 additions & 1 deletion packages/ckeditor5-link/src/linkui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,21 @@ export default class LinkUI extends Plugin {
const editor = this.editor;
const linkCommand: LinkCommand = editor.commands.get( 'link' )!;
const defaultProtocol = editor.config.get( 'link.defaultProtocol' );
const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' );

const formView = new ( CssTransitionDisablerMixin( LinkFormView ) )( editor.locale, linkCommand );

formView.urlInputView.fieldView.bind( 'value' ).to( linkCommand, 'value' );

// Form elements should be read-only when corresponding commands are disabled.
formView.urlInputView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );

// Disable the "save" button if the command is disabled or the input is empty despite being required.
formView.saveButtonView.bind( 'isEnabled' ).to(
linkCommand, 'isEnabled',
formView.urlInputView, 'isEmpty',
( isCommandEnabled, isInputEmpty ) => isCommandEnabled && ( allowCreatingEmptyLinks || !isInputEmpty )
);

// Execute link command after clicking the "Save" button.
this.listenTo( formView, 'submit', () => {
Expand Down Expand Up @@ -390,6 +397,9 @@ export default class LinkUI extends Plugin {
// See https://github.com/ckeditor/ckeditor5/issues/1501.
this.formView!.saveButtonView.focus();

// Reset the URL field to update the state of the submit button.
this.formView!.urlInputView.fieldView.reset();

this._balloon.remove( this.formView! );

// Because the form has an input which has focus, the focus must be brought back
Expand Down
7 changes: 6 additions & 1 deletion packages/ckeditor5-link/tests/linkediting.js
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,15 @@ describe( 'LinkEditing', () => {
url: 'tel:123456789'
}
];
it( 'link.addTargetToExternalLinks is predefined as false value', () => {

it( 'link.addTargetToExternalLinks has a default value of `false`', () => {
expect( editor.config.get( 'link.addTargetToExternalLinks' ) ).to.be.false;
} );

it( 'link.allowCreatingEmptyLinks has a default value of `false`', () => {
expect( editor.config.get( 'link.allowCreatingEmptyLinks' ) ).to.be.false;
} );

describe( 'for link.addTargetToExternalLinks = false', () => {
let editor, model;

Expand Down
75 changes: 69 additions & 6 deletions packages/ckeditor5-link/tests/linkui.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ describe( 'LinkUI', () => {
formView = linkUIFeature.formView;
actionsView = linkUIFeature.actionsView;

formView.urlInputView.fieldView.value = 'ckeditor.com';

editor.commands.get( 'link' ).isEnabled = true;
editor.commands.get( 'unlink' ).isEnabled = true;

Expand Down Expand Up @@ -1403,6 +1405,30 @@ describe( 'LinkUI', () => {
} );
};

const createEditorWithEmptyLinks = allowCreatingEmptyLinks => {
return ClassicTestEditor
.create( editorElement, {
plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ],
link: { allowCreatingEmptyLinks }
} )
.then( editor => {
const linkUIFeature = editor.plugins.get( LinkUI );

linkUIFeature._createViews();

const formView = linkUIFeature.formView;

formView.render();

editor.model.schema.extend( '$text', {
allowIn: '$root',
allowAttributes: 'linkHref'
} );

return { editor, formView };
} );
};

beforeEach( () => {
// Make sure that forms are lazy initiated.
expect( linkUIFeature.formView ).to.be.null;
Expand All @@ -1427,6 +1453,40 @@ describe( 'LinkUI', () => {
expect( editor.ui.focusTracker.isFocused ).to.be.true;
} );

describe( 'empty links', () => {
it( 'should not allow empty links by default', () => {
const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' );

expect( allowCreatingEmptyLinks ).to.equal( false );
} );

it( 'should allow enabling empty links', () => {
return createEditorWithEmptyLinks( true ).then( ( { editor } ) => {
const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' );

expect( allowCreatingEmptyLinks ).to.equal( true );

return editor.destroy();
} );
} );

it( 'should not allow submitting empty form when link is required', () => {
return createEditorWithEmptyLinks( false ).then( ( { editor, formView } ) => {
expect( formView.saveButtonView.isEnabled ).to.be.false;

return editor.destroy();
} );
} );

it( 'should allow submitting empty form when link is not required', () => {
return createEditorWithEmptyLinks( true ).then( ( { editor, formView } ) => {
expect( formView.saveButtonView.isEnabled ).to.be.true;

return editor.destroy();
} );
} );
} );

describe( 'link protocol', () => {
it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', () => {
return ClassicTestEditor
Expand All @@ -1445,10 +1505,14 @@ describe( 'LinkUI', () => {
} );

it( 'should not add a protocol without the configuration', () => {
const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' );

formView.urlInputView.fieldView.value = 'ckeditor.com';
formView.fire( 'submit' );

expect( formView.urlInputView.fieldView.value ).to.equal( 'ckeditor.com' );
sinon.assert.calledWith( linkCommandSpy, 'ckeditor.com', sinon.match.any );

return editor.destroy();
} );

it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', () => {
Expand Down Expand Up @@ -1481,7 +1545,6 @@ describe( 'LinkUI', () => {
formView.urlInputView.fieldView.value = 'http://example.com';
formView.fire( 'submit' );

expect( formView.urlInputView.fieldView.value ).to.equal( 'http://example.com' );
sinon.assert.calledWith( linkCommandSpy, 'http://example.com', sinon.match.any );

return editor.destroy();
Expand Down Expand Up @@ -1536,7 +1599,6 @@ describe( 'LinkUI', () => {
formView.urlInputView.fieldView.value = '[email protected]';
formView.fire( 'submit' );

expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:[email protected]' );
expect( getModelData( editor.model ) ).to.equal(
'[<$text linkHref="mailto:[email protected]">[email protected]</$text>]'
);
Expand All @@ -1552,19 +1614,20 @@ describe( 'LinkUI', () => {
formView.urlInputView.fieldView.value = '[email protected]';
formView.fire( 'submit' );

expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:[email protected]' );
expect( getModelData( editor.model ) ).to.equal(
'<paragraph>[<$text linkHref="mailto:[email protected]">[email protected]</$text>]</paragraph>'
);
} );

it( 'should not add an email protocol when given provided within the value' +
it( 'should not add an email protocol when given provided within the value ' +
'even when `config.link.defaultProtocol` configured', () => {
return createEditorWithDefaultProtocol( 'mailto:' ).then( ( { editor, formView } ) => {
const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' );

formView.urlInputView.fieldView.value = 'mailto:[email protected]';
formView.fire( 'submit' );

expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:[email protected]' );
sinon.assert.calledWith( linkCommandSpy, 'mailto:[email protected]', sinon.match.any );

return editor.destroy();
} );
Expand Down
16 changes: 8 additions & 8 deletions packages/ckeditor5-ui/src/labeledfield/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ import type { LabeledFieldViewCreator } from './labeledfieldview.js';
* ```
*
* @param labeledFieldView The instance of the labeled field view.
* @param viewUid An UID string that allows DOM logical connection between the
* @param viewUid A UID string that allows DOM logical connection between the
filipsobol marked this conversation as resolved.
Show resolved Hide resolved
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the input.
* @param statusUid An UID string that allows DOM logical connection between the
* @param statusUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the input.
* @returns The input text view instance.
*/
Expand Down Expand Up @@ -86,9 +86,9 @@ const createLabeledInputText: LabeledFieldViewCreator<InputTextView> = ( labeled
* ```
*
* @param labeledFieldView The instance of the labeled field view.
* @param viewUid An UID string that allows DOM logical connection between the
* @param viewUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the input.
* @param statusUid An UID string that allows DOM logical connection between the
* @param statusUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the input.
* @returns The input number view instance.
*/
Expand Down Expand Up @@ -136,9 +136,9 @@ const createLabeledInputNumber: LabeledFieldViewCreator<InputNumberView> = ( lab
* ```
*
* @param labeledFieldView The instance of the labeled field view.
* @param viewUid An UID string that allows DOM logical connection between the
* @param viewUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the textarea.
* @param statusUid An UID string that allows DOM logical connection between the
* @param statusUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the textarea.
* @returns The textarea view instance.
*/
Expand Down Expand Up @@ -183,9 +183,9 @@ const createLabeledTextarea: LabeledFieldViewCreator<TextareaView> = ( labeledFi
* ```
*
* @param labeledFieldView The instance of the labeled field view.
* @param viewUid An UID string that allows DOM logical connection between the
* @param viewUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view label} and the dropdown.
* @param statusUid An UID string that allows DOM logical connection between the
* @param statusUid A UID string that allows DOM logical connection between the
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view status} and the dropdown.
* @returns The dropdown view instance.
*/
Expand Down