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 2 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
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 link with empty `href` is loaded into the editor, it will be left as-is.
filipsobol marked this conversation as resolved.
Show resolved Hide resolved
*
* @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
9 changes: 8 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
10 changes: 5 additions & 5 deletions packages/ckeditor5-link/src/ui/linkformview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
SwitchButtonView,
View,
ViewCollection,
createLabeledInputText,
createLabeledInputUrl,
submitHandler,
type InputTextView
type InputUrlView
} from 'ckeditor5/src/ui.js';
import {
FocusTracker,
Expand Down Expand Up @@ -53,7 +53,7 @@ export default class LinkFormView extends View {
/**
* The URL input view.
*/
public urlInputView: LabeledFieldView<InputTextView>;
public urlInputView: LabeledFieldView<InputUrlView>;

/**
* The Save button view.
Expand Down Expand Up @@ -207,9 +207,9 @@ export default class LinkFormView extends View {
*
* @returns Labeled field view instance.
*/
private _createUrlInput(): LabeledFieldView<InputTextView> {
private _createUrlInput(): LabeledFieldView<InputUrlView> {
const t = this.locale!.t;
const labeledInput = new LabeledFieldView( this.locale, createLabeledInputText );
const labeledInput = new LabeledFieldView( this.locale, createLabeledInputUrl );

labeledInput.label = t( 'Link URL' );

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
60 changes: 60 additions & 0 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 Down
1 change: 1 addition & 0 deletions packages/ckeditor5-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {

export { default as IconView } from './icon/iconview.js';
export { default as InputView } from './input/inputview.js';
export { default as InputUrlView } from './inputurl/inputurlview.js';
export { default as InputTextView } from './inputtext/inputtextview.js';
export { default as InputNumberView } from './inputnumber/inputnumberview.js';

Expand Down
33 changes: 33 additions & 0 deletions packages/ckeditor5-ui/src/inputurl/inputurlview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/**
* @module ui/inputurl/inputurlview
*/

import InputView from '../input/inputview.js';

import type { Locale } from '@ckeditor/ckeditor5-utils';

/**
* The URL input view class.
*/
export default class InputUrlView extends InputView {
/**
* @inheritDoc
*/
constructor( locale?: Locale ) {
super( locale );

this.extendTemplate( {
attributes: {
type: 'url',
class: [
'ck-input-url'
]
}
} );
}
}
51 changes: 51 additions & 0 deletions packages/ckeditor5-ui/src/labeledfield/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import InputTextView from '../inputtext/inputtextview.js';
import InputNumberView from '../inputnumber/inputnumberview.js';
import InputUrlView from '../inputurl/inputurlview.js';
import TextareaView from '../textarea/textareaview.js';
import { createDropdown } from '../dropdown/utils.js';

Expand Down Expand Up @@ -164,6 +165,55 @@ const createLabeledTextarea: LabeledFieldViewCreator<TextareaView> = ( labeledFi
return textareaView;
};

/**
* A helper for creating labeled URL inputs.
*
* It creates an instance of a {@link module:ui/inputurl/inputurlview~InputUrlView input url} that is
* logically related to a {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView labeled view} in DOM.
*
* The helper does the following:
*
* * It sets input's `id` and `ariaDescribedById` attributes.
* * It binds input's `isReadOnly` to the labeled view.
* * It binds input's `hasError` to the labeled view.
* * It enables a logic that cleans up the error when user starts typing in the input.
*
* Usage:
*
* ```ts
* const labeledInputView = new LabeledFieldView( locale, createLabeledInputUrl );
* console.log( labeledInputView.fieldView ); // A url input instance.
* ```
*
* @param labeledFieldView The instance of the labeled field view.
* @param viewUid An 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
filipsobol marked this conversation as resolved.
Show resolved Hide resolved
* {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the input.
* @returns The input url view instance.
*/
const createLabeledInputUrl: LabeledFieldViewCreator<InputUrlView> = ( labeledFieldView, viewUid, statusUid ) => {
const inputView = new InputUrlView( labeledFieldView.locale );

inputView.set( {
id: viewUid,
ariaDescribedById: statusUid
} );

inputView.bind( 'isReadOnly' ).to( labeledFieldView, 'isEnabled', value => !value );
inputView.bind( 'hasError' ).to( labeledFieldView, 'errorText', value => !!value );

inputView.on<InputViewInputEvent>( 'input', () => {
// UX: Make the error text disappear and disable the error indicator as the user
// starts fixing the errors.
labeledFieldView.errorText = null;
} );

labeledFieldView.bind( 'isEmpty', 'isFocused', 'placeholder' ).to( inputView );

return inputView;
};

/**
* A helper for creating labeled dropdowns.
*
Expand Down Expand Up @@ -206,5 +256,6 @@ export {
createLabeledInputNumber,
createLabeledInputText,
createLabeledTextarea,
createLabeledInputUrl,
createLabeledDropdown
};
32 changes: 32 additions & 0 deletions packages/ckeditor5-ui/tests/inputurl/inputurlview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

import InputView from '../../src/input/inputview.js';
import InputUrlView from '../../src/inputurl/inputurlview.js';

describe( 'InputUrlView', () => {
let view;

beforeEach( () => {
view = new InputUrlView();
view.render();
} );

afterEach( () => {
view.destroy();
} );

describe( 'constructor()', () => {
it( 'should extend InputView', () => {
expect( view ).to.be.instanceOf( InputView );
} );

it( 'should creates element from template', () => {
filipsobol marked this conversation as resolved.
Show resolved Hide resolved
expect( view.element.getAttribute( 'type' ) ).to.equal( 'url' );
expect( view.element.type ).to.equal( 'url' );
expect( view.element.classList.contains( 'ck-input-url' ) ).to.be.true;
} );
} );
} );
Loading