diff --git a/packages/common/src/editors/__tests__/multipleSelectEditor.spec.ts b/packages/common/src/editors/__tests__/multipleSelectEditor.spec.ts new file mode 100644 index 000000000..2a9593ce7 --- /dev/null +++ b/packages/common/src/editors/__tests__/multipleSelectEditor.spec.ts @@ -0,0 +1,129 @@ +// import 3rd party lib multiple-select for the tests +import 'multiple-select-adapted/src/multiple-select.js'; + +import { Editors } from '../index'; +import { MultipleSelectEditor } from '../multipleSelectEditor'; +import { CollectionService } from '../../services/collection.service'; +import { Column, EditorArguments, GridOption } from '../../interfaces'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const dataViewStub = { + refresh: jest.fn(), +}; + +const gridOptionMock = { + autoCommitEdit: false, + editable: true, + i18n: null, +} as GridOption; + +const getEditorLockMock = { + commitCurrentEdit: jest.fn(), +}; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getEditorLock: () => getEditorLockMock, + getHeaderRowColumn: jest.fn(), + navigateNext: jest.fn(), + navigatePrev: jest.fn(), + render: jest.fn(), +}; + +describe('MultipleSelectEditor', () => { + let translateService: TranslateServiceStub; + let divContainer: HTMLDivElement; + let editor: MultipleSelectEditor; + let editorArguments: EditorArguments; + let mockColumn: Column; + let mockItemData: any; + let collectionService: CollectionService; + + beforeEach(() => { + collectionService = new CollectionService(translateService); + + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + + mockColumn = { id: 'gender', field: 'gender', editable: true, editor: { model: Editors.multipleSelect }, internalColumnEditor: {} } as Column; + + editorArguments = { + grid: gridStub, + column: mockColumn, + item: mockItemData, + event: null, + cancelChanges: jest.fn(), + commitChanges: jest.fn(), + container: divContainer, + columnMetaData: null, + dataView: dataViewStub, + gridPosition: { top: 0, left: 0, bottom: 10, right: 10, height: 100, width: 100, visible: true }, + position: { top: 0, left: 0, bottom: 10, right: 10, height: 100, width: 100, visible: true }, + }; + }); + + describe('with valid Editor instance', () => { + beforeEach(() => { + mockItemData = { id: 1, gender: 'male', isActive: true }; + mockColumn = { id: 'gender', field: 'gender', editable: true, editor: { model: Editors.multipleSelect }, internalColumnEditor: {} } as Column; + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + + editorArguments.column = mockColumn; + editorArguments.item = mockItemData; + }); + + afterEach(() => { + editor.destroy(); + }); + + it('should initialize the editor', (done) => { + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + gridOptionMock.i18n = translateService; + editor = new MultipleSelectEditor(editorArguments); + const editorCount = document.body.querySelectorAll('select.ms-filter.editor-gender').length; + const spy = jest.spyOn(editor, 'show'); + + setTimeout(() => { + expect(spy).toHaveBeenCalled(); + expect(editorCount).toBe(1); + done(); + }); + }); + + it('should call "setValue" with a single string and expect the string to be returned as an array when calling "getValue"', () => { + editor = new MultipleSelectEditor(editorArguments); + editor.setValue(['male']); + + expect(editor.getValue()).toEqual(['male']); + }); + + it('should hide the DOM element div wrapper when the "hide" method is called', () => { + editor = new MultipleSelectEditor(editorArguments); + const editorElm = document.body.querySelector('[name=editor-gender].ms-drop'); + + editor.show(); + expect(editorElm.style.display).toBe(''); + + editor.hide(); + expect(editorElm.style.display).toBe('none'); + }); + + it('should show the DOM element div wrapper when the "show" method is called', () => { + editor = new MultipleSelectEditor(editorArguments); + const editorElm = document.body.querySelector('[name=editor-gender].ms-drop'); + + editor.hide(); + expect(editorElm.style.display).toBe('none'); + + editor.show(); + expect(editorElm.style.display).toBe(''); + }); + }); +}); diff --git a/packages/common/src/editors/__tests__/selectEditor.spec.ts b/packages/common/src/editors/__tests__/selectEditor.spec.ts new file mode 100644 index 000000000..30f33608c --- /dev/null +++ b/packages/common/src/editors/__tests__/selectEditor.spec.ts @@ -0,0 +1,684 @@ +// import 3rd party lib multiple-select for the tests +import 'multiple-select-adapted/src/multiple-select.js'; + +import { Editors } from '../index'; +import { SelectEditor } from '../selectEditor'; +import { CollectionService } from './../../services/collection.service'; +import { FieldType, OperatorType } from '../../enums'; +import { AutocompleteOption, Column, EditorArgs, EditorArguments, GridOption, } from '../../interfaces'; +import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; + +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const dataViewStub = { + refresh: jest.fn(), +}; + +const gridOptionMock = { + autoCommitEdit: false, + editable: true, + i18n: null, +} as GridOption; + +const getEditorLockMock = { + commitCurrentEdit: jest.fn(), +}; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getEditorLock: () => getEditorLockMock, + getHeaderRowColumn: jest.fn(), + navigateNext: jest.fn(), + navigatePrev: jest.fn(), + render: jest.fn(), +}; + +describe('SelectEditor', () => { + let translateService: TranslateServiceStub; + let divContainer: HTMLDivElement; + let editor: SelectEditor; + let editorArguments: EditorArguments; + let mockColumn: Column; + let mockItemData: any; + let collectionService: CollectionService; + + beforeEach(() => { + translateService = new TranslateServiceStub(); + collectionService = new CollectionService(translateService); + + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + + mockColumn = { id: 'gender', field: 'gender', editable: true, editor: { model: Editors.multipleSelect }, internalColumnEditor: {} } as Column; + + editorArguments = { + grid: gridStub, + column: mockColumn, + item: mockItemData, + event: null, + cancelChanges: jest.fn(), + commitChanges: jest.fn(), + container: divContainer, + columnMetaData: null, + dataView: dataViewStub, + gridPosition: { top: 0, left: 0, bottom: 10, right: 10, height: 100, width: 100, visible: true }, + position: { top: 0, left: 0, bottom: 10, right: 10, height: 100, width: 100, visible: true }, + }; + }); + + describe('with invalid Editor instance', () => { + it('should throw an error when trying to call init without any arguments', (done) => { + try { + editor = new SelectEditor(null, true); + } catch (e) { + expect(e.toString()).toContain(`[Slickgrid-Universal] Something is wrong with this grid, an Editor must always have valid arguments.`); + done(); + } + }); + + it('should throw an error when there is no collection provided in the editor property', (done) => { + try { + mockColumn.internalColumnEditor.collection = undefined; + editor = new SelectEditor(editorArguments, true); + } catch (e) { + expect(e.toString()).toContain(`[Slickgrid-Universal] You need to pass a "collection" (or "collectionAsync") inside Column Definition Editor for the MultipleSelect/SingleSelect Editor to work correctly.`); + done(); + } + }); + + it('should throw an error when collection is not a valid array', (done) => { + try { + // @ts-ignore + mockColumn.internalColumnEditor.collection = { hello: 'world' }; + editor = new SelectEditor(editorArguments, true); + } catch (e) { + expect(e.toString()).toContain(`The "collection" passed to the Select Editor is not a valid array.`); + done(); + } + }); + + it('should throw an error when collection is not a valid value/label pair array', (done) => { + try { + mockColumn.internalColumnEditor.collection = [{ hello: 'world' }]; + editor = new SelectEditor(editorArguments, true); + } catch (e) { + expect(e.toString()).toContain(`[select-editor] A collection with value/label (or value/labelKey when using Locale) is required to populate the Select list`); + done(); + } + }); + + it('should throw an error when "enableTranslateLabel" is set without a valid I18N Service', (done) => { + try { + translateService = undefined; + mockColumn.internalColumnEditor.enableTranslateLabel = true; + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + editor = new SelectEditor(editorArguments, true); + } catch (e) { + expect(e.toString()).toContain(`[Slickgrid-Universal] requires a Translate Service to be installed and configured when the grid option "enableTranslate" is enabled.`); + done(); + } + }); + }); + + describe('with valid Editor instance', () => { + beforeEach(() => { + mockItemData = { id: 1, gender: 'male', isActive: true }; + mockColumn = { id: 'gender', field: 'gender', editable: true, editor: { model: Editors.multipleSelect }, internalColumnEditor: {} } as Column; + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }, { value: 'other', label: 'other' }]; + + editorArguments.column = mockColumn; + editorArguments.item = mockItemData; + }); + + afterEach(() => { + editor.destroy(); + }); + + it('should initialize the editor', () => { + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + gridOptionMock.i18n = translateService; + editor = new SelectEditor(editorArguments, true); + editor.focus(); + const editorCount = document.body.querySelectorAll('select.ms-filter.editor-gender').length; + + expect(editorCount).toBe(1); + }); + + it('should initialize the editor even when user define his own editor options', () => { + mockColumn.internalColumnEditor.editorOptions = { minLength: 3 } as AutocompleteOption; + editor = new SelectEditor(editorArguments, true); + const editorCount = document.body.querySelectorAll('select.ms-filter.editor-gender').length; + + expect(editorCount).toBe(1); + }); + + it('should have a placeholder when defined in its column definition', () => { + const testValue = 'test placeholder'; + mockColumn.internalColumnEditor.placeholder = testValue; + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + + editor = new SelectEditor(editorArguments, true); + const editorElm = divContainer.querySelector('.ms-filter.editor-gender .placeholder'); + + expect(editorElm.innerHTML).toBe(testValue); + }); + + it('should call "columnEditor" GETTER and expect to equal the editor settings we provided', () => { + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + mockColumn.internalColumnEditor.placeholder = 'test placeholder'; + + editor = new SelectEditor(editorArguments, true); + + expect(editor.columnEditor).toEqual(mockColumn.internalColumnEditor); + }); + + it('should call "setValue" with a single string and expect the string to be returned in a single string array when calling "getValue" when using single select', () => { + editor = new SelectEditor(editorArguments, true); + editor.setValue(['male']); + + expect(editor.getValue()).toEqual(['male']); + }); + + it('should define an item datacontext containing a string as cell value and expect this value to be loaded in the editor when calling "loadValue"', () => { + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const editorElm = editor.editorDomElement; + + expect(editor.getValue()).toEqual(['male']); + expect(editorElm[0].value).toEqual('male'); + }); + + it('should create the multi-select filter with a blank entry at the beginning of the collection when "addBlankEntry" is set in the "collectionOptions" property', () => { + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + mockColumn.internalColumnEditor.collectionOptions = { addBlankEntry: true }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + const editorOkElm = divContainer.querySelector(`[name=editor-gender].ms-drop .ms-ok-button`); + editorBtnElm.click(); + editorOkElm.click(); + + expect(editorListElm.length).toBe(3); + expect(editorListElm[1].textContent).toBe(''); + }); + + describe('isValueChanged method', () => { + it('should return True after doing a check of an option and clicking on the OK button', () => { + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + const editorOkElm = divContainer.querySelector(`[name=editor-gender].ms-drop .ms-ok-button`); + editorBtnElm.click(); + + // we can use property "checked" or dispatch an event + editorListElm[0].dispatchEvent(new CustomEvent('click')); + editorOkElm.click(); + + expect(editorListElm.length).toBe(3); + expect(editor.isValueChanged()).toBe(true); + }); + + it('should return False after doing a check & uncheck of the same option and clicking on the OK button', () => { + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + const editorOkElm = divContainer.querySelector(`[name=editor-gender].ms-drop .ms-ok-button`); + editorBtnElm.click(); + + // we can use property "checked" or dispatch an event + // check and uncheck the same option + editorListElm[0].checked = true; + editorListElm[0].checked = false; + editorOkElm.click(); + + expect(editorListElm.length).toBe(3); + expect(editor.isValueChanged()).toBe(true); + }); + }); + + describe('applyValue method', () => { + it('should apply the value to the gender property when it passes validation', () => { + mockColumn.internalColumnEditor.validator = null; + mockItemData = { id: 1, gender: 'male', isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.applyValue(mockItemData, 'female'); + + expect(mockItemData).toEqual({ id: 1, gender: 'female', isActive: true }); + }); + + it('should apply the value to the gender (last property) when field has a dot notation (complex object) that passes validation', () => { + mockColumn.internalColumnEditor.validator = null; + mockColumn.field = 'person.bio.gender'; + mockItemData = { id: 1, person: { bio: { gender: 'male' } }, isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.applyValue(mockItemData, 'female'); + + expect(mockItemData).toEqual({ id: 1, person: { bio: { gender: 'female' } }, isActive: true }); + }); + + it('should apply the value to the bio property (second last) when field has a dot notation (complex object) value provided is an object and it that passes validation', () => { + mockColumn.internalColumnEditor.validator = null; + mockColumn.internalColumnEditor.complexObjectPath = 'person.bio'; + mockColumn.field = 'person.bio.gender'; + mockItemData = { id: 1, person: { bio: { gender: 'male' } }, isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.applyValue(mockItemData, { gender: 'female' }); + + expect(mockItemData).toEqual({ id: 1, person: { bio: { gender: 'female' } }, isActive: true }); + }); + + it('should return item data with an empty string in its value when it fails the custom validation', () => { + mockColumn.internalColumnEditor.validator = (value: any, args: EditorArgs) => { + if (value.length < 10) { + return { valid: false, msg: 'Must be at least 10 chars long.' }; + } + return { valid: true, msg: '' }; + }; + mockItemData = { id: 1, gender: 'male', isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.applyValue(mockItemData, 'female'); + + expect(mockItemData).toEqual({ id: 1, gender: '', isActive: true }); + }); + + it('should apply the value to the gender property as an array with multiple when the input value is a CSV string', () => { + mockColumn.internalColumnEditor.validator = null; + mockItemData = { id: 1, gender: 'male', isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.applyValue(mockItemData, 'male,other'); + + expect(mockItemData).toEqual({ id: 1, gender: ['male', 'other'], isActive: true }); + }); + + it('should parse the value as a float when field type is defined as float then apply the value', () => { + mockColumn = { id: 'age', field: 'age', type: FieldType.boolean, editable: true, editor: { model: Editors.multipleSelect }, internalColumnEditor: {} } as Column; + mockItemData = { id: 1, gender: 'male', isActive: true, age: 26 }; + mockColumn.internalColumnEditor.collection = [{ value: 20, label: '20' }, { value: 25, label: '25' }]; + + editorArguments.column = mockColumn; + editor = new SelectEditor(editorArguments, true); + editor.applyValue(mockItemData, 25); + + expect(mockItemData).toEqual({ id: 1, gender: 'male', isActive: true, age: 25 }); + }); + }); + + describe('serializeValue method', () => { + it('should return serialized value as a string', () => { + mockItemData = { id: 1, gender: 'male', isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toEqual(['male']); + }); + + it('should return serialized value as an empty array when item value is also an empty string', () => { + mockItemData = { id: 1, gender: '', isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toEqual([]); + }); + + it('should return serialized value as an empty string when item value is null', () => { + mockItemData = { id: 1, gender: null, isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + const currentValue = editor.currentValue; + + expect(output).toEqual([]); + expect(currentValue).toEqual(''); + }); + + it('should return value as a string when using a dot (.) notation for complex object with a collection of string values', () => { + mockColumn.field = 'employee.gender'; + mockColumn.internalColumnEditor.collection = ['male', 'female']; + mockItemData = { id: 1, employee: { id: 24, gender: 'male' }, isActive: true }; + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toEqual(['male']); + }); + + it('should return object value when using a dot (.) notation for complex object with a collection of option/label pair', () => { + mockColumn.field = 'employee.gender'; + mockItemData = { id: 1, employee: { id: 24, gender: ['male', 'other'] }, isActive: true }; + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + const currentValue = editor.currentValue; + + expect(output).toEqual([{ label: 'male', value: 'male' }, { label: 'other', value: 'other' }]); + expect(currentValue).toEqual({}); + }); + + it('should return object value when using a dot (.) notation and we override the object path using "complexObjectPath" to find correct values', () => { + mockColumn.field = 'employee.bio'; + mockItemData = { id: 1, employee: { id: 24, bio: { gender: ['male', 'other'] } }, isActive: true }; + mockColumn.internalColumnEditor.complexObjectPath = 'employee.bio.gender'; + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toEqual([{ label: 'male', value: 'male' }, { label: 'other', value: 'other' }]); + }); + }); + + describe('save method', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call "getEditorLock" method when "hasAutoCommitEdit" is enabled', () => { + mockItemData = { id: 1, gender: 'male', isActive: true }; + gridOptionMock.autoCommitEdit = true; + const spy = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + editor.save(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should not call anything when "hasAutoCommitEdit" is disabled', () => { + mockItemData = { id: 1, gender: 'male', isActive: true }; + gridOptionMock.autoCommitEdit = false; + const spy = jest.spyOn(editorArguments, 'commitChanges'); + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + editor.save(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should not call anything when the input value is empty but is required', () => { + mockItemData = { id: 1, gender: '', isActive: true }; + mockColumn.internalColumnEditor.required = true; + gridOptionMock.autoCommitEdit = true; + const spy = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + editor.save(); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('validate method', () => { + it('should return False when field is required and field is empty', () => { + mockColumn.internalColumnEditor.required = true; + editor = new SelectEditor(editorArguments, true); + const validation = editor.validate(''); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should return True when field is required and input is a valid input value', () => { + mockColumn.internalColumnEditor.required = true; + editor = new SelectEditor(editorArguments, true); + const validation = editor.validate('text'); + + expect(validation).toEqual({ valid: true, msg: null }); + }); + }); + + describe('initialize with collection', () => { + it('should create the multi-select filter with a default search term when passed as a filter argument even with collection an array of strings', () => { + mockColumn.internalColumnEditor.collection = ['male', 'female']; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + const editorOkElm = divContainer.querySelector(`[name=editor-gender].ms-drop .ms-ok-button`); + editorBtnElm.click(); + editorOkElm.click(); + + expect(editorListElm.length).toBe(2); + expect(editorListElm[0].value).toBe('male'); + expect(editorListElm[1].value).toBe('female'); + }); + }); + + describe('collectionSortBy setting', () => { + it('should create the multi-select filter and sort the string collection when "collectionSortBy" is set', () => { + mockColumn.internalColumnEditor = { + collection: ['other', 'male', 'female'], + collectionSortBy: { + sortDesc: true, + fieldType: FieldType.string + } + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(3); + expect(editorListElm[0].value).toBe('other'); + expect(editorListElm[1].value).toBe('male'); + expect(editorListElm[2].value).toBe('female'); + }); + + it('should create the multi-select filter and sort the value/label pair collection when "collectionSortBy" is set', () => { + mockColumn.internalColumnEditor = { + collection: [{ value: 'other', description: 'other' }, { value: 'male', description: 'male' }, { value: 'female', description: 'female' }], + collectionSortBy: { + property: 'value', + sortDesc: false, + fieldType: FieldType.string + }, + customStructure: { + value: 'value', + label: 'description', + }, + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(3); + expect(editorListElm[0].value).toBe('female'); + expect(editorListElm[1].value).toBe('male'); + expect(editorListElm[2].value).toBe('other'); + }); + }); + + describe('collectionFilterBy setting', () => { + it('should create the multi-select filter and filter the string collection when "collectionFilterBy" is set', () => { + mockColumn.internalColumnEditor = { + collection: ['other', 'male', 'female'], + collectionFilterBy: { + operator: OperatorType.equal, + value: 'other' + } + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(1); + expect(editorListElm[0].value).toBe('other'); + }); + + it('should create the multi-select filter and filter the value/label pair collection when "collectionFilterBy" is set', () => { + mockColumn.internalColumnEditor = { + collection: [{ value: 'other', description: 'other' }, { value: 'male', description: 'male' }, { value: 'female', description: 'female' }], + collectionFilterBy: [ + { property: 'value', operator: OperatorType.notEqual, value: 'other' }, + { property: 'value', operator: OperatorType.notEqual, value: 'male' } + ], + customStructure: { + value: 'value', + label: 'description', + }, + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(1); + expect(editorListElm[0].value).toBe('female'); + }); + + it('should create the multi-select filter and filter the value/label pair collection when "collectionFilterBy" is set and "filterResultAfterEachPass" is set to "merge"', () => { + mockColumn.internalColumnEditor = { + collection: [{ value: 'other', description: 'other' }, { value: 'male', description: 'male' }, { value: 'female', description: 'female' }], + collectionFilterBy: [ + { property: 'value', operator: OperatorType.equal, value: 'other' }, + { property: 'value', operator: OperatorType.equal, value: 'male' } + ], + collectionOptions: { + filterResultAfterEachPass: 'merge' + }, + customStructure: { + value: 'value', + label: 'description', + }, + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(2); + expect(editorListElm[0].value).toBe('other'); + expect(editorListElm[1].value).toBe('male'); + }); + }); + + describe('collectionInsideObjectProperty setting', () => { + it('should create the multi-select editor with a value/label pair collection that is inside an object when "collectionInsideObjectProperty" is defined with a dot notation', () => { + mockColumn.internalColumnEditor = { + // @ts-ignore + collection: { deep: { myCollection: [{ value: 'other', description: 'other' }, { value: 'male', description: 'male' }, { value: 'female', description: 'female' }] } }, + collectionOptions: { + collectionInsideObjectProperty: 'deep.myCollection' + }, + customStructure: { + value: 'value', + label: 'description', + }, + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li input[type=checkbox]`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(3); + expect(editorListElm[0].value).toBe('other'); + expect(editorListElm[1].value).toBe('male'); + expect(editorListElm[2].value).toBe('female'); + }); + }); + + describe('enableRenderHtml property', () => { + it('should create the multi-select filter with a default search term and have the HTML rendered when "enableRenderHtml" is set', () => { + mockColumn.internalColumnEditor = { + enableRenderHtml: true, + collection: [{ value: true, label: 'True', labelPrefix: ` ` }, { value: false, label: 'False' }], + customStructure: { + value: 'isEffort', + label: 'label', + labelPrefix: 'labelPrefix', + }, + }; + + editor = new SelectEditor(editorArguments, true); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li span`); + editorBtnElm.click(); + + expect(editorListElm.length).toBe(2); + expect(editorListElm[0].innerHTML).toBe(' True'); + }); + + it('should create the multi-select filter with a default search term and have the HTML rendered and sanitized when "enableRenderHtml" is set and has ` }, { isEffort: false, label: 'False' }], + collectionOptions: { + separatorBetweenTextLabels: ': ', + includePrefixSuffixToSelectedValues: true, + }, + customStructure: { + value: 'isEffort', + label: 'label', + labelPrefix: 'labelPrefix', + }, + }; + mockItemData = { id: 1, gender: 'male', isEffort: false }; + + editor = new SelectEditor(editorArguments, true); + editor.loadValue(mockItemData); + editor.setValue([false]); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li span`); + editorBtnElm.click(); + + expect(editor.getValue()).toEqual(['']); + expect(editorListElm.length).toBe(2); + expect(editorListElm[0].innerHTML).toBe(' : True'); + }); + + it('should create the multi-select filter with a default search term and have the HTML rendered and sanitized when using a custom "sanitizer" and "enableRenderHtml" flag is set and has ` }, { isEffort: false, label: 'False' }], + collectionOptions: { + separatorBetweenTextLabels: ': ', + includePrefixSuffixToSelectedValues: true, + }, + customStructure: { + value: 'isEffort', + label: 'label', + labelPrefix: 'labelPrefix', + }, + }; + mockItemData = { id: 1, gender: 'male', isEffort: false }; + gridOptionMock.sanitizer = (dirtyHtml) => dirtyHtml.replace(/( ` }, { isEffort: false, label: 'False' }], + collectionOptions: { + separatorBetweenTextLabels: ': ', + includePrefixSuffixToSelectedValues: true, + }, + customStructure: { + value: 'isEffort', + label: 'label', + labelPrefix: 'labelPrefix', + }, + }; + mockItemData = { id: 1, gender: 'male', isEffort: false }; + + editor = new SingleSelectEditor(editorArguments); + editor.loadValue(mockItemData); + const editorBtnElm = divContainer.querySelector('.ms-parent.ms-filter.editor-gender button.ms-choice'); + const editorListElm = divContainer.querySelectorAll(`[name=editor-gender].ms-drop ul>li span`); + editorBtnElm.click(); + + expect(editor.getValue()).toEqual(` : true`); + expect(editorListElm.length).toBe(2); + expect(editorListElm[0].innerHTML).toBe(' : True'); + }); + }); + }); +}); diff --git a/packages/common/src/editors/index.ts b/packages/common/src/editors/index.ts index cea38684f..11d721f61 100644 --- a/packages/common/src/editors/index.ts +++ b/packages/common/src/editors/index.ts @@ -1,4 +1,6 @@ import { LongTextEditor } from './longTextEditor'; +import { MultipleSelectEditor } from './multipleSelectEditor'; +import { SingleSelectEditor } from './singleSelectEditor'; import { SliderEditor } from './sliderEditor'; import { TextEditor } from './textEditor'; @@ -6,6 +8,12 @@ export const Editors = { /** Long Text Editor (uses a textarea) */ longText: LongTextEditor, + /** Multiple Select editor (which uses 3rd party lib "multiple-select.js") */ + multipleSelect: MultipleSelectEditor, + + /** Single Select editor (which uses 3rd party lib "multiple-select.js") */ + singleSelect: SingleSelectEditor, + /** Slider Editor */ slider: SliderEditor, diff --git a/packages/common/src/editors/multipleSelectEditor.ts b/packages/common/src/editors/multipleSelectEditor.ts new file mode 100644 index 000000000..81219cb77 --- /dev/null +++ b/packages/common/src/editors/multipleSelectEditor.ts @@ -0,0 +1,10 @@ +import { SelectEditor } from './selectEditor'; + +export class MultipleSelectEditor extends SelectEditor { + /** + * Initialize the Editor + */ + constructor(protected args: any) { + super(args, true); + } +} diff --git a/packages/common/src/editors/selectEditor.ts b/packages/common/src/editors/selectEditor.ts new file mode 100644 index 000000000..f56b3307d --- /dev/null +++ b/packages/common/src/editors/selectEditor.ts @@ -0,0 +1,628 @@ +import * as DOMPurify from 'dompurify'; + +import { Constants } from '../constants'; +import { FieldType } from './../enums/index'; +import { + CollectionCustomStructure, + CollectionOption, + Column, + ColumnEditor, + Editor, + EditorArguments, + EditorValidator, + EditorValidatorOutput, + GridOption, + Locale, + MultipleSelectOption, + SelectOption, +} from './../interfaces/index'; +import { CollectionService, findOrDefault, TranslaterService } from '../services/index'; +import { charArraysEqual, getDescendantProperty, htmlEncode, setDeepValue } from '../services/utilities'; + +/** + * Slickgrid editor class for multiple/single select lists + */ +export class SelectEditor implements Editor { + /** The JQuery DOM element */ + $editorElm: any; + + /** Editor Multiple-Select options */ + editorElmOptions: MultipleSelectOption; + + /** DOM Element Name, useful for auto-detecting positioning (dropup / dropdown) */ + elementName: string; + + /** The multiple-select options for a multiple select list */ + defaultOptions: MultipleSelectOption; + + /** The original item values that are set at the beginning */ + originalValue: any[]; + + /** The property name for labels in the collection */ + labelName: string; + + /** The property name for a prefix that can be added to the labels in the collection */ + labelPrefixName: string; + + /** The property name for a suffix that can be added to the labels in the collection */ + labelSuffixName: string; + + /** A label that can be added to each option and can be used as an alternative to display selected options */ + optionLabel: string; + + /** The property name for values in the collection */ + valueName: string; + + /** Grid options */ + gridOptions: GridOption; + + /** Do we translate the label? */ + enableTranslateLabel: boolean; + + /** Locales */ + protected _locales: Locale; + + // flag to signal that the editor is destroying itself, helps prevent + // commit changes from being called twice and erroring + protected _destroying = false; + + /** Collection Service */ + protected _collectionService: CollectionService; + + /** The translate library */ + protected _translaterService: TranslaterService; + + /** SlickGrid Grid object */ + grid: any; + + constructor(protected args: EditorArguments, protected isMultipleSelect: boolean) { + if (!args) { + throw new Error('[Slickgrid-Universal] Something is wrong with this grid, an Editor must always have valid arguments.'); + } + this.grid = args.grid; + this.gridOptions = (this.grid.getOptions() || {}) as GridOption; + if (this.gridOptions && this.gridOptions.i18n) { + this._translaterService = this.gridOptions.i18n; + } + + // get locales provided by user in main file or else use default English locales via the Constants + this._locales = this.gridOptions.locales || Constants.locales; + + // provide the name attribute to the DOM element which will be needed to auto-adjust drop position (dropup / dropdown) + const columnId = this.columnDef && this.columnDef.id; + this.elementName = `editor-${columnId}`; + + const libOptions: MultipleSelectOption = { + autoAdjustDropHeight: true, + autoAdjustDropPosition: true, + autoAdjustDropWidthByTextSize: true, + container: 'body', + filter: false, + maxHeight: 275, + name: this.elementName, + single: true, + textTemplate: ($elm) => { + // render HTML code or not, by default it is sanitized and won't be rendered + const isRenderHtmlEnabled = this.columnEditor && this.columnEditor.enableRenderHtml || false; + return isRenderHtmlEnabled ? $elm.text() : $elm.html(); + }, + onClose: () => this.save(), + }; + + if (isMultipleSelect) { + libOptions.single = false; + libOptions.addTitle = true; + libOptions.okButton = true; + libOptions.selectAllDelimiter = ['', '']; + + if (this._translaterService && this._translaterService.translate && this._translaterService.getCurrentLocale && this._translaterService.getCurrentLocale()) { + libOptions.countSelected = this._translaterService.translate('X_OF_Y_SELECTED'); + libOptions.allSelected = this._translaterService.translate('ALL_SELECTED'); + libOptions.selectAllText = this._translaterService.translate('SELECT_ALL'); + libOptions.okButtonText = this._translaterService.translate('OK'); + } else { + libOptions.countSelected = this._locales && this._locales.TEXT_X_OF_Y_SELECTED; + libOptions.allSelected = this._locales && this._locales.TEXT_ALL_SELECTED; + libOptions.selectAllText = this._locales && this._locales.TEXT_SELECT_ALL; + libOptions.okButtonText = this._locales && this._locales.TEXT_OK; + } + } + + // assign the multiple select lib options + this.defaultOptions = libOptions; + this.init(); + } + + /** Get the Collection */ + get collection(): SelectOption[] { + return this.columnEditor && this.columnEditor.collection || []; + } + + /** Getter for the Collection Options */ + get collectionOptions(): CollectionOption | undefined { + return this.columnEditor && this.columnEditor.collectionOptions; + } + + /** Get Column Definition object */ + get columnDef(): Column | undefined { + return this.args && this.args.column; + } + + /** Get Column Editor object */ + get columnEditor(): ColumnEditor | undefined { + return this.columnDef && this.columnDef.internalColumnEditor || {}; + } + + /** Get the Editor DOM Element */ + get editorDomElement(): any { + return this.$editorElm; + } + + /** Getter for the Custom Structure if exist */ + protected get customStructure(): CollectionCustomStructure | undefined { + return this.columnDef && this.columnDef.internalColumnEditor && this.columnDef.internalColumnEditor.customStructure; + } + + get hasAutoCommitEdit() { + return this.grid.getOptions().autoCommitEdit; + } + + /** + * The current selected values (multiple select) from the collection + */ + get currentValues(): any[] | null { + const elmValue = this.$editorElm.val(); + + // collection of strings, just return the filtered string that are equals + if (this.collection.every(x => typeof x === 'string')) { + return this.collection.filter(c => elmValue.indexOf(c.toString()) !== -1); + } + + // collection of label/value pair + const separatorBetweenLabels = this.collectionOptions && this.collectionOptions.separatorBetweenTextLabels || ''; + const isIncludingPrefixSuffix = this.collectionOptions && this.collectionOptions.includePrefixSuffixToSelectedValues || false; + + return this.collection + .filter(c => elmValue.indexOf(c.hasOwnProperty(this.valueName) && c[this.valueName].toString()) !== -1) + .map(c => { + const labelText = c[this.valueName]; + let prefixText = c[this.labelPrefixName] || ''; + let suffixText = c[this.labelSuffixName] || ''; + + // when it's a complex object, then pull the object name only, e.g.: "user.firstName" => "user" + const fieldName = this.columnDef && this.columnDef.field || ''; + + // is the field a complex object, "address.streetNumber" + const isComplexObject = fieldName.indexOf('.') > 0; + if (isComplexObject && typeof c === 'object') { + return c; + } + + // also translate prefix/suffix if enableTranslateLabel is true and text is a string + prefixText = (this.enableTranslateLabel && prefixText && typeof prefixText === 'string') ? this._translaterService.translate(prefixText || ' ') : prefixText; + suffixText = (this.enableTranslateLabel && suffixText && typeof suffixText === 'string') ? this._translaterService.translate(suffixText || ' ') : suffixText; + + if (isIncludingPrefixSuffix) { + const tmpOptionArray = [prefixText, labelText, suffixText].filter((text) => text); // add to a temp array for joining purpose and filter out empty text + return tmpOptionArray.join(separatorBetweenLabels); + } + return labelText; + }); + } + + /** + * The current selected values (single select) from the collection + */ + get currentValue(): number | string { + const elmValue = this.$editorElm.val(); + const fieldName = this.columnDef && this.columnDef.field; + + if (fieldName !== undefined) { + // collection of strings, just return the filtered string that are equals + if (this.collection.every(x => typeof x === 'string')) { + return findOrDefault(this.collection, (c: any) => c.toString() === elmValue); + } + + // collection of label/value pair + const separatorBetweenLabels = this.collectionOptions && this.collectionOptions.separatorBetweenTextLabels || ''; + const isIncludingPrefixSuffix = this.collectionOptions && this.collectionOptions.includePrefixSuffixToSelectedValues || false; + const itemFound = findOrDefault(this.collection, (c: any) => c.hasOwnProperty(this.valueName) && c[this.valueName].toString() === elmValue); + + // is the field a complex object, "address.streetNumber" + const isComplexObject = fieldName.indexOf('.') > 0; + + if (isComplexObject && typeof itemFound === 'object') { + return itemFound; + } else if (itemFound && itemFound.hasOwnProperty(this.valueName)) { + const labelText = itemFound[this.valueName]; + + if (isIncludingPrefixSuffix) { + let prefixText = itemFound[this.labelPrefixName] || ''; + let suffixText = itemFound[this.labelSuffixName] || ''; + + // also translate prefix/suffix if enableTranslateLabel is true and text is a string + prefixText = (this.enableTranslateLabel && prefixText && typeof prefixText === 'string') ? this._translaterService.translate(prefixText || ' ') : prefixText; + suffixText = (this.enableTranslateLabel && suffixText && typeof suffixText === 'string') ? this._translaterService.translate(suffixText || ' ') : suffixText; + + // add to a temp array for joining purpose and filter out empty text + const tmpOptionArray = [prefixText, labelText, suffixText].filter((text) => text); + return tmpOptionArray.join(separatorBetweenLabels); + } + return labelText; + } + } + return ''; + } + + /** Get the Validator function, can be passed in Editor property or Column Definition */ + get validator(): EditorValidator | undefined { + return (this.columnEditor && this.columnEditor.validator) || (this.columnDef && this.columnDef.validator); + } + + init() { + if (!this.columnDef || !this.columnDef.internalColumnEditor || (!this.columnDef.internalColumnEditor.collection && !this.columnDef.internalColumnEditor.collectionAsync)) { + throw new Error(`[Slickgrid-Universal] You need to pass a "collection" (or "collectionAsync") inside Column Definition Editor for the MultipleSelect/SingleSelect Editor to work correctly. + Also each option should include a value/label pair (or value/labelKey when using Locale). + For example: { editor: { collection: [{ value: true, label: 'True' },{ value: false, label: 'False'}] } }`); + } + + this._collectionService = new CollectionService(this._translaterService); + this.enableTranslateLabel = this.columnEditor && this.columnEditor.enableTranslateLabel || false; + this.labelName = this.customStructure && this.customStructure.label || 'label'; + this.labelPrefixName = this.customStructure && this.customStructure.labelPrefix || 'labelPrefix'; + this.labelSuffixName = this.customStructure && this.customStructure.labelSuffix || 'labelSuffix'; + this.optionLabel = this.customStructure && this.customStructure.optionLabel || 'value'; + this.valueName = this.customStructure && this.customStructure.value || 'value'; + + if (this.enableTranslateLabel && (!this._translaterService || typeof this._translaterService.translate !== 'function')) { + throw new Error('[Slickgrid-Universal] requires a Translate Service to be installed and configured when the grid option "enableTranslate" is enabled.'); + } + + // always render the Select (dropdown) DOM element, even if user passed a "collectionAsync", + // if that is the case, the Select will simply be without any options but we still have to render it (else SlickGrid would throw an error) + this.renderDomElement(this.collection); + } + + getValue(): any | any[] { + return (this.isMultipleSelect) ? this.currentValues : this.currentValue; + } + + setValue(value: any | any[]) { + if (this.isMultipleSelect && Array.isArray(value)) { + this.loadMultipleValues(value); + } else { + this.loadSingleValue(value); + } + } + + hide() { + if (this.$editorElm && typeof this.$editorElm.multipleSelect === 'function') { + this.$editorElm.multipleSelect('close'); + } + } + + show() { + if (this.$editorElm && typeof this.$editorElm.multipleSelect === 'function') { + this.$editorElm.multipleSelect('open'); + } + } + + applyValue(item: any, state: any): void { + const fieldName = this.columnDef && this.columnDef.field; + const fieldType = this.columnDef && this.columnDef.type; + let newValue = state; + + if (fieldName !== undefined) { + // when the provided user defined the column field type as a possible number then try parsing the state value as that + if (fieldType === FieldType.number || fieldType === FieldType.integer || fieldType === FieldType.boolean) { + newValue = parseFloat(state); + } + + // when set as a multiple selection, we can assume that the 3rd party lib multiple-select will return a CSV string + // we need to re-split that into an array to be the same as the original column + if (this.isMultipleSelect && typeof state === 'string' && state.indexOf(',') >= 0) { + newValue = state.split(','); + } + + // is the field a complex object, "user.address.streetNumber" + const isComplexObject = fieldName !== undefined && fieldName.indexOf('.') > 0; + + // validate the value before applying it (if not valid we'll set an empty string) + const validation = this.validate(newValue); + newValue = (validation && validation.valid) ? newValue : ''; + + // set the new value to the item datacontext + if (isComplexObject) { + // when it's a complex object, user could override the object path (where the editable object is located) + // else we use the path provided in the Field Column Definition + const objectPath = this.columnEditor && this.columnEditor.complexObjectPath || fieldName || ''; + setDeepValue(item, objectPath, newValue); + } else if (fieldName !== undefined) { + item[fieldName] = newValue; + } + } + } + + destroy() { + this._destroying = true; + if (this.$editorElm && typeof this.$editorElm.multipleSelect === 'function') { + this.$editorElm.multipleSelect('destroy'); + const elementClassName = this.elementName.toString().replace('.', '\\.'); // make sure to escape any dot "." from CSS class to avoid console error + $(`[name=${elementClassName}].ms-drop`).remove(); + } + if (this.$editorElm && typeof this.$editorElm.remove === 'function') { + this.$editorElm.remove(); + } + } + + loadValue(item: any): void { + const fieldName = this.columnDef && this.columnDef.field; + + // is the field a complex object, "address.streetNumber" + const isComplexObject = fieldName !== undefined && fieldName.indexOf('.') > 0; + + if (item && this.columnDef && fieldName !== undefined && (item.hasOwnProperty(fieldName) || isComplexObject)) { + // when it's a complex object, user could override the object path (where the editable object is located) + // else we use the path provided in the Field Column Definition + const objectPath = this.columnEditor && this.columnEditor.complexObjectPath || fieldName; + const currentValue = (isComplexObject) ? getDescendantProperty(item, objectPath as string) : (item.hasOwnProperty(fieldName) && item[fieldName]); + const value = (isComplexObject && currentValue.hasOwnProperty(this.valueName)) ? currentValue[this.valueName] : currentValue; + + if (this.isMultipleSelect && Array.isArray(value)) { + this.loadMultipleValues(value); + } else { + this.loadSingleValue(value); + } + this.refresh(); + } + } + + loadMultipleValues(currentValues: any[]) { + // convert to string because that is how the DOM will return these values + if (Array.isArray(currentValues)) { + // keep the default values in memory for references + this.originalValue = currentValues.map((i: any) => i); + + // compare all the array values but as string type since multiple-select always return string + const currentStringValues = currentValues.map((i: any) => i.toString()); + this.$editorElm.find('option').each((i: number, $e: any) => { + $e.selected = (currentStringValues.indexOf($e.value) !== -1); + }); + } + } + + loadSingleValue(currentValue: any) { + // keep the default value in memory for references + this.originalValue = typeof currentValue === 'number' ? `${currentValue}` : currentValue; + this.$editorElm.val(currentValue); + + // make sure the prop exists first + this.$editorElm.find('option').each((i: number, $e: any) => { + // check equality after converting originalValue to string since the DOM value will always be of type string + const strValue = currentValue && currentValue.toString && currentValue.toString(); + $e.selected = (strValue === $e.value); + }); + } + + save() { + // autocommit will not focus the next editor + const validation = this.validate(); + if (validation && validation.valid && this.isValueChanged()) { + if (!this._destroying && this.hasAutoCommitEdit) { + // do not use args.commitChanges() as this sets the focus to the next + // row. Also the select list will stay shown when clicking off the grid + this.grid.getEditorLock().commitCurrentEdit(); + } + } + } + + serializeValue(): any | any[] { + return (this.isMultipleSelect) ? this.currentValues : this.currentValue; + } + + focus() { + if (this.$editorElm && this.$editorElm.multipleSelect) { + this.$editorElm.multipleSelect('focus'); + } + } + + isValueChanged(): boolean { + if (this.isMultipleSelect) { + return !charArraysEqual(this.$editorElm.val(), this.originalValue); + } + return this.$editorElm.val() !== this.originalValue; + } + + validate(inputValue?: any): EditorValidatorOutput { + const isRequired = this.columnEditor && this.columnEditor.required; + const elmValue = (inputValue !== undefined) ? inputValue : this.$editorElm && this.$editorElm.val && this.$editorElm.val(); + const errorMsg = this.columnEditor && this.columnEditor.errorMessage; + + if (this.validator) { + const value = (inputValue !== undefined) ? inputValue : (this.isMultipleSelect ? this.currentValues : this.currentValue); + return this.validator(value, this.args); + } + + // by default the editor is almost always valid (except when it's required but not provided) + if (isRequired && (elmValue === '' || (Array.isArray(elmValue) && elmValue.length === 0))) { + return { + valid: false, + msg: errorMsg || Constants.VALIDATION_REQUIRED_FIELD + }; + } + + return { + valid: true, + msg: null + }; + } + + // + // protected functions + // ------------------ + + /** + * user might want to filter certain items of the collection + * @param inputCollection + * @return outputCollection filtered and/or sorted collection + */ + protected filterCollection(inputCollection: any[]): any[] { + let outputCollection = inputCollection; + + // user might want to filter certain items of the collection + if (this.columnEditor && this.columnEditor.collectionFilterBy) { + const filterBy = this.columnEditor.collectionFilterBy; + const filterCollectionBy = this.columnEditor.collectionOptions && this.columnEditor.collectionOptions.filterResultAfterEachPass || null; + outputCollection = this._collectionService.filterCollection(outputCollection, filterBy, filterCollectionBy); + } + + return outputCollection; + } + + /** + * user might want to sort the collection in a certain way + * @param inputCollection + * @return outputCollection filtered and/or sorted collection + */ + protected sortCollection(inputCollection: any[]): any[] { + let outputCollection = inputCollection; + + // user might want to sort the collection + if (this.columnDef && this.columnEditor && this.columnEditor.collectionSortBy) { + const sortBy = this.columnEditor.collectionSortBy; + outputCollection = this._collectionService.sortCollection(this.columnDef, outputCollection, sortBy, this.enableTranslateLabel); + } + + return outputCollection; + } + + protected renderDomElement(collection: any[]) { + if (!Array.isArray(collection) && this.collectionOptions && (this.collectionOptions.collectionInsideObjectProperty || this.collectionOptions.collectionInObjectProperty)) { + const collectionInsideObjectProperty = this.collectionOptions.collectionInsideObjectProperty || this.collectionOptions.collectionInObjectProperty; + collection = getDescendantProperty(collection, collectionInsideObjectProperty); + } + if (!Array.isArray(collection)) { + throw new Error('The "collection" passed to the Select Editor is not a valid array.'); + } + + // user can optionally add a blank entry at the beginning of the collection + if (this.collectionOptions && this.collectionOptions.addBlankEntry) { + collection.unshift(this.createBlankEntry()); + } + + // assign the collection to a temp variable before filtering/sorting the collection + let newCollection = collection; + + // user might want to filter and/or sort certain items of the collection + newCollection = this.filterCollection(newCollection); + newCollection = this.sortCollection(newCollection); + + // step 1, create HTML string template + const editorTemplate = this.buildTemplateHtmlString(newCollection); + + // step 2, create the DOM Element of the editor + // also subscribe to the onClose event + this.createDomElement(editorTemplate); + } + + /** Build the template HTML string */ + protected buildTemplateHtmlString(collection: any[]): string { + let options = ''; + const columnId = this.columnDef && this.columnDef.id || ''; + const separatorBetweenLabels = this.collectionOptions && this.collectionOptions.separatorBetweenTextLabels || ''; + const isRenderHtmlEnabled = this.columnEditor && this.columnEditor.enableRenderHtml || false; + const sanitizedOptions = this.gridOptions && this.gridOptions.sanitizeHtmlOptions || {}; + + // collection could be an Array of Strings OR Objects + if (collection.every((x: any) => typeof x === 'string')) { + collection.forEach((option: string) => { + options += ``; + }); + } else { + // array of objects will require a label/value pair unless a customStructure is passed + collection.forEach((option: SelectOption) => { + if (!option || (option[this.labelName] === undefined && option.labelKey === undefined)) { + throw new Error('[select-editor] A collection with value/label (or value/labelKey when using ' + + 'Locale) is required to populate the Select list, for example: ' + + '{ collection: [ { value: \'1\', label: \'One\' } ])'); + } + const labelKey = (option.labelKey || option[this.labelName]) as string; + const labelText = ((option.labelKey || this.enableTranslateLabel) && labelKey) ? this._translaterService.translate(labelKey || ' ') : labelKey; + let prefixText = option[this.labelPrefixName] || ''; + let suffixText = option[this.labelSuffixName] || ''; + let optionLabel = option[this.optionLabel] || ''; + optionLabel = optionLabel.toString().replace(/\"/g, '\''); // replace double quotes by single quotes to avoid interfering with regular html + + // also translate prefix/suffix if enableTranslateLabel is true and text is a string + prefixText = (this.enableTranslateLabel && prefixText && typeof prefixText === 'string') ? this._translaterService.translate(prefixText || ' ') : prefixText; + suffixText = (this.enableTranslateLabel && suffixText && typeof suffixText === 'string') ? this._translaterService.translate(suffixText || ' ') : suffixText; + optionLabel = (this.enableTranslateLabel && optionLabel && typeof optionLabel === 'string') ? this._translaterService.translate(optionLabel || ' ') : optionLabel; + + // add to a temp array for joining purpose and filter out empty text + const tmpOptionArray = [prefixText, labelText, suffixText].filter(text => (text !== undefined && text !== '')); + let optionText = tmpOptionArray.join(separatorBetweenLabels); + + // if user specifically wants to render html text, he needs to opt-in else it will stripped out by default + // also, the 3rd party lib will saninitze any html code unless it's encoded, so we'll do that + if (isRenderHtmlEnabled) { + // sanitize any unauthorized html tags like script and others + // for the remaining allowed tags we'll permit all attributes + let sanitizedText = ''; + if (this.gridOptions && typeof this.gridOptions.sanitizer === 'function') { + sanitizedText = this.gridOptions.sanitizer(optionText); + } else { + sanitizedText = (DOMPurify.sanitize(optionText, sanitizedOptions) || '').toString(); + } + optionText = htmlEncode(sanitizedText); + } + + options += ``; + }); + } + return ``; + } + + /** Create a blank entry that can be added to the collection. It will also reuse the same collection structure provided by the user */ + protected createBlankEntry(): any { + const blankEntry = { + [this.labelName]: '', + [this.valueName]: '' + }; + if (this.labelPrefixName) { + blankEntry[this.labelPrefixName] = ''; + } + if (this.labelSuffixName) { + blankEntry[this.labelSuffixName] = ''; + } + return blankEntry; + } + + /** From the html template string, create the DOM element of the Multiple/Single Select Editor */ + protected createDomElement(editorTemplate: string) { + this.$editorElm = $(editorTemplate); + + if (this.$editorElm && typeof this.$editorElm.appendTo === 'function') { + this.$editorElm.appendTo(this.args.container); + } + + // add placeholder when found + const placeholder = this.columnEditor && this.columnEditor.placeholder || ''; + this.defaultOptions.placeholder = placeholder || ''; + + if (typeof this.$editorElm.multipleSelect === 'function') { + const elementOptions = (this.columnEditor) ? this.columnEditor.elementOptions : {}; + const editorOptions = (this.columnDef && this.columnDef.internalColumnEditor) ? this.columnDef.internalColumnEditor.editorOptions : {}; + this.editorElmOptions = { ...this.defaultOptions, ...elementOptions, ...editorOptions }; + this.$editorElm = this.$editorElm.multipleSelect(this.editorElmOptions); + setTimeout(() => this.show()); + } + } + + // refresh the jquery object because the selected checkboxes were already set + // prior to this method being called + protected refresh() { + if (typeof this.$editorElm.multipleSelect === 'function') { + this.$editorElm.multipleSelect('refresh'); + } + } +} diff --git a/packages/common/src/editors/singleSelectEditor.ts b/packages/common/src/editors/singleSelectEditor.ts new file mode 100644 index 000000000..d9abcae7f --- /dev/null +++ b/packages/common/src/editors/singleSelectEditor.ts @@ -0,0 +1,10 @@ +import { SelectEditor } from './selectEditor'; + +export class SingleSelectEditor extends SelectEditor { + /** + * Initialize the Editor + */ + constructor(protected args: any) { + super(args, false); + } +} diff --git a/packages/common/src/formatters/__tests__/checkmarkMaterialFormatter.spec.ts b/packages/common/src/formatters/__tests__/checkmarkMaterialFormatter.spec.ts new file mode 100644 index 000000000..850733f62 --- /dev/null +++ b/packages/common/src/formatters/__tests__/checkmarkMaterialFormatter.spec.ts @@ -0,0 +1,98 @@ +import { Column } from '../../interfaces/index'; +import { checkmarkMaterialFormatter } from '../checkmarkMaterialFormatter'; + +describe('the Checkmark Formatter with Material Design Icon', () => { + it('should return an empty string when no value is passed', () => { + const value = null; + const result = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result).toBe(''); + }); + + it('should return an empty string when False is provided', () => { + const value = false; + const result = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result).toBe(''); + }); + + it('should return an empty string when the string "FALSE" (case insensitive) is provided', () => { + const value = 'FALSE'; + const result1 = checkmarkMaterialFormatter(0, 0, value.toLowerCase(), {} as Column, {}); + const result2 = checkmarkMaterialFormatter(0, 0, value.toUpperCase(), {} as Column, {}); + expect(result1).toBe(''); + expect(result2).toBe(''); + }); + + it('should return the Font Awesome Checkmark icon when the string "True" (case insensitive) is provided', () => { + const value = 'True'; + const result1 = checkmarkMaterialFormatter(0, 0, value.toLowerCase(), {} as Column, {}); + const result2 = checkmarkMaterialFormatter(0, 0, value.toUpperCase(), {} as Column, {}); + expect(result1).toBe(''); + expect(result2).toBe(''); + }); + + it('should return the Font Awesome Checkmark icon when input is True', () => { + const value = true; + const result = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result).toBe(''); + }); + + it('should return the Font Awesome Checkmark icon when input is a string even if it start with 0', () => { + const value = '005A00ABC'; + const result1 = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result1).toBe(''); + }); + + it('should return an empty string when the string "0" is provided', () => { + const value = '0'; + const result = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result).toBe(''); + }); + + it('should return the Font Awesome Checkmark icon when input is a number greater than 0', () => { + const value = 0.000001; + const result1 = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result1).toBe(''); + }); + + it('should return the Font Awesome Checkmark icon when input is a number as a text greater than 0', () => { + const value = '0.000001'; + const result1 = checkmarkMaterialFormatter(0, 0, value, {} as Column, {}); + expect(result1).toBe(''); + }); + + it('should return an empty string when input is a number lower or equal to 0', () => { + const value1 = 0; + const value2 = -0.5; + const result1 = checkmarkMaterialFormatter(0, 0, value1, {} as Column, {}); + const result2 = checkmarkMaterialFormatter(0, 0, value2, {} as Column, {}); + expect(result1).toBe(''); + expect(result2).toBe(''); + }); + + it('should return an empty string when input is a number as a text and lower or equal to 0', () => { + const value1 = '0'; + const value2 = '-0.5'; + const result1 = checkmarkMaterialFormatter(0, 0, value1, {} as Column, {}); + const result2 = checkmarkMaterialFormatter(0, 0, value2, {} as Column, {}); + expect(result1).toBe(''); + expect(result2).toBe(''); + }); + + it('should return an empty string when input is type null or undefined', () => { + const value1 = null; + const value2 = undefined; + const result1 = checkmarkMaterialFormatter(0, 0, value1, {} as Column, {}); + const result2 = checkmarkMaterialFormatter(0, 0, value2, {} as Column, {}); + expect(result1).toBe(''); + expect(result2).toBe(''); + }); + + it('should return the Font Awesome Checkmark icon when input is the "null" or "undefined"', () => { + const value1 = 'null'; + const value2 = 'undefined'; + const result1 = checkmarkMaterialFormatter(0, 0, value1, {} as Column, {}); + const result2 = checkmarkMaterialFormatter(0, 0, value2, {} as Column, {}); + expect(result1).toBe(''); + expect(result2).toBe(''); + }); +}); diff --git a/packages/common/src/formatters/__tests__/collectionEditorFormatter.spec.ts b/packages/common/src/formatters/__tests__/collectionEditorFormatter.spec.ts index d616e33eb..aee016454 100644 --- a/packages/common/src/formatters/__tests__/collectionEditorFormatter.spec.ts +++ b/packages/common/src/formatters/__tests__/collectionEditorFormatter.spec.ts @@ -4,7 +4,7 @@ import { Editors } from '../../editors'; jest.mock('flatpickr', () => { }); -xdescribe('the CollectionEditor Formatter', () => { +describe('the CollectionEditor Formatter', () => { let columnDef: Column; beforeEach(() => { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 0186277fe..8471ed3e9 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,3 +1,5 @@ +import 'multiple-select-adapted/src/multiple-select.js'; + // Public classes. export * from './global-grid-options'; export * from './enums/index'; @@ -14,4 +16,4 @@ export * from './grouping-formatters/index'; export * from './sorters/index'; import * as Enums from './enums/index'; -export { Enums } \ No newline at end of file +export { Enums } diff --git a/packages/common/src/interfaces/gridOption.interface.ts b/packages/common/src/interfaces/gridOption.interface.ts index d221d2ded..8e62e137f 100644 --- a/packages/common/src/interfaces/gridOption.interface.ts +++ b/packages/common/src/interfaces/gridOption.interface.ts @@ -394,6 +394,12 @@ export interface GridOption { */ sanitizeHtmlOptions?: any; + /** + * By default the lib will use DOMPurify to sanitize any Html, + * but you could optionally pass your own sanitizer function which will run instead of DOM Purify + */ + sanitizer?: (dirtyHtml: string) => string; + /** CSS class name used when cell is selected */ selectedCellCssClass?: string; diff --git a/packages/common/src/interfaces/index.ts b/packages/common/src/interfaces/index.ts index 17301a227..bd771a919 100644 --- a/packages/common/src/interfaces/index.ts +++ b/packages/common/src/interfaces/index.ts @@ -7,6 +7,7 @@ export * from './backendServiceOption.interface'; export * from './cellArgs.interface'; export * from './cellMenu.interface'; export * from './checkboxSelector.interface'; +export * from './collectionCustomStructure.interface'; export * from './collectionFilterBy.interface'; export * from './collectionOption.interface'; export * from './collectionSortBy.interface'; @@ -19,10 +20,9 @@ export * from './columnSort.interface'; export * from './contextMenu.interface'; export * from './currentColumn.interface'; export * from './currentFilter.interface'; -export * from './collectionCustomStructure.interface'; export * from './currentPagination.interface'; -export * from './currentSorter.interface'; export * from './currentRowSelection.interface'; +export * from './currentSorter.interface'; export * from './draggableGrouping.interface'; export * from './editCommand.interface'; export * from './editor.interface'; @@ -76,6 +76,7 @@ export * from './onEventArgs.interface'; export * from './pagination.interface'; export * from './paginationChangedArgs.interface'; export * from './selectedRange.interface'; +export * from './selectOption.interface'; export * from './slickEvent.interface'; export * from './slickEventData.interface'; export * from './slickEventHandler.interface'; diff --git a/packages/common/src/interfaces/selectOption.interface.ts b/packages/common/src/interfaces/selectOption.interface.ts new file mode 100644 index 000000000..e0237c691 --- /dev/null +++ b/packages/common/src/interfaces/selectOption.interface.ts @@ -0,0 +1,6 @@ +export interface SelectOption { + label: string; + labelKey: string; + value: number | string; + [labelValue: string]: number | string; +} diff --git a/packages/common/src/services/collection.service.ts b/packages/common/src/services/collection.service.ts new file mode 100644 index 000000000..f4f4574a9 --- /dev/null +++ b/packages/common/src/services/collection.service.ts @@ -0,0 +1,162 @@ +import { + FilterMultiplePassType, + FilterMultiplePassTypeString, + FieldType, + OperatorType, + SortDirectionNumber, +} from './../enums/index'; +import { CollectionFilterBy, CollectionSortBy, Column } from './../interfaces/index'; +import { sortByFieldType } from '../sorters/sorterUtilities'; +import { uniqueArray } from './utilities'; +import { TranslaterService } from './translater.service'; + +export class CollectionService { + constructor(private translaterService: TranslaterService) { } + + /** + * Filter 1 or more items from a collection + * @param collection + * @param filterByOptions + */ + filterCollection(collection: any[], filterByOptions: CollectionFilterBy | CollectionFilterBy[], filterResultBy: FilterMultiplePassType | FilterMultiplePassTypeString | null = FilterMultiplePassType.chain): any[] { + let filteredCollection: any[] = []; + + // when it's array, we will use the new filtered collection after every pass + // basically if input collection has 10 items on 1st pass and 1 item is filtered out, then on 2nd pass the input collection will be 9 items + if (Array.isArray(filterByOptions)) { + filteredCollection = (filterResultBy === FilterMultiplePassType.merge) ? [] : [...collection]; + + for (const filter of filterByOptions) { + if (filterResultBy === FilterMultiplePassType.merge) { + const filteredPass = this.singleFilterCollection(collection, filter); + filteredCollection = uniqueArray([...filteredCollection, ...filteredPass]); + } else { + filteredCollection = this.singleFilterCollection(filteredCollection, filter); + } + } + } else { + filteredCollection = this.singleFilterCollection(collection, filterByOptions); + } + + return filteredCollection; + } + + /** + * Filter an item from a collection + * @param collection + * @param filterBy + */ + singleFilterCollection(collection: any[], filterBy: CollectionFilterBy): any[] { + let filteredCollection: any[] = []; + + if (filterBy) { + const objectProperty = filterBy.property; + const operator = filterBy.operator || OperatorType.equal; + // just check for undefined since the filter value could be null, 0, '', false etc + const value = typeof filterBy.value === 'undefined' ? '' : filterBy.value; + + switch (operator) { + case OperatorType.equal: + if (objectProperty) { + filteredCollection = collection.filter((item) => item[objectProperty] === value); + } else { + filteredCollection = collection.filter((item) => item === value); + } + break; + case OperatorType.contains: + if (objectProperty) { + filteredCollection = collection.filter((item) => item[objectProperty].toString().indexOf(value.toString()) !== -1); + } else { + filteredCollection = collection.filter((item) => (item !== null && item !== undefined) && item.toString().indexOf(value.toString()) !== -1); + } + break; + case OperatorType.notContains: + if (objectProperty) { + filteredCollection = collection.filter((item) => item[objectProperty].toString().indexOf(value.toString()) === -1); + } else { + filteredCollection = collection.filter((item) => (item !== null && item !== undefined) && item.toString().indexOf(value.toString()) === -1); + } + break; + case OperatorType.notEqual: + default: + if (objectProperty) { + filteredCollection = collection.filter((item) => item[objectProperty] !== value); + } else { + filteredCollection = collection.filter((item) => item !== value); + } + } + } + return filteredCollection; + } + + /** + * Sort 1 or more items in a collection + * @param column definition + * @param collection + * @param sortByOptions + * @param enableTranslateLabel + */ + sortCollection(columnDef: Column, collection: any[], sortByOptions: CollectionSortBy | CollectionSortBy[], enableTranslateLabel?: boolean): any[] { + if (enableTranslateLabel && (!this.translaterService || !this.translaterService.translate)) { + throw new Error('[Aurelia-Slickgrid] requires "I18N" to be installed and configured when the grid option "enableTranslate" is enabled.'); + } + + let sortedCollection: any[] = []; + + if (sortByOptions) { + if (Array.isArray(sortByOptions)) { + // multi-sort + sortedCollection = collection.sort((dataRow1: any, dataRow2: any) => { + for (let i = 0, l = sortByOptions.length; i < l; i++) { + const sortBy = sortByOptions[i]; + + if (sortBy && sortBy.property) { + // collection of objects with a property name provided + const sortDirection = sortBy.sortDesc ? SortDirectionNumber.desc : SortDirectionNumber.asc; + const objectProperty = sortBy.property; + const fieldType = sortBy.fieldType || FieldType.string; + const value1 = (enableTranslateLabel) ? this.translaterService && this.translaterService.translate && this.translaterService.getCurrentLocale && this.translaterService.getCurrentLocale() && this.translaterService.translate(dataRow1[objectProperty] || ' ') : dataRow1[objectProperty]; + const value2 = (enableTranslateLabel) ? this.translaterService && this.translaterService.translate && this.translaterService.getCurrentLocale && this.translaterService.getCurrentLocale() && this.translaterService.translate(dataRow2[objectProperty] || ' ') : dataRow2[objectProperty]; + + const sortResult = sortByFieldType(fieldType, value1, value2, sortDirection, columnDef); + if (sortResult !== SortDirectionNumber.neutral) { + return sortResult; + } + } + } + return SortDirectionNumber.neutral; + }); + } else if (sortByOptions && sortByOptions.property) { + // single sort + // collection of objects with a property name provided + const objectProperty = sortByOptions.property; + const sortDirection = sortByOptions.sortDesc ? SortDirectionNumber.desc : SortDirectionNumber.asc; + const fieldType = sortByOptions.fieldType || FieldType.string; + + sortedCollection = collection.sort((dataRow1: any, dataRow2: any) => { + const value1 = (enableTranslateLabel) ? this.translaterService && this.translaterService.translate && this.translaterService.getCurrentLocale && this.translaterService.getCurrentLocale() && this.translaterService.translate(dataRow1[objectProperty] || ' ') : dataRow1[objectProperty]; + const value2 = (enableTranslateLabel) ? this.translaterService && this.translaterService.translate && this.translaterService.getCurrentLocale && this.translaterService.getCurrentLocale() && this.translaterService.translate(dataRow2[objectProperty] || ' ') : dataRow2[objectProperty]; + const sortResult = sortByFieldType(fieldType, value1, value2, sortDirection, columnDef); + if (sortResult !== SortDirectionNumber.neutral) { + return sortResult; + } + return SortDirectionNumber.neutral; + }); + } else if (sortByOptions && !sortByOptions.property) { + const sortDirection = sortByOptions.sortDesc ? SortDirectionNumber.desc : SortDirectionNumber.asc; + const fieldType = sortByOptions.fieldType || FieldType.string; + + sortedCollection = collection.sort((dataRow1: any, dataRow2: any) => { + const value1 = (enableTranslateLabel) ? this.translaterService && this.translaterService.translate && this.translaterService.getCurrentLocale && this.translaterService.getCurrentLocale() && this.translaterService.translate(dataRow1 || ' ') : dataRow1; + const value2 = (enableTranslateLabel) ? this.translaterService && this.translaterService.translate && this.translaterService.getCurrentLocale && this.translaterService.getCurrentLocale() && this.translaterService.translate(dataRow2 || ' ') : dataRow2; + const sortResult = sortByFieldType(fieldType, value1, value2, sortDirection, columnDef); + if (sortResult !== SortDirectionNumber.neutral) { + return sortResult; + } + return SortDirectionNumber.neutral; + }); + } + } + return sortedCollection; + } +} diff --git a/packages/common/src/services/index.ts b/packages/common/src/services/index.ts index d5ac797c7..3277d4398 100644 --- a/packages/common/src/services/index.ts +++ b/packages/common/src/services/index.ts @@ -1,3 +1,4 @@ +export * from './collection.service'; export * from './export-utilities'; export * from './extension.service'; export * from './grid.service'; diff --git a/packages/common/src/services/translater.service.d.ts b/packages/common/src/services/translater.service.d.ts deleted file mode 100644 index f72c06f85..000000000 --- a/packages/common/src/services/translater.service.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -export declare abstract class TranslaterService { - /** - * Method to return the current locale used by the App - * @return {string} current locale - */ - getCurrentLocale(): string; - /** - * Method to set the locale to use in the App - * @param locale - */ - setLocale(locale: string): Promise; - /** - * Method which receives a translation key and returns the translated value from that key - * @param {string} translation key - * @return {string} translated value - */ - translate(translationKey: string): string; -} -//# sourceMappingURL=translater.service.d.ts.map \ No newline at end of file diff --git a/packages/common/src/services/translater.service.d.ts.map b/packages/common/src/services/translater.service.d.ts.map deleted file mode 100644 index 65cae0eff..000000000 --- a/packages/common/src/services/translater.service.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"translater.service.d.ts","sourceRoot":"","sources":["translater.service.ts"],"names":[],"mappings":"AAAA,8BAAsB,iBAAiB;IACrC;;;OAGG;IACH,gBAAgB,IAAI,MAAM;IAM1B;;;OAGG;IACH,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAIvC;;;;OAIG;IACH,SAAS,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM;CAI1C"} \ No newline at end of file