├── .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 | 
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 |
--------------------------------------------------------------------------------