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