├── .gitignore ├── image.png ├── image-1.png ├── image-2.png ├── image-3.png ├── image-4.png ├── src ├── models │ ├── enums │ │ ├── conditon_property.ts │ │ ├── field_type.ts │ │ └── comparer.ts │ └── classes │ │ ├── uploadEntry.ts │ │ ├── context.ts │ │ ├── cell.ts │ │ ├── condition.ts │ │ └── option.ts ├── modules │ └── common.ts └── main.ts ├── tsconfig.json ├── index.html ├── package.json ├── contributions.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaoufGhrissi/csv_matchy/HEAD/image.png -------------------------------------------------------------------------------- /image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaoufGhrissi/csv_matchy/HEAD/image-1.png -------------------------------------------------------------------------------- /image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaoufGhrissi/csv_matchy/HEAD/image-2.png -------------------------------------------------------------------------------- /image-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaoufGhrissi/csv_matchy/HEAD/image-3.png -------------------------------------------------------------------------------- /image-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RaoufGhrissi/csv_matchy/HEAD/image-4.png -------------------------------------------------------------------------------- /src/models/enums/conditon_property.ts: -------------------------------------------------------------------------------- 1 | export enum ConditonProperty { 2 | value = "value", 3 | regex = "regex", 4 | length = "length", 5 | } -------------------------------------------------------------------------------- /src/models/enums/field_type.ts: -------------------------------------------------------------------------------- 1 | export enum FieldType { 2 | float = "float", 3 | integer = "integer", 4 | string = "string", 5 | bool = "bool", 6 | } -------------------------------------------------------------------------------- /src/models/classes/uploadEntry.ts: -------------------------------------------------------------------------------- 1 | import { Cell } from "./cell.js"; 2 | 3 | export class UploadEntry { 4 | lines: Object[] = []; 5 | 6 | constructor() {} 7 | } -------------------------------------------------------------------------------- /src/models/enums/comparer.ts: -------------------------------------------------------------------------------- 1 | export enum Comparer { 2 | gt = "gt", 3 | gte = "gte", 4 | lt = "lt", 5 | lte = "lte", 6 | e = "e", 7 | in = "in", 8 | } -------------------------------------------------------------------------------- /src/models/classes/context.ts: -------------------------------------------------------------------------------- 1 | export class Context { 2 | preSubmitFileContext: boolean; 3 | 4 | constructor() { 5 | this.preSubmitFileContext = false; 6 | } 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "module": "es2015", 5 | "lib": [ 6 | "ESNext", 7 | "DOM", 8 | ], 9 | "outDir": "dist", 10 | "rootDir": "./src", 11 | "strict": true, 12 | "moduleResolution": "node", 13 | }, 14 | } -------------------------------------------------------------------------------- /src/models/classes/cell.ts: -------------------------------------------------------------------------------- 1 | export class Cell { 2 | value: string; 3 | rowIndex: number; 4 | colIndex: number; 5 | 6 | constructor(value: string, rowIndex: number, colIndex: number) { 7 | this.value = value; 8 | this.rowIndex = rowIndex; 9 | this.colIndex = colIndex; 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |

Upload CSV File

11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/models/classes/condition.ts: -------------------------------------------------------------------------------- 1 | import { Comparer } from "../enums/comparer"; 2 | import { ConditonProperty } from "../enums/conditon_property"; 3 | 4 | export class Condition { 5 | property: ConditonProperty; 6 | comparer: Comparer; 7 | value: number | string | string[]; 8 | custom_fail_message: string | null; 9 | 10 | constructor(property: ConditonProperty, value: number | string | string[], comparer: Comparer = Comparer.e, custom_fail_message: string | null = null) { 11 | this.property = property; 12 | this.comparer = comparer; 13 | this.value = value; 14 | this.custom_fail_message = custom_fail_message; 15 | } 16 | } -------------------------------------------------------------------------------- /src/models/classes/option.ts: -------------------------------------------------------------------------------- 1 | import { FieldType } from "../enums/field_type"; 2 | import { Condition } from "./condition"; 3 | 4 | export class Option { 5 | display_value: string; 6 | value: string | null; 7 | mandatory: boolean; 8 | type: FieldType; 9 | conditions: Condition[]; 10 | 11 | constructor(display_value: string = "", value: string | null = null, mandatory: boolean = false, type: FieldType = FieldType.string, conditions: Condition[] = []) { 12 | this.display_value = display_value; 13 | this.mandatory = mandatory; 14 | this.type = type; 15 | this.value = value; 16 | this.conditions = conditions; 17 | } 18 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "csv_matchy", 3 | "version": "1.0.1", 4 | "description": "This library provides functionality to upload CSV files, match the file header with existing options, and validate the contents of the CSV files based on predefined conditions. The validation includes checking for mandatory fields, maximum or minimum length, and data type (string, integer, boolean, float, or regex pattern).", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/RaoufGhrissi/matchy.git" 12 | }, 13 | "keywords": [ 14 | "csv", 15 | "validator" 16 | ], 17 | "author": "Mahdi Cheikhrouhou & Abderraouf Ghrissi", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/RaoufGhrissi/matchy/issues" 21 | }, 22 | "homepage": "https://github.com/RaoufGhrissi/matchy#readme", 23 | "dependencies": { 24 | "@types/string-similarity": "^4.0.2", 25 | "date-fns": "^4.1.0", 26 | "read-excel-file": "^5.8.6", 27 | "string-similarity": "^4.0.4", 28 | "xlsx": "^0.18.5" 29 | }, 30 | "devDependencies": { 31 | "@types/date-fns": "^2.6.3", 32 | "@types/xlsx": "^0.0.36" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /contributions.md: -------------------------------------------------------------------------------- 1 | Contribution Guidelines 2 | 3 | Thank you for considering contributing to the Matchy project! We welcome contributions from everyone, whether it's reporting bugs, fixing issues, or adding new features. Your contributions help improve the library for everyone. 4 | 5 | Ways to Contribute 6 | There are several ways to contribute: 7 | 8 | Report Bugs: If you encounter any bugs or unexpected behavior, please open an issue on our issue tracker. 9 | Fix Issues: You can browse through the open issues on our issue tracker and pick any issue you'd like to work on. If you fix an issue, please submit a pull request. 10 | Add Features: Have an idea for a new feature? Feel free to implement it and submit a pull request. 11 | Improve Documentation: Documentation is crucial for a successful project. If you find any gaps or errors in the documentation, please help improve it. 12 | Review Pull Requests: Reviewing pull requests is a valuable way to contribute. Your feedback can help ensure the quality and correctness of contributions. 13 | Spread the Word: If you find the Matchy library useful, consider spreading the word about it. Share it with your friends, colleagues, or on social media. 14 | 15 | Getting Started 16 | If you're new to contributing to open-source projects or to Git and GitHub, don't worry! Here's how you can get started: 17 | 18 | Fork the Repository: Click the "Fork" button on the top right corner of the repository's page to create your copy of the project. 19 | Clone the Repository: Clone the repository to your local machine using Git: 20 | 21 | git clone https://github.com/RaoufGhrissi/matchy 22 | 23 | Create a Branch: Create a new branch for your work. Choose a descriptive name that summarizes the purpose of your changes: 24 | 25 | ``` 26 | git checkout -b feature/add-new-validation 27 | ``` 28 | Make Changes: Make your changes to the codebase. Ensure that your changes adhere to the coding standards and conventions used in the project. 29 | 30 | Do your changes in src folder in ts files then run 31 | ``` 32 | tsc 33 | ``` 34 | to generate an updated dist folder with your new changes. 35 | 36 | Test Your Changes: Test your changes thoroughly to ensure that they work as expected and do not introduce any regressions. 37 | Commit Your Changes: Once you're satisfied with your changes, commit them to your branch: 38 | 39 | ``` 40 | git add . 41 | git commit -m "Add new validation feature" 42 | ``` 43 | Push Your Changes: Push your changes to your forked repository on GitHub: 44 | ``` 45 | git push origin feature/add-new-validation 46 | ``` 47 | Submit a Pull Request: Go to the GitHub page of your forked repository, select your branch, and click the "Pull Request" button to submit your changes for review. 48 | Code Style and Guidelines 49 | Please follow the existing code style and guidelines used in the project. This helps maintain consistency and readability across the codebase. 50 | 51 | Code of Conduct 52 | We expect all contributors to adhere to the project's Code of Conduct. Please be respectful and considerate towards others. 53 | 54 | Feedback 55 | If you have any feedback or suggestions on how we can improve the contribution process, please let us know. We're always looking for ways to make it easier for people to contribute to the project. 56 | 57 | Thank you for contributing to the CSV Validator project! 🎉 -------------------------------------------------------------------------------- /src/modules/common.ts: -------------------------------------------------------------------------------- 1 | import { Comparer } from "../models/enums/comparer"; 2 | 3 | //ids, boostrap, field, tag should be moved to enums 4 | export const ids = { 5 | optionType: "optionType", 6 | optionTypeContainer: "optionTypeContainer", 7 | operator: "operator", 8 | operatorContainer: "operatorContainer", 9 | secondOperator: "secondOperator", 10 | secondOperatorContainer: "secondOperatorContainer", 11 | comparer: "comparer", 12 | comparerContainer: "comparerContainer", 13 | secondComparer: "secondComparer", 14 | secondComparerContainer: "secondComparerContainer", 15 | andComparer: "andComparer", 16 | stringProps: "stringProps", 17 | attribute: "attribute", 18 | attributeContainer: "attributeContainer", 19 | submitBtn: "submitBtn", 20 | submitFile: "submit", 21 | preSubmitFile: "preSubmit", 22 | uploadFile: "upload", 23 | resetFile: "reset", 24 | fileContent: "fileContent", 25 | uploadedFile: "uploadedFile", 26 | }; 27 | 28 | export const bootstrap: { [key: string]: string } = { 29 | row: "row", 30 | col: "col", 31 | container: "container", 32 | btn: "btn", 33 | btnPrimary: "btn-primary", 34 | formGroup: "form-group", 35 | formControl: "form-control", 36 | formSelect: "form-select", 37 | formCheck: "form-check", 38 | formCheckInput: "form-check-input", 39 | formCheckLabel: "form-check-label", 40 | formSwitch: "form-switch", 41 | table: "table", 42 | trashIcon: "delete_icon", 43 | editableCell: "table-active", 44 | editedCell: "table-success", 45 | invalidCell: "table-danger", 46 | }; 47 | 48 | export const field = { 49 | id: "id", 50 | name: "name", 51 | required: "required", 52 | placeholder: "placeholder", 53 | value: "value", 54 | display: "display", 55 | mandatory: "mandatory", 56 | attribute: "attribute", 57 | type: "type", 58 | rel: "rel", 59 | href: "href", 60 | for: "for", 61 | step: "step", 62 | scope: "scope", 63 | conditions: "conditions", 64 | optionsList: "options-list", 65 | optionsLine: "option-line", 66 | title: "title", 67 | row: "row", 68 | col: "col", 69 | }; 70 | 71 | export const tag = { 72 | div: "div", 73 | span: "span", 74 | input: "input", 75 | option: "option", 76 | select: "select", 77 | head: "head", 78 | form: "form", 79 | button: "button", 80 | label: "label", 81 | thead: "thead", 82 | tbody: "tbody", 83 | table: "table", 84 | link: "link", 85 | th: "th", 86 | tr: "tr", 87 | td: "td", 88 | i: "i", 89 | p: "p", 90 | }; 91 | 92 | export const button = { 93 | submit: "submit", 94 | button: "button", 95 | }; 96 | 97 | function createLinkElement(href: string) { 98 | const link = document.createElement("link"); 99 | link.setAttribute(field.rel, "stylesheet"); 100 | link.setAttribute(field.href, href); 101 | return link; 102 | } 103 | 104 | export function importExternalUi(shadow: ShadowRoot) { 105 | const head = document.getElementsByTagName(tag.head)[0]; 106 | for (let href of [ 107 | "https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css", 108 | ]) { 109 | const link = createLinkElement(href); 110 | head.appendChild(link); 111 | shadow.appendChild(link); 112 | } 113 | } 114 | 115 | export function isEmpty(value: string | undefined | null) { 116 | return ["", null, undefined].includes(value); 117 | } 118 | 119 | export function createElement(tagName: string, attributes: Object = {}, classes: string[] = [], events: object = {}) { 120 | const element = document.createElement(tagName); 121 | if (classes.length > 0) { 122 | element.classList.add(...classes); 123 | } 124 | 125 | for (const [key, value] of Object.entries(attributes)) { 126 | if (!isEmpty(value)) 127 | element.setAttribute(key, value); 128 | } 129 | 130 | for (const [key, value] of Object.entries(events)) { 131 | element.addEventListener(key, value); 132 | } 133 | 134 | return element; 135 | } 136 | 137 | export function createHtmlElement(tag: any, text: string, attributes: Object = {}, classes: string[] = [], events: Object = {}) { 138 | const button = createElement(tag, attributes, classes, events); 139 | button.innerText = text; 140 | return button; 141 | } 142 | 143 | export function createButton(text: string, attributes: Object = {}, classes: string[] = [], events: Object = {}) { 144 | return createHtmlElement(tag.button, text, attributes, classes, events); 145 | } 146 | 147 | export function createLabel(text: string, attributes: Object = {}, classes: string[] = [], events: Object = {}) { 148 | return createHtmlElement(tag.label, text, attributes, classes, events); 149 | } 150 | 151 | export const evaluateConditions = { 152 | [Comparer.gt]: (x: number, y: number) => eval(`${x}>${y}`), 153 | [Comparer.lt]: (x: number, y: number) => eval(`${x}<${y}`), 154 | [Comparer.e]: (x: number, y: number) => eval(`${x}===${y}`), 155 | [Comparer.gte]: (x: number, y: number) => eval(`${x}>=${y}`), 156 | [Comparer.lte]: (x: number, y: number) => eval(`${x}<=${y}`), 157 | [Comparer.in]: (x: string, y: string[]) => y.map(e => e.toUpperCase()).includes(x.toUpperCase()), 158 | regExp: (x: string, y: string) => { 159 | const regex = new RegExp(y); 160 | return regex.test(x); 161 | }, 162 | }; 163 | 164 | export const textPerComparer = { 165 | [Comparer.gt]: "greater than", 166 | [Comparer.lt]: "lower than", 167 | [Comparer.e]: "equal", 168 | [Comparer.gte]: "greater or equal than", 169 | [Comparer.lte]: "lower or equal than", 170 | [Comparer.in]: "in" 171 | }; 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matchy Library 2 | 3 | This library provides functionality to upload CSV files, match the file header with existing options, and validate the contents of the CSV files based on predefined conditions. The validation includes checking for mandatory fields, maximum or minimum length, and data type (string, integer, boolean, float, or regex pattern). 4 | 5 | ## Features 6 | 7 | 1- Upload CSV files 8 | 9 | 2- Match file header with existing options 10 | 11 | 3- Validate cells based on conditions 12 | 13 | 4- Directly update cells without altering the file 14 | 15 | 5- Visual indication of validation status (red for invalid, green for valid) 16 | 17 | ## Overview 18 | 19 | ##### Option fields: 20 | 21 | `display_value`: This field represents the value displayed to users. For instance, if the field represents a person's first name, the display_value might be "First Name". 22 | 23 | `value`: The value field corresponds to the actual field name or identifier used in your database or backend systems. For example, if the field represents a person's first name and the database column is named "first_name", then value would be "first_name". 24 | 25 | `mandatory`: This is a boolean field indicating whether the field is mandatory or required. If mandatory is set to true, it means that the field must have a value provided for it to be considered valid. 26 | 27 | `type`: The type field specifies the data type of the field. It is typically represented by an enumeration Field Type with possible values (float, integer, string) 28 | 29 | `conditions`: This field represents an array of conditions that need to be satisfied for the field to be considered valid. 30 | 31 | ##### Condition fields: 32 | 33 | `property`: This field is an enum representing the property of the data being evaluated in the condition. It can take one of three values: 34 | 35 | - **value**: Indicates that the condition applies directly to the value itself. 36 | - **regex**: Specifies that the condition involves a regular expression pattern match. 37 | - **length**: Denotes that the condition pertains to the length of the value, such as string length or array length. 38 | 39 | `comparer`: This field is an enum representing the comparison operation to be applied in the condition. It can take one of six values: 40 | 41 | - **gt**: Greater than comparison. 42 | - **gte**: Greater than or equal to comparison. 43 | - **lt**: Less than comparison. 44 | - **lte**: Less than or equal to comparison. 45 | - **e**: Equality comparison. 46 | - **in**: Membership comparison, checking if the value exists in a specified list or range. 47 | value: This field represents the value against which the condition is evaluated. It can be a number, a string, or an array of strings. The type of value depends on the context of the condition and the property being evaluated. 48 | 49 | `custom_fail_message`: This field contains a custom failure message that can be displayed if the condition is not met. It is optional and can be either a string or null. If provided, this message overrides any default failure message associated with the condition. 50 | 51 | These fields collectively define the criteria for validating data against specific conditions. They allow for flexible and customizable validation rules to ensure that data meets the required criteria within the application. 52 | 53 | ## Using Matchy with Angular 54 | First you need to copy the `src` folder from this repository to your angular app. 55 | 56 | In this example, this is my project structure 57 | ![alt text](image-4.png) 58 | 59 | 1- create a new component (⚠️ ⚠️ ⚠️ ⚠️ don't name it matchy, details: "app-matchy" is already defined here https://github.com/RaoufGhrissi/csv_matchy/blob/9151790e44e84f0d83c93f691aa2bb2ae3923e72/src/main.ts#L613) 60 | 61 | 2- your html file 62 | ```html 63 |
64 | ``` 65 | 3- in your TS file 66 | 67 | ```ts 68 | import { Component, OnInit } from '@angular/core'; 69 | import { Matchy } from 'src/libs/matchy/src/main'; 70 | import { Condition } from 'src/libs/matchy/src/models/classes/condition'; 71 | import { Option } from 'src/libs/matchy/src/models/classes/option'; 72 | import { Comparer } from 'src/libs/matchy/src/models/enums/comparer'; 73 | import { ConditonProperty } from 'src/libs/matchy/src/models/enums/conditon_property'; 74 | import { FieldType } from 'src/libs/matchy/src/models/enums/field_type'; 75 | 76 | export interface MatchyWrongCell { 77 | message: string 78 | rowIndex: string 79 | colIndex: string 80 | } 81 | @Component({ 82 | selector: 'app-root', 83 | templateUrl: './app.component.html', 84 | styleUrls: ['./app.component.css'] 85 | }) 86 | export class MyComponent implements OnInit { 87 | warning?: string; 88 | errors?: string; 89 | wrongCells: MatchyWrongCell[] = []; 90 | title = 'matchy_test'; 91 | 92 | ngOnInit() { 93 | const options = [ 94 | new Option("First Name", "first_name", true, FieldType.string, [ 95 | new Condition(ConditonProperty.length, 20, Comparer.gte), 96 | new Condition(ConditonProperty.length, 30, Comparer.lt, "not safe choice"), 97 | ]), 98 | new Option("Last Name", "last_name", true, FieldType.string, [ 99 | new Condition(ConditonProperty.value, ["AA", "BB"], Comparer.in) 100 | ]), 101 | new Option("Age", "age", true, FieldType.integer, [ 102 | new Condition(ConditonProperty.value, 0, Comparer.gte), 103 | new Condition(ConditonProperty.value, 40, Comparer.lte), 104 | ]), 105 | new Option("Registration Number", "registration_num", true, FieldType.string, [ 106 | new Condition(ConditonProperty.regex, '^\\d{8}-\\d{2}$'), 107 | ]), 108 | new Option("%", "percentage", true, FieldType.float, [ 109 | new Condition(ConditonProperty.value, 0, Comparer.gte), 110 | new Condition(ConditonProperty.value, 100, Comparer.lte), 111 | ]), 112 | ]; 113 | 114 | const matchy = new Matchy(options); 115 | document.getElementById("matchy")?.appendChild(matchy); 116 | 117 | // Submit method should be overriden to implemnt your logic 118 | matchy.submit = async(data:any) => { 119 | // use data and send it to your api 120 | 121 | const success = false; // Hardcoded , get it from your api response 122 | 123 | if (success) { 124 | // do what you want 125 | } else { 126 | this.warning = data.warnings; 127 | this.errors = data.errors; 128 | this.wrongCells = data.wrong_cells ? data.wrong_cells : []; 129 | // if you want to invalidate cells based on wrong cells received from your api response, 130 | // each td element in the table has col and row attributes, use matchyQuerySelectorAll() to get 131 | // the wrong cells and invalidate each one using markInvalidCell() 132 | const patterns = []; 133 | const message_per_cell = new Map(); 134 | for (const cell of this.wrongCells) { 135 | const rowIndex = cell.rowIndex; 136 | const colIndex = cell.colIndex; 137 | 138 | patterns.push(`td[col="${colIndex}"][row="${rowIndex}"]`); 139 | message_per_cell.set(`${colIndex}, ${rowIndex}`, cell.message); 140 | } 141 | matchy.matchyQuerySelectorAll(patterns.join(', ')).forEach((htmlCell) => { 142 | const rowIndex = htmlCell.getAttribute("row"); 143 | const colIndex = htmlCell.getAttribute("col"); 144 | matchy.markInvalidCell(htmlCell, [message_per_cell.get(`${colIndex}, ${rowIndex}`)]); 145 | }) 146 | } 147 | }; 148 | } 149 | } 150 | ``` 151 | 152 | ## Using Matchy with React 153 | 154 | 155 | 1- First you need to copy the `src` folder from this repository to your react app. 156 | create a TS file and import some classes and enums from matchy to be able to create the options. 157 | 158 | 2- Create the component which will use matchy 159 | 160 | ```ts 161 | import { Matchy } from "src/libs/matchy/src/main"; 162 | import { useEffect, useRef } from "react"; 163 | import { Condition } from 'src/libs/matchy/src/models/classes/condition'; 164 | import { Option } from 'src/libs/matchy/src/models/classes/option'; 165 | import { Comparer } from 'src/libs/matchy/src/models/enums/comparer'; 166 | import { ConditonProperty } from 'src/libs/matchy/src/models/enums/conditon_property'; 167 | import { FieldType } from 'src/libs/matchy/src/models/enums/field_type'; 168 | 169 | const ComponentWithMatchy = () => { 170 | const matchyRef = useRef(null); 171 | const options = [ 172 | new Option("First Name", "first_name", true, FieldType.string, [ 173 | new Condition(ConditonProperty.length, 20, Comparer.gte), 174 | new Condition(ConditonProperty.length, 30, Comparer.lt, "not safe choice"), 175 | ]), 176 | new Option("Last Name", "last_name", true, FieldType.string, [ 177 | new Condition(ConditonProperty.value, ["AA", "BB"], Comparer.in) 178 | ]), 179 | new Option("Age", "age", true, FieldType.integer, [ 180 | new Condition(ConditonProperty.value, 0, Comparer.gte), 181 | new Condition(ConditonProperty.value, 40, Comparer.lte), 182 | ]), 183 | new Option("Registration Number", "registration_num", true, FieldType.string, [ 184 | new Condition(ConditonProperty.regex, '^\\d{8}-\\d{2}$'), 185 | ]), 186 | new Option("%", "percentage", true, FieldType.float, [ 187 | new Condition(ConditonProperty.value, 0, Comparer.gte), 188 | new Condition(ConditonProperty.value, 100, Comparer.lte), 189 | ]), 190 | ]; 191 | useEffect(() => { 192 | const matchy_div = document.getElementById("matchy"); 193 | if (matchy_div) { 194 | if (!matchy_div.querySelector("app-matchy")) { 195 | // To prevent inserting 2 times in case you have React.StrictMode 196 | matchy_div.appendChild(new Matchy(options)); 197 | 198 | // Submit method should be overriden to implemnt your logic 199 | matchy.submit = async(data:any) => { 200 | // use data and send it to your api 201 | }; 202 | } 203 | } 204 | }, []); 205 | return ( 206 |
207 | 208 | ); 209 | }; 210 | 211 | export default ComponentWithMatchy; 212 | 213 | ``` 214 | 215 | ## Contributing 216 | Contributions are welcome! Please feel free to submit a pull request. 217 | Check contributions.md for more details 218 | 219 | ## Support 220 | For any questions or issues, please open an issue. 221 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Condition } from "./models/classes/condition"; 2 | import { Comparer } from "./models/enums/comparer"; 3 | import { ConditonProperty } from "./models/enums/conditon_property"; 4 | import { FieldType } from "./models/enums/field_type"; 5 | import { Option } from "./models/classes/option"; 6 | import { 7 | importExternalUi, 8 | createButton, 9 | tag, 10 | field, 11 | bootstrap, 12 | ids, 13 | createLabel, 14 | createElement, 15 | button, 16 | isEmpty, 17 | evaluateConditions, 18 | textPerComparer, 19 | } from "./modules/common"; 20 | import { Context } from "./models/classes/context"; 21 | import { Cell } from "./models/classes/cell"; 22 | import { UploadEntry } from "./models/classes/uploadEntry"; 23 | 24 | const editableCellClassName = "editableCell"; 25 | const editedCellClassName = "editedCell"; 26 | const invalidCellClassName = "invalidCell"; 27 | 28 | import { format } from "date-fns"; 29 | import { fr } from "date-fns/locale"; 30 | 31 | import readXlsxFile from "read-excel-file"; 32 | import * as stringSimilarity from "string-similarity"; 33 | 34 | interface Settings { 35 | validate: boolean; 36 | } 37 | 38 | class SelectedCell { 39 | cell: HTMLElement; 40 | rowIndex: number; 41 | colIndex: number; 42 | 43 | constructor(cell: HTMLElement, rowIndex: number, colIndex: number) { 44 | this.cell = cell; 45 | this.rowIndex = rowIndex; 46 | this.colIndex = colIndex; 47 | } 48 | } 49 | 50 | export class Matchy extends HTMLElement { 51 | shadow: ShadowRoot; 52 | defaultOption: Option; 53 | options: Option[]; 54 | settings: Settings; 55 | rows: string[][]; 56 | cols: Option[]; 57 | fileHeader: string[]; 58 | values: Map; 59 | context: Context; 60 | deletedRows: Set; 61 | currentSelectedcell: SelectedCell | null = null; 62 | 63 | constructor( 64 | mainOptions: Option[] = [], 65 | setting: Settings = { validate: true } 66 | ) { 67 | super(); 68 | this.defaultOption = new Option(); 69 | this.options = []; 70 | this.rows = []; 71 | this.cols = []; 72 | this.fileHeader = []; 73 | this.values = new Map(); 74 | this.context = new Context(); 75 | this.deletedRows = new Set(); 76 | this.setPossibleOptions(mainOptions); 77 | this.settings = setting; 78 | 79 | this.shadow = this.attachShadow({ mode: "open" }); 80 | this.shadow.innerHTML = ``; 140 | 141 | importExternalUi(this.shadow); 142 | this.initializeComponent(); 143 | this.init(); 144 | } 145 | 146 | setPossibleOptions(options: Option[]) { 147 | this.defaultOption = new Option(); 148 | this.options = [this.defaultOption, ...options]; 149 | } 150 | 151 | initializeComponent() { 152 | const container = createElement(tag.div, {}, [bootstrap["container"]]); 153 | const form = createElement(tag.form); 154 | const formGroup = createElement(tag.div, {}, [bootstrap["formGroup"]]); 155 | const selectFileLabel = createLabel("Select an Excel File :", { 156 | for: ids.uploadedFile, 157 | }); 158 | const fileInput = createElement( 159 | tag.input, 160 | { 161 | type: "file", 162 | id: ids.uploadedFile, 163 | name: "uploadedFile", 164 | }, 165 | ["form-control"] 166 | ); 167 | fileInput.addEventListener("change", this.uploadFile.bind(this)); 168 | formGroup.appendChild(selectFileLabel); 169 | formGroup.appendChild(fileInput); 170 | form.appendChild(formGroup); 171 | form.appendChild( 172 | createButton( 173 | "Pre Submit", 174 | { 175 | id: ids.preSubmitFile, 176 | type: button.button, 177 | }, 178 | ["btn", "btn-primary", "mt-2", "m-2"], 179 | { click: () => this.preSubmitFile() } 180 | ) 181 | ); 182 | form.appendChild( 183 | createButton( 184 | "Submit", 185 | { 186 | id: ids.submitFile, 187 | type: button.button, 188 | }, 189 | ["btn", "btn-success", "mt-2", "m-2"], 190 | { click: () => this.submitFile() } 191 | ) 192 | ); 193 | form.appendChild( 194 | createButton( 195 | "Reset", 196 | { 197 | id: ids.resetFile, 198 | type: button.button, 199 | }, 200 | ["btn", "btn-success", "mt-2", "m-2"], 201 | { 202 | click: () => { 203 | this.uploadFile(); 204 | this.hideElementById(ids.resetFile); 205 | this.hideElementById(ids.submitFile); 206 | }, 207 | } 208 | ) 209 | ); 210 | container.appendChild(form); 211 | this.shadow.appendChild(container); 212 | const table = createElement(tag.table, { id: ids.fileContent }, [ 213 | bootstrap["table"], 214 | "table-hover", 215 | "table-striped", 216 | ]); 217 | this.shadow.appendChild(table); 218 | this.hideElementById(ids.preSubmitFile); 219 | this.hideElementById(ids.submitFile); 220 | this.hideElementById(ids.resetFile); 221 | this.initEventListeners(); 222 | } 223 | 224 | initEventListeners() { 225 | this.addEventListener("keydown", (event) => { 226 | // Check if the pressed key is Tab 227 | if (event.key === "Tab") { 228 | if (this.currentSelectedcell !== null) { 229 | this.selectNextCell(); 230 | event.preventDefault(); 231 | } 232 | } 233 | }); 234 | } 235 | 236 | selectNextCell() { 237 | if (this.currentSelectedcell == null) { 238 | return; 239 | } 240 | const currentCell = this.currentSelectedcell.cell; 241 | let nextCell = null; 242 | if (currentCell.nextElementSibling) { 243 | nextCell = currentCell.nextElementSibling; 244 | } else if (currentCell.parentElement?.nextElementSibling) { 245 | nextCell = currentCell.parentElement?.nextElementSibling.children[0]; 246 | } 247 | this.endEditMode( 248 | currentCell, 249 | this.currentSelectedcell.rowIndex, 250 | this.currentSelectedcell.colIndex 251 | ); 252 | if (nextCell) { 253 | this.startEditMode( 254 | nextCell as HTMLElement, 255 | parseInt(nextCell.getAttribute("row") as string), 256 | parseInt(nextCell.getAttribute("col") as string) 257 | ); 258 | } 259 | } 260 | 261 | init() { 262 | this.rows = []; 263 | this.cols = []; 264 | this.values = new Map(); 265 | this.context = new Context(); 266 | this.deletedRows = new Set(); 267 | } 268 | 269 | onSelectOption(select: any, th: any) { 270 | if (!isEmpty(select.previousValue)) { 271 | this.shadow 272 | .querySelectorAll(`option[value="${select.previousValue}"]`) 273 | .forEach((option: any) => { 274 | option.disabled = false; 275 | }); 276 | } 277 | 278 | if (!isEmpty(select.value)) { 279 | this.values.set(select.id, select.value); 280 | this.shadow 281 | .querySelectorAll(`option[value="${select.value}"]`) 282 | .forEach((option: any) => { 283 | option.disabled = true; 284 | }); 285 | } else { 286 | this.values.delete(select.id); 287 | } 288 | 289 | select.previousValue = select.value; 290 | this.cols[select.id] = new Option( 291 | select.options[select.selectedIndex].text, 292 | select.value 293 | ); 294 | } 295 | 296 | addOptions(th: any, index: any) { 297 | const select = createElement(tag.select, { 298 | id: index, 299 | }); 300 | 301 | select.onchange = () => { 302 | this.onSelectOption(select, th); 303 | }; 304 | 305 | for (const element of this.options) { 306 | const option: any = createElement(tag.option, { 307 | value: element.value, 308 | }); 309 | option.innerText = element.display_value; 310 | select.appendChild(option); 311 | } 312 | th.appendChild(select); 313 | } 314 | 315 | generateTableHead(table: any) { 316 | const thead = table.createTHead(); 317 | const row = thead.insertRow(); 318 | for (const [index, col] of this.fileHeader.entries()) { 319 | const th = createElement(tag.th); 320 | const text = createElement(tag.p); 321 | text.innerHTML = col; 322 | th.appendChild(text); 323 | row.appendChild(th); 324 | this.addOptions(th, index); 325 | } 326 | this.cols = Array(this.fileHeader.length).fill(this.defaultOption); 327 | } 328 | 329 | markInvalidCell(cell: any, message: any) { 330 | this.addCellState(cell, invalidCellClassName); 331 | cell.setAttribute(field.title, message.join("\n")); 332 | } 333 | 334 | markValidCell(cell: any) { 335 | if (cell.classList.contains(invalidCellClassName)) { 336 | this.removeCellState(cell, invalidCellClassName); 337 | } 338 | cell.removeAttribute(field.title); 339 | } 340 | 341 | checkValidity(cell: any, index: any) { 342 | const value = this.values.get(String(index)); 343 | if (isEmpty(value)) return; 344 | const option = this.options.find((x) => x.value === value); 345 | if (option == null) return; 346 | const { valid, message } = this.checkCell(cell.innerText, option); 347 | if (!valid) { 348 | this.markInvalidCell(cell, message); 349 | } else { 350 | this.markValidCell(cell); 351 | } 352 | } 353 | 354 | removeCellState(cell: any, state: string) { 355 | cell.classList.remove(state); 356 | cell.classList.remove(bootstrap[state]); 357 | } 358 | 359 | addCellState(cell: any, state: string) { 360 | cell.classList.add(state); 361 | cell.classList.add(bootstrap[state]); 362 | } 363 | 364 | endEditMode(cell: any, rowIndex: number, colIndex: number) { 365 | const input = cell.querySelector(tag.input); 366 | const content = input.value; 367 | this.rows[rowIndex][colIndex] = content; 368 | cell.innerHTML = ""; 369 | const text = document.createTextNode(this.rows[rowIndex][colIndex]); 370 | cell.appendChild(text); 371 | this.addCellState(cell, editedCellClassName); 372 | this.removeCellState(cell, editableCellClassName); 373 | this.currentSelectedcell = null; 374 | if (!this.context.preSubmitFileContext) return; 375 | this.checkValidity(cell, colIndex); 376 | } 377 | 378 | startEditMode(cell: any, rowIndex: number, colIndex: number) { 379 | if (cell.classList.contains(editableCellClassName)) retutabrn; 380 | this.currentSelectedcell = new SelectedCell(cell, rowIndex, colIndex); 381 | this.markValidCell(cell); 382 | const prevContent = cell.innerText; 383 | const input = createElement( 384 | tag.input, 385 | { type: "text", value: prevContent }, 386 | ["form-control"], 387 | { 388 | blur: () => { 389 | this.endEditMode(cell, rowIndex, colIndex); 390 | }, 391 | } 392 | ); 393 | if (cell.classList.contains(editedCellClassName)) { 394 | this.removeCellState(cell, editedCellClassName); 395 | } 396 | 397 | this.addCellState(cell, editableCellClassName); 398 | cell.innerHTML = ""; 399 | cell.insertBefore(input, cell.firstChild); 400 | input.focus(); 401 | cell.addEventListener("keypress", (e: any) => { 402 | if (e.key === "Enter") { 403 | this.endEditMode(cell, rowIndex, colIndex); 404 | } 405 | }); 406 | } 407 | 408 | matchyQuerySelectorAll(pattern: string) { 409 | return this.shadow.querySelectorAll(pattern); 410 | } 411 | 412 | matchyQuerySelector(pattern: string) { 413 | return this.shadow.querySelector(pattern); 414 | } 415 | 416 | hideRow(row: string) { 417 | this.shadow.querySelector(`tr[row="${row}"]`)?.remove(); 418 | } 419 | 420 | deleteRow(row: any) { 421 | this.deletedRows.add(row); 422 | this.hideRow(row); 423 | } 424 | 425 | generateTableBody(table: any) { 426 | for (const [rowIndex, row] of this.rows.entries()) { 427 | if (this.deletedRows.has(rowIndex)) { 428 | continue; 429 | } 430 | const tableRow = table.insertRow(); 431 | tableRow.setAttribute(field.row, rowIndex); 432 | for (const [colIndex, col] of this.fileHeader.entries()) { 433 | const cell = tableRow.insertCell(); 434 | cell.setAttribute(field.row, rowIndex); 435 | cell.setAttribute(field.col, colIndex); 436 | const text = document.createTextNode(row[colIndex]); 437 | cell.onclick = () => { 438 | this.startEditMode(cell, rowIndex, colIndex); 439 | }; 440 | cell.appendChild(text); 441 | } 442 | const cell = tableRow.insertCell(); 443 | const icon = createElement(tag.i, {}, [bootstrap["trashIcon"]], { 444 | click: () => { 445 | const confirmDelete = confirm( 446 | "Are you sure you want to delete this item ?" 447 | ); 448 | 449 | if (!!confirmDelete) { 450 | this.deleteRow(rowIndex); 451 | } 452 | }, 453 | }); 454 | cell.appendChild(icon); 455 | } 456 | } 457 | 458 | validateFile(file: any) { 459 | if (!file) { 460 | alert("Please select a file."); 461 | return false; 462 | } 463 | if ( 464 | ![ 465 | "application/vnd.ms-excel", 466 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 467 | ].includes(file.type) 468 | ) { 469 | alert("Please select an Excel file."); 470 | return false; 471 | } 472 | 473 | return true; 474 | } 475 | 476 | buildTable() { 477 | const table = this.shadow.querySelector("table[id='fileContent']"); 478 | if (table == null) { 479 | return; 480 | } 481 | 482 | table.innerHTML = ""; 483 | this.generateTableBody(table); 484 | this.generateTableHead(table); 485 | this.autoMatch(); 486 | } 487 | 488 | handleFileUpload(event: any) { 489 | const file = event.files[0]; 490 | if (!file) return; 491 | 492 | readXlsxFile(file).then((rows: any) => { 493 | this.fileHeader = rows[0].map((item: any) => String(item)); 494 | this.rows = rows 495 | .splice(1) 496 | .map((row: any) => 497 | row.map((cell: any) => 498 | cell == null 499 | ? "" 500 | : cell instanceof Date 501 | ? format(cell, "dd/MM/yyyy", { locale: fr }) 502 | : cell 503 | ) 504 | ) as string[][]; 505 | 506 | this.buildTable(); 507 | this.displayElementById( 508 | this.settings.validate ? ids.preSubmitFile : ids.submitFile 509 | ); 510 | }); 511 | } 512 | 513 | uploadFile() { 514 | this.init(); 515 | this.context.preSubmitFileContext = false; 516 | const fileInput: HTMLInputElement = this.shadow.getElementById( 517 | ids.uploadedFile 518 | ) as HTMLInputElement; 519 | const input = fileInput.files?.[0]; 520 | if (!this.validateFile(input)) { 521 | return; 522 | } 523 | this.handleFileUpload(fileInput); 524 | } 525 | 526 | setDisplayProp(id: string, prop: string) { 527 | const elt = this.shadow.getElementById(id); 528 | if (elt != null) { 529 | elt.style.display = prop; 530 | } 531 | } 532 | 533 | hideElementById(id: string) { 534 | this.setDisplayProp(id, "none"); 535 | } 536 | 537 | displayElementById(id: any) { 538 | this.setDisplayProp(id, "inline"); 539 | } 540 | 541 | generateValues() { 542 | this.shadow.querySelectorAll("th div:not([data-selected='null'])").forEach((div) => { 543 | const selectId = div.id; 544 | const selectedValue = div.getAttribute("data-selected") 545 | if (selectedValue != null) { 546 | this.values.set(selectId.toString(), selectedValue) 547 | } 548 | }); 549 | } 550 | 551 | preSubmitFile() { 552 | this.generateValues(); 553 | this.context.preSubmitFileContext = true; 554 | this.deleteNotMatchedColumns(); 555 | for (let colIndex = 0; colIndex < this.cols.length; colIndex++) { 556 | if (!this.values.has(String(colIndex))) continue; 557 | 558 | this.shadow.querySelectorAll(`tr td[col="${colIndex}"]`).forEach((td) => { 559 | this.checkValidity(td, colIndex); 560 | }); 561 | } 562 | 563 | this.hideElementById(ids.preSubmitFile); 564 | this.displayElementById(ids.resetFile); 565 | this.displayElementById(ids.submitFile); 566 | } 567 | 568 | deleteNotMatchedColumns() { 569 | this.shadow.querySelectorAll("th").forEach((th) => { 570 | const selectId = th.children[1].id; 571 | if (!this.values.has(selectId)) { 572 | th.remove(); 573 | this.shadow 574 | .querySelectorAll(`td[col="${selectId}"]`) 575 | .forEach((el) => el.remove()); 576 | } 577 | }); 578 | } 579 | 580 | async submit(data: any) { 581 | console.log("this should be overriden"); 582 | } 583 | 584 | async submitFile() { 585 | const data = this.generateResult(); 586 | await this.submit(data); 587 | } 588 | 589 | generateResult() { 590 | const content = new UploadEntry(); 591 | for (const [rowIndex, row] of this.rows.entries()) { 592 | if (this.deletedRows.has(rowIndex)) continue; 593 | const data: { [key: string]: Cell } = {}; 594 | for (const [colIndex, header] of this.cols.entries()) { 595 | if (header.value == null) continue; 596 | 597 | data[header.value] = new Cell(row[colIndex], rowIndex, colIndex); 598 | } 599 | content.lines.push(data); 600 | } 601 | return content; 602 | } 603 | 604 | checkCell(cellValue: string, option: Option) { 605 | if (isEmpty(cellValue)) { 606 | if (option.mandatory) { 607 | return { valid: false, message: ["Mandatory field missing"] }; 608 | } else { 609 | return { valid: true, message: [] }; 610 | } 611 | } 612 | 613 | const [result, msg] = this.checkType(cellValue, option.type); 614 | if (!result) { 615 | return { valid: false, message: [msg] }; 616 | } 617 | const message = []; 618 | for (const condition of option.conditions) { 619 | if (!this.checkConstraint(cellValue, condition)) { 620 | message.push(this.getInvalidCheckMessage(condition)); 621 | } 622 | } 623 | return { 624 | valid: message.length === 0, 625 | message, 626 | }; 627 | } 628 | 629 | getInvalidCheckMessage(condition: Condition) { 630 | if (!isEmpty(condition.custom_fail_message)) { 631 | return condition.custom_fail_message; 632 | } else if (condition.property === ConditonProperty.regex) { 633 | return `Text doesn't match the regex pattern ${condition.value}`; 634 | } else if (condition.property === ConditonProperty.length) { 635 | return `Text length is not ${textPerComparer[condition.comparer]} ${ 636 | condition.value 637 | }`; 638 | } else if (condition.property === ConditonProperty.value) { 639 | return `Value is not ${textPerComparer[condition.comparer]} ${ 640 | condition.value 641 | }`; 642 | } 643 | return ""; 644 | } 645 | 646 | checkConstraint(value: string, condition: Condition) { 647 | if (condition.property === ConditonProperty.length) { 648 | if (condition.comparer === Comparer.in) { 649 | return evaluateConditions[condition.comparer]( 650 | String(value.length), 651 | condition.value as string[] 652 | ); 653 | } 654 | 655 | return evaluateConditions[condition.comparer]( 656 | value.length, 657 | Number(condition.value) 658 | ); 659 | } else if (condition.property === ConditonProperty.value) { 660 | if (condition.comparer === Comparer.in) { 661 | return evaluateConditions[Comparer.in]( 662 | value, 663 | condition.value as string[] 664 | ); 665 | } 666 | return evaluateConditions[condition.comparer]( 667 | Number(value), 668 | Number(condition.value) 669 | ); 670 | } else if (condition.property === ConditonProperty.regex) { 671 | return this.checkRegExpConditions(value, String(condition.value)); 672 | } 673 | } 674 | 675 | checkRegExpConditions(value: string, conditionValue: string) { 676 | return evaluateConditions["regExp"](value, conditionValue); 677 | } 678 | 679 | isValidInteger(value: string): boolean { 680 | const intValue = parseInt(value, 10); 681 | return !isNaN(intValue) && value.trim() === intValue.toString(); 682 | } 683 | 684 | isValidFloat(value: string): boolean { 685 | return !isNaN(parseFloat(value)); 686 | } 687 | 688 | checkType(value: string, type: FieldType) { 689 | if (type === FieldType.integer) { 690 | return [this.isValidInteger(value), "It's not a valid integer"]; 691 | } else if (type === FieldType.float) { 692 | return [this.isValidFloat(value), "It's not a valid float"]; 693 | } else if (type === FieldType.bool) { 694 | return [value in ["Yes", "No"], "Possible values are 'Yes' or 'No'"]; 695 | } 696 | 697 | return [true, ""]; 698 | } 699 | 700 | autoMatch() { 701 | const edgesList: [string, string, number][] = []; 702 | for (const field of this.fileHeader) { 703 | if (field == null) continue; 704 | for (const option of this.options) { 705 | const similarity = stringSimilarity.compareTwoStrings( 706 | field.toLowerCase(), 707 | option.display_value.toLowerCase() 708 | ); 709 | if (similarity > 0.1) { 710 | edgesList.push([field, option.value as string, similarity]); 711 | } 712 | } 713 | } 714 | 715 | const matching: { [key: string]: string } = {}; 716 | edgesList.sort((a, b) => (b[2] as any) - (a[2] as any)); 717 | const connectionsA: { [key: string]: string } = {}; 718 | const connectionsB: { [key: string]: string } = {}; 719 | for (const edge of edgesList) { 720 | const [nodeA, nodeB, weight] = edge; 721 | if (!connectionsA[nodeA as any] && !connectionsB[nodeB as any]) { 722 | matching[nodeA] = nodeB; 723 | connectionsA[nodeA] = nodeB; 724 | connectionsB[nodeB] = nodeA; 725 | } 726 | } 727 | 728 | this.onMatchingFormValueChanged(matching); 729 | } 730 | 731 | onMatchingFormValueChanged(matching: { [key: string]: string }) { 732 | for (let index = 0; index < this.fileHeader.length; index++) { 733 | const field = this.fileHeader[index]; 734 | const match = matching[field]; 735 | if (match) { 736 | const select = this.shadow.querySelector( 737 | `select[id="${index}"]` 738 | ) as HTMLSelectElement; 739 | // [Raouf][to fix later] I'm not sure if this is the right way to set the value of a select 740 | select.value = match; 741 | this.onSelectOption(select, select.parentElement); 742 | } 743 | } 744 | } 745 | } 746 | 747 | customElements.define("app-matchy", Matchy); 748 | --------------------------------------------------------------------------------