Compare commits

..

1 Commits

Author SHA1 Message Date
Dave Richer
2c8f5c7184 feature/IO-3060-Realtime-Notification-System: Weird sql Lind endings 2025-01-15 11:11:03 -08:00
615 changed files with 34641 additions and 49567 deletions

View File

@@ -9,13 +9,13 @@ orbs:
jobs:
imex-api-deploy:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
steps:
- checkout
- eb/setup
- run:
command: |
eb init imex-online-production-api -r ca-central-1 -p "Node.js 22 running on 64bit Amazon Linux 2023"
eb init imex-online-production-api -r ca-central-1 -p "Node.js 18 running on 64bit Amazon Linux 2"
eb status --verbose
eb deploy
eb status
@@ -28,7 +28,7 @@ jobs:
imex-hasura-migrate:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
parameters:
secret:
type: string
@@ -52,7 +52,7 @@ jobs:
pipeline_number: << pipeline.number >>
imex-app-build:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
resource_class: large
working_directory: ~/repo/client
steps:
@@ -77,7 +77,7 @@ jobs:
imex-app-beta-build:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
resource_class: large
working_directory: ~/repo/client
@@ -88,7 +88,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:imex
- run: npm run build:production:imex
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -114,7 +114,7 @@ jobs:
- eb/setup
- run:
command: |
eb init romeonline-productionapi -r us-east-2 -p "Node.js 22 running on 64bit Amazon Linux 2023"
eb init romeonline-productionapi -r us-east-2 -p "Node.js 18 running on 64bit Amazon Linux 2"
eb status --verbose
eb deploy
eb status
@@ -126,7 +126,7 @@ jobs:
pipeline_number: << pipeline.number >>
rome-hasura-migrate:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
parameters:
secret:
type: string
@@ -150,8 +150,8 @@ jobs:
pipeline_number: << pipeline.number >>
rome-app-build:
docker:
- image: cimg/node:22.13.1
resource_class: large
- image: cimg/node:18.18.2
working_directory: ~/repo/client
steps:
@@ -161,7 +161,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:rome
- run: npm run build:production:rome
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -181,7 +181,7 @@ jobs:
test-rome-hasura-migrate:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
parameters:
secret:
type: string
@@ -208,8 +208,8 @@ jobs:
test-rome-app-build:
docker:
- image: cimg/node:22.13.1
resource_class: large
- image: cimg/node:18.18.2
working_directory: ~/repo/client
steps:
@@ -219,7 +219,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:rome
- run: npm run build:test:rome
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID
@@ -239,7 +239,7 @@ jobs:
test-hasura-migrate:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
parameters:
secret:
type: string
@@ -266,7 +266,7 @@ jobs:
imex-test-app-build:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
resource_class: large
working_directory: ~/repo/client
@@ -277,7 +277,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
- run: npm run build:test:imex
- aws-s3/sync:
from: build
@@ -286,7 +286,7 @@ jobs:
imex-test-app-beta-build:
docker:
- image: cimg/node:22.13.1
- image: cimg/node:18.18.2
resource_class: large
working_directory: ~/repo/client
@@ -298,7 +298,7 @@ jobs:
name: Install Dependencies
command: npm i
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
- run: npm run build:test:imex
- aws-cli/setup:
aws_access_key_id: AWS_ACCESS_KEY_ID

1
.gitattributes vendored
View File

@@ -67,6 +67,7 @@
*.rb text eol=lf
*.java text eol=lf
*.php text eol=lf
*.sql text eol=lf
# Git configuration files
.gitattributes text eol=lf

11
.gitignore vendored
View File

@@ -121,14 +121,3 @@ logs/oAuthClient-log.log
/*.env.*
.idea/*
.idea
# Vitest
vitest-report*/
vitest-coverage/
*.vitest.log
test-output.txt
server/job/test/fixtures
.github
_reference/ragmate/.ragmate.env
docker_data

View File

@@ -1,2 +1,2 @@
client_max_body_size 50M;
client_body_buffer_size 5M;
client_body_buffer_size 5M;

View File

@@ -3,7 +3,7 @@ FROM amazonlinux:2023
# Install Git and Node.js (Amazon Linux 2023 uses the DNF package manager)
RUN dnf install -y git \
&& curl -sL https://rpm.nodesource.com/setup_22.x | bash - \
&& curl -sL https://rpm.nodesource.com/setup_20.x | bash - \
&& dnf install -y nodejs \
&& dnf clean all
@@ -56,5 +56,4 @@ COPY . .
EXPOSE 4000 9229
# Start the application
RUN echo "Starting the application..."
CMD ["nodemon", "--ignore", "./server/job/test/fixtures", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]
CMD ["nodemon", "--legacy-watch", "--inspect=0.0.0.0:9229", "server.js"]

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@
"license": "ISC",
"description": "",
"dependencies": {
"express": "^5.1.0",
"mailparser": "^3.7.2",
"express": "^4.21.1",
"mailparser": "^3.7.1",
"node-fetch": "^3.3.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BG3tzU7L2BXlGZ_3VLK4PNaRceoEXEnmHfxcVbRMF5o5g05ejslhVPki9kBM9cBBT-08Ad9kN3HSpS6JmrWD6h4'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=/api/
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=IMEX
TEST_USERNAME="test@imex.dev"
TEST_PASSWORD="test123"

View File

@@ -10,9 +10,7 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BP1B7ZTYpn-KMt6nOxlld6aS8Skt3Q7ZLEqP0hAvGHxG4UojPYiXZ6kPlzZkUC5jH-EcWXomTLtmadAIxurfcHo'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=/api/
VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_COUNTRY=USA
VITE_APP_INSTANCE=ROME
TEST_USERNAME="test@imex.dev"
TEST_PASSWORD="test123"

View File

@@ -9,7 +9,7 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BN2GcDPjipR5MTEosO5dT4CfQ3cmrdBIsI4juoOQrRijn_5aRiHlwj1mlq0W145mOusx6xynEKl_tvYJhpCc9lo'
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
VITE_APP_AXIOS_BASE_API_URL=https://api.test.imex.online/
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
VITE_APP_IS_TEST=true
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
VITE_APP_INSTANCE=IMEX

11
client/.gitignore vendored
View File

@@ -1,14 +1,3 @@
# Vitest
vitest-report*/
vitest-coverage/
*.vitest.log
test-output.txt
# Playwright
playwright-report/
test-results/
playwright/.cache/
*.playwright.log
# Sentry Config File
.sentryclirc

17
client/cypress.config.js Normal file
View File

@@ -0,0 +1,17 @@
const { defineConfig } = require("cypress");
module.exports = defineConfig({
experimentalStudio: true,
env: {
FIREBASE_USERNAME: "cypress@imex.test",
FIREBASE_PASSWORD: "cypress"
},
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require("./cypress/plugins/index.js")(on, config);
},
baseUrl: "https://localhost:3000"
}
});

View File

@@ -0,0 +1,19 @@
/// <reference types="Cypress" />
const { FIREBASE_USERNAME, FIREBASE_PASSWORcD } = Cypress.env();
describe("Renders the General Page", () => {
beforeEach(() => {
cy.visit("/");
});
it("Renders Correctly", () => {});
it("Has the Slogan", () => {
cy.findByText("A whole x22new kind of shop management system.").should("exist");
/* ==== Generated with Cypress Studio ==== */
cy.get(".ant-menu-item-active > .ant-menu-title-content > .header0-item-block").click();
cy.get("#email").clear();
cy.get("#email").type("patrick@imex.dev");
cy.get("#password").clear();
cy.get("#password").type("patrick123{enter}");
cy.get(".ant-form > .ant-btn").click();
/* ==== End Cypress Studio ==== */
});
});

View File

@@ -0,0 +1,124 @@
/// <reference types="cypress" />
// Welcome to Cypress!
//
// This spec file contains a variety of sample tests
// for a todo list app that are designed to demonstrate
// the power of writing tests in Cypress.
//
// To learn more about how Cypress works and
// what makes it such an awesome testing tool,
// please read our getting started guide:
// https://on.cypress.io/introduction-to-cypress
describe("example to-do app", () => {
beforeEach(() => {
// Cypress starts out with a blank slate for each test
// so we must tell it to visit our website with the `cy.visit()` command.
// Since we want to visit the same URL at the start of all our tests,
// we include it in our beforeEach function so that it runs before each test
cy.visit("https://example.cypress.io/todo");
});
it("displays two todo items by default", () => {
// We use the `cy.get()` command to get all elements that match the selector.
// Then, we use `should` to assert that there are two matched items,
// which are the two default items.
cy.get(".todo-list li").should("have.length", 2);
// We can go even further and check that the default todos each contain
// the correct text. We use the `first` and `last` functions
// to get just the first and last matched elements individually,
// and then perform an assertion with `should`.
cy.get(".todo-list li").first().should("have.text", "Pay electric bill");
cy.get(".todo-list li").last().should("have.text", "Walk the dog");
});
it("can add new todo items", () => {
// We'll store our item text in a variable so we can reuse it
const newItem = "Feed the cat";
// Let's get the input element and use the `type` command to
// input our new list item. After typing the content of our item,
// we need to type the enter key as well in order to submit the input.
// This input has a data-test attribute so we'll use that to select the
// element in accordance with best practices:
// https://on.cypress.io/selecting-elements
cy.get("[data-test=new-todo]").type(`${newItem}{enter}`);
// Now that we've typed our new item, let's check that it actually was added to the list.
// Since it's the newest item, it should exist as the last element in the list.
// In addition, with the two default items, we should have a total of 3 elements in the list.
// Since assertions yield the element that was asserted on,
// we can chain both of these assertions together into a single statement.
cy.get(".todo-list li").should("have.length", 3).last().should("have.text", newItem);
});
it("can check off an item as completed", () => {
// In addition to using the `get` command to get an element by selector,
// we can also use the `contains` command to get an element by its contents.
// However, this will yield the <label>, which is lowest-level element that contains the text.
// In order to check the item, we'll find the <input> element for this <label>
// by traversing up the dom to the parent element. From there, we can `find`
// the child checkbox <input> element and use the `check` command to check it.
cy.contains("Pay electric bill").parent().find("input[type=checkbox]").check();
// Now that we've checked the button, we can go ahead and make sure
// that the list element is now marked as completed.
// Again we'll use `contains` to find the <label> element and then use the `parents` command
// to traverse multiple levels up the dom until we find the corresponding <li> element.
// Once we get that element, we can assert that it has the completed class.
cy.contains("Pay electric bill").parents("li").should("have.class", "completed");
});
context("with a checked task", () => {
beforeEach(() => {
// We'll take the command we used above to check off an element
// Since we want to perform multiple tests that start with checking
// one element, we put it in the beforeEach hook
// so that it runs at the start of every test.
cy.contains("Pay electric bill").parent().find("input[type=checkbox]").check();
});
it("can filter for uncompleted tasks", () => {
// We'll click on the "active" button in order to
// display only incomplete items
cy.contains("Active").click();
// After filtering, we can assert that there is only the one
// incomplete item in the list.
cy.get(".todo-list li").should("have.length", 1).first().should("have.text", "Walk the dog");
// For good measure, let's also assert that the task we checked off
// does not exist on the page.
cy.contains("Pay electric bill").should("not.exist");
});
it("can filter for completed tasks", () => {
// We can perform similar steps as the test above to ensure
// that only completed tasks are shown
cy.contains("Completed").click();
cy.get(".todo-list li").should("have.length", 1).first().should("have.text", "Pay electric bill");
cy.contains("Walk the dog").should("not.exist");
});
it("can delete all completed tasks", () => {
// First, let's click the "Clear completed" button
// `contains` is actually serving two purposes here.
// First, it's ensuring that the button exists within the dom.
// This button only appears when at least one task is checked
// so this command is implicitly verifying that it does exist.
// Second, it selects the button so we can click it.
cy.contains("Clear completed").click();
// Then we can make sure that there is only one element
// in the list and our element does not exist
cy.get(".todo-list li").should("have.length", 1).should("not.have.text", "Pay electric bill");
// Finally, make sure that the clear button no longer exists.
cy.contains("Clear completed").should("not.exist");
});
});
});

View File

@@ -0,0 +1,284 @@
/// <reference types="cypress" />
context("Actions", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/actions");
});
// https://on.cypress.io/interacting-with-elements
it(".type() - type into a DOM element", () => {
// https://on.cypress.io/type
cy.get(".action-email")
.type("fake@email.com")
.should("have.value", "fake@email.com")
// .type() with special character sequences
.type("{leftarrow}{rightarrow}{uparrow}{downarrow}")
.type("{del}{selectall}{backspace}")
// .type() with key modifiers
.type("{alt}{option}") //these are equivalent
.type("{ctrl}{control}") //these are equivalent
.type("{meta}{command}{cmd}") //these are equivalent
.type("{shift}")
// Delay each keypress by 0.1 sec
.type("slow.typing@email.com", { delay: 100 })
.should("have.value", "slow.typing@email.com");
cy.get(".action-disabled")
// Ignore error checking prior to type
// like whether the input is visible or disabled
.type("disabled error checking", { force: true })
.should("have.value", "disabled error checking");
});
it(".focus() - focus on a DOM element", () => {
// https://on.cypress.io/focus
cy.get(".action-focus").focus().should("have.class", "focus").prev().should("have.attr", "style", "color: orange;");
});
it(".blur() - blur off a DOM element", () => {
// https://on.cypress.io/blur
cy.get(".action-blur")
.type("About to blur")
.blur()
.should("have.class", "error")
.prev()
.should("have.attr", "style", "color: red;");
});
it(".clear() - clears an input or textarea element", () => {
// https://on.cypress.io/clear
cy.get(".action-clear")
.type("Clear this text")
.should("have.value", "Clear this text")
.clear()
.should("have.value", "");
});
it(".submit() - submit a form", () => {
// https://on.cypress.io/submit
cy.get(".action-form").find('[type="text"]').type("HALFOFF");
cy.get(".action-form").submit().next().should("contain", "Your form has been submitted!");
});
it(".click() - click on a DOM element", () => {
// https://on.cypress.io/click
cy.get(".action-btn").click();
// You can click on 9 specific positions of an element:
// -----------------------------------
// | topLeft top topRight |
// | |
// | |
// | |
// | left center right |
// | |
// | |
// | |
// | bottomLeft bottom bottomRight |
// -----------------------------------
// clicking in the center of the element is the default
cy.get("#action-canvas").click();
cy.get("#action-canvas").click("topLeft");
cy.get("#action-canvas").click("top");
cy.get("#action-canvas").click("topRight");
cy.get("#action-canvas").click("left");
cy.get("#action-canvas").click("right");
cy.get("#action-canvas").click("bottomLeft");
cy.get("#action-canvas").click("bottom");
cy.get("#action-canvas").click("bottomRight");
// .click() accepts an x and y coordinate
// that controls where the click occurs :)
cy.get("#action-canvas")
.click(80, 75) // click 80px on x coord and 75px on y coord
.click(170, 75)
.click(80, 165)
.click(100, 185)
.click(125, 190)
.click(150, 185)
.click(170, 165);
// click multiple elements by passing multiple: true
cy.get(".action-labels>.label").click({ multiple: true });
// Ignore error checking prior to clicking
cy.get(".action-opacity>.btn").click({ force: true });
});
it(".dblclick() - double click on a DOM element", () => {
// https://on.cypress.io/dblclick
// Our app has a listener on 'dblclick' event in our 'scripts.js'
// that hides the div and shows an input on double click
cy.get(".action-div").dblclick().should("not.be.visible");
cy.get(".action-input-hidden").should("be.visible");
});
it(".rightclick() - right click on a DOM element", () => {
// https://on.cypress.io/rightclick
// Our app has a listener on 'contextmenu' event in our 'scripts.js'
// that hides the div and shows an input on right click
cy.get(".rightclick-action-div").rightclick().should("not.be.visible");
cy.get(".rightclick-action-input-hidden").should("be.visible");
});
it(".check() - check a checkbox or radio element", () => {
// https://on.cypress.io/check
// By default, .check() will check all
// matching checkbox or radio elements in succession, one after another
cy.get('.action-checkboxes [type="checkbox"]').not("[disabled]").check().should("be.checked");
cy.get('.action-radios [type="radio"]').not("[disabled]").check().should("be.checked");
// .check() accepts a value argument
cy.get('.action-radios [type="radio"]').check("radio1").should("be.checked");
// .check() accepts an array of values
cy.get('.action-multiple-checkboxes [type="checkbox"]').check(["checkbox1", "checkbox2"]).should("be.checked");
// Ignore error checking prior to checking
cy.get(".action-checkboxes [disabled]").check({ force: true }).should("be.checked");
cy.get('.action-radios [type="radio"]').check("radio3", { force: true }).should("be.checked");
});
it(".uncheck() - uncheck a checkbox element", () => {
// https://on.cypress.io/uncheck
// By default, .uncheck() will uncheck all matching
// checkbox elements in succession, one after another
cy.get('.action-check [type="checkbox"]').not("[disabled]").uncheck().should("not.be.checked");
// .uncheck() accepts a value argument
cy.get('.action-check [type="checkbox"]').check("checkbox1").uncheck("checkbox1").should("not.be.checked");
// .uncheck() accepts an array of values
cy.get('.action-check [type="checkbox"]')
.check(["checkbox1", "checkbox3"])
.uncheck(["checkbox1", "checkbox3"])
.should("not.be.checked");
// Ignore error checking prior to unchecking
cy.get(".action-check [disabled]").uncheck({ force: true }).should("not.be.checked");
});
it(".select() - select an option in a <select> element", () => {
// https://on.cypress.io/select
// at first, no option should be selected
cy.get(".action-select").should("have.value", "--Select a fruit--");
// Select option(s) with matching text content
cy.get(".action-select").select("apples");
// confirm the apples were selected
// note that each value starts with "fr-" in our HTML
cy.get(".action-select").should("have.value", "fr-apples");
cy.get(".action-select-multiple")
.select(["apples", "oranges", "bananas"])
// when getting multiple values, invoke "val" method first
.invoke("val")
.should("deep.equal", ["fr-apples", "fr-oranges", "fr-bananas"]);
// Select option(s) with matching value
cy.get(".action-select")
.select("fr-bananas")
// can attach an assertion right away to the element
.should("have.value", "fr-bananas");
cy.get(".action-select-multiple")
.select(["fr-apples", "fr-oranges", "fr-bananas"])
.invoke("val")
.should("deep.equal", ["fr-apples", "fr-oranges", "fr-bananas"]);
// assert the selected values include oranges
cy.get(".action-select-multiple").invoke("val").should("include", "fr-oranges");
});
it(".scrollIntoView() - scroll an element into view", () => {
// https://on.cypress.io/scrollintoview
// normally all of these buttons are hidden,
// because they're not within
// the viewable area of their parent
// (we need to scroll to see them)
cy.get("#scroll-horizontal button").should("not.be.visible");
// scroll the button into view, as if the user had scrolled
cy.get("#scroll-horizontal button").scrollIntoView().should("be.visible");
cy.get("#scroll-vertical button").should("not.be.visible");
// Cypress handles the scroll direction needed
cy.get("#scroll-vertical button").scrollIntoView().should("be.visible");
cy.get("#scroll-both button").should("not.be.visible");
// Cypress knows to scroll to the right and down
cy.get("#scroll-both button").scrollIntoView().should("be.visible");
});
it(".trigger() - trigger an event on a DOM element", () => {
// https://on.cypress.io/trigger
// To interact with a range input (slider)
// we need to set its value & trigger the
// event to signal it changed
// Here, we invoke jQuery's val() method to set
// the value and trigger the 'change' event
cy.get(".trigger-input-range")
.invoke("val", 25)
.trigger("change")
.get("input[type=range]")
.siblings("p")
.should("have.text", "25");
});
it("cy.scrollTo() - scroll the window or element to a position", () => {
// https://on.cypress.io/scrollto
// You can scroll to 9 specific positions of an element:
// -----------------------------------
// | topLeft top topRight |
// | |
// | |
// | |
// | left center right |
// | |
// | |
// | |
// | bottomLeft bottom bottomRight |
// -----------------------------------
// if you chain .scrollTo() off of cy, we will
// scroll the entire window
cy.scrollTo("bottom");
cy.get("#scrollable-horizontal").scrollTo("right");
// or you can scroll to a specific coordinate:
// (x axis, y axis) in pixels
cy.get("#scrollable-vertical").scrollTo(250, 250);
// or you can scroll to a specific percentage
// of the (width, height) of the element
cy.get("#scrollable-both").scrollTo("75%", "25%");
// control the easing of the scroll (default is 'swing')
cy.get("#scrollable-vertical").scrollTo("center", { easing: "linear" });
// control the duration of the scroll (in ms)
cy.get("#scrollable-both").scrollTo("center", { duration: 2000 });
});
});

View File

@@ -0,0 +1,35 @@
/// <reference types="cypress" />
context("Aliasing", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/aliasing");
});
it(".as() - alias a DOM element for later use", () => {
// https://on.cypress.io/as
// Alias a DOM element for use later
// We don't have to traverse to the element
// later in our code, we reference it with @
cy.get(".as-table").find("tbody>tr").first().find("td").first().find("button").as("firstBtn");
// when we reference the alias, we place an
// @ in front of its name
cy.get("@firstBtn").click();
cy.get("@firstBtn").should("have.class", "btn-success").and("contain", "Changed");
});
it(".as() - alias a route for later use", () => {
// Alias the route to wait for its response
cy.intercept("GET", "**/comments/*").as("getComment");
// we have code that gets a comment when
// the button is clicked in scripts.js
cy.get(".network-btn").click();
// https://on.cypress.io/wait
cy.wait("@getComment").its("response.statusCode").should("eq", 200);
});
});

View File

@@ -0,0 +1,173 @@
/// <reference types="cypress" />
context("Assertions", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/assertions");
});
describe("Implicit Assertions", () => {
it(".should() - make an assertion about the current subject", () => {
// https://on.cypress.io/should
cy.get(".assertion-table")
.find("tbody tr:last")
.should("have.class", "success")
.find("td")
.first()
// checking the text of the <td> element in various ways
.should("have.text", "Column content")
.should("contain", "Column content")
.should("have.html", "Column content")
// chai-jquery uses "is()" to check if element matches selector
.should("match", "td")
// to match text content against a regular expression
// first need to invoke jQuery method text()
// and then match using regular expression
.invoke("text")
.should("match", /column content/i);
// a better way to check element's text content against a regular expression
// is to use "cy.contains"
// https://on.cypress.io/contains
cy.get(".assertion-table")
.find("tbody tr:last")
// finds first <td> element with text content matching regular expression
.contains("td", /column content/i)
.should("be.visible");
// for more information about asserting element's text
// see https://on.cypress.io/using-cypress-faq#How-do-I-get-an-elements-text-contents
});
it(".and() - chain multiple assertions together", () => {
// https://on.cypress.io/and
cy.get(".assertions-link").should("have.class", "active").and("have.attr", "href").and("include", "cypress.io");
});
});
describe("Explicit Assertions", () => {
// https://on.cypress.io/assertions
it("expect - make an assertion about a specified subject", () => {
// We can use Chai's BDD style assertions
expect(true).to.be.true;
const o = { foo: "bar" };
expect(o).to.equal(o);
expect(o).to.deep.equal({ foo: "bar" });
// matching text using regular expression
expect("FooBar").to.match(/bar$/i);
});
it("pass your own callback function to should()", () => {
// Pass a function to should that can have any number
// of explicit assertions within it.
// The ".should(cb)" function will be retried
// automatically until it passes all your explicit assertions or times out.
cy.get(".assertions-p")
.find("p")
.should(($p) => {
// https://on.cypress.io/$
// return an array of texts from all of the p's
// @ts-ignore TS6133 unused variable
const texts = $p.map((i, el) => Cypress.$(el).text());
// jquery map returns jquery object
// and .get() convert this to simple array
const paragraphs = texts.get();
// array should have length of 3
expect(paragraphs, "has 3 paragraphs").to.have.length(3);
// use second argument to expect(...) to provide clear
// message with each assertion
expect(paragraphs, "has expected text in each paragraph").to.deep.eq([
"Some text from first p",
"More text from second p",
"And even more text from third p"
]);
});
});
it("finds element by class name regex", () => {
cy.get(".docs-header")
.find("div")
// .should(cb) callback function will be retried
.should(($div) => {
expect($div).to.have.length(1);
const className = $div[0].className;
expect(className).to.match(/heading-/);
})
// .then(cb) callback is not retried,
// it either passes or fails
.then(($div) => {
expect($div, "text content").to.have.text("Introduction");
});
});
it("can throw any error", () => {
cy.get(".docs-header")
.find("div")
.should(($div) => {
if ($div.length !== 1) {
// you can throw your own errors
throw new Error("Did not find 1 element");
}
const className = $div[0].className;
if (!className.match(/heading-/)) {
throw new Error(`Could not find class "heading-" in ${className}`);
}
});
});
it("matches unknown text between two elements", () => {
/**
* Text from the first element.
* @type {string}
*/
let text;
/**
* Normalizes passed text,
* useful before comparing text with spaces and different capitalization.
* @param {string} s Text to normalize
*/
const normalizeText = (s) => s.replace(/\s/g, "").toLowerCase();
cy.get(".two-elements")
.find(".first")
.then(($first) => {
// save text from the first element
text = normalizeText($first.text());
});
cy.get(".two-elements")
.find(".second")
.should(($div) => {
// we can massage text before comparing
const secondText = normalizeText($div.text());
expect(secondText, "second text").to.equal(text);
});
});
it("assert - assert shape of an object", () => {
const person = {
name: "Joe",
age: 20
};
assert.isObject(person, "value is object");
});
it("retries the should callback until assertions pass", () => {
cy.get("#random-number").should(($div) => {
const n = parseFloat($div.text());
expect(n).to.be.gte(1).and.be.lte(10);
});
});
});
});

View File

@@ -0,0 +1,96 @@
/// <reference types="cypress" />
context("Connectors", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/connectors");
});
it(".each() - iterate over an array of elements", () => {
// https://on.cypress.io/each
cy.get(".connectors-each-ul>li").each(($el, index, $list) => {
console.log($el, index, $list);
});
});
it(".its() - get properties on the current subject", () => {
// https://on.cypress.io/its
cy.get(".connectors-its-ul>li")
// calls the 'length' property yielding that value
.its("length")
.should("be.gt", 2);
});
it(".invoke() - invoke a function on the current subject", () => {
// our div is hidden in our script.js
// $('.connectors-div').hide()
// https://on.cypress.io/invoke
cy.get(".connectors-div")
.should("be.hidden")
// call the jquery method 'show' on the 'div.container'
.invoke("show")
.should("be.visible");
});
it(".spread() - spread an array as individual args to callback function", () => {
// https://on.cypress.io/spread
const arr = ["foo", "bar", "baz"];
cy.wrap(arr).spread((foo, bar, baz) => {
expect(foo).to.eq("foo");
expect(bar).to.eq("bar");
expect(baz).to.eq("baz");
});
});
describe(".then()", () => {
it("invokes a callback function with the current subject", () => {
// https://on.cypress.io/then
cy.get(".connectors-list > li").then(($lis) => {
expect($lis, "3 items").to.have.length(3);
expect($lis.eq(0), "first item").to.contain("Walk the dog");
expect($lis.eq(1), "second item").to.contain("Feed the cat");
expect($lis.eq(2), "third item").to.contain("Write JavaScript");
});
});
it("yields the returned value to the next command", () => {
cy.wrap(1)
.then((num) => {
expect(num).to.equal(1);
return 2;
})
.then((num) => {
expect(num).to.equal(2);
});
});
it("yields the original subject without return", () => {
cy.wrap(1)
.then((num) => {
expect(num).to.equal(1);
// note that nothing is returned from this callback
})
.then((num) => {
// this callback receives the original unchanged value 1
expect(num).to.equal(1);
});
});
it("yields the value yielded by the last Cypress command inside", () => {
cy.wrap(1)
.then((num) => {
expect(num).to.equal(1);
// note how we run a Cypress command
// the result yielded by this Cypress command
// will be passed to the second ".then"
cy.wrap(2);
})
.then((num) => {
// this callback receives the value yielded by "cy.wrap(2)"
expect(num).to.equal(2);
});
});
});
});

View File

@@ -0,0 +1,79 @@
/// <reference types="cypress" />
context("Cookies", () => {
beforeEach(() => {
Cypress.Cookies.debug(true);
cy.visit("https://example.cypress.io/commands/cookies");
// clear cookies again after visiting to remove
// any 3rd party cookies picked up such as cloudflare
cy.clearCookies();
});
it("cy.getCookie() - get a browser cookie", () => {
// https://on.cypress.io/getcookie
cy.get("#getCookie .set-a-cookie").click();
// cy.getCookie() yields a cookie object
cy.getCookie("token").should("have.property", "value", "123ABC");
});
it("cy.getCookies() - get browser cookies", () => {
// https://on.cypress.io/getcookies
cy.getCookies().should("be.empty");
cy.get("#getCookies .set-a-cookie").click();
// cy.getCookies() yields an array of cookies
cy.getCookies()
.should("have.length", 1)
.should((cookies) => {
// each cookie has these properties
expect(cookies[0]).to.have.property("name", "token");
expect(cookies[0]).to.have.property("value", "123ABC");
expect(cookies[0]).to.have.property("httpOnly", false);
expect(cookies[0]).to.have.property("secure", false);
expect(cookies[0]).to.have.property("domain");
expect(cookies[0]).to.have.property("path");
});
});
it("cy.setCookie() - set a browser cookie", () => {
// https://on.cypress.io/setcookie
cy.getCookies().should("be.empty");
cy.setCookie("foo", "bar");
// cy.getCookie() yields a cookie object
cy.getCookie("foo").should("have.property", "value", "bar");
});
it("cy.clearCookie() - clear a browser cookie", () => {
// https://on.cypress.io/clearcookie
cy.getCookie("token").should("be.null");
cy.get("#clearCookie .set-a-cookie").click();
cy.getCookie("token").should("have.property", "value", "123ABC");
// cy.clearCookies() yields null
cy.clearCookie("token").should("be.null");
cy.getCookie("token").should("be.null");
});
it("cy.clearCookies() - clear browser cookies", () => {
// https://on.cypress.io/clearcookies
cy.getCookies().should("be.empty");
cy.get("#clearCookies .set-a-cookie").click();
cy.getCookies().should("have.length", 1);
// cy.clearCookies() yields null
cy.clearCookies();
cy.getCookies().should("be.empty");
});
});

View File

@@ -0,0 +1,208 @@
/// <reference types="cypress" />
context("Cypress.Commands", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
// https://on.cypress.io/custom-commands
it(".add() - create a custom command", () => {
Cypress.Commands.add(
"console",
{
prevSubject: true
},
(subject, method) => {
// the previous subject is automatically received
// and the commands arguments are shifted
// allow us to change the console method used
method = method || "log";
// log the subject to the console
// @ts-ignore TS7017
console[method]("The subject is", subject);
// whatever we return becomes the new subject
// we don't want to change the subject so
// we return whatever was passed in
return subject;
}
);
// @ts-ignore TS2339
cy.get("button")
.console("info")
.then(($button) => {
// subject is still $button
});
});
});
context("Cypress.Cookies", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
// https://on.cypress.io/cookies
it(".debug() - enable or disable debugging", () => {
Cypress.Cookies.debug(true);
// Cypress will now log in the console when
// cookies are set or cleared
cy.setCookie("fakeCookie", "123ABC");
cy.clearCookie("fakeCookie");
cy.setCookie("fakeCookie", "123ABC");
cy.clearCookie("fakeCookie");
cy.setCookie("fakeCookie", "123ABC");
});
it(".preserveOnce() - preserve cookies by key", () => {
// normally cookies are reset after each test
cy.getCookie("fakeCookie").should("not.be.ok");
// preserving a cookie will not clear it when
// the next test starts
cy.setCookie("lastCookie", "789XYZ");
Cypress.Cookies.preserveOnce("lastCookie");
});
it(".defaults() - set defaults for all cookies", () => {
// now any cookie with the name 'session_id' will
// not be cleared before each new test runs
Cypress.Cookies.defaults({
preserve: "session_id"
});
});
});
context("Cypress.arch", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
it("Get CPU architecture name of underlying OS", () => {
// https://on.cypress.io/arch
expect(Cypress.arch).to.exist;
});
});
context("Cypress.config()", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
it("Get and set configuration options", () => {
// https://on.cypress.io/config
let myConfig = Cypress.config();
expect(myConfig).to.have.property("animationDistanceThreshold", 5);
expect(myConfig).to.have.property("baseUrl", null);
expect(myConfig).to.have.property("defaultCommandTimeout", 4000);
expect(myConfig).to.have.property("requestTimeout", 5000);
expect(myConfig).to.have.property("responseTimeout", 30000);
expect(myConfig).to.have.property("viewportHeight", 660);
expect(myConfig).to.have.property("viewportWidth", 1000);
expect(myConfig).to.have.property("pageLoadTimeout", 60000);
expect(myConfig).to.have.property("waitForAnimations", true);
expect(Cypress.config("pageLoadTimeout")).to.eq(60000);
// this will change the config for the rest of your tests!
Cypress.config("pageLoadTimeout", 20000);
expect(Cypress.config("pageLoadTimeout")).to.eq(20000);
Cypress.config("pageLoadTimeout", 60000);
});
});
context("Cypress.dom", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
// https://on.cypress.io/dom
it(".isHidden() - determine if a DOM element is hidden", () => {
let hiddenP = Cypress.$(".dom-p p.hidden").get(0);
let visibleP = Cypress.$(".dom-p p.visible").get(0);
// our first paragraph has css class 'hidden'
expect(Cypress.dom.isHidden(hiddenP)).to.be.true;
expect(Cypress.dom.isHidden(visibleP)).to.be.false;
});
});
context("Cypress.env()", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
// We can set environment variables for highly dynamic values
// https://on.cypress.io/environment-variables
it("Get environment variables", () => {
// https://on.cypress.io/env
// set multiple environment variables
Cypress.env({
host: "veronica.dev.local",
api_server: "http://localhost:8888/v1/"
});
// get environment variable
expect(Cypress.env("host")).to.eq("veronica.dev.local");
// set environment variable
Cypress.env("api_server", "http://localhost:8888/v2/");
expect(Cypress.env("api_server")).to.eq("http://localhost:8888/v2/");
// get all environment variable
expect(Cypress.env()).to.have.property("host", "veronica.dev.local");
expect(Cypress.env()).to.have.property("api_server", "http://localhost:8888/v2/");
});
});
context("Cypress.log", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
it("Control what is printed to the Command Log", () => {
// https://on.cypress.io/cypress-log
});
});
context("Cypress.platform", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
it("Get underlying OS name", () => {
// https://on.cypress.io/platform
expect(Cypress.platform).to.be.exist;
});
});
context("Cypress.version", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
it("Get current version of Cypress being run", () => {
// https://on.cypress.io/version
expect(Cypress.version).to.be.exist;
});
});
context("Cypress.spec", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/cypress-api");
});
it("Get current spec information", () => {
// https://on.cypress.io/spec
// wrap the object so we can inspect it easily by clicking in the command log
cy.wrap(Cypress.spec).should("include.keys", ["name", "relative", "absolute"]);
});
});

View File

@@ -0,0 +1,86 @@
/// <reference types="cypress" />
/// JSON fixture file can be loaded directly using
// the built-in JavaScript bundler
// @ts-ignore
const requiredExample = require("../../fixtures/example");
context("Files", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/files");
});
beforeEach(() => {
// load example.json fixture file and store
// in the test context object
cy.fixture("example.json").as("example");
});
it("cy.fixture() - load a fixture", () => {
// https://on.cypress.io/fixture
// Instead of writing a response inline you can
// use a fixture file's content.
// when application makes an Ajax request matching "GET **/comments/*"
// Cypress will intercept it and reply with the object in `example.json` fixture
cy.intercept("GET", "**/comments/*", { fixture: "example.json" }).as("getComment");
// we have code that gets a comment when
// the button is clicked in scripts.js
cy.get(".fixture-btn").click();
cy.wait("@getComment")
.its("response.body")
.should("have.property", "name")
.and("include", "Using fixtures to represent data");
});
it("cy.fixture() or require - load a fixture", function () {
// we are inside the "function () { ... }"
// callback and can use test context object "this"
// "this.example" was loaded in "beforeEach" function callback
expect(this.example, "fixture in the test context").to.deep.equal(requiredExample);
// or use "cy.wrap" and "should('deep.equal', ...)" assertion
cy.wrap(this.example).should("deep.equal", requiredExample);
});
it("cy.readFile() - read file contents", () => {
// https://on.cypress.io/readfile
// You can read a file and yield its contents
// The filePath is relative to your project's root.
cy.readFile("cypress.json").then((json) => {
expect(json).to.be.an("object");
});
});
it("cy.writeFile() - write to a file", () => {
// https://on.cypress.io/writefile
// You can write to a file
// Use a response from a request to automatically
// generate a fixture file for use later
cy.request("https://jsonplaceholder.cypress.io/users").then((response) => {
cy.writeFile("cypress/fixtures/users.json", response.body);
});
cy.fixture("users").should((users) => {
expect(users[0].name).to.exist;
});
// JavaScript arrays and objects are stringified
// and formatted into text.
cy.writeFile("cypress/fixtures/profile.json", {
id: 8739,
name: "Jane",
email: "jane@example.com"
});
cy.fixture("profile").should((profile) => {
expect(profile.name).to.eq("Jane");
});
});
});

View File

@@ -0,0 +1,58 @@
/// <reference types="cypress" />
context("Local Storage", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/local-storage");
});
// Although local storage is automatically cleared
// in between tests to maintain a clean state
// sometimes we need to clear the local storage manually
it("cy.clearLocalStorage() - clear all data in local storage", () => {
// https://on.cypress.io/clearlocalstorage
cy.get(".ls-btn")
.click()
.should(() => {
expect(localStorage.getItem("prop1")).to.eq("red");
expect(localStorage.getItem("prop2")).to.eq("blue");
expect(localStorage.getItem("prop3")).to.eq("magenta");
});
// clearLocalStorage() yields the localStorage object
cy.clearLocalStorage().should((ls) => {
expect(ls.getItem("prop1")).to.be.null;
expect(ls.getItem("prop2")).to.be.null;
expect(ls.getItem("prop3")).to.be.null;
});
cy.get(".ls-btn")
.click()
.should(() => {
expect(localStorage.getItem("prop1")).to.eq("red");
expect(localStorage.getItem("prop2")).to.eq("blue");
expect(localStorage.getItem("prop3")).to.eq("magenta");
});
// Clear key matching string in Local Storage
cy.clearLocalStorage("prop1").should((ls) => {
expect(ls.getItem("prop1")).to.be.null;
expect(ls.getItem("prop2")).to.eq("blue");
expect(ls.getItem("prop3")).to.eq("magenta");
});
cy.get(".ls-btn")
.click()
.should(() => {
expect(localStorage.getItem("prop1")).to.eq("red");
expect(localStorage.getItem("prop2")).to.eq("blue");
expect(localStorage.getItem("prop3")).to.eq("magenta");
});
// Clear keys matching regex in Local Storage
cy.clearLocalStorage(/prop1|2/).should((ls) => {
expect(ls.getItem("prop1")).to.be.null;
expect(ls.getItem("prop2")).to.be.null;
expect(ls.getItem("prop3")).to.eq("magenta");
});
});
});

View File

@@ -0,0 +1,32 @@
/// <reference types="cypress" />
context("Location", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/location");
});
it("cy.hash() - get the current URL hash", () => {
// https://on.cypress.io/hash
cy.hash().should("be.empty");
});
it("cy.location() - get window.location", () => {
// https://on.cypress.io/location
cy.location().should((location) => {
expect(location.hash).to.be.empty;
expect(location.href).to.eq("https://example.cypress.io/commands/location");
expect(location.host).to.eq("example.cypress.io");
expect(location.hostname).to.eq("example.cypress.io");
expect(location.origin).to.eq("https://example.cypress.io");
expect(location.pathname).to.eq("/commands/location");
expect(location.port).to.eq("");
expect(location.protocol).to.eq("https:");
expect(location.search).to.be.empty;
});
});
it("cy.url() - get the current URL", () => {
// https://on.cypress.io/url
cy.url().should("eq", "https://example.cypress.io/commands/location");
});
});

View File

@@ -0,0 +1,98 @@
/// <reference types="cypress" />
context("Misc", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/misc");
});
it(".end() - end the command chain", () => {
// https://on.cypress.io/end
// cy.end is useful when you want to end a chain of commands
// and force Cypress to re-query from the root element
cy.get(".misc-table").within(() => {
// ends the current chain and yields null
cy.contains("Cheryl").click().end();
// queries the entire table again
cy.contains("Charles").click();
});
});
it("cy.exec() - execute a system command", () => {
// execute a system command.
// so you can take actions necessary for
// your test outside the scope of Cypress.
// https://on.cypress.io/exec
// we can use Cypress.platform string to
// select appropriate command
// https://on.cypress/io/platform
cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`);
// on CircleCI Windows build machines we have a failure to run bash shell
// https://github.com/cypress-io/cypress/issues/5169
// so skip some of the tests by passing flag "--env circle=true"
const isCircleOnWindows = Cypress.platform === "win32" && Cypress.env("circle");
if (isCircleOnWindows) {
cy.log("Skipping test on CircleCI");
return;
}
// cy.exec problem on Shippable CI
// https://github.com/cypress-io/cypress/issues/6718
const isShippable = Cypress.platform === "linux" && Cypress.env("shippable");
if (isShippable) {
cy.log("Skipping test on ShippableCI");
return;
}
cy.exec("echo Jane Lane").its("stdout").should("contain", "Jane Lane");
if (Cypress.platform === "win32") {
cy.exec("print cypress.json").its("stderr").should("be.empty");
} else {
cy.exec("cat cypress.json").its("stderr").should("be.empty");
cy.exec("pwd").its("code").should("eq", 0);
}
});
it("cy.focused() - get the DOM element that has focus", () => {
// https://on.cypress.io/focused
cy.get(".misc-form").find("#name").click();
cy.focused().should("have.id", "name");
cy.get(".misc-form").find("#description").click();
cy.focused().should("have.id", "description");
});
context("Cypress.Screenshot", function () {
it("cy.screenshot() - take a screenshot", () => {
// https://on.cypress.io/screenshot
cy.screenshot("my-image");
});
it("Cypress.Screenshot.defaults() - change default config of screenshots", function () {
Cypress.Screenshot.defaults({
blackout: [".foo"],
capture: "viewport",
clip: { x: 0, y: 0, width: 200, height: 200 },
scale: false,
disableTimersAndAnimations: true,
screenshotOnRunFailure: true,
onBeforeScreenshot() {},
onAfterScreenshot() {}
});
});
});
it("cy.wrap() - wrap an object", () => {
// https://on.cypress.io/wrap
cy.wrap({ foo: "bar" }).should("have.property", "foo").and("include", "bar");
});
});

View File

@@ -0,0 +1,56 @@
/// <reference types="cypress" />
context("Navigation", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io");
cy.get(".navbar-nav").contains("Commands").click();
cy.get(".dropdown-menu").contains("Navigation").click();
});
it("cy.go() - go back or forward in the browser's history", () => {
// https://on.cypress.io/go
cy.location("pathname").should("include", "navigation");
cy.go("back");
cy.location("pathname").should("not.include", "navigation");
cy.go("forward");
cy.location("pathname").should("include", "navigation");
// clicking back
cy.go(-1);
cy.location("pathname").should("not.include", "navigation");
// clicking forward
cy.go(1);
cy.location("pathname").should("include", "navigation");
});
it("cy.reload() - reload the page", () => {
// https://on.cypress.io/reload
cy.reload();
// reload the page without using the cache
cy.reload(true);
});
it("cy.visit() - visit a remote url", () => {
// https://on.cypress.io/visit
// Visit any sub-domain of your current domain
// Pass options to the visit
cy.visit("https://example.cypress.io/commands/navigation", {
timeout: 50000, // increase total time for the visit to resolve
onBeforeLoad(contentWindow) {
// contentWindow is the remote page's window object
expect(typeof contentWindow === "object").to.be.true;
},
onLoad(contentWindow) {
// contentWindow is the remote page's window object
expect(typeof contentWindow === "object").to.be.true;
}
});
});
});

View File

@@ -0,0 +1,165 @@
/// <reference types="cypress" />
context("Network Requests", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/network-requests");
});
// Manage HTTP requests in your app
it("cy.request() - make an XHR request", () => {
// https://on.cypress.io/request
cy.request("https://jsonplaceholder.cypress.io/comments").should((response) => {
expect(response.status).to.eq(200);
// the server sometimes gets an extra comment posted from another machine
// which gets returned as 1 extra object
expect(response.body).to.have.property("length").and.be.oneOf([500, 501]);
expect(response).to.have.property("headers");
expect(response).to.have.property("duration");
});
});
it("cy.request() - verify response using BDD syntax", () => {
cy.request("https://jsonplaceholder.cypress.io/comments").then((response) => {
// https://on.cypress.io/assertions
expect(response).property("status").to.equal(200);
expect(response).property("body").to.have.property("length").and.be.oneOf([500, 501]);
expect(response).to.include.keys("headers", "duration");
});
});
it("cy.request() with query parameters", () => {
// will execute request
// https://jsonplaceholder.cypress.io/comments?postId=1&id=3
cy.request({
url: "https://jsonplaceholder.cypress.io/comments",
qs: {
postId: 1,
id: 3
}
})
.its("body")
.should("be.an", "array")
.and("have.length", 1)
.its("0") // yields first element of the array
.should("contain", {
postId: 1,
id: 3
});
});
it("cy.request() - pass result to the second request", () => {
// first, let's find out the userId of the first user we have
cy.request("https://jsonplaceholder.cypress.io/users?_limit=1")
.its("body") // yields the response object
.its("0") // yields the first element of the returned list
// the above two commands its('body').its('0')
// can be written as its('body.0')
// if you do not care about TypeScript checks
.then((user) => {
expect(user).property("id").to.be.a("number");
// make a new post on behalf of the user
cy.request("POST", "https://jsonplaceholder.cypress.io/posts", {
userId: user.id,
title: "Cypress Test Runner",
body: "Fast, easy and reliable testing for anything that runs in a browser."
});
})
// note that the value here is the returned value of the 2nd request
// which is the new post object
.then((response) => {
expect(response).property("status").to.equal(201); // new entity created
expect(response).property("body").to.contain({
title: "Cypress Test Runner"
});
// we don't know the exact post id - only that it will be > 100
// since JSONPlaceholder has built-in 100 posts
expect(response.body).property("id").to.be.a("number").and.to.be.gt(100);
// we don't know the user id here - since it was in above closure
// so in this test just confirm that the property is there
expect(response.body).property("userId").to.be.a("number");
});
});
it("cy.request() - save response in the shared test context", () => {
// https://on.cypress.io/variables-and-aliases
cy.request("https://jsonplaceholder.cypress.io/users?_limit=1")
.its("body")
.its("0") // yields the first element of the returned list
.as("user") // saves the object in the test context
.then(function () {
// NOTE 👀
// By the time this callback runs the "as('user')" command
// has saved the user object in the test context.
// To access the test context we need to use
// the "function () { ... }" callback form,
// otherwise "this" points at a wrong or undefined object!
cy.request("POST", "https://jsonplaceholder.cypress.io/posts", {
userId: this.user.id,
title: "Cypress Test Runner",
body: "Fast, easy and reliable testing for anything that runs in a browser."
})
.its("body")
.as("post"); // save the new post from the response
})
.then(function () {
// When this callback runs, both "cy.request" API commands have finished
// and the test context has "user" and "post" objects set.
// Let's verify them.
expect(this.post, "post has the right user id").property("userId").to.equal(this.user.id);
});
});
it("cy.intercept() - route responses to matching requests", () => {
// https://on.cypress.io/intercept
let message = "whoa, this comment does not exist";
// Listen to GET to comments/1
cy.intercept("GET", "**/comments/*").as("getComment");
// we have code that gets a comment when
// the button is clicked in scripts.js
cy.get(".network-btn").click();
// https://on.cypress.io/wait
cy.wait("@getComment").its("response.statusCode").should("be.oneOf", [200, 304]);
// Listen to POST to comments
cy.intercept("POST", "**/comments").as("postComment");
// we have code that posts a comment when
// the button is clicked in scripts.js
cy.get(".network-post").click();
cy.wait("@postComment").should(({ request, response }) => {
expect(request.body).to.include("email");
expect(request.headers).to.have.property("content-type");
expect(response && response.body).to.have.property("name", "Using POST in cy.intercept()");
});
// Stub a response to PUT comments/ ****
cy.intercept(
{
method: "PUT",
url: "**/comments/*"
},
{
statusCode: 404,
body: { error: message },
headers: { "access-control-allow-origin": "*" },
delayMs: 500
}
).as("putComment");
// we have code that puts a comment when
// the button is clicked in scripts.js
cy.get(".network-put").click();
cy.wait("@putComment");
// our 404 statusCode logic in scripts.js executed
cy.get(".network-put-comment").should("contain", message);
});
});

View File

@@ -0,0 +1,100 @@
/// <reference types="cypress" />
context("Querying", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/querying");
});
// The most commonly used query is 'cy.get()', you can
// think of this like the '$' in jQuery
it("cy.get() - query DOM elements", () => {
// https://on.cypress.io/get
cy.get("#query-btn").should("contain", "Button");
cy.get(".query-btn").should("contain", "Button");
cy.get("#querying .well>button:first").should("contain", "Button");
// ↲
// Use CSS selectors just like jQuery
cy.get('[data-test-id="test-example"]').should("have.class", "example");
// 'cy.get()' yields jQuery object, you can get its attribute
// by invoking `.attr()` method
cy.get('[data-test-id="test-example"]').invoke("attr", "data-test-id").should("equal", "test-example");
// or you can get element's CSS property
cy.get('[data-test-id="test-example"]').invoke("css", "position").should("equal", "static");
// or use assertions directly during 'cy.get()'
// https://on.cypress.io/assertions
cy.get('[data-test-id="test-example"]')
.should("have.attr", "data-test-id", "test-example")
.and("have.css", "position", "static");
});
it("cy.contains() - query DOM elements with matching content", () => {
// https://on.cypress.io/contains
cy.get(".query-list").contains("bananas").should("have.class", "third");
// we can pass a regexp to `.contains()`
cy.get(".query-list").contains(/^b\w+/).should("have.class", "third");
cy.get(".query-list").contains("apples").should("have.class", "first");
// passing a selector to contains will
// yield the selector containing the text
cy.get("#querying").contains("ul", "oranges").should("have.class", "query-list");
cy.get(".query-button").contains("Save Form").should("have.class", "btn");
});
it(".within() - query DOM elements within a specific element", () => {
// https://on.cypress.io/within
cy.get(".query-form").within(() => {
cy.get("input:first").should("have.attr", "placeholder", "Email");
cy.get("input:last").should("have.attr", "placeholder", "Password");
});
});
it("cy.root() - query the root DOM element", () => {
// https://on.cypress.io/root
// By default, root is the document
cy.root().should("match", "html");
cy.get(".query-ul").within(() => {
// In this within, the root is now the ul DOM element
cy.root().should("have.class", "query-ul");
});
});
it("best practices - selecting elements", () => {
// https://on.cypress.io/best-practices#Selecting-Elements
cy.get("[data-cy=best-practices-selecting-elements]").within(() => {
// Worst - too generic, no context
cy.get("button").click();
// Bad. Coupled to styling. Highly subject to change.
cy.get(".btn.btn-large").click();
// Average. Coupled to the `name` attribute which has HTML semantics.
cy.get("[name=submission]").click();
// Better. But still coupled to styling or JS event listeners.
cy.get("#main").click();
// Slightly better. Uses an ID but also ensures the element
// has an ARIA role attribute
cy.get("#main[role=button]").click();
// Much better. But still coupled to text content that may change.
cy.contains("Submit").click();
// Best. Insulated from all changes.
cy.get("[data-cy=submit]").click();
});
});
});

View File

@@ -0,0 +1,203 @@
/// <reference types="cypress" />
// remove no check once Cypress.sinon is typed
// https://github.com/cypress-io/cypress/issues/6720
context("Spies, Stubs, and Clock", () => {
it("cy.spy() - wrap a method in a spy", () => {
// https://on.cypress.io/spy
cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
const obj = {
foo() {}
};
const spy = cy.spy(obj, "foo").as("anyArgs");
obj.foo();
expect(spy).to.be.called;
});
it("cy.spy() retries until assertions pass", () => {
cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
const obj = {
/**
* Prints the argument passed
* @param x {any}
*/
foo(x) {
console.log("obj.foo called with", x);
}
};
cy.spy(obj, "foo").as("foo");
setTimeout(() => {
obj.foo("first");
}, 500);
setTimeout(() => {
obj.foo("second");
}, 2500);
cy.get("@foo").should("have.been.calledTwice");
});
it("cy.stub() - create a stub and/or replace a function with stub", () => {
// https://on.cypress.io/stub
cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
const obj = {
/**
* prints both arguments to the console
* @param a {string}
* @param b {string}
*/
foo(a, b) {
console.log("a", a, "b", b);
}
};
const stub = cy.stub(obj, "foo").as("foo");
obj.foo("foo", "bar");
expect(stub).to.be.called;
});
it("cy.clock() - control time in the browser", () => {
// https://on.cypress.io/clock
// create the date in UTC so its always the same
// no matter what local timezone the browser is running in
const now = new Date(Date.UTC(2017, 2, 14)).getTime();
cy.clock(now);
cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
cy.get("#clock-div").click().should("have.text", "1489449600");
});
it("cy.tick() - move time in the browser", () => {
// https://on.cypress.io/tick
// create the date in UTC so its always the same
// no matter what local timezone the browser is running in
const now = new Date(Date.UTC(2017, 2, 14)).getTime();
cy.clock(now);
cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
cy.get("#tick-div").click().should("have.text", "1489449600");
cy.tick(10000); // 10 seconds passed
cy.get("#tick-div").click().should("have.text", "1489449610");
});
it("cy.stub() matches depending on arguments", () => {
// see all possible matchers at
// https://sinonjs.org/releases/latest/matchers/
const greeter = {
/**
* Greets a person
* @param {string} name
*/
greet(name) {
return `Hello, ${name}!`;
}
};
cy.stub(greeter, "greet")
.callThrough() // if you want non-matched calls to call the real method
.withArgs(Cypress.sinon.match.string)
.returns("Hi")
.withArgs(Cypress.sinon.match.number)
.throws(new Error("Invalid name"));
expect(greeter.greet("World")).to.equal("Hi");
// @ts-ignore
expect(() => greeter.greet(42)).to.throw("Invalid name");
expect(greeter.greet).to.have.been.calledTwice;
// non-matched calls goes the actual method
// @ts-ignore
expect(greeter.greet()).to.equal("Hello, undefined!");
});
it("matches call arguments using Sinon matchers", () => {
// see all possible matchers at
// https://sinonjs.org/releases/latest/matchers/
const calculator = {
/**
* returns the sum of two arguments
* @param a {number}
* @param b {number}
*/
add(a, b) {
return a + b;
}
};
const spy = cy.spy(calculator, "add").as("add");
expect(calculator.add(2, 3)).to.equal(5);
// if we want to assert the exact values used during the call
expect(spy).to.be.calledWith(2, 3);
// let's confirm "add" method was called with two numbers
expect(spy).to.be.calledWith(Cypress.sinon.match.number, Cypress.sinon.match.number);
// alternatively, provide the value to match
expect(spy).to.be.calledWith(Cypress.sinon.match(2), Cypress.sinon.match(3));
// match any value
expect(spy).to.be.calledWith(Cypress.sinon.match.any, 3);
// match any value from a list
expect(spy).to.be.calledWith(Cypress.sinon.match.in([1, 2, 3]), 3);
/**
* Returns true if the given number is event
* @param {number} x
*/
const isEven = (x) => x % 2 === 0;
// expect the value to pass a custom predicate function
// the second argument to "sinon.match(predicate, message)" is
// shown if the predicate does not pass and assertion fails
expect(spy).to.be.calledWith(Cypress.sinon.match(isEven, "isEven"), 3);
/**
* Returns a function that checks if a given number is larger than the limit
* @param {number} limit
* @returns {(x: number) => boolean}
*/
const isGreaterThan = (limit) => (x) => x > limit;
/**
* Returns a function that checks if a given number is less than the limit
* @param {number} limit
* @returns {(x: number) => boolean}
*/
const isLessThan = (limit) => (x) => x < limit;
// you can combine several matchers using "and", "or"
expect(spy).to.be.calledWith(
Cypress.sinon.match.number,
Cypress.sinon.match(isGreaterThan(2), "> 2").and(Cypress.sinon.match(isLessThan(4), "< 4"))
);
expect(spy).to.be.calledWith(
Cypress.sinon.match.number,
Cypress.sinon.match(isGreaterThan(200), "> 200").or(Cypress.sinon.match(3))
);
// matchers can be used from BDD assertions
cy.get("@add").should("have.been.calledWith", Cypress.sinon.match.number, Cypress.sinon.match(3));
// you can alias matchers for shorter test code
const { match: M } = Cypress.sinon;
cy.get("@add").should("have.been.calledWith", M.number, M(3));
});
});

View File

@@ -0,0 +1,97 @@
/// <reference types="cypress" />
context("Traversal", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/traversal");
});
it(".children() - get child DOM elements", () => {
// https://on.cypress.io/children
cy.get(".traversal-breadcrumb").children(".active").should("contain", "Data");
});
it(".closest() - get closest ancestor DOM element", () => {
// https://on.cypress.io/closest
cy.get(".traversal-badge").closest("ul").should("have.class", "list-group");
});
it(".eq() - get a DOM element at a specific index", () => {
// https://on.cypress.io/eq
cy.get(".traversal-list>li").eq(1).should("contain", "siamese");
});
it(".filter() - get DOM elements that match the selector", () => {
// https://on.cypress.io/filter
cy.get(".traversal-nav>li").filter(".active").should("contain", "About");
});
it(".find() - get descendant DOM elements of the selector", () => {
// https://on.cypress.io/find
cy.get(".traversal-pagination").find("li").find("a").should("have.length", 7);
});
it(".first() - get first DOM element", () => {
// https://on.cypress.io/first
cy.get(".traversal-table td").first().should("contain", "1");
});
it(".last() - get last DOM element", () => {
// https://on.cypress.io/last
cy.get(".traversal-buttons .btn").last().should("contain", "Submit");
});
it(".next() - get next sibling DOM element", () => {
// https://on.cypress.io/next
cy.get(".traversal-ul").contains("apples").next().should("contain", "oranges");
});
it(".nextAll() - get all next sibling DOM elements", () => {
// https://on.cypress.io/nextall
cy.get(".traversal-next-all").contains("oranges").nextAll().should("have.length", 3);
});
it(".nextUntil() - get next sibling DOM elements until next el", () => {
// https://on.cypress.io/nextuntil
cy.get("#veggies").nextUntil("#nuts").should("have.length", 3);
});
it(".not() - remove DOM elements from set of DOM elements", () => {
// https://on.cypress.io/not
cy.get(".traversal-disabled .btn").not("[disabled]").should("not.contain", "Disabled");
});
it(".parent() - get parent DOM element from DOM elements", () => {
// https://on.cypress.io/parent
cy.get(".traversal-mark").parent().should("contain", "Morbi leo risus");
});
it(".parents() - get parent DOM elements from DOM elements", () => {
// https://on.cypress.io/parents
cy.get(".traversal-cite").parents().should("match", "blockquote");
});
it(".parentsUntil() - get parent DOM elements from DOM elements until el", () => {
// https://on.cypress.io/parentsuntil
cy.get(".clothes-nav").find(".active").parentsUntil(".clothes-nav").should("have.length", 2);
});
it(".prev() - get previous sibling DOM element", () => {
// https://on.cypress.io/prev
cy.get(".birds").find(".active").prev().should("contain", "Lorikeets");
});
it(".prevAll() - get all previous sibling DOM elements", () => {
// https://on.cypress.io/prevall
cy.get(".fruits-list").find(".third").prevAll().should("have.length", 2);
});
it(".prevUntil() - get all previous sibling DOM elements until el", () => {
// https://on.cypress.io/prevuntil
cy.get(".foods-list").find("#nuts").prevUntil("#veggies").should("have.length", 3);
});
it(".siblings() - get all sibling DOM elements", () => {
// https://on.cypress.io/siblings
cy.get(".traversal-pills .active").siblings().should("have.length", 2);
});
});

View File

@@ -0,0 +1,108 @@
/// <reference types="cypress" />
context("Utilities", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/utilities");
});
it("Cypress._ - call a lodash method", () => {
// https://on.cypress.io/_
cy.request("https://jsonplaceholder.cypress.io/users").then((response) => {
let ids = Cypress._.chain(response.body).map("id").take(3).value();
expect(ids).to.deep.eq([1, 2, 3]);
});
});
it("Cypress.$ - call a jQuery method", () => {
// https://on.cypress.io/$
let $li = Cypress.$(".utility-jquery li:first");
cy.wrap($li).should("not.have.class", "active").click().should("have.class", "active");
});
it("Cypress.Blob - blob utilities and base64 string conversion", () => {
// https://on.cypress.io/blob
cy.get(".utility-blob").then(($div) => {
// https://github.com/nolanlawson/blob-util#imgSrcToDataURL
// get the dataUrl string for the javascript-logo
return Cypress.Blob.imgSrcToDataURL(
"https://example.cypress.io/assets/img/javascript-logo.png",
undefined,
"anonymous"
).then((dataUrl) => {
// create an <img> element and set its src to the dataUrl
let img = Cypress.$("<img />", { src: dataUrl });
// need to explicitly return cy here since we are initially returning
// the Cypress.Blob.imgSrcToDataURL promise to our test
// append the image
$div.append(img);
cy.get(".utility-blob img").click().should("have.attr", "src", dataUrl);
});
});
});
it("Cypress.minimatch - test out glob patterns against strings", () => {
// https://on.cypress.io/minimatch
let matching = Cypress.minimatch("/users/1/comments", "/users/*/comments", {
matchBase: true
});
expect(matching, "matching wildcard").to.be.true;
matching = Cypress.minimatch("/users/1/comments/2", "/users/*/comments", {
matchBase: true
});
expect(matching, "comments").to.be.false;
// ** matches against all downstream path segments
matching = Cypress.minimatch("/foo/bar/baz/123/quux?a=b&c=2", "/foo/**", {
matchBase: true
});
expect(matching, "comments").to.be.true;
// whereas * matches only the next path segment
matching = Cypress.minimatch("/foo/bar/baz/123/quux?a=b&c=2", "/foo/*", {
matchBase: false
});
expect(matching, "comments").to.be.false;
});
it("Cypress.Promise - instantiate a bluebird promise", () => {
// https://on.cypress.io/promise
let waited = false;
/**
* @return Bluebird<string>
*/
function waitOneSecond() {
// return a promise that resolves after 1 second
// @ts-ignore TS2351 (new Cypress.Promise)
return new Cypress.Promise((resolve, reject) => {
setTimeout(() => {
// set waited to true
waited = true;
// resolve with 'foo' string
resolve("foo");
}, 1000);
});
}
cy.then(() => {
// return a promise to cy.then() that
// is awaited until it resolves
// @ts-ignore TS7006
return waitOneSecond().then((str) => {
expect(str).to.eq("foo");
expect(waited).to.be.true;
});
});
});
});

View File

@@ -0,0 +1,59 @@
/// <reference types="cypress" />
context("Viewport", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/viewport");
});
it("cy.viewport() - set the viewport size and dimension", () => {
// https://on.cypress.io/viewport
cy.get("#navbar").should("be.visible");
cy.viewport(320, 480);
// the navbar should have collapse since our screen is smaller
cy.get("#navbar").should("not.be.visible");
cy.get(".navbar-toggle").should("be.visible").click();
cy.get(".nav").find("a").should("be.visible");
// lets see what our app looks like on a super large screen
cy.viewport(2999, 2999);
// cy.viewport() accepts a set of preset sizes
// to easily set the screen to a device's width and height
// We added a cy.wait() between each viewport change so you can see
// the change otherwise it is a little too fast to see :)
cy.viewport("macbook-15");
cy.wait(200);
cy.viewport("macbook-13");
cy.wait(200);
cy.viewport("macbook-11");
cy.wait(200);
cy.viewport("ipad-2");
cy.wait(200);
cy.viewport("ipad-mini");
cy.wait(200);
cy.viewport("iphone-6+");
cy.wait(200);
cy.viewport("iphone-6");
cy.wait(200);
cy.viewport("iphone-5");
cy.wait(200);
cy.viewport("iphone-4");
cy.wait(200);
cy.viewport("iphone-3");
cy.wait(200);
// cy.viewport() accepts an orientation for all presets
// the default orientation is 'portrait'
cy.viewport("ipad-2", "portrait");
cy.wait(200);
cy.viewport("iphone-4", "landscape");
cy.wait(200);
// The viewport will be reset back to the default dimensions
// in between tests (the default can be set in cypress.json)
});
});

View File

@@ -0,0 +1,31 @@
/// <reference types="cypress" />
context("Waiting", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/waiting");
});
// BE CAREFUL of adding unnecessary wait times.
// https://on.cypress.io/best-practices#Unnecessary-Waiting
// https://on.cypress.io/wait
it("cy.wait() - wait for a specific amount of time", () => {
cy.get(".wait-input1").type("Wait 1000ms after typing");
cy.wait(1000);
cy.get(".wait-input2").type("Wait 1000ms after typing");
cy.wait(1000);
cy.get(".wait-input3").type("Wait 1000ms after typing");
cy.wait(1000);
});
it("cy.wait() - wait for a specific route", () => {
// Listen to GET to comments/1
cy.intercept("GET", "**/comments/*").as("getComment");
// we have code that gets a comment when
// the button is clicked in scripts.js
cy.get(".network-btn").click();
// wait for GET comments/1
cy.wait("@getComment").its("response.statusCode").should("be.oneOf", [200, 304]);
});
});

View File

@@ -0,0 +1,22 @@
/// <reference types="cypress" />
context("Window", () => {
beforeEach(() => {
cy.visit("https://example.cypress.io/commands/window");
});
it("cy.window() - get the global window object", () => {
// https://on.cypress.io/window
cy.window().should("have.property", "top");
});
it("cy.document() - get the document object", () => {
// https://on.cypress.io/document
cy.document().should("have.property", "charset").and("eq", "UTF-8");
});
it("cy.title() - get the title", () => {
// https://on.cypress.io/title
cy.title().should("include", "Kitchen Sink");
});
});

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,5 @@
{
"id": 8739,
"name": "Jane",
"email": "jane@example.com"
}

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,22 @@
/// <reference types="cypress" />
// ***********************************************************
// This example plugins/index.jsx can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
/**
* @type {Cypress.PluginConfig}
*/
// eslint-disable-next-line no-unused-vars
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
};

View File

@@ -0,0 +1,27 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import "@testing-library/cypress/add-commands";

View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.jsx is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"allowJs": true,
"baseUrl": "../node_modules",
"types": ["cypress"]
},
"include": ["**/*.*"]
}

View File

@@ -62,20 +62,52 @@
t = d.getElementsByTagName("script")[0];
t.parentNode.insertBefore(s, t);
</script>
<script type="text/javascript">
window.$crisp = [];
window.CRISP_WEBSITE_ID = "36724f62-2eb0-4b29-9cdd-9905fb99913e";
(function () {
d = document;
s = d.createElement("script");
s.src = "https://client.crisp.chat/l.js";
s.async = 1;
d.getElementsByTagName("head")[0].appendChild(s);
<% } %>
<script>
!(function () {
"use strict";
var e = [
"debug",
"destroy",
"do",
"help",
"identify",
"is",
"off",
"on",
"ready",
"render",
"reset",
"safe",
"set"
];
if (window.noticeable) console.warn("Noticeable SDK code snippet loaded more than once");
else {
var n = (window.noticeable = window.noticeable || []);
function t(e) {
return function () {
var t = Array.prototype.slice.call(arguments);
return t.unshift(e), n.push(t), n;
};
}
!(function () {
for (var o = 0; o < e.length; o++) {
var r = e[o];
n[r] = t(r);
}
})(),
(function () {
var e = document.createElement("script");
(e.async = !0), (e.src = "https://sdk.noticeable.io/l.js");
var n = document.head;
n.insertBefore(e, n.firstChild);
})();
}
})();
</script>
<% } %>
<script>!function(w,d,i,s){function l(){if(!d.getElementById(i)){var f=d.getElementsByTagName(s)[0],e=d.createElement(s);e.type="text/javascript",e.async=!0,e.src="https://canny.io/sdk.js",f.parentNode.insertBefore(e,f)}}if("function"!=typeof w.Canny){var c=function(){c.q.push(arguments)};c.q=[],w.Canny=c,"complete"===d.readyState?l():w.attachEvent?w.attachEvent("onload",l):w.addEventListener("load",l,!1)}}(window,document,"canny-jssdk","script");</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

12241
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,87 +2,84 @@
"name": "bodyshop",
"version": "0.2.1",
"engines": {
"node": ">=22.0.0"
"node": ">=18.18.2"
},
"type": "module",
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@ant-design/pro-layout": "^7.22.4",
"@apollo/client": "^3.13.6",
"@ant-design/pro-layout": "^7.19.12",
"@apollo/client": "^3.11.8",
"@emotion/is-prop-valid": "^1.3.1",
"@fingerprintjs/fingerprintjs": "^4.6.1",
"@firebase/analytics": "^0.10.16",
"@firebase/app": "^0.13.1",
"@firebase/auth": "^1.10.6",
"@firebase/firestore": "^4.7.17",
"@firebase/messaging": "^0.12.21",
"@fingerprintjs/fingerprintjs": "^4.5.0",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.8.2",
"@sentry/cli": "^2.47.1",
"@sentry/react": "^9.38.0",
"@sentry/vite-plugin": "^3.5.0",
"@splitsoftware/splitio-react": "^2.3.1",
"@tanem/react-nprogress": "^5.0.53",
"antd": "^5.25.4",
"@reduxjs/toolkit": "^2.2.7",
"@sentry/cli": "^2.36.2",
"@sentry/react": "^7.114.0",
"@splitsoftware/splitio-react": "^1.13.0",
"@tanem/react-nprogress": "^5.0.51",
"@vitejs/plugin-react": "^4.3.1",
"antd": "^5.20.1",
"apollo-link-logger": "^2.0.1",
"apollo-link-sentry": "^4.3.0",
"apollo-link-sentry": "^3.3.0",
"autosize": "^6.0.1",
"axios": "^1.8.4",
"axios": "^1.7.7",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.13",
"dayjs-business-days2": "^1.3.0",
"dayjs-business-days2": "^1.2.2",
"dinero.js": "^1.9.1",
"dotenv": "^16.4.7",
"dotenv": "^16.4.5",
"env-cmd": "^10.1.0",
"exifr": "^7.1.3",
"graphql": "^16.11.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.1.0",
"firebase": "^10.13.2",
"graphql": "^16.9.0",
"i18next": "^23.15.1",
"i18next-browser-languagedetector": "^8.0.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.10",
"logrocket": "^9.0.2",
"markerjs2": "^2.32.4",
"libphonenumber-js": "^1.11.9",
"logrocket": "^8.1.2",
"markerjs2": "^2.32.2",
"memoize-one": "^6.0.0",
"normalize-url": "^8.0.2",
"normalize-url": "^8.0.1",
"object-hash": "^3.0.0",
"phone": "^3.1.59",
"prop-types": "^15.8.1",
"query-string": "^9.2.0",
"query-string": "^9.1.0",
"raf-schd": "^4.0.3",
"react": "^18.3.1",
"react-big-calendar": "^1.19.2",
"react-big-calendar": "^1.14.1",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-cookie": "^7.2.0",
"react-dom": "^18.3.1",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "1.3.4",
"react-i18next": "^15.5.2",
"react-icons": "^5.5.0",
"react-i18next": "^14.1.3",
"react-icons": "^5.3.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
"react-number-format": "^5.4.3",
"react-markdown": "^9.0.1",
"react-number-format": "^5.4.2",
"react-popopo": "^2.1.9",
"react-product-fruits": "^2.2.61",
"react-redux": "^9.2.0",
"react-redux": "^9.1.2",
"react-resizable": "^3.0.5",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.26.2",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.12.8",
"recharts": "^2.15.2",
"react-virtualized": "^9.22.5",
"react-virtuoso": "^4.10.4",
"recharts": "^2.12.7",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
"redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"sass": "^1.89.1",
"socket.io-client": "^4.8.1",
"styled-components": "^6.1.18",
"sass": "^1.79.3",
"socket.io-client": "^4.8.0",
"styled-components": "^6.1.13",
"subscriptions-transport-ws": "^0.11.0",
"use-memo-one": "^1.1.3",
"userpilot": "^1.3.6",
"vite-plugin-ejs": "^1.7.0",
"web-vitals": "^3.5.2"
},
@@ -99,15 +96,11 @@
"build:test:rome": "env-cmd -f .env.test.rome npm run build",
"build:production:imex": "env-cmd -f .env.production.imex npm run build",
"build:production:rome": "env-cmd -f .env.production.rome npm run build",
"test": "cypress open",
"eject": "react-scripts eject",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js",
"test:unit": "vitest run",
"test:watch": "vitest",
"test:e2e:imex": "playwright test --config playwright.config.js",
"test:e2e:rome": "playwright test --config playwright.rome.config.js",
"test:e2e:imex:headed": "playwright test --config playwright.config.js --headed",
"test:e2e:rome:headed": "playwright test --config playwright.rome.config.js --headed",
"test:e2e:report": "playwright show-report"
"sentry:sourcemaps:imex": "sentry-cli sourcemaps inject --org imex --project imexonline ./build && sentry-cli sourcemaps upload --org imex --project imexonline ./build"
},
"browserslist": {
"production": [
@@ -128,40 +121,36 @@
"@rollup/rollup-linux-x64-gnu": "4.6.1"
},
"devDependencies": {
"@ant-design/icons": "^6.0.0",
"@ant-design/icons": "^5.5.1",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.27.1",
"@dotenvx/dotenvx": "^1.47.5",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.31.0",
"@playwright/test": "^1.54.1",
"@sentry/webpack-plugin": "^3.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^4.5.1",
"browserslist": "^4.25.0",
"@babel/preset-react": "^7.24.7",
"@dotenvx/dotenvx": "^1.14.1",
"@emotion/babel-plugin": "^11.12.0",
"@emotion/react": "^11.13.3",
"@eslint/js": "^9.15.0",
"@sentry/webpack-plugin": "^2.22.4",
"@testing-library/cypress": "^10.0.2",
"browserslist": "^4.23.3",
"browserslist-to-esbuild": "^2.1.1",
"chalk": "^5.4.1",
"chalk": "^5.3.0",
"cross-env": "^7.0.3",
"cypress": "^13.14.2",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"memfs": "^4.17.2",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-react": "^7.37.2",
"globals": "^15.12.0",
"memfs": "^4.12.0",
"os-browserify": "^0.3.0",
"playwright": "^1.54.1",
"react-error-overlay": "^6.1.0",
"react-error-overlay": "6.0.11",
"redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.3",
"vite": "^6.3.5",
"vite-plugin-babel": "^1.3.1",
"vite": "^5.4.7",
"vite-plugin-babel": "^1.2.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^1.0.0",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-pwa": "^0.20.5",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^3.2.3",
"workbox-window": "^7.3.0"
"workbox-window": "^7.1.0"
}
}

View File

@@ -1,25 +0,0 @@
import { defineConfig } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config({
path: "./.env.development.imex",
prefix: "TEST_"
});
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "*.e2e.js",
timeout: 60 * 1000,
reporter: [["list"], ["html"]],
use: {
baseURL: "https://localhost:3000",
browser: "chromium",
ignoreHTTPSErrors: true
},
webServer: {
command: "npm run start:imex",
ignoreHTTPSErrors: true,
url: "https://localhost:3000/health", // Health check endpoint will tell us when the server is ready
reuseExistingServer: !process.env.CI // Reuse server locally, not in CI
}
});

View File

@@ -1,25 +0,0 @@
import { defineConfig } from "@playwright/test";
import dotenv from "dotenv";
dotenv.config({
path: "./.env.development.rome",
prefix: "TEST_"
});
export default defineConfig({
testDir: "./tests/e2e",
testMatch: "*.e2e.js",
timeout: 60 * 1000,
reporter: [["list"], ["html"]],
use: {
baseURL: "https://localhost:3000",
browser: "chromium",
ignoreHTTPSErrors: true
},
webServer: {
command: "npm run start:rome",
ignoreHTTPSErrors: true,
url: "https://localhost:3000/health", // Health check endpoint will tell us when the server is ready
reuseExistingServer: !process.env.CI // Reuse server locally, not in CI
}
});

View File

@@ -1,66 +1,53 @@
import { ApolloProvider } from "@apollo/client";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { SplitFactoryProvider, SplitSdk } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import themeProvider from "./themeProvider";
import { CookiesProvider } from "react-cookie";
// Base Split configuration
import themeProvider from "./themeProvider";
import { Userpilot } from "userpilot";
// Initialize Userpilot
if (import.meta.env.DEV) {
Userpilot.initialize("NX-69145f08");
}
const config = {
core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon" // Default key, overridden dynamically by SplitClientProvider
key: "anon"
}
};
// Custom provider to manage the Split client key based on imexshopid from Redux
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); // Access imexshopid from Redux store
const splitClient = useSplitClient({ key: imexshopid || "anon" }); // Use imexshopid or fallback to "anon"
useEffect(() => {
if (splitClient && imexshopid) {
// Log readiness for debugging; no need for ready() since isReady is available
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
export const factory = SplitSdk(config);
function AppContainer() {
const { t } = useTranslation();
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
<App />
</SplitClientProvider>
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
</CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
//componentSize="small"
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={themeProvider}
form={{
validateMessages: {
// eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar />
<SplitFactoryProvider factory={factory}>
<App />
</SplitFactoryProvider>
</ConfigProvider>
</ApolloProvider>
);
}

View File

@@ -1,10 +1,10 @@
import { useSplitClient } from "@splitsoftware/splitio-react";
import { Button, Result } from "antd";
import LogRocket from "logrocket";
import { lazy, Suspense, useEffect, useState } from "react";
import React, { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Route, Routes, useNavigate } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
@@ -21,8 +21,7 @@ import "./App.styles.scss";
import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr";
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
@@ -46,7 +45,6 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
const client = useSplitClient().client;
const [listenersAdded, setListenersAdded] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
if (!navigator.onLine) {
@@ -142,89 +140,89 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
>
<ProductFruitsWrapper
currentUser={currentUser}
bodyshop={bodyshop}
workspaceCode={bodyshop?.tours_enabled ? "9BkbEseqNqxw8jUH" : ""}
workspaceCode={InstanceRenderMgr({
imex: null,
rome: "9BkbEseqNqxw8jUH"
})}
/>
<NotificationProvider>
<Routes>
<Route
path="*"
element={
<ErrorBoundary>
<LandingPage />
</ErrorBoundary>
}
/>
<Route
path="/signin"
element={
<ErrorBoundary>
<SignInPage />
</ErrorBoundary>
}
/>
<Route
path="/resetpassword"
element={
<ErrorBoundary>
<ResetPassword />
</ErrorBoundary>
}
/>
<Route
path="/csi/:surveyId"
element={
<ErrorBoundary>
<CsiPage />
</ErrorBoundary>
}
/>
<Route
path="/disclaimer"
element={
<ErrorBoundary>
<DisclaimerPage />
</ErrorBoundary>
}
/>
<Route
path="/mp/:paymentIs"
element={
<ErrorBoundary>
<MobilePaymentContainer />
</ErrorBoundary>
}
/>
<Route
path="/manage/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<ManagePage />} />
</Route>
<Route
path="/tech/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<TechPageContainer />} />
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
</Routes>
</NotificationProvider>
<Routes>
<Route
path="*"
element={
<ErrorBoundary>
<LandingPage />
</ErrorBoundary>
}
/>
<Route
path="/signin"
element={
<ErrorBoundary>
<SignInPage />
</ErrorBoundary>
}
/>
<Route
path="/resetpassword"
element={
<ErrorBoundary>
<ResetPassword />
</ErrorBoundary>
}
/>
<Route
path="/csi/:surveyId"
element={
<ErrorBoundary>
<CsiPage />
</ErrorBoundary>
}
/>
<Route
path="/disclaimer"
element={
<ErrorBoundary>
<DisclaimerPage />
</ErrorBoundary>
}
/>
<Route
path="/mp/:paymentIs"
element={
<ErrorBoundary>
<MobilePaymentContainer />
</ErrorBoundary>
}
/>
<Route
path="/manage/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<ManagePage />} />
</Route>
<Route
path="/tech/*"
element={
<ErrorBoundary>
<SocketProvider bodyshop={bodyshop}>
<PrivateRoute isAuthorized={currentUser.authorized} />
</SocketProvider>
</ErrorBoundary>
}
>
<Route path="*" element={<TechPageContainer />} />
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
<Route path="*" element={<DocumentEditorContainer />} />
</Route>
</Routes>
</Suspense>
);
}

View File

@@ -5,13 +5,6 @@
border-bottom: 1px solid #74695c !important;
}
// TODO: This was added because the newest release of ant was making the text color and the background color the same on a selected header
// Tried all available tokens (https://ant.design/components/menu?locale=en-US) and even reverted all our custom styles, to no avail
// This should be kept an eye on, especially if implementing DARK MODE
.ant-menu-submenu-title {
color: rgba(255, 255, 255, 0.65) !important;
}
.imex-table-header {
display: flex;
flex-wrap: wrap;
@@ -180,13 +173,3 @@
.muted-button:hover {
color: darkgrey;
}
.notification-alert-unordered-list {
cursor: pointer;
padding: 0;
margin: 0;
.notification-alert-unordered-list-item {
margin-right: 0;
}
}

View File

@@ -1,16 +1,8 @@
import React from "react";
import PropTypes from "prop-types";
import { ProductFruits } from "react-product-fruits";
import dayjs from "dayjs";
const ProductFruitsWrapper = React.memo(({ currentUser, bodyshop, workspaceCode }) => {
const featureProps = bodyshop?.features
? Object.entries(bodyshop.features).reduce((acc, [key, value]) => {
acc[key] = value === true || (typeof value === "string" && dayjs(value).isAfter(dayjs()));
return acc;
}, {})
: {};
import PropTypes from "prop-types";
const ProductFruitsWrapper = React.memo(({ currentUser, workspaceCode }) => {
return (
workspaceCode &&
currentUser?.authorized === true &&
@@ -22,8 +14,7 @@ const ProductFruitsWrapper = React.memo(({ currentUser, bodyshop, workspaceCode
language="en"
user={{
email: currentUser.email,
username: currentUser.email,
props: featureProps
username: currentUser.email
}}
/>
)
@@ -37,6 +28,5 @@ ProductFruitsWrapper.propTypes = {
authorized: PropTypes.bool,
email: PropTypes.string
}),
workspaceCode: PropTypes.string,
bodyshop: PropTypes.object
workspaceCode: PropTypes.string
};

View File

@@ -1,6 +1,6 @@
import { Card, Checkbox, Input, Space, Table } from "antd";
import queryString from "query-string";
import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -16,13 +16,12 @@ import PayableExportAll from "../payable-export-all-button/payable-export-all-bu
import PayableExportButton from "../payable-export-button/payable-export-button.component";
import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import useLocalStorage from "./../../utils/useLocalStorage";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -32,7 +31,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
const { t } = useTranslation();
const [selectedBills, setSelectedBills] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useLocalStorage("accounting-payables-table-state", {
const [state, setState] = useState({
sortedInfo: {},
search: ""
});
@@ -182,7 +181,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedBills(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows) => {
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedBills(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -1,5 +1,5 @@
import { Card, Input, Space, Table } from "antd";
import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -10,7 +10,6 @@ import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter";
import { exportPageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import PaymentExportButton from "../payment-export-button/payment-export-button.component";
@@ -22,7 +21,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
@@ -32,7 +31,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
const { t } = useTranslation();
const [selectedPayments, setSelectedPayments] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useLocalStorage("accounting-payments-table-state", {
const [state, setState] = useState({
sortedInfo: {},
search: ""
});
@@ -86,17 +85,6 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
sortOrder: state.sortedInfo.columnKey === "amount" && state.sortedInfo.order,
render: (text, record) => <CurrencyFormatter>{record.amount}</CurrencyFormatter>
},
{
title: t("payments.fields.type"),
dataIndex: "type",
key: "type",
sorter: (a, b) => a.type.localeCompare(b.type),
sortOrder: state.sortedInfo.columnKey === "type" && state.sortedInfo.order,
filters: bodyshop.md_payment_types.map((s) => {
return { text: s, value: [s] };
}),
onFilter: (value, record) => value.includes(record.type)
},
{
title: t("payments.fields.memo"),
dataIndex: "memo",
@@ -195,7 +183,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedPayments(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows) => {
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedPayments(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -1,5 +1,5 @@
import { Button, Card, Input, Space, Table } from "antd";
import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
@@ -10,7 +10,6 @@ import { exportPageLimit } from "../../utils/config";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter";
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component";
@@ -21,7 +20,7 @@ import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(AccountingReceivablesTableComponent);
@@ -31,7 +30,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
const [selectedJobs, setSelectedJobs] = useState([]);
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useLocalStorage("accounting-receivables-table-state", {
const [state, setState] = useState({
sortedInfo: {},
search: ""
});
@@ -208,7 +207,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) => setSelectedJobs(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows) => {
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedJobs(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Alert component should render Alert component 1`] = `ShallowWrapper {}`;

View File

@@ -0,0 +1,19 @@
import { shallow } from "enzyme";
import React from "react";
import Alert from "./alert.component";
describe("Alert component", () => {
let wrapper;
beforeEach(() => {
const mockProps = {
type: "error",
message: "Test error message."
};
wrapper = shallow(<Alert {...mockProps} />);
});
it("should render Alert component", () => {
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -1,31 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import AlertComponent from "./alert.component";
describe("AlertComponent", () => {
it("renders with default props", () => {
render(<AlertComponent message="Default Alert" />);
expect(screen.getByText("Default Alert")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("ant-alert");
});
it("applies type prop correctly", () => {
render(<AlertComponent message="Success Alert" type="success" />);
const alert = screen.getByRole("alert");
expect(screen.getByText("Success Alert")).toBeInTheDocument();
expect(alert).toHaveClass("ant-alert-success");
});
it("displays description when provided", () => {
render(<AlertComponent message="Error Alert" description="Something went wrong" type="error" />);
expect(screen.getByText("Error Alert")).toBeInTheDocument();
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveClass("ant-alert-error");
});
it("is closable and shows icon when props are set", () => {
render(<AlertComponent message="Warning Alert" type="warning" showIcon closable />);
expect(screen.getByText("Warning Alert")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument(); // Close button
});
});

View File

@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllocationsAssignmentComponent component should create an allocation on save 1`] = `ReactWrapper {}`;
exports[`AllocationsAssignmentComponent component should render AllocationsAssignmentComponent component 1`] = `ReactWrapper {}`;

View File

@@ -0,0 +1,35 @@
import { mount } from "enzyme";
import React from "react";
import { MockBodyshop } from "../../utils/TestingHelpers";
import { AllocationsAssignmentComponent } from "./allocations-assignment.component";
describe("AllocationsAssignmentComponent component", () => {
let wrapper;
beforeEach(() => {
const mockProps = {
bodyshop: MockBodyshop,
handleAssignment: jest.fn(),
assignment: {},
setAssignment: jest.fn(),
visibilityState: [false, jest.fn()],
maxHours: 4
};
wrapper = mount(<AllocationsAssignmentComponent {...mockProps} />);
});
it("should render AllocationsAssignmentComponent component", () => {
expect(wrapper).toMatchSnapshot();
});
it("should render a list of employees", () => {
const empList = wrapper.find("#employeeSelector");
expect(empList.children()).to.have.lengthOf(2);
});
it("should create an allocation on save", () => {
wrapper.find("Button").simulate("click");
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -3,7 +3,7 @@ import AllocationsAssignmentComponent from "./allocations-assignment.component";
import { useMutation } from "@apollo/client";
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { notification } from "antd";
export default function AllocationsAssignmentContainer({ jobLineId, hours, refetch }) {
const visibilityState = useState(false);
@@ -14,7 +14,6 @@ export default function AllocationsAssignmentContainer({ jobLineId, hours, refet
employeeid: null
});
const [insertAllocation] = useMutation(INSERT_ALLOCATION);
const notification = useNotification();
const handleAssignment = () => {
insertAllocation({ variables: { alloc: { ...assignment } } })

View File

@@ -3,7 +3,7 @@ import AllocationsBulkAssignment from "./allocations-bulk-assignment.component";
import { useMutation } from "@apollo/client";
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { notification } from "antd";
export default function AllocationsBulkAssignmentContainer({ jobLines, refetch }) {
const visibilityState = useState(false);
@@ -12,7 +12,6 @@ export default function AllocationsBulkAssignmentContainer({ jobLines, refetch }
employeeid: null
});
const [insertAllocation] = useMutation(INSERT_ALLOCATION);
const notification = useNotification();
const handleAssignment = () => {
const allocs = jobLines.reduce((acc, value) => {

View File

@@ -2,13 +2,12 @@ import React from "react";
import { useMutation } from "@apollo/client";
import { DELETE_ALLOCATION } from "../../graphql/allocations.queries";
import AllocationsLabelComponent from "./allocations-employee-label.component";
import { notification } from "antd";
import { useTranslation } from "react-i18next";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function AllocationsLabelContainer({ allocation, refetch }) {
const [deleteAllocation] = useMutation(DELETE_ALLOCATION);
const { t } = useTranslation();
const notification = useNotification();
const handleClick = (e) => {
e.preventDefault();

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, Popconfirm } from "antd";
import { Button, notification, Popconfirm } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { DELETE_BILL } from "../../graphql/bills.queries";
@@ -9,7 +9,6 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({
@@ -22,7 +21,6 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [deleteBill] = useMutation(DELETE_BILL);
const notification = useNotification();
const handleDelete = async () => {
setLoading(true);

View File

@@ -14,21 +14,8 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "partsOrder"
})
),
insertAuditTrail: ({ jobid, operation, type }) =>
dispatch(
insertAuditTrail({
jobid,
operation,
type
})
)
setPartsOrderContext: (context) => dispatch(setModalContext({ context: context, modal: "partsOrder" })),
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn);
@@ -82,7 +69,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, b
<Modal
open={open}
onCancel={() => setOpen(false)}
destroyOnHidden
destroyOnClose
title={t("bills.actions.return")}
onOk={() => form.submit()}
>

View File

@@ -29,7 +29,7 @@ export default function BillDetailEditcontainer() {
delete search.billid;
history({ search: queryString.stringify(search) });
}}
destroyOnHidden
destroyOnClose
open={search.billid}
>
<BillDetailEditComponent />

View File

@@ -1,12 +1,11 @@
import { useApolloClient, useMutation } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import { Button, Checkbox, Form, Modal, notification, Space } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries";
@@ -25,7 +24,6 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -51,13 +49,12 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const [loading, setLoading] = useState(false);
const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
const notification = useNotification();
const {
treatments: { Enhanced_Payroll, Imgproxy }
treatments: { Enhanced_Payroll }
} = useSplitTreatments({
attributes: {},
names: ["Enhanced_Payroll", "Imgproxy"],
names: ["Enhanced_Payroll"],
splitKey: bodyshop.imexshopid
});
@@ -197,7 +194,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
job: { lbr_adjustments: newAdjustments }
}
});
if (jobUpdate.errors) {
if (!!jobUpdate.errors) {
notification["error"]({
message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors)
@@ -214,7 +211,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
variables: { partsLineIds: markPolReceived.map((p) => p.id) },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
});
if (r2.errors) {
if (!!r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -225,7 +222,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
}
if (r1.errors) {
if (!!r1.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -245,7 +242,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
consumedbybillid: billId
}
});
if (r2.errors) {
if (!!r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
@@ -294,44 +291,23 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
jobid: values.jobid,
invoice_number: remainingValues.invoice_number,
vendorid: remainingValues.vendorid
},
notification
}
});
});
} else {
//Check if using Imgproxy or cloudinary
if (Imgproxy.treatment === "on") {
upload.forEach((u) => {
handleUploadToImageProxy(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
}
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
}
);
});
}
}
///////////////////////////
@@ -349,9 +325,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}
},
{},
"p",
null,
notification
"p"
);
}
@@ -416,7 +390,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()} id="save-bill-enter-modal">
<Button loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
@@ -426,14 +400,13 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
onClick={() => {
setEnterAgain(true);
}}
id="save-and-new-bill-enter-modal"
>
{t("general.actions.saveandnew")}
</Button>
)}
</Space>
}
destroyOnHidden
destroyOnClose
>
<Form
onFinish={handleFinish}

View File

@@ -473,7 +473,7 @@ export function BillEnterModalLinesComponent({
valuePropName: "checked",
initialValue: InstanceRenderManager({
imex: true,
rome: false
rome: false,
}),
name: [field.name, "applicable_taxes", "federal"]
};
@@ -625,15 +625,11 @@ const EditableCell = ({
wrapper,
...restProps
}) => {
const propsFinal = formItemProps && formItemProps(record);
if (propsFinal && "key" in propsFinal) {
delete propsFinal.key;
}
if (additional)
return (
<td {...restProps}>
<div size="small">
<Form.Item name={dataIndex} labelCol={{ span: 0 }} {...propsFinal}>
<Form.Item name={dataIndex} labelCol={{ span: 0 }} {...(formItemProps && formItemProps(record))}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
{additional && additional(record, record.name)}
@@ -644,7 +640,7 @@ const EditableCell = ({
return (
<wrapper>
<td {...restProps}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...(formItemProps && formItemProps(record))}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
</td>
@@ -652,7 +648,7 @@ const EditableCell = ({
);
return (
<td {...restProps}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...propsFinal}>
<Form.Item labelCol={{ span: 0 }} name={dataIndex} {...(formItemProps && formItemProps(record))}>
{(formInput && formInput(record, record.name)) || children}
</Form.Item>
</td>

View File

@@ -1,5 +1,5 @@
import { Select } from "antd";
import { forwardRef } from "react";
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import InstanceRenderMgr from "../../utils/instanceRenderMgr";
@@ -43,7 +43,7 @@ const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps },
item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim(),
label: (
<div style={{ whiteSpace: "normal", wordBreak: "break-word" }}>
<div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
<span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : ""

View File

@@ -1,5 +1,5 @@
import { gql, useMutation } from "@apollo/client";
import { Button } from "antd";
import { Button, notification } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -8,7 +8,6 @@ import { createStructuredSelector } from "reselect";
import { selectAuthLevel, selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -25,7 +24,6 @@ export function BillMarkExportedButton({ currentUser, bodyshop, authLevel, bill
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [insertExportLog] = useMutation(INSERT_EXPORT_LOG);
const notification = useNotification();
const [updateBill] = useMutation(gql`
mutation UPDATE_BILL($billId: uuid!) {

View File

@@ -3,13 +3,11 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function BillPrintButton({ billid }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const Templates = TemplateList("job_special");
const notification = useNotification();
const submitHandler = async () => {
setLoading(true);
@@ -22,9 +20,7 @@ export default function BillPrintButton({ billid }) {
}
},
{},
"p",
null,
notification
"p"
);
} catch (e) {
console.warn("Warning: Error generating a document.");

View File

@@ -1,5 +1,5 @@
import { gql, useMutation } from "@apollo/client";
import { Button } from "antd";
import { Button, notification } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -7,7 +7,6 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -22,7 +21,6 @@ export default connect(mapStateToProps, mapDispatchToProps)(BillMarkForReexportB
export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const notification = useNotification();
const [updateBill] = useMutation(gql`
mutation UPDATE_BILL($billId: uuid!) {

View File

@@ -1,6 +1,6 @@
import { FileAddFilled } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Button, Tooltip } from "antd";
import { Button, notification, Tooltip } from "antd";
import { t } from "i18next";
import dayjs from "./../../utils/day";
import React, { useState } from "react";
@@ -11,7 +11,6 @@ import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selecto
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import queryString from "query-string";
import { useLocation } from "react-router-dom";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -26,7 +25,6 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
const [loading, setLoading] = useState(false);
const { billid } = queryString.parse(useLocation().search);
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const notification = useNotification();
const addToInventory = async () => {
setLoading(true);

View File

@@ -1,6 +1,6 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { FaTasks } from "react-icons/fa";
import { connect } from "react-redux";
@@ -209,7 +209,6 @@ export function BillsListTableComponent({
}
});
}}
id="reconcile-bills-button"
>
<LockerWrapperComponent featureName="bills"> {t("jobs.actions.reconcile")}</LockerWrapperComponent>
</Button>

View File

@@ -9,7 +9,6 @@ import { selectCaBcEtfTableConvert } from "../../redux/modals/modals.selectors";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import CaBcEtfTableModalComponent from "./ca-bc-etf-table.modal.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
caBcEtfTableModal: selectCaBcEtfTableConvert
@@ -26,7 +25,6 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const EtfTemplate = TemplateList("special").ca_bc_etf_table;
const notification = useNotification();
const handleFinish = async (values) => {
logImEXEvent("ca_bc_etf_table_parse");
@@ -55,9 +53,7 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
}
},
{},
values.sendby === "email" ? "e" : "p",
null,
notification
values.sendby === "email" ? "e" : "p"
);
setLoading(false);
};
@@ -75,7 +71,7 @@ export function ContractsFindModalContainer({ caBcEtfTableModal, toggleModalVisi
title={t("payments.labels.findermodal")}
onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()}
destroyOnHidden
destroyOnClose
forceRender
>
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>

View File

@@ -3,7 +3,6 @@ import { Button, Form, InputNumber, Popover, Space } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
export default function CABCpvrtCalculator({ disabled, form }) {
const [visibility, setVisibility] = useState(false);
@@ -40,7 +39,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
);
return (
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Popover destroyTooltipOnHide content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled />
</Button>

View File

@@ -1,6 +1,6 @@
import { CopyFilled, DeleteFilled } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Button, Card, Col, Form, Input, message, Row, Space, Spin, Statistic } from "antd";
import { Button, Card, Col, Form, Input, message, notification, Row, Space, Spin, Statistic } from "antd";
import axios from "axios";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -15,7 +15,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
import { getCurrentUser } from "../../firebase/firebase.utils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment,
@@ -49,7 +48,6 @@ const CardPaymentModalComponent = ({
const [loading, setLoading] = useState(false);
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
const { t } = useTranslation();
const notification = useNotification();
const [, { data, refetch, queryLoading }] = useLazyQuery(QUERY_RO_AND_OWNER_BY_JOB_PKS, {
variables: { jobids: [context.jobid] },
@@ -135,19 +133,15 @@ const CardPaymentModalComponent = ({
if (window.intellipay) {
// eslint-disable-next-line no-eval
eval(response.data);
pollForIntelliPay(() => {
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
});
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
} else {
const rg = document.createRange();
const node = rg.createContextualFragment(response.data);
document.documentElement.appendChild(node);
pollForIntelliPay(() => {
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
window.intellipay.initialize();
});
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
window.intellipay.initialize();
}
} catch (error) {
notification.open({
@@ -351,27 +345,3 @@ const CardPaymentModalComponent = ({
};
export default connect(mapStateToProps, mapDispatchToProps)(CardPaymentModalComponent);
//Poll for window.IntelliPay.fixAmount for 5 seconds. If it doesn't come up, just try anyways to force the possible error.
function pollForIntelliPay(callbackFunction) {
const timeout = 5000;
const interval = 150; // Poll every 100 milliseconds
const startTime = Date.now();
function checkFixAmount() {
if (window.intellipay && window.intellipay.fixAmount !== undefined) {
callbackFunction();
return;
}
if (Date.now() - startTime >= timeout) {
console.log("Stopped polling IntelliPay after 10 seconds. Attemping to set functions anyways.");
callbackFunction();
return;
}
setTimeout(checkFixAmount, interval);
}
checkFixAmount();
}

View File

@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectCardPayment } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CardPaymentModalComponent from "./card-payment-modal.component";
import CardPaymentModalComponent from "./card-payment-modal.component.";
const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment,
@@ -40,7 +40,7 @@ function CardPaymentModalContainer({ cardPaymentModal, toggleModalVisible, bodys
</Button>
]}
width="80%"
destroyOnHidden
destroyOnClose
>
<CardPaymentModalComponent />
</Modal>

View File

@@ -1,18 +1,18 @@
import { useApolloClient } from "@apollo/client";
import { getToken } from "@firebase/messaging";
import axios from "axios";
import { useEffect } from "react";
import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss";
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
export function ChatAffixContainer({ bodyshop, chatVisible }) {
const { t } = useTranslation();
const client = useApolloClient();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;
@@ -34,14 +34,16 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
SubscribeToTopicForFCMNotification();
// Register WebSocket handlers
//Register WS handlers
if (socket && socket.connected) {
registerMessagingHandlers({ socket, client });
return () => {
unregisterMessagingHandlers({ socket });
};
}
return () => {
if (socket && socket.connected) {
unregisterMessagingHandlers({ socket });
}
};
}, [bodyshop, socket, t, client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;

View File

@@ -202,6 +202,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
text: message.text
};
// Add cases for other known message types as needed
default:
// Log a warning for unhandled message types
logLocal("handleMessageChanged - Unhandled message type", { type: message.type });
@@ -209,7 +211,7 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
}
return messageRef;
return messageRef; // Keep other messages unchanged
});
}
}
@@ -243,8 +245,11 @@ export const registerMessagingHandlers = ({ socket, client }) => {
});
const updatedList = existingList?.conversations
? [newConversation, ...existingList.conversations.filter((conv) => conv.id !== newConversation.id)]
: [newConversation]; // Prevent duplicates
? [
newConversation,
...existingList.conversations.filter((conv) => conv.id !== newConversation.id) // Prevent duplicates
]
: [newConversation];
client.cache.writeQuery({
query: CONVERSATION_LIST_QUERY,
@@ -398,7 +403,6 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
break;
default:
logLocal("handleConversationChanged - Unhandled type", { type });
client.cache.modify({
@@ -415,95 +419,10 @@ export const registerMessagingHandlers = ({ socket, client }) => {
}
};
// Existing handler for phone number opt-out
const handlePhoneNumberOptedOut = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedOut - Start", data);
try {
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
const phoneNumberExists = existing.some(
(ref) => readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid
);
if (phoneNumberExists) {
logLocal("handlePhoneNumberOptedOut - Phone number already in cache", { phone_number, bodyshopid });
return existing;
}
const newOptOut = {
__typename: "phone_number_opt_out",
id: `temporary-${phone_number}-${Date.now()}`,
bodyshopid,
phone_number,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return [...existing, newOptOut];
}
},
broadcast: true
});
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedOut - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-out:", error);
logLocal("handlePhoneNumberOptedOut - Error", { error: error.message });
}
};
// New handler for phone number opt-in
const handlePhoneNumberOptedIn = async (data) => {
const { bodyshopid, phone_number } = data;
logLocal("handlePhoneNumberOptedIn - Start", data);
try {
// Update the Apollo cache for GET_PHONE_NUMBER_OPT_OUTS by removing the phone number
client.cache.modify({
id: "ROOT_QUERY",
fields: {
phone_number_opt_out(existing = [], { readField }) {
// Filter out the phone number from the opt-out list
return existing.filter(
(ref) => !(readField("phone_number", ref) === phone_number && readField("bodyshopid", ref) === bodyshopid)
);
}
},
broadcast: true // Trigger UI updates
});
// Evict the cache entry to force a refetch on next query
client.cache.evict({
id: "ROOT_QUERY",
fieldName: "phone_number_opt_out",
args: { bodyshopid, search: phone_number }
});
client.cache.gc();
logLocal("handlePhoneNumberOptedIn - Cache updated successfully", data);
} catch (error) {
console.error("Error updating cache for phone number opt-in:", error);
logLocal("handlePhoneNumberOptedIn - Error", { error: error.message });
}
};
socket.on("new-message-summary", handleNewMessageSummary);
socket.on("new-message-detailed", handleNewMessageDetailed);
socket.on("message-changed", handleMessageChanged);
socket.on("conversation-changed", handleConversationChanged);
socket.on("phone-number-opted-out", handlePhoneNumberOptedOut);
socket.on("phone-number-opted-in", handlePhoneNumberOptedIn);
};
export const unregisterMessagingHandlers = ({ socket }) => {
@@ -512,6 +431,4 @@ export const unregisterMessagingHandlers = ({ socket }) => {
socket.off("new-message-detailed");
socket.off("message-changed");
socket.off("conversation-changed");
socket.off("phone-number-opted-out");
socket.off("phone-number-opted-in");
};

View File

@@ -1,12 +1,12 @@
import { useMutation } from "@apollo/client";
import { Button } from "antd";
import { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -18,7 +18,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleToggleArchive = async () => {
setLoading(true);

View File

@@ -1,5 +1,5 @@
import { Badge, Card, List, Space, Tag, Tooltip } from "antd";
import { useEffect, useMemo, useState } from "react";
import { Badge, Card, List, Space, Tag } from "antd";
import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import { Virtuoso } from "react-virtuoso";
import { createStructuredSelector } from "reselect";
@@ -9,62 +9,36 @@ import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter";
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import "./chat-conversation-list.styles.scss";
import { useQuery } from "@apollo/client";
import { GET_PHONE_NUMBER_OPT_OUTS } from "../../graphql/phone-number-opt-out.queries.js";
import { phone } from "phone";
import { useTranslation } from "react-i18next";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop
selectedConversation: selectSelectedConversation
});
const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => dispatch(setSelectedConversation(conversationId))
});
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation, bodyshop }) {
const { t } = useTranslation();
function ChatConversationListComponent({ conversationList, selectedConversation, setSelectedConversation }) {
// That comma is there for a reason, do not remove it
const [, forceUpdate] = useState(false);
const phoneNumbers = conversationList.map((item) => phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, ""));
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUTS, {
variables: {
bodyshopid: bodyshop.id,
phone_numbers: phoneNumbers
},
skip: !conversationList.length,
fetchPolicy: "cache-and-network"
});
const optOutMap = useMemo(() => {
const map = new Map();
optOutData?.phone_number_opt_out?.forEach((optOut) => {
map.set(optOut.phone_number, true);
});
return map;
}, [optOutData?.phone_number_opt_out]);
// Re-render every minute
useEffect(() => {
const interval = setInterval(() => {
forceUpdate((prev) => !prev);
}, 60000);
return () => clearInterval(interval);
forceUpdate((prev) => !prev); // Toggle state to trigger re-render
}, 60000); // 1 minute in milliseconds
return () => clearInterval(interval); // Cleanup on unmount
}, []);
const sortedConversationList = useMemo(() => {
// Memoize the sorted conversation list
const sortedConversationList = React.useMemo(() => {
return _.orderBy(conversationList, ["updated_at"], ["desc"]);
}, [conversationList]);
const renderConversation = (index, t) => {
const renderConversation = (index) => {
const item = sortedConversationList[index];
const normalizedPhone = phone(item.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const hasOptOutEntry = optOutMap.has(normalizedPhone);
const cardContentRight = <TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft =
item.job_conversations.length > 0
@@ -86,18 +60,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
</>
);
const cardExtra = (
<>
<Badge count={item.messages_aggregate.aggregate.count} />
{hasOptOutEntry && (
<Tooltip title={t("consent.text_body")}>
<Tag color="red" icon={<ExclamationCircleOutlined />}>
{t("messaging.labels.no_consent")}
</Tag>
</Tooltip>
)}
</>
);
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count} />;
const getCardStyle = () =>
item.id === selectedConversation
@@ -110,25 +73,9 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<div
style={{
display: "inline-block",
width: "70%",
textAlign: "left"
}}
>
{cardContentLeft}
</div>
<div
style={{
display: "inline-block",
width: "30%",
textAlign: "right"
}}
>
{cardContentRight}
</div>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>
</List.Item>
);
@@ -138,7 +85,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
<div className="chat-list-container">
<Virtuoso
data={sortedConversationList}
itemContent={(index) => renderConversation(index, t)}
itemContent={(index) => renderConversation(index)}
style={{ height: "100%", width: "100%" }}
/>
</div>

View File

@@ -24,7 +24,7 @@
/* Add spacing and better alignment for items */
.chat-list-item {
padding: 0.2rem 0; /* Add spacing between list items */
padding: 0.5rem 0; /* Add spacing between list items */
.ant-card {
border-radius: 8px; /* Slight rounding for card edges */

View File

@@ -1,13 +1,14 @@
import { useMutation } from "@apollo/client";
import { Tag } from "antd";
import React, { useContext } from "react";
import { Link } from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -17,7 +18,7 @@ const mapDispatchToProps = () => ({});
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleRemoveTag = async (jobId) => {
const convId = jobConversations[0].conversationid;

View File

@@ -1,13 +1,13 @@
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext";
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatConversationComponent from "./chat-conversation.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
function ChatConversationContainer({ bodyshop, selectedConversation }) {
const client = useApolloClient();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
// Fetch conversation details
@@ -58,7 +58,6 @@ function ChatConversationContainer({ bodyshop, selectedConversation }) {
userid
created_at
read
is_system
}
`,
data: message

View File

@@ -1,14 +1,13 @@
import { PlusOutlined } from "@ant-design/icons";
import { useMutation } from "@apollo/client";
import { Input, Spin, Tag, Tooltip } from "antd";
import { useState } from "react";
import { Input, notification, Spin, Tag, Tooltip } from "antd";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -20,8 +19,7 @@ export function ChatLabel({ conversation, bodyshop }) {
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const { socket } = useSocket();
const notification = useNotification();
const { socket } = useContext(SocketContext);
const { t } = useTranslation();
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);

View File

@@ -1,8 +1,7 @@
import { PictureFilled } from "@ant-design/icons";
import { useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Badge, Popover } from "antd";
import { useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -10,36 +9,28 @@ import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-media-selector.styles.scss";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({});
const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, conversation }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const {
treatments: { Imgproxy }
} = useSplitTreatments({
attributes: {},
names: ["Imgproxy"],
splitKey: bodyshop && bodyshop.imexshopid
});
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
variables: {
jobId: conversation.job_conversations[0]?.jobid
jobId: conversation.job_conversations[0] && conversation.job_conversations[0].jobid
},
skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
});
@@ -51,48 +42,24 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
setSelectedMedia([]);
}, [setSelectedMedia, conversation]);
//Knowingly taking on the technical debt of poor implementation below. Done this way to avoid an edge case where no component may be displayed.
//Cloudinary will be removed once the migration is completed.
//If Imageproxy is on, rely only on the LMS selector
//If not on, use the old methods.
const content = (
<div className="media-selector-content">
<div>
{loading && <LoadingSpinner />}
{error && <AlertComponent message={error.message} type="error" />}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div className="error-message">{t("messaging.labels.maxtenimages")}</div>
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div>
) : null}
{Imgproxy.treatment === "on" ? (
<>
{!bodyshop.uselocalmediaserver && (
<JobsDocumentImgproxyGalleryExternal
jobId={conversation.job_conversations[0]?.jobid}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0]?.jobid}
/>
)}
</>
) : (
<>
{!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal
data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0]?.jobid}
/>
)}
</>
{!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal
data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]}
/>
)}
{bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
/>
)}
</div>
);
@@ -100,17 +67,12 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
return (
<Popover
content={
conversation.job_conversations.length === 0 ? (
<div className="no-jobs-message">{t("messaging.errors.noattachedjobs")}</div>
) : (
content
)
conversation.job_conversations.length === 0 ? <div>{t("messaging.errors.noattachedjobs")}</div> : content
}
title={t("messaging.labels.selectmedia")}
trigger="click"
open={open}
onOpenChange={handleVisibleChange}
classNames={{ root: "media-selector-popover" }}
>
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} />

View File

@@ -1,48 +0,0 @@
.media-selector-popover {
.ant-popover-inner-content {
position: relative;
max-width: 640px;
max-height: 480px;
overflow-y: auto;
padding: 8px;
background-color: #fff;
border-radius: 8px;
}
}
.media-selector-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.error-message {
color: red;
font-size: 12px;
text-align: center;
margin-bottom: 8px;
}
.no-jobs-message {
font-size: 14px;
color: #888;
text-align: center;
padding: 8px;
}
/* Style images within gallery components */
.media-selector-content img {
object-fit: cover;
border-radius: 4px;
margin: 4px;
cursor: pointer;
}
/* Grid layout for gallery components */
.media-selector-content .ant-image, /* Assuming gallery components use Ant Design's Image */
.media-selector-content .gallery-container { /* Fallback for custom gallery classes */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
}

View File

@@ -4,16 +4,13 @@
height: 100%;
width: 100%;
}
.archive-button {
height: 20px;
border-radius: 4px;
}
.chat-title {
margin-bottom: 5px;
}
.messages {
display: flex;
flex-direction: column;
@@ -40,13 +37,11 @@
gap: 8px;
}
}
.chat-send-message-button {
.chat-send-message-button{
margin: 0.3rem;
padding-left: 0.5rem;
}
.message-icon {
position: absolute;
bottom: 0.1rem;
@@ -130,37 +125,6 @@
}
}
.system {
align-items: center;
margin: 0.5rem 10%;
.message {
background-color: #f5f5f5;
border-radius: 10px;
padding: 0.5rem 1rem;
text-align: center;
font-style: italic;
color: #555;
width: fit-content;
max-width: 80%;
}
.system-label {
font-size: 0.75rem;
color: #888;
margin-bottom: 0.2rem;
display: block;
}
.system-date {
font-size: 0.75rem;
color: #888;
margin-top: 0.2rem;
text-align: center;
}
}
.virtuoso-container {
flex: 1;
overflow: auto;

View File

@@ -2,29 +2,17 @@ import Icon from "@ant-design/icons";
import { Tooltip } from "antd";
import i18n from "i18next";
import dayjs from "../../utils/day";
import { MdClose, MdDone, MdDoneAll } from "react-icons/md";
import { MdDone, MdDoneAll } from "react-icons/md";
import { DateTimeFormatter } from "../../utils/DateFormatter";
export const renderMessage = (messages, index) => {
const message = messages[index];
const isSystem = message.is_system;
// Determine message class
const messageClass = isSystem ? "system messages" : message.isoutbound ? "mine messages" : "yours messages";
// Tooltip content based on message type
const tooltipTitle = isSystem ? (
i18n.t("consent.text_body")
) : (
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
);
return (
<div key={index} className={messageClass}>
<div key={index} className={`${message.isoutbound ? "mine messages" : "yours messages"}`}>
<div className="message msgmargin">
<Tooltip title={tooltipTitle}>
<Tooltip title={DateTimeFormatter({ children: message.created_at })}>
<div>
{isSystem && <span className="system-label">System</span>}
{/* Render images if available */}
{message.image && message.image_path?.length > 0 && (
<div className="message-images">
@@ -38,31 +26,20 @@ export const renderMessage = (messages, index) => {
</div>
)}
{/* Render text if available */}
{message.text && <div className="message-text">{message.text}</div>}
{/* Render date for system messages */}
{isSystem && (
<div className="system-date">
<DateTimeFormatter>{message.created_at}</DateTimeFormatter>
</div>
)}
{message.text && <div>{message.text}</div>}
</div>
</Tooltip>
{/* Message status icons for non-system messages */}
{!isSystem &&
message.status &&
(message.status === "sent" || message.status === "delivered" || message.status === "failed") && (
<div className="message-status">
<Icon
component={message.status === "sent" ? MdDone : message.status === "delivered" ? MdDoneAll : MdClose}
className="message-icon"
style={message.status === "failed" ? { color: "#ff0000" } : undefined}
/>
</div>
)}
{/* Message status icons */}
{message.status && (message.status === "sent" || message.status === "delivered") && (
<div className="message-status">
<Icon component={message.status === "sent" ? MdDone : MdDoneAll} className="message-icon" />
</div>
)}
</div>
{/* Outbound message metadata for non-system messages */}
{!isSystem && message.isoutbound && (
{/* Outbound message metadata */}
{message.isoutbound && (
<div style={{ fontSize: 10 }}>
{i18n.t("messaging.labels.sentby", {
by: message.userid,

View File

@@ -1,11 +1,12 @@
import { PlusCircleFilled } from "@ant-design/icons";
import { Button, Form, Popover } from "antd";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -17,7 +18,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatNewConversation({ openChatByPhone }) {
const { t } = useTranslation();
const [form] = Form.useForm();
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber, socket });

View File

@@ -1,4 +1,6 @@
import { notification } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import React, { useContext } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
@@ -7,8 +9,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -21,8 +22,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
const { t } = useTranslation();
const { socket } = useSocket();
const notification = useNotification();
const { socket } = useContext(SocketContext);
if (!phone) return <></>;

View File

@@ -1,7 +1,7 @@
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import { useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -12,9 +12,8 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation,
@@ -28,7 +27,7 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
const { t } = useTranslation();
const [pollInterval, setPollInterval] = useState(0);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const client = useApolloClient(); // Apollo Client instance for cache operations
// Lazy query for conversations
@@ -43,7 +42,8 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
pollInterval: 60 * 1000 // TODO: This is a fix for now, should be coming from sockets
skip: chatVisible, // Skip when chat is visible
...(pollInterval > 0 ? { pollInterval } : {})
});
// Socket connection status
@@ -85,25 +85,29 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
// Get unread count from the cache
const unreadCount = (() => {
try {
const cachedData = client.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
if (chatVisible) {
try {
const cachedData = client.readQuery({
query: CONVERSATION_LIST_QUERY,
variables: { offset: 0 }
});
if (!cachedData?.conversations) {
return unreadData?.messages_aggregate?.aggregate?.count;
if (!cachedData?.conversations) return 0;
// Aggregate unread message count
return cachedData.conversations.reduce((total, conversation) => {
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
return total + unread;
}, 0);
} catch (error) {
console.warn("Unread count not found in cache:", error);
return 0; // Fallback if not in cache
}
// Aggregate unread message count
return cachedData.conversations.reduce((total, conversation) => {
const unread = conversation.messages_aggregate?.aggregate?.count || 0;
return total + unread;
}, 0);
} catch (error) {
console.warn("Unread count not found in cache:", error);
return 0; // Fallback if not in cache
} else if (unreadData?.messages_aggregate?.aggregate?.count) {
// Use the unread count from the query result
return unreadData.messages_aggregate.aggregate.count;
}
return 0;
})();
return (

View File

@@ -6,7 +6,6 @@ import { createStructuredSelector } from "reselect";
import { setEmailOptions } from "../../redux/email/email.actions";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({});
@@ -16,7 +15,6 @@ const mapDispatchToProps = (dispatch) => ({
export function ChatPrintButton({ conversation }) {
const [loading, setLoading] = useState(false);
const notification = useNotification();
const generateDocument = (type) => {
setLoading(true);
@@ -29,8 +27,7 @@ export function ChatPrintButton({ conversation }) {
subject: TemplateList("messaging").conversation_list.subject
},
type,
conversation.id,
notification
conversation.id
).catch((e) => {
console.warn("Something went wrong generating a document.");
});

View File

@@ -1,6 +1,6 @@
import { ExclamationCircleOutlined, LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Alert, Input, Space, Spin, Tooltip } from "antd";
import { useEffect, useRef, useState } from "react";
import { LoadingOutlined, SendOutlined } from "@ant-design/icons";
import { Input, Spin } from "antd";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -10,9 +10,6 @@ import { selectIsSending, selectMessage } from "../../redux/messaging/messaging.
import { selectBodyshop } from "../../redux/user/user.selectors";
import ChatMediaSelector from "../chat-media-selector/chat-media-selector.component";
import ChatPresetsComponent from "../chat-presets/chat-presets.component";
import { useQuery } from "@apollo/client";
import { phone } from "phone";
import { GET_PHONE_NUMBER_OPT_OUT } from "../../graphql/phone-number-opt-out.queries";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -28,24 +25,16 @@ const mapDispatchToProps = (dispatch) => ({
function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSending, message, setMessage }) {
const inputArea = useRef(null);
const [selectedMedia, setSelectedMedia] = useState([]);
const { t } = useTranslation();
const normalizedPhone = phone(conversation.phone_num, "CA").phoneNumber.replace(/^\+1/, "");
const { data: optOutData } = useQuery(GET_PHONE_NUMBER_OPT_OUT, {
variables: { bodyshopid: bodyshop.id, phone_number: normalizedPhone },
fetchPolicy: "cache-and-network"
});
const isOptedOut = !!optOutData?.phone_number_opt_out?.[0];
useEffect(() => {
inputArea.current.focus();
}, [isSending, setMessage]);
const { t } = useTranslation();
const handleEnter = () => {
const selectedImages = selectedMedia.filter((i) => i.isSelected);
if ((message === "" || !message) && selectedImages.length === 0) return;
if (isOptedOut) return; // Prevent sending if phone number is opted out
logImEXEvent("messaging_send_message");
if (selectedImages.length < 11) {
@@ -55,8 +44,7 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
messagingServiceSid: bodyshop.messagingservicesid,
conversationid: conversation.id,
selectedMedia: selectedImages,
imexshopid: bodyshop.imexshopid,
bodyshopid: bodyshop.id
imexshopid: bodyshop.imexshopid
};
sendMessage(newMessage);
setSelectedMedia(
@@ -68,67 +56,47 @@ function ChatSendMessageComponent({ conversation, bodyshop, sendMessage, isSendi
};
return (
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{isOptedOut && (
<Tooltip title={t("consent.text_body")}>
<Alert
showIcon={true}
icon={<ExclamationCircleOutlined />}
message={t("messaging.errors.no_consent")}
type="error"
/>
</Tooltip>
)}
<div className="imex-flex-row" style={{ width: "100%" }}>
{!isOptedOut && (
<>
<ChatPresetsComponent disabled={isSending} className="imex-flex-row__margin" />
<ChatMediaSelector
disabled={isSending}
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
</>
)}
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending || isOptedOut}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!event.shiftKey && !isOptedOut) handleEnter();
}}
/>
</span>
{!isOptedOut && (
<SendOutlined
className="chat-send-message-button"
disabled={isSending || message === "" || !message}
onClick={handleEnter}
/>
)}
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/>
}
<div className="imex-flex-row" style={{ width: "100%" }}>
<ChatPresetsComponent className="imex-flex-row__margin" />
<ChatMediaSelector
conversation={conversation}
selectedMedia={selectedMedia}
setSelectedMedia={setSelectedMedia}
/>
<span style={{ flex: 1 }}>
<Input.TextArea
className="imex-flex-row__margin imex-flex-row__grow"
allowClear
autoFocus
ref={inputArea}
autoSize={{ minRows: 1, maxRows: 4 }}
value={message}
disabled={isSending}
placeholder={t("messaging.labels.typeamessage")}
onChange={(e) => setMessage(e.target.value)}
onPressEnter={(event) => {
event.preventDefault();
if (!!!event.shiftKey) handleEnter();
}}
/>
</div>
</Space>
</span>
<SendOutlined
className="chat-send-message-button"
// disabled={message === "" || !message}
onClick={handleEnter}
/>
<Spin
style={{ display: `${isSending ? "" : "none"}` }}
indicator={
<LoadingOutlined
style={{
fontSize: 24
}}
spin
/>
}
/>
</div>
);
}

View File

@@ -2,16 +2,16 @@ import { PlusOutlined } from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client";
import { Tag } from "antd";
import _ from "lodash";
import { useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
import ChatTagRo from "./chat-tag-ro.component";
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors.js";
import { connect } from "react-redux";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -22,7 +22,7 @@ const mapDispatchToProps = () => ({});
export function ChatTagRoContainer({ conversation, bodyshop }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { socket } = useSocket();
const { socket } = useContext(SocketContext);
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@apollo/client";
import { Button, Form, InputNumber, Popover, Radio, Select, Space } from "antd";
import { Button, Form, InputNumber, notification, Popover, Radio, Select, Space } from "antd";
import axios from "axios";
import dayjs from "../../utils/day";
import React, { useState } from "react";
@@ -9,7 +9,6 @@ import { useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -26,7 +25,6 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
const [loading, setLoading] = useState(false);
const [insertJob] = useMutation(INSERT_NEW_JOB);
const history = useNavigate();
const notification = useNotification();
const handleFinish = async (values) => {
setLoading(true);

View File

@@ -1,14 +1,12 @@
import { useLazyQuery } from "@apollo/client";
import { Button } from "antd";
import { Button, notification } from "antd";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { GET_JOB_FOR_CC_CONTRACT } from "../../graphql/jobs.queries";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
export default function ContractCreateJobPrefillComponent({ jobId, form }) {
const [call, { loading, error, data }] = useLazyQuery(GET_JOB_FOR_CC_CONTRACT);
const { t } = useTranslation();
const notification = useNotification();
const handleClick = () => {
call({ variables: { id: jobId } });

View File

@@ -1,10 +1,10 @@
import { forwardRef, useEffect, useState } from "react";
import React, { forwardRef, useEffect, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
const { Option } = Select;
const ContractStatusComponent = ({ value, onChange }) => {
const ContractStatusComponent = ({ value, onChange }, ref) => {
const [option, setOption] = useState(value);
const { t } = useTranslation();

View File

@@ -63,7 +63,7 @@ export function ContractsFindModalContainer({
title={t("contracts.labels.findermodal")}
onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()}
destroyOnHidden
destroyOnClose
forceRender
>
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish}>

View File

@@ -1,5 +1,5 @@
import { Slider } from "antd";
import { forwardRef } from "react";
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
const CourtesyCarFuelComponent = (props, ref) => {

View File

@@ -1,4 +1,4 @@
import { Form, Modal } from "antd";
import { Form, Modal, notification } from "antd";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
@@ -10,7 +10,6 @@ import CourtesyCarReturnModalComponent from "./courtesy-car-return-modal.compone
import dayjs from "../../utils/day";
import { RETURN_CONTRACT } from "../../graphql/cccontracts.queries";
import { useMutation } from "@apollo/client";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({
courtesyCarReturnModal: selectCourtesyCarReturn,
@@ -27,8 +26,6 @@ export function CCReturnModalContainer({ courtesyCarReturnModal, toggleModalVisi
const { t } = useTranslation();
const [form] = Form.useForm();
const [updateContract] = useMutation(RETURN_CONTRACT);
const notification = useNotification();
const handleFinish = (values) => {
setLoading(true);
updateContract({

Some files were not shown because too many files have changed in this diff Show More