From 27b1a36e9d7088acb29ff1473c8b60199346e109 Mon Sep 17 00:00:00 2001 From: Karlie Fang <380923800@qq.com> Date: Fri, 5 Jul 2024 09:33:58 -0400 Subject: [PATCH] refactor: create mei validation class Refs: #100 --- assets/css/style.css | 27 ++++ assets/img/info-icon.svg | 4 + src/Editor/ColumnTools.ts | 4 +- src/Editor/CressTable.ts | 107 ++++++++++------ .../{ExportHandler.ts => ExportTools.ts} | 2 +- src/Editor/{ImageHandler.ts => ImageTools.ts} | 2 +- src/Editor/MeiTools.ts | 109 +++++++++++++++++ src/Editor/ValidationTools.ts | 115 ++++++++++++++++++ src/Validation.ts | 115 ------------------ 9 files changed, 328 insertions(+), 157 deletions(-) create mode 100644 assets/img/info-icon.svg rename src/Editor/{ExportHandler.ts => ExportTools.ts} (98%) rename src/Editor/{ImageHandler.ts => ImageTools.ts} (99%) create mode 100644 src/Editor/MeiTools.ts create mode 100644 src/Editor/ValidationTools.ts delete mode 100755 src/Validation.ts diff --git a/assets/css/style.css b/assets/css/style.css index 0a72218..9e6315d 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -665,3 +665,30 @@ a:hover { width: 100%; height: 100%; } + +/* ----------------------- */ +/* Section: Invalid Cell +/* ----------------------- */ + +.invalid-container { + display: flex; + align-items: flex-start; + background-color: #ffbeba !important; + justify-content: space-between; +} + +.tooltip-icon { + width: 20px; + margin: 5px; +} + +.tooltip-text { + display: none; + position: absolute; + background-color: white; + border: 1px solid black; + padding: 5px; + z-index: 1; + width: 30vw; + color: black; +} \ No newline at end of file diff --git a/assets/img/info-icon.svg b/assets/img/info-icon.svg new file mode 100644 index 0000000..6afd6ee --- /dev/null +++ b/assets/img/info-icon.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/Editor/ColumnTools.ts b/src/Editor/ColumnTools.ts index d4256cf..9ff9cf6 100644 --- a/src/Editor/ColumnTools.ts +++ b/src/Editor/ColumnTools.ts @@ -1,4 +1,3 @@ -import * as Validation from '../Validation'; export class ColumnTools { public validationInProgress = false; @@ -22,8 +21,7 @@ export class ColumnTools { } else if (headers[i].includes('mei')) { columns.push({ data: headers[i], - validator: Validation.meiValidator, - allowInvalid: true, + renderer: 'meiRenderer', }); } else { columns.push({ diff --git a/src/Editor/CressTable.ts b/src/Editor/CressTable.ts index 853a0df..86e2b98 100644 --- a/src/Editor/CressTable.ts +++ b/src/Editor/CressTable.ts @@ -1,7 +1,8 @@ import Handsontable from 'handsontable'; -import * as Validation from '../Validation'; -import { ImageHandler } from './ImageHandler'; -import { ExportHandler } from './ExportHandler'; +import { ImageTools } from './ImageTools'; +import { MeiTools } from './MeiTools'; +import { ValidationTools, updateStatus } from './ValidationTools'; +import { ExportTools } from './ExportTools'; import { ColumnTools } from './ColumnTools'; import { updateAttachment } from '../Dashboard/Storage'; import { setSavedStatus } from '../utils/Unsaved'; @@ -24,16 +25,20 @@ const changeHooks: TableEvent[] = [ export class CressTable { private table: Handsontable; private images: any[] = []; // Array to store images - private imageHandler: ImageHandler; - private exportHandler: ExportHandler; + private imageTools: ImageTools; + private meiTools: MeiTools; + private validationTools: ValidationTools; + private exportTools: ExportTools; private ColumnTools: ColumnTools; constructor(id: string, inputHeader: string[], body: any[]) { const container = document.getElementById('hot-container'); - // Initialize handlers - this.imageHandler = new ImageHandler(this.images); - this.exportHandler = new ExportHandler(); + // Initialize Toolss + this.imageTools = new ImageTools(this.images); + this.meiTools = new MeiTools(); + this.validationTools = new ValidationTools(); + this.exportTools = new ExportTools(); this.ColumnTools = new ColumnTools(inputHeader); // Convert all quote signs to inch marks in mei data @@ -42,7 +47,13 @@ export class CressTable { // Register the custom image renderer Handsontable.renderers.registerRenderer( 'imgRenderer', - this.imageHandler.imgRender.bind(this.imageHandler), + this.imageTools.imgRender.bind(this.imageTools), + ); + + // Register the custom mei renderer + Handsontable.renderers.registerRenderer( + 'meiRenderer', + this.meiTools.meiRender.bind(this.meiTools), ); // Prepare table configuration @@ -53,7 +64,11 @@ export class CressTable { // Process images let inputImgHeader = inputHeader.find((header) => header.includes('image')); - this.imageHandler.storeImages(inputImgHeader, body); + this.imageTools.storeImages(inputImgHeader, body); + + // Process mei data + let inputMeiHeader = inputHeader.find((header) => header.includes('mei')); + this.meiTools.initMeiData(inputMeiHeader, body); // Initialize table this.table = new Handsontable(container, { @@ -79,13 +94,7 @@ export class CressTable { dropdownMenu: true, className: 'table-menu-btn', licenseKey: 'non-commercial-and-evaluation', - afterChange(_, source) { - if (source == 'loadData') { - this.validateCells(); - } - }, - beforeValidate: (value) => this.setProcessStatus(value), - afterValidate: (isValid) => this.setResultStatus(isValid), + afterChange: this.afterChange, }); this.initFileListener(id, inputHeader, body, headers); @@ -100,13 +109,13 @@ export class CressTable { ) { const exportPlugin = this.table.getPlugin('exportFile'); document.getElementById('export-to-csv').addEventListener('click', () => { - this.exportHandler.exportToCsv(exportPlugin); + this.exportTools.exportToCsv(exportPlugin); }); document .getElementById('export-to-excel') .addEventListener('click', async () => { - await this.exportHandler.exportToExcel( + await this.exportTools.exportToExcel( inputHeader, body, headers, @@ -139,24 +148,24 @@ export class CressTable { }); } - private setProcessStatus(value: any) { - if (!this.ColumnTools.validationInProgress) { - this.ColumnTools.validationInProgress = true; - Validation.updateStatus('processing'); - } - // Update `pendingValidations` if value is not empty - if (value) this.ColumnTools.pendingValidations++; - } - - private setResultStatus(isValid: boolean) { - if (!isValid) this.ColumnTools.hasInvalid = true; - this.ColumnTools.pendingValidations--; - if (this.ColumnTools.pendingValidations === 0) { - this.ColumnTools.validationInProgress = false; - Validation.updateStatus('done', this.ColumnTools.hasInvalid); - this.ColumnTools.hasInvalid = false; - } - } + // private setProcessStatus(value: any) { + // if (!this.ColumnTools.validationInProgress) { + // this.ColumnTools.validationInProgress = true; + // updateStatus('processing'); + // } + // // Update `pendingValidations` if value is not empty + // if (value) this.ColumnTools.pendingValidations++; + // } + + // private setResultStatus(isValid: boolean) { + // if (!isValid) this.ColumnTools.hasInvalid = true; + // this.ColumnTools.pendingValidations--; + // if (this.ColumnTools.pendingValidations === 0) { + // this.ColumnTools.validationInProgress = false; + // updateStatus('done', this.ColumnTools.hasInvalid); + // this.ColumnTools.hasInvalid = false; + // } + // } private initChangeListener() { changeHooks.forEach((hook) => { @@ -165,4 +174,28 @@ export class CressTable { }); }); } + + afterChange = (changes, source) => { + if (source == 'loadData') { + // Validate mei data and update the validation status + this.meiTools.getMeiData().forEach((mei) => { + this.validationTools.meiValidator(mei.mei).then(([isValid, errorMsg]) => { + this.meiTools.updateMeiData(mei.row, mei.mei, isValid, errorMsg); + this.table.render(); + }); + }); + } else { + changes?.forEach(([row, prop, oldValue, newValue]) => { + if (prop === 'mei' && oldValue !== newValue) { + // validate the new edited mei data and update the validation status + this.meiTools.updateMeiData(row, newValue, undefined, undefined); + this.table.render(); + this.validationTools.meiValidator(newValue).then(([isValid, errorMsg]) => { + this.meiTools.updateMeiData(row, undefined, isValid, errorMsg); + this.table.render(); + }); + } + }); + } + }; } diff --git a/src/Editor/ExportHandler.ts b/src/Editor/ExportTools.ts similarity index 98% rename from src/Editor/ExportHandler.ts rename to src/Editor/ExportTools.ts index b1afc85..a40c28a 100644 --- a/src/Editor/ExportHandler.ts +++ b/src/Editor/ExportTools.ts @@ -1,6 +1,6 @@ import { saveAs } from 'file-saver'; -export class ExportHandler { +export class ExportTools { exportToCsv(exportPlugin: any) { exportPlugin.downloadFile('csv', { bom: true, diff --git a/src/Editor/ImageHandler.ts b/src/Editor/ImageTools.ts similarity index 99% rename from src/Editor/ImageHandler.ts rename to src/Editor/ImageTools.ts index a7b86dc..6c5725b 100644 --- a/src/Editor/ImageHandler.ts +++ b/src/Editor/ImageTools.ts @@ -1,6 +1,6 @@ import Handsontable from 'handsontable'; -export class ImageHandler { +export class ImageTools { private images: any[]; constructor(images: any[]) { diff --git a/src/Editor/MeiTools.ts b/src/Editor/MeiTools.ts new file mode 100644 index 0000000..b5fb1d6 --- /dev/null +++ b/src/Editor/MeiTools.ts @@ -0,0 +1,109 @@ +import Handsontable from 'handsontable'; + +export class MeiTools { + private meiData: any[]; + + constructor() { + this.meiData = []; + } + + // Mei Initialization + public initMeiData(inputMeiHeader: string, body: any[]) { + body.forEach((row, rowIndex) => { + const mei = row[inputMeiHeader]; + if (mei) { + this.meiData.push({ + mei, + row: rowIndex, + isValid: null, + errorMsg: null, + }); + } + }); + } + + // Getters + public getMeiData() { + return this.meiData; + } + + // Update the mei data + public updateMeiData( + row: number, + mei?: string, + isValid?: boolean, + errorMsg?: string[] + ) { + const meiData = this.meiData.find((meiData) => meiData.row === row); + if (meiData) { + if (mei !== undefined) { + meiData.mei = mei; + } + if (isValid !== undefined) { + meiData.isValid = isValid; + } + if (errorMsg !== undefined) { + meiData.errorMsg = errorMsg; + } + } else { + this.meiData.push({ + row, + mei: mei ?? meiData.mei, + isValid: isValid ?? meiData.isValid, + errorMsg: errorMsg ?? meiData.errorMsg, + }); + } + } + + // Mei Renderer Functions + meiRender( + instance: Handsontable, + td: HTMLElement, + row: number, + col: number, + prop: string, + value: any, + cellProperties: Handsontable.CellProperties, + ) { + Handsontable.dom.empty(td); + + const mei = this.meiData.find((mei) => mei.row === row); + if (mei) { + + if (mei.isValid === false) { + + // container for the invalid cell + const invalidContainer = document.createElement('div'); + invalidContainer.className = 'invalid-container'; + + // mei data + const meiData = document.createElement('span'); + meiData.textContent = mei.mei; + invalidContainer.appendChild(meiData); + + // tooltip icon and text + const tooltipIcon = document.createElement('img'); + tooltipIcon.src = './Cress-gh/assets/img/info-icon.svg'; + tooltipIcon.className = 'tooltip-icon'; + invalidContainer.appendChild(tooltipIcon); + const tooltipText = document.createElement('span'); + tooltipText.className = 'tooltip-text'; + tooltipText.textContent = mei.errorMsg.join('\n\n'); + invalidContainer.appendChild(tooltipText); + tooltipIcon.addEventListener('mouseover', () => { + tooltipText.style.display = 'block'; + }); + tooltipIcon.addEventListener('mouseout', () => { + tooltipText.style.display = 'none'; + }); + + td.appendChild(invalidContainer); + } else { + td.textContent = mei.mei; + } + } + + return td; + } + +} diff --git a/src/Editor/ValidationTools.ts b/src/Editor/ValidationTools.ts new file mode 100644 index 0000000..d7daed6 --- /dev/null +++ b/src/Editor/ValidationTools.ts @@ -0,0 +1,115 @@ +import { validationStatus } from '../Types'; + +/** + * Update the UI with the validation results. Called when the WebWorker finishes validating. + */ +export function updateStatus( + status: validationStatus, + hasInvalid?: boolean +): void { + const meiStatus: HTMLSpanElement = document.getElementById('validation_status')!; + switch (status) { + case 'processing': + meiStatus.textContent = 'checking...'; + meiStatus.style.color = 'gray'; + break; + + case 'done': + if (hasInvalid) { + meiStatus.textContent = 'INVALID'; + meiStatus.style.color = 'red'; + } else { + meiStatus.textContent = 'VALID'; + meiStatus.style.color = '#4bc14b'; + } + break; + + default: + meiStatus.textContent = 'unknown'; + meiStatus.style.color = 'gray'; + break; + } +} + +export class ValidationTools { + private schemaPromise: Promise | null = null; + private templatePromise: Promise | null = null; + + constructor() { + this.fetchSchemaAndTemplate(); + } + + private async fetchSchemaAndTemplate(): Promise { + this.schemaPromise = fetch( + __ASSET_PREFIX__ + 'assets/validation/mei-all.rng' + ).then((response) => response.text()); + this.templatePromise = fetch( + __ASSET_PREFIX__ + 'assets/validation/mei_template.mei' + ).then((response) => response.text()); + } + + /** + * MEI validation function + */ + public meiValidator(value: string): Promise<[boolean, string[] | null]> { + return new Promise(async (resolve) => { + if (this.schemaPromise === null || this.templatePromise === null) { + await this.fetchSchemaAndTemplate(); + } + + try { + const schema = await this.schemaPromise; + const meiTemplate = await this.templatePromise; + + const errors = await this.validateMEI(value, schema, meiTemplate); + if (errors == null) { + resolve([true, null]); + } else { + resolve([false, [errors]]); + } + } catch (e) { + resolve([false, ['Failed to validate MEI']]); + } + }); + } + + private validateMEI( + value: string, + schema: string, + meiTemplate: string + ): Promise { + return new Promise((resolve) => { + try { + const parser = new DOMParser(); + const meiDoc = parser.parseFromString(meiTemplate, 'text/xml'); + const mei = meiDoc.documentElement; + + const layer = mei.querySelector('layer'); + layer.innerHTML = value; + const serializer = new XMLSerializer(); + const toBeValidated = serializer.serializeToString(meiDoc); + + /** + * TODO: optimize performance + * use id to track each worker request + */ + const worker = new Worker( + __ASSET_PREFIX__ + 'workers/ValidationWorker.js' + ); + + worker.postMessage({ + mei: toBeValidated, + schema: schema, + }); + + worker.onmessage = (message: { data: string }) => { + const errors = message.data; + resolve(errors); + worker.terminate(); + }; + } catch (e) { + resolve('Failed to validate MEI'); + } + }); + } +} diff --git a/src/Validation.ts b/src/Validation.ts deleted file mode 100755 index eb9ae94..0000000 --- a/src/Validation.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { validationStatus } from './Types'; - -let schemaPromise: Promise | null = null; -let templatePromise: Promise | null = null; - -/** - * Update the UI with the validation results. Called when the WebWorker finishes validating. - */ -export function updateStatus( - status: validationStatus, - hasInvalid?: boolean, -): void { - const meiStatus: HTMLSpanElement = - document.getElementById('validation_status')!; - switch (status) { - case 'processing': - meiStatus.textContent = 'checking...'; - meiStatus.style.color = 'gray'; - break; - - case 'done': - if (hasInvalid) { - meiStatus.textContent = 'INVALID'; - meiStatus.style.color = 'red'; - } else { - meiStatus.textContent = 'VALID'; - meiStatus.style.color = '#4bc14b'; - } - break; - - default: - meiStatus.textContent = 'unknown'; - meiStatus.style.color = 'gray'; - break; - } -} - -async function fetchSchemaAndTemplate(): Promise { - schemaPromise = fetch( - __ASSET_PREFIX__ + 'assets/validation/mei-all.rng', - ).then((response) => response.text()); - templatePromise = fetch( - __ASSET_PREFIX__ + 'assets/validation/mei_template.mei', - ).then((response) => response.text()); -} - -/** - * MEI validation based on custom cell validator in Handsontable - * https://handsontable.com/docs/javascript-data-grid/cell-validator/#full-featured-example - * @param {string} value - */ -export const meiValidator = async ( - value: string, - callback: (result: boolean) => void, -): Promise => { - if (schemaPromise === null || templatePromise === null) { - await fetchSchemaAndTemplate(); - } - - try { - let errors; - const [schema, meiTemplate] = await Promise.all([ - schemaPromise!, - templatePromise!, - ]); - errors = await validateMEI(value, schema, meiTemplate); - if (errors == null) { - callback(true); - } else { - callback(false); - } - } catch (e) { - callback(false); - } -}; - -function validateMEI( - value: string, - schema: string, - meiTemplate: string, -): Promise { - return new Promise((resolve) => { - try { - const parser = new DOMParser(); - const meiDoc = parser.parseFromString(meiTemplate, 'text/xml'); - const mei = meiDoc.documentElement; - - const layer = mei.querySelector('layer'); - layer.innerHTML = value; - const serializer = new XMLSerializer(); - const toBeValidated = serializer.serializeToString(meiDoc); - - /** - * TODO: optimize performance - * use id to track each worker request - */ - const worker = new Worker( - __ASSET_PREFIX__ + 'workers/ValidationWorker.js', - ); - - worker.postMessage({ - mei: toBeValidated, - schema: schema, - }); - - worker.onmessage = (message: { data: string }) => { - const errors = message.data; - resolve(errors); - worker.terminate(); - }; - } catch (e) { - resolve('Failed to validate MEI'); - } - }); -}