diff --git a/assets/css/style.css b/assets/css/style.css
index 0a72218..dd21bdf 100644
--- a/assets/css/style.css
+++ b/assets/css/style.css
@@ -665,3 +665,48 @@ a:hover {
width: 100%;
height: 100%;
}
+
+/* ----------------------- */
+/* Section: Invalid Cell
+/* ----------------------- */
+
+.invalid-container {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
+.tooltip-container {
+ display: flex;
+ align-items: flex-start;
+ justify-content: end;
+ max-width: 50%;
+}
+
+.tooltip-container .tooltip-text {
+ display: none;
+ position: relative;
+ background-color: #fff8c5;
+ border: 1px solid #d4a72c;
+ padding: 5px;
+ margin-top: 5px;
+ margin-bottom: 5px;
+ z-index: 10;
+ color: black;
+ border-radius: 5px;
+}
+
+.tooltip-container:hover .tooltip-text {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+}
+
+.tooltip-container .tooltip-icon {
+ width: 20px;
+ margin: 5px;
+}
+
+.tooltip-copy:hover {
+ cursor: pointer;
+}
diff --git a/assets/img/copy-icon.svg b/assets/img/copy-icon.svg
new file mode 100644
index 0000000..a939f58
--- /dev/null
+++ b/assets/img/copy-icon.svg
@@ -0,0 +1,3 @@
+
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..f22cabf 100644
--- a/src/Editor/ColumnTools.ts
+++ b/src/Editor/ColumnTools.ts
@@ -1,10 +1,4 @@
-import * as Validation from '../Validation';
-
export class ColumnTools {
- public validationInProgress = false;
- public pendingValidations = 0;
- public hasInvalid = false;
-
constructor(private inputHeader: string[]) {}
getColumns(headers: string[]) {
@@ -22,8 +16,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..ee8bc74 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 } from './ValidationTools';
+import { ExportTools } from './ExportTools';
import { ColumnTools } from './ColumnTools';
import { updateAttachment } from '../Dashboard/Storage';
import { setSavedStatus } from '../utils/Unsaved';
@@ -24,36 +25,50 @@ const changeHooks: TableEvent[] = [
export class CressTable {
private table: Handsontable;
private images: any[] = []; // Array to store images
- private imageHandler: ImageHandler;
- private exportHandler: ExportHandler;
- private ColumnTools: ColumnTools;
+ 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();
- this.ColumnTools = new ColumnTools(inputHeader);
+ // 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
- this.ColumnTools.convertMeiQuoteSign(body);
+ this.columnTools.convertMeiQuoteSign(body);
// 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
const headers = ['image', 'name', 'classification', 'mei'];
- const columns = this.ColumnTools.getColumns(headers);
- const colWidths = this.ColumnTools.getColWidths(headers);
- const indices = this.ColumnTools.getIndices(body).map(String);
+ const columns = this.columnTools.getColumns(headers);
+ const colWidths = this.columnTools.getColWidths(headers);
+ const indices = this.columnTools.getIndices(body).map(String);
// 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: (changes, source) => this.validateMei(changes, source),
});
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,25 +148,6 @@ 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 initChangeListener() {
changeHooks.forEach((hook) => {
this.table.addHook(hook, (source) => {
@@ -165,4 +155,36 @@ export class CressTable {
});
});
}
+
+ private validateMei(changes, source) {
+ if (source == 'loadData') {
+ // Validate mei data and update the validation status
+ this.meiTools.getMeiData().forEach((mei) => {
+ this.meiTools.setProcessStatus(mei);
+ this.validationTools
+ .meiValidator(mei.mei)
+ .then(([isValid, errorMsg]) => {
+ this.meiTools.updateMeiData(mei.row, mei.mei, isValid, errorMsg);
+ this.table.render();
+ this.meiTools.setResultStatus(isValid);
+ });
+ });
+ } 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.setProcessStatus(newValue);
+ 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();
+ this.meiTools.setResultStatus(isValid);
+ });
+ }
+ });
+ }
+ }
}
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..92231ec
--- /dev/null
+++ b/src/Editor/MeiTools.ts
@@ -0,0 +1,149 @@
+import Handsontable from 'handsontable';
+import { updateStatus } from './ValidationTools';
+import * as Notification from '../utils/Notification';
+
+export class MeiTools {
+ private meiData: any[];
+ public validationInProgress = false;
+ public pendingValidations = 0;
+ public hasInvalid = false;
+
+ 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,
+ });
+ }
+ }
+
+ public setProcessStatus(value: any) {
+ if (!this.validationInProgress) {
+ this.validationInProgress = true;
+ updateStatus('processing');
+ }
+ // Update `pendingValidations` if value is not empty
+ if (value) this.pendingValidations++;
+ }
+
+ public setResultStatus(isValid: boolean) {
+ if (!isValid) this.hasInvalid = true;
+ this.pendingValidations--;
+ if (this.pendingValidations === 0) {
+ this.validationInProgress = false;
+ updateStatus('done', this.hasInvalid);
+ this.hasInvalid = false;
+ }
+ }
+
+ // Mei Renderer Functions
+ public 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 tooltipContainer = document.createElement('div');
+ tooltipContainer.className = 'tooltip-container';
+
+ const tooltipContent = document.createElement('div');
+ tooltipContent.className = 'tooltip-text';
+ tooltipContent.textContent = mei.errorMsg;
+
+ const copyBtn = document.createElement('img');
+ copyBtn.src = './Cress-gh/assets/img/copy-icon.svg';
+ copyBtn.className = 'tooltip-copy';
+
+ const tooltipIcon = document.createElement('img');
+ tooltipIcon.src = './Cress-gh/assets/img/info-icon.svg';
+ tooltipIcon.className = 'tooltip-icon';
+
+ tooltipContent.appendChild(copyBtn);
+ tooltipContainer.appendChild(tooltipContent);
+ tooltipContainer.appendChild(tooltipIcon);
+ invalidContainer.appendChild(tooltipContainer);
+
+ td.appendChild(invalidContainer);
+ td.style.backgroundColor = '#ffbeba';
+
+ copyBtn.addEventListener('click', () => {
+ // Copy the tooltipContent's text to the clipboard
+ navigator.clipboard
+ .writeText(mei.errorMsg)
+ .then(() => {
+ Notification.queueNotification('Copied to clipboard', 'success');
+ })
+ .catch((err) => {
+ Notification.queueNotification('Failed to copy text', 'error');
+ console.error('Failed to copy text: ', err);
+ });
+ });
+ } 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..79b1a9c
--- /dev/null
+++ b/src/Editor/ValidationTools.ts
@@ -0,0 +1,120 @@
+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 {
+ let log = '';
+ errors.forEach((line) => {
+ log += line + '\n';
+ });
+ resolve([false, log]);
+ }
+ } 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');
- }
- });
-}