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

Refactor quill-scripture.ts to allow testing #3032

Merged
merged 3 commits into from
Feb 26, 2025
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
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"csharp.format.enable": true,
"cSpell.words": [
"appbuilder",
"attributors",
"backout",
"bowser",
"bson",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Quill, { Delta, Range } from 'quill';
import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito';
import { TextComponent } from '../text.component';
import { DisableHtmlClipboard } from './quill-clipboard';

describe('DisableHtmlClipboard', () => {
let quill: Quill;
let quillMock: Quill;
let textComponentMock: TextComponent;
let clipboard: DisableHtmlClipboard;
let mockRange: Range;
let mockFormat: { segment: string; bold: boolean };

beforeEach(() => {
quillMock = mock<Quill>();
textComponentMock = mock(TextComponent);

when(quillMock.root).thenReturn(document.createElement('div'));
when(quillMock.scroll).thenReturn({ domNode: document.createElement('div') } as any);

quill = instance(quillMock);
clipboard = new DisableHtmlClipboard(quill, { textComponent: instance(textComponentMock) });

mockRange = { index: 5, length: 0 };
mockFormat = { segment: 'verse_1_1', bold: true };
});

describe('onCapturePaste', () => {
const createPasteEvent = (text: string = '', isPrevented = false): ClipboardEvent => {
const event = new ClipboardEvent('paste');
if (text) {
const dataTransfer = new DataTransfer();
dataTransfer.setData('text/plain', text);
Object.defineProperty(event, 'clipboardData', { value: dataTransfer });
}
if (isPrevented) {
Object.defineProperty(event, 'defaultPrevented', { value: true });
}
return event;
};

beforeEach(() => {
when(quillMock.isEnabled()).thenReturn(true);
when(quillMock.getSelection(true)).thenReturn(mockRange);
when(quillMock.getFormat(mockRange.index)).thenReturn(mockFormat);
when(quillMock.getFormat(mockRange.index, 1)).thenReturn(mockFormat);
when(textComponentMock.isValidSelectionForCurrentSegment(mockRange)).thenReturn(true);
});

afterEach(() => {
expect(true).toBe(true); // Prevent SPEC HAS NO EXPECTATIONS warning
});

it('should clean and paste text with attributes', () => {
const inputText = 'test\ntext\\with\\backslashes';
const expectedText = 'test text with backslashes';

const event = createPasteEvent(inputText);
const pasteDelta = new Delta().insert(expectedText);
clipboard.convert = () => pasteDelta;

clipboard.onCapturePaste(event);

verify(
quillMock.updateContents(
deepEqual(new Delta().retain(mockRange.index).insert(expectedText, mockFormat).delete(mockRange.length)),
'user'
)
).once();
});

interface InvalidCase {
name: string;
setup: () => ClipboardEvent;
}

const invalidCases: InvalidCase[] = [
{
name: 'prevented event',
setup: () => createPasteEvent('', true)
},
{
name: 'disabled editor',
setup: () => {
when(quillMock.isEnabled()).thenReturn(false);
return createPasteEvent();
}
},
{
name: 'null selection',
setup: () => {
when(quillMock.getSelection(true)).thenReturn(null as any);
return createPasteEvent();
}
},
{
name: 'invalid selection',
setup: () => {
when(textComponentMock.isValidSelectionForCurrentSegment(mockRange)).thenReturn(false);
return createPasteEvent();
}
}
];

invalidCases.forEach(({ name, setup }) => {
it(`should return early for ${name}`, () => {
clipboard.onCapturePaste(setup());
verify(quillMock.updateContents(anything(), anything())).never();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Quill, { Delta, Range } from 'quill';
import QuillClipboard from 'quill/modules/clipboard';
import { StringMap } from 'rich-text';
import { getAttributesAtPosition } from '../quill-util';
import { TextComponent } from '../text.component';

export class DisableHtmlClipboard extends QuillClipboard {
private _textComponent: TextComponent;

constructor(quill: Quill, options: StringMap) {
super(quill, options);
this._textComponent = options.textComponent;
}

onCapturePaste(e: ClipboardEvent): void {
if (e.defaultPrevented || !this.quill.isEnabled() || e.clipboardData == null) {
return;
}

// Prevent further handling by browser, which can cause the paste to
// happen anyway even if we stop processing here.
e.preventDefault();

const range: Range = this.quill.getSelection(true);
if (range == null) {
return;

Check warning on line 26 in src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-clipboard.ts

View check run for this annotation

Codecov / codecov/patch

src/SIL.XForge.Scripture/ClientApp/src/app/shared/text/quill-editor-registration/quill-clipboard.ts#L26

Added line #L26 was not covered by tests
}

if (!this._textComponent.isValidSelectionForCurrentSegment(range)) {
return;
}

let delta = new Delta().retain(range.index);

const text = e.clipboardData.getData('text/plain');
const cleanedText = text
.replace(/(?:\r?\n)+/, ' ') // Replace new lines with spaces
.replace(/\\/g, ''); // Remove backslashes

const pasteDelta = this.convert({ text: cleanedText });

// add the attributes to the paste delta which should just be 1 insert op
for (const op of pasteDelta.ops ?? []) {
op.attributes = getAttributesAtPosition(this.quill, range.index);
}

delta = delta.concat(pasteDelta).delete(range.length);
this.quill.updateContents(delta, 'user');
this.quill.setSelection(delta.length() - range.length, 'silent');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Parchment } from 'quill';
import {
CheckingQuestionCountAttribute,
CheckingQuestionSegmentClass,
ClassAttributor,
CommenterSelectedSegmentClass,
DeleteSegmentClass,
DraftClass,
HighlightParaClass,
HighlightSegmentClass,
InsertSegmentClass,
InvalidBlockClass,
InvalidInlineClass,
isAttributor,
NoteThreadHighlightClass,
NoteThreadSegmentClass,
ParaStyleDescriptionAttribute
} from './quill-attributors';

describe('Quill Attributors', () => {
describe('ClassAttributor', () => {
let attributor: ClassAttributor;
let node: HTMLElement;

beforeEach(() => {
attributor = new ClassAttributor('test-attr', 'test-class', {
scope: Parchment.Scope.INLINE_ATTRIBUTE
});
node = document.createElement('div');
});

it('should add class when value is true', () => {
const result = attributor.add(node, true);

expect(result).toBe(true);
expect(node.classList.contains('test-class')).toBe(true);
});

it('should add value using parent when value is not true', () => {
const result = attributor.add(node, 'custom-value');

expect(result).toBe(true);
expect(node.getAttribute('class')).toBe('test-class-custom-value');
});

it('should remove class', () => {
node.classList.add('test-class');

attributor.remove(node);

expect(node.classList.contains('test-class')).toBe(false);
});

it('should return true when class exists', () => {
node.classList.add('test-class');

const result = attributor.value(node);

expect(result).toBe(true);
});

it('should return parent value when class does not exist', () => {
node.setAttribute('class', 'test-class-custom');

const result = attributor.value(node);

expect(result).toBe('custom');
});
});

describe('Attributor instances', () => {
it('should create InsertSegmentClass with correct scope', () => {
expect(InsertSegmentClass.attrName).toBe('insert-segment');
expect(InsertSegmentClass.keyName).toBe('insert-segment');
expect(InsertSegmentClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create DeleteSegmentClass with correct scope', () => {
expect(DeleteSegmentClass.attrName).toBe('delete-segment');
expect(DeleteSegmentClass.keyName).toBe('delete-segment');
expect(DeleteSegmentClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create HighlightSegmentClass with correct scope', () => {
expect(HighlightSegmentClass.attrName).toBe('highlight-segment');
expect(HighlightSegmentClass.keyName).toBe('highlight-segment');
expect(HighlightSegmentClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create HighlightParaClass with correct scope', () => {
expect(HighlightParaClass.attrName).toBe('highlight-para');
expect(HighlightParaClass.keyName).toBe('highlight-para');
expect(HighlightParaClass.scope).toBe(Parchment.Scope.BLOCK_ATTRIBUTE);
});

it('should create CheckingQuestionSegmentClass with correct scope', () => {
expect(CheckingQuestionSegmentClass.attrName).toBe('question-segment');
expect(CheckingQuestionSegmentClass.keyName).toBe('question-segment');
expect(CheckingQuestionSegmentClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create NoteThreadSegmentClass with correct scope', () => {
expect(NoteThreadSegmentClass.attrName).toBe('note-thread-segment');
expect(NoteThreadSegmentClass.keyName).toBe('note-thread-segment');
expect(NoteThreadSegmentClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create NoteThreadHighlightClass with correct scope', () => {
expect(NoteThreadHighlightClass.attrName).toBe('note-thread-highlight');
expect(NoteThreadHighlightClass.keyName).toBe('note-thread-highlight');
expect(NoteThreadHighlightClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create CommenterSelectedSegmentClass with correct scope', () => {
expect(CommenterSelectedSegmentClass.attrName).toBe('commenter-selection');
expect(CommenterSelectedSegmentClass.keyName).toBe('commenter-selection');
expect(CommenterSelectedSegmentClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create InvalidBlockClass with correct scope', () => {
expect(InvalidBlockClass.attrName).toBe('invalid-block');
expect(InvalidBlockClass.keyName).toBe('invalid-block');
expect(InvalidBlockClass.scope).toBe(Parchment.Scope.BLOCK_ATTRIBUTE);
});

it('should create InvalidInlineClass with correct scope', () => {
expect(InvalidInlineClass.attrName).toBe('invalid-inline');
expect(InvalidInlineClass.keyName).toBe('invalid-inline');
expect(InvalidInlineClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create DraftClass with correct scope', () => {
expect(DraftClass.attrName).toBe('draft');
expect(DraftClass.keyName).toBe('draft');
expect(DraftClass.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create CheckingQuestionCountAttribute with correct scope', () => {
expect(CheckingQuestionCountAttribute.attrName).toBe('question-count');
expect(CheckingQuestionCountAttribute.keyName).toBe('data-question-count');
expect(CheckingQuestionCountAttribute.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});

it('should create ParaStyleDescriptionAttribute with correct scope', () => {
expect(ParaStyleDescriptionAttribute.attrName).toBe('style-description');
expect(ParaStyleDescriptionAttribute.keyName).toBe('data-style-description');
expect(ParaStyleDescriptionAttribute.scope).toBe(Parchment.Scope.INLINE_ATTRIBUTE);
});
});

describe('isAttributor', () => {
it('should return true for Attributor instances', () => {
expect(isAttributor(InsertSegmentClass)).toBe(true);
expect(isAttributor(CheckingQuestionCountAttribute)).toBe(true);
});

it('should return false for non-Attributor values', () => {
expect(isAttributor(null)).toBe(false);
expect(isAttributor({})).toBe(false);
expect(isAttributor('not an attributor')).toBe(false);
});
});
});
Loading
Loading