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');
- }
- });
-}