enum JsonPatchSaveStatuses { Pending = "json-patch-status-pending", Success = "json-patch-status-success", Error = "json-patch-status-error", Invalid = "json-patch-status-invalid" }; class LocalizedJhrottlerStrings { private _locale: 'ru' | 'en' = 'ru' private _strings = { messages: { finalFail: { 'ru': 'Не удалось сохранить изменения. Пожалуйста, обновите страницу', 'en': 'Failed to save changes. Please refresh the page' }, fail: { 'ru': 'Ошибка при сохранении изменений. Пробуем еще раз', 'en': 'Error saving changes. Let\'s try again' }, success: { 'ru': 'Изменения успешно сохранены', 'en': 'Changes saved successfully' }, pending: { 'ru': 'Ваши изменения сохранятся через', 'en': 'Your changes will be saved after' }, } } constructor() { this._locale = $('.page-wrapper').data('lang') || 'ru' } public get getFinalFailMessage(): string { return this._strings.messages.finalFail[this._locale] } public get getFailMessage(): string { return this._strings.messages.fail[this._locale] } public get getSuccessMessage(): string { return this._strings.messages.success[this._locale] } public get getPendingMessage(): string { return this._strings.messages.pending[this._locale] } } class JsonPatchThrottler { private _Rx = window['rxjs']; private _UpdateOperations: JsonPatchInputModel[]; private _DebouncedUpdateFunc: Function; private _DebounceDelay = 3000; private _CloseWindowLock: boolean = false; private _OneRequestAtTimeLock: boolean = false; private _Options: JsonPatchThrottlerOptions; private _Url: string; private _StorageKey = "rosgrant.jsonPatchThrottler"; constructor(updateUrl: string, options?: JsonPatchThrottlerOptions) { //Мерж пришедших параметров с по-умолчанию this._Options = Object.assign(new JsonPatchThrottlerOptions(), options); this._Url = updateUrl; this._UpdateOperations = new Array(); this._DebouncedUpdateFunc = Debounce(this.buildRequestAndSendUpdate, options.isDebounceDelay ? options.debounceDelay : this._DebounceDelay); //window.addEventListener('beforeunload', this.preventClosing.bind(this)); this.loadFromStorageAndSendToServer(); if (this._Options.useAutoInputs) { $(this._Options.autoInputsParent + " [js-patch-throttler-auto]") .each((index, input: HTMLInputElement) => this.stageForUpdate(input, input.getAttribute("js-patch-throttler-onevent") || "input", 500)); } }; public stageForUpdate( inputElement: HTMLInputElement, eventName: string, debounceTime: number = null, customOperations: JsonPatchInputModel[] = null, rootFromId: string = ""): void { this.stageForUpdateWithValidate(inputElement, eventName, debounceTime, null, customOperations, rootFromId); } public stageForUpdateWithValidateModel(model: JsonPatchStageForValidateParameterModel): void { this.stageForUpdateWithValidate(model.inputElement, model.eventName, model.debounceTime, model.validateFunc, model.customOperations, model.rootFromId, model.isValidateRenderEnabled, model.isFakeValidate); } public stageForUpdateWithValidate( inputElement: HTMLInputElement, eventName: string, debounceTime: number = null, validateFunc: (el: HTMLInputElement) => boolean = null, customOperations: JsonPatchInputModel[] = null, rootFromId: string = "", isValidateRenderEnabled = false, isFakeValidate = false ): void { if (!inputElement) { return; } //Исходный ивент let inputChangedRawEvent$ = this._Rx.fromEvent(inputElement, eventName); if (debounceTime === null) { debounceTime = this._DebounceDelay; } //Задебонсенный и отфильтрованный ивент. const inputChangedDebounced$ = inputChangedRawEvent$ .pipe( this._Rx.operators.debounceTime(debounceTime), this._Rx.operators.map(x => x.target), this._Rx.operators.filter(element => { if (validateFunc === null) { return true; } if (isValidateRenderEnabled) { ValidationIndicator.enableIndication(element); } if (validateFunc(element)) { ValidationIndicator.setSuccess(element); return true; } else { ValidationIndicator.setError(element); if (!isFakeValidate) { document.dispatchEvent(new CustomEvent(JsonPatchSaveStatuses.Invalid, { detail: element } as any)); } return isFakeValidate; } } //this._Rx.operators.distinctUntilChanged(x => x.value) )); inputChangedRawEvent$.subscribe(val => { //Блокируем покидание окна, до того как прйдет время дебаунс. this._CloseWindowLock = true; //Иногда вызывается queueUpdate иногда нет. Для корретного статуса и тут и там(актульано для about секции (выпад списки)) document.dispatchEvent(new CustomEvent(JsonPatchSaveStatuses.Pending, { detail: inputElement } as any)); return val; }); inputChangedDebounced$.subscribe(this.queueUpdate.bind(this, $(inputElement)[0], null, customOperations, rootFromId)); } public queueUpdate(inputElement: HTMLInputElement, value: any = null, customOperations: JsonPatchInputModel[] = null, rootFromId: string = "",): void { document.dispatchEvent(new CustomEvent(JsonPatchSaveStatuses.Pending, { detail: inputElement } as any)); this._UpdateOperations = this._UpdateOperations.concat(this.getOperations(inputElement, value, rootFromId)); if (customOperations != null && customOperations.length > 0) { this._UpdateOperations = this._UpdateOperations.concat(customOperations); } this._DebouncedUpdateFunc(); } public queueUpdateValidate = function (inputElement: HTMLInputElement, validateFunc: (el: HTMLInputElement) => boolean = null, value: any = null) { const isValid = validateFunc(inputElement); if (!isValid) { document.dispatchEvent(new CustomEvent(JsonPatchSaveStatuses.Error, { detail: inputElement } as any)); ValidationIndicator.setError(inputElement); return; } this.queueUpdate(inputElement, value); }; private getOperations(inputElement: HTMLInputElement, value: any, rootFromId: string): Array { let result = Array(); const valueArray = inputElement.value.split(" "); const jsonPathArray = inputElement.dataset.jsonPath.split(" "); if (valueArray.length > 1 && valueArray.length == jsonPathArray.length) { for (let i = 0; i < valueArray.length; i++) { const inputModel = new JsonPatchInputModel(); inputModel.value = valueArray[i]; inputModel.path = jsonPathArray[i]; inputModel.inputElement = inputElement; inputModel.from = rootFromId; result.push(inputModel); } } else if (jsonPathArray.length > 1 && !value) { for (let i = 0; i < jsonPathArray.length; i++) { const inputModel = new JsonPatchInputModel(); inputModel.value = value; inputModel.path = jsonPathArray[i]; inputModel.inputElement = inputElement; inputModel.from = rootFromId; result.push(inputModel); } } else { const inputModel = new JsonPatchInputModel(); inputModel.value = value !== null ? value : inputElement.value; inputModel.path = inputElement.dataset.jsonPath; inputModel.inputElement = inputElement; inputModel.from = rootFromId; result.push(inputModel); } return result; } //Когда не смогли доставить обновление более 5 раз private handleDeadUpdate() { //TODO: R-1264 if (this._Options.showfinalFailMessage) { MessageShower.showError(this._Options.finalFailMessage); } this.saveToStorage(); if (this._Options.showFinalFailModal) { var modal = $(this._Options.finalFailModalSelector); if (modal && modal.length > 0) { this._CloseWindowLock = false; /* Modal.createModal({ object: modal, title: "Нам не удаётся отправить ваши изменения, рекомендуем перезагрузить страницу и проверить наличие интернет связи. Либо остаться, и попробовать еще раз.", buttons: [{ text: "Перезагрузить", class: "btn btn-primary", click(): void { location.reload(); } }], addCancelButton: true }); */ } } } //отправляем один большой адпейт на сервер private async buildRequestAndSendUpdate(currentTry: number = 1) { if (currentTry > 5) { this.handleDeadUpdate(); return; } if (this._OneRequestAtTimeLock) { return; } this._OneRequestAtTimeLock = true; let result = null; while (this._UpdateOperations.length > 0) { const localUpdateOperations = this._UpdateOperations.slice(0); this._UpdateOperations = []; try { result = await this.trySendUpdate(this.buildJsonPatchData(localUpdateOperations)); for (var item of localUpdateOperations) { document.dispatchEvent(new CustomEvent(JsonPatchSaveStatuses.Success, { detail: item.inputElement } as any)); } } catch (err) { for (var item of localUpdateOperations) { document.dispatchEvent(new CustomEvent(JsonPatchSaveStatuses.Error, { detail: item.inputElement } as any)); } //Если конкарренси то не пытаемся еще раз переотправить if (err.statusText == "Conflict") { MessageShower.showErrorBadUserInput(err); this._UpdateOperations = []; this._OneRequestAtTimeLock = false; return; } //Показываем только как синглтон, чтобы не спамить юзера каждую секунду if (this._Options.showFailMessage) { if (err.responseJSON && err.responseJSON.error && err.responseJSON.error.Message) { MessageShower.showSingleton(err.responseJSON.error.Message, "error"); } else { MessageShower.showSingleton(this._Options.failMessage, "error"); } } this._UpdateOperations = this._UpdateOperations.concat(localUpdateOperations); this._OneRequestAtTimeLock = false; //прогрессивная отправка, сперва в 1с, потом через 2с, 3с, 4с, и т.д setTimeout(this.buildRequestAndSendUpdate.bind(this), 1500 * currentTry, currentTry + 1); break; } } this._OneRequestAtTimeLock = false; if (this._UpdateOperations.length === 0) { if (this._Options.showSuccessMessage) { MessageShower.showSingleton(this._Options.successMessage, "success"); } if (this._Options.successCallback) { this._Options.successCallback(); } this._CloseWindowLock = false; this.clearStorage(); } } private buildJsonPatchData(operations: JsonPatchInputModel[]): any { let patchData: any[] = new Array(); for (let operation of operations) { let patch = { "op": "replace", "path": operation.path, "value": operation.value, "from": operation.from }; patchData.push(patch); } if (this._Options.inputVersionEntitySelector) { let element = document.querySelector(this._Options.inputVersionEntitySelector); patchData.push({ "op": "replace", "path": "Version", "value": (element as any).value }); } return patchData; } private async trySendUpdate(data) { const request = await $.ajax({ type: "PATCH", url: this._Url, contentType: "application/json; charset=utf-8", dataType: "json", data: JSON.stringify(data), }).fail(function (jqXHR, textStatus, errorThrown) { throw new Error(textStatus + errorThrown); }).done((responseText, textStatus, obj) => { if (this._Options.inputVersionEntitySelector) { let item = document.querySelector(this._Options.inputVersionEntitySelector); if (item) { (item as any).value = responseText; } } }); return request; }; private preventClosing(e: BeforeUnloadEvent) { if (this._CloseWindowLock) { e.preventDefault(); e.returnValue = 'Сохранение данных в процессе. Пожалуйста, не закрывайте страницу.'; } } private loadFromStorageAndSendToServer() { var operations = JSON.parse(sessionStorage.getItem(this.getPageStorageKey())) || []; if (operations.length > 0) { this._UpdateOperations = operations.map(t => ({ value: t.value, path: t.path, from: t.from, inputElement: $(t.selector).get(0) })); this._UpdateOperations.forEach(t => { if (t.inputElement) { t.inputElement.value = t.value } }); MessageShower.showSingleton("Ваши данные были успешно восстановлены!", "success"); this.buildRequestAndSendUpdate(); } } private saveToStorage() { try { var operations = this._UpdateOperations.map(t => ({ path: t.path, value: t.value, "@from": t["@from"], selector: `[id=${t.inputElement.getAttribute("id")}][name='${t.inputElement.getAttribute("name")}']` })); sessionStorage.setItem(this.getPageStorageKey(), JSON.stringify(operations)); } catch (e) { //Если превысили размер в 5 мб MessageShower.showError("Ваши данные слишком велики для временного сохранения!"); } } private clearStorage() { sessionStorage.removeItem(this.getPageStorageKey()); } private getPageStorageKey() { return this._StorageKey + this._Url; } } class JsonPatchStageForValidateParameterModel { inputElement: HTMLInputElement; eventName: string; debounceTime: number = null; validateFunc: (el: HTMLInputElement) => boolean = null; customOperations: JsonPatchInputModel[] = null; rootFromId: string = ""; isValidateRenderEnabled = false; isFakeValidate = false; } class JsonPatchInputModel { public path: string; public value: string; public inputElement: HTMLInputElement; public from: string; } class JsonPatchThrottlerOptions { private localizedJhrottlerStrings public pendingMessage: string; public showPendingMessage: boolean = false; public successCallback: Function; public successMessage: string; public showSuccessMessage: boolean = false; public failCallback: Function; public failMessage: string; public showFailMessage: boolean = true; public finalFailCallback: Function; public finalFailMessage: string; public showfinalFailMessage: boolean = true; public finalFailModalSelector: string; public showFinalFailModal: boolean = false; public isDebounceDelay: boolean = false; public debounceDelay: number = 1000; public useAutoInputs: boolean = false; public autoInputsParent: string = null; public inputVersionEntitySelector: string = null; constructor() { this.localizedJhrottlerStrings = new LocalizedJhrottlerStrings(); this.pendingMessage = this.localizedJhrottlerStrings.getPendingMessage; this.successMessage = this.localizedJhrottlerStrings.getSuccessMessage; this.failMessage = this.localizedJhrottlerStrings.getFailMessage; this.finalFailMessage = this.localizedJhrottlerStrings.getFinalFailMessage; } }