feature/IO-3092-imgproxy - Merge release to take care of PR conflicts.
This commit is contained in:
@@ -88,7 +88,7 @@ jobs:
|
|||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
command: npm i
|
command: npm i
|
||||||
|
|
||||||
- run: npm run build:production:imex
|
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:imex
|
||||||
|
|
||||||
- aws-cli/setup:
|
- aws-cli/setup:
|
||||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
rome-app-build:
|
rome-app-build:
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/node:22.13.1
|
- image: cimg/node:22.13.1
|
||||||
|
resource_class: large
|
||||||
working_directory: ~/repo/client
|
working_directory: ~/repo/client
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -161,7 +161,7 @@ jobs:
|
|||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
command: npm i
|
command: npm i
|
||||||
|
|
||||||
- run: npm run build:production:rome
|
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:production:rome
|
||||||
|
|
||||||
- aws-cli/setup:
|
- aws-cli/setup:
|
||||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||||
@@ -209,7 +209,7 @@ jobs:
|
|||||||
test-rome-app-build:
|
test-rome-app-build:
|
||||||
docker:
|
docker:
|
||||||
- image: cimg/node:22.13.1
|
- image: cimg/node:22.13.1
|
||||||
|
resource_class: large
|
||||||
working_directory: ~/repo/client
|
working_directory: ~/repo/client
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -219,7 +219,7 @@ jobs:
|
|||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
command: npm i
|
command: npm i
|
||||||
|
|
||||||
- run: npm run build:test:rome
|
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:rome
|
||||||
|
|
||||||
- aws-cli/setup:
|
- aws-cli/setup:
|
||||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||||
@@ -277,7 +277,7 @@ jobs:
|
|||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
command: npm i
|
command: npm i
|
||||||
|
|
||||||
- run: npm run build:test:imex
|
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
|
||||||
|
|
||||||
- aws-s3/sync:
|
- aws-s3/sync:
|
||||||
from: build
|
from: build
|
||||||
@@ -298,7 +298,7 @@ jobs:
|
|||||||
name: Install Dependencies
|
name: Install Dependencies
|
||||||
command: npm i
|
command: npm i
|
||||||
|
|
||||||
- run: npm run build:test:imex
|
- run: NODE_OPTIONS=--max-old-space-size=8192 npm run build:test:imex
|
||||||
|
|
||||||
- aws-cli/setup:
|
- aws-cli/setup:
|
||||||
aws_access_key_id: AWS_ACCESS_KEY_ID
|
aws_access_key_id: AWS_ACCESS_KEY_ID
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
|
|||||||
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BG3tzU7L2BXlGZ_3VLK4PNaRceoEXEnmHfxcVbRMF5o5g05ejslhVPki9kBM9cBBT-08Ad9kN3HSpS6JmrWD6h4'
|
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BG3tzU7L2BXlGZ_3VLK4PNaRceoEXEnmHfxcVbRMF5o5g05ejslhVPki9kBM9cBBT-08Ad9kN3HSpS6JmrWD6h4'
|
||||||
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
||||||
VITE_APP_AXIOS_BASE_API_URL=/api/
|
VITE_APP_AXIOS_BASE_API_URL=/api/
|
||||||
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
|
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
|
||||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||||
VITE_APP_INSTANCE=IMEX
|
VITE_APP_INSTANCE=IMEX
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ VITE_APP_CLOUDINARY_THUMB_TRANSFORMATIONS=c_fill,h_250,w_250
|
|||||||
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BP1B7ZTYpn-KMt6nOxlld6aS8Skt3Q7ZLEqP0hAvGHxG4UojPYiXZ6kPlzZkUC5jH-EcWXomTLtmadAIxurfcHo'
|
VITE_APP_FIREBASE_PUBLIC_VAPID_KEY='BP1B7ZTYpn-KMt6nOxlld6aS8Skt3Q7ZLEqP0hAvGHxG4UojPYiXZ6kPlzZkUC5jH-EcWXomTLtmadAIxurfcHo'
|
||||||
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
||||||
VITE_APP_AXIOS_BASE_API_URL=/api/
|
VITE_APP_AXIOS_BASE_API_URL=/api/
|
||||||
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
|
VITE_APP_REPORTS_SERVER_URL=https://reports.test.romeonline.io
|
||||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||||
VITE_APP_COUNTRY=USA
|
VITE_APP_COUNTRY=USA
|
||||||
VITE_APP_INSTANCE=ROME
|
VITE_APP_INSTANCE=ROME
|
||||||
|
|||||||
@@ -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_FIREBASE_PUBLIC_VAPID_KEY='BN2GcDPjipR5MTEosO5dT4CfQ3cmrdBIsI4juoOQrRijn_5aRiHlwj1mlq0W145mOusx6xynEKl_tvYJhpCc9lo'
|
||||||
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
VITE_APP_STRIPE_PUBLIC_KEY=pk_test_51GqB4TJl3nQjrZ0wCQWAxAhlNF8jKe0tipIa6ExBaxwJGitwvFsIZUEua4dUzaMIAuXp4qwYHXx7lgjyQSwP0Pe900vzm38C7g
|
||||||
VITE_APP_AXIOS_BASE_API_URL=https://api.test.imex.online/
|
VITE_APP_AXIOS_BASE_API_URL=https://api.test.imex.online/
|
||||||
VITE_APP_REPORTS_SERVER_URL=https://reports3.test.imex.online
|
VITE_APP_REPORTS_SERVER_URL=https://reports.test.imex.online
|
||||||
VITE_APP_IS_TEST=true
|
VITE_APP_IS_TEST=true
|
||||||
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
VITE_APP_SPLIT_API=ts615lqgnmk84thn72uk18uu5pgce6e0l4rc
|
||||||
VITE_APP_INSTANCE=IMEX
|
VITE_APP_INSTANCE=IMEX
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/// <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 ==== */
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
/// <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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
/// <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-element’s-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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/// <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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/// <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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/// <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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
/// <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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
/// <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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/// <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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/// <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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Using fixtures to represent data",
|
|
||||||
"email": "hello@cypress.io",
|
|
||||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"id": 8739,
|
|
||||||
"name": "Jane",
|
|
||||||
"email": "jane@example.com"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/// <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
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// ***********************************************
|
|
||||||
// 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";
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// ***********************************************************
|
|
||||||
// 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')
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"baseUrl": "../node_modules",
|
|
||||||
"types": ["cypress"]
|
|
||||||
},
|
|
||||||
"include": ["**/*.*"]
|
|
||||||
}
|
|
||||||
3574
client/package-lock.json
generated
3574
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,44 +2,49 @@
|
|||||||
"name": "bodyshop",
|
"name": "bodyshop",
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.18.2"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"proxy": "http://localhost:4000",
|
"proxy": "http://localhost:4000",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/pro-layout": "^7.22.0",
|
"@ant-design/pro-layout": "^7.22.3",
|
||||||
"@apollo/client": "^3.12.6",
|
"@apollo/client": "^3.13.5",
|
||||||
"@emotion/is-prop-valid": "^1.3.1",
|
"@emotion/is-prop-valid": "^1.3.1",
|
||||||
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
"@fingerprintjs/fingerprintjs": "^4.6.1",
|
||||||
|
"@firebase/analytics": "^0.10.12",
|
||||||
|
"@firebase/app": "^0.11.3",
|
||||||
|
"@firebase/auth": "^1.9.1",
|
||||||
|
"@firebase/firestore": "^4.7.10",
|
||||||
|
"@firebase/messaging": "^0.12.17",
|
||||||
"@jsreport/browser-client": "^3.1.0",
|
"@jsreport/browser-client": "^3.1.0",
|
||||||
"@reduxjs/toolkit": "^2.5.0",
|
"@reduxjs/toolkit": "^2.6.1",
|
||||||
"@sentry/cli": "^2.40.0",
|
"@sentry/cli": "^2.42.4",
|
||||||
"@sentry/react": "^7.114.0",
|
"@sentry/react": "^9.9.0",
|
||||||
"@splitsoftware/splitio-react": "^1.13.0",
|
"@sentry/vite-plugin": "^3.2.2",
|
||||||
|
"@splitsoftware/splitio-react": "^2.0.1",
|
||||||
"@tanem/react-nprogress": "^5.0.53",
|
"@tanem/react-nprogress": "^5.0.53",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"antd": "^5.23.1",
|
"antd": "^5.24.5",
|
||||||
"apollo-link-logger": "^2.0.1",
|
"apollo-link-logger": "^2.0.1",
|
||||||
"apollo-link-sentry": "^3.3.0",
|
"apollo-link-sentry": "^4.2.0",
|
||||||
"autosize": "^6.0.1",
|
"autosize": "^6.0.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.8.4",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"css-box-model": "^1.2.1",
|
"css-box-model": "^1.2.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dayjs-business-days2": "^1.2.3",
|
"dayjs-business-days2": "^1.3.0",
|
||||||
"dinero.js": "^1.9.1",
|
"dinero.js": "^1.9.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"firebase": "^10.13.2",
|
|
||||||
"graphql": "^16.10.0",
|
"graphql": "^16.10.0",
|
||||||
"i18next": "^23.15.1",
|
"i18next": "^24.2.3",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"immutability-helper": "^3.1.1",
|
"immutability-helper": "^3.1.1",
|
||||||
"libphonenumber-js": "^1.11.18",
|
"libphonenumber-js": "^1.12.6",
|
||||||
"logrocket": "^8.1.2",
|
"logrocket": "^9.0.2",
|
||||||
"markerjs2": "^2.32.3",
|
"markerjs2": "^2.32.4",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"normalize-url": "^8.0.1",
|
"normalize-url": "^8.0.1",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
@@ -47,25 +52,25 @@
|
|||||||
"query-string": "^9.1.1",
|
"query-string": "^9.1.1",
|
||||||
"raf-schd": "^4.0.3",
|
"raf-schd": "^4.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-big-calendar": "^1.17.1",
|
"react-big-calendar": "^1.18.0",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-cookie": "^7.2.2",
|
"react-cookie": "^8.0.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drag-listview": "^2.0.0",
|
"react-drag-listview": "^2.0.0",
|
||||||
"react-grid-gallery": "^1.0.1",
|
"react-grid-gallery": "^1.0.1",
|
||||||
"react-grid-layout": "1.3.4",
|
"react-grid-layout": "1.3.4",
|
||||||
"react-i18next": "^14.1.3",
|
"react-i18next": "^15.4.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-image-lightbox": "^5.1.4",
|
"react-image-lightbox": "^5.1.4",
|
||||||
"react-markdown": "^9.0.3",
|
"react-markdown": "^10.1.0",
|
||||||
"react-number-format": "^5.4.3",
|
"react-number-format": "^5.4.3",
|
||||||
"react-popopo": "^2.1.9",
|
"react-popopo": "^2.1.9",
|
||||||
"react-product-fruits": "^2.2.61",
|
"react-product-fruits": "^2.2.61",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-resizable": "^3.0.5",
|
"react-resizable": "^3.0.5",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.30.0",
|
||||||
"react-sticky": "^6.0.3",
|
"react-sticky": "^6.0.3",
|
||||||
"react-virtuoso": "^4.10.4",
|
"react-virtuoso": "^4.12.5",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"redux": "^5.0.1",
|
"redux": "^5.0.1",
|
||||||
"redux-actions": "^3.0.3",
|
"redux-actions": "^3.0.3",
|
||||||
@@ -73,12 +78,11 @@
|
|||||||
"redux-saga": "^1.3.0",
|
"redux-saga": "^1.3.0",
|
||||||
"redux-state-sync": "^3.1.4",
|
"redux-state-sync": "^3.1.4",
|
||||||
"reselect": "^5.1.1",
|
"reselect": "^5.1.1",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.86.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"styled-components": "^6.1.14",
|
"styled-components": "^6.1.16",
|
||||||
"subscriptions-transport-ws": "^0.11.0",
|
"subscriptions-transport-ws": "^0.11.0",
|
||||||
"use-memo-one": "^1.1.3",
|
"use-memo-one": "^1.1.3",
|
||||||
"userpilot": "^1.3.6",
|
|
||||||
"vite-plugin-ejs": "^1.7.0",
|
"vite-plugin-ejs": "^1.7.0",
|
||||||
"web-vitals": "^3.5.2"
|
"web-vitals": "^3.5.2"
|
||||||
},
|
},
|
||||||
@@ -95,11 +99,8 @@
|
|||||||
"build:test:rome": "env-cmd -f .env.test.rome npm run build",
|
"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:imex": "env-cmd -f .env.production.imex npm run build",
|
||||||
"build:production:rome": "env-cmd -f .env.production.rome 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 .",
|
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
|
||||||
"eulaize": "node src/utils/eulaize.js",
|
"eulaize": "node src/utils/eulaize.js"
|
||||||
"sentry:sourcemaps:imex": "sentry-cli sourcemaps inject --org imex --project imexonline ./build && sentry-cli sourcemaps upload --org imex --project imexonline ./build"
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@@ -120,35 +121,31 @@
|
|||||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ant-design/icons": "^5.5.2",
|
"@ant-design/icons": "^6.0.0",
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@babel/preset-react": "^7.26.3",
|
"@babel/preset-react": "^7.26.3",
|
||||||
"@dotenvx/dotenvx": "^1.33.0",
|
"@dotenvx/dotenvx": "^1.39.0",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.23.0",
|
||||||
"@sentry/webpack-plugin": "^2.22.4",
|
"@sentry/webpack-plugin": "^3.2.2",
|
||||||
"@testing-library/cypress": "^10.0.2",
|
|
||||||
"browserslist": "^4.24.4",
|
"browserslist": "^4.24.4",
|
||||||
"browserslist-to-esbuild": "^2.1.1",
|
"browserslist-to-esbuild": "^2.1.1",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"cypress": "^13.17.0",
|
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-react-app": "^7.0.1",
|
"eslint-config-react-app": "^7.0.1",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.15.0",
|
||||||
"memfs": "^4.17.0",
|
"memfs": "^4.17.0",
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "^0.3.0",
|
||||||
"react-error-overlay": "6.0.11",
|
"react-error-overlay": "^6.1.0",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"source-map-explorer": "^2.5.3",
|
"source-map-explorer": "^2.5.3",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.2.3",
|
||||||
"vite-plugin-babel": "^1.3.0",
|
"vite-plugin-babel": "^1.3.0",
|
||||||
"vite-plugin-eslint": "^1.8.1",
|
"vite-plugin-eslint": "^1.8.1",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
"vite-plugin-style-import": "^2.0.0",
|
"vite-plugin-style-import": "^2.0.0",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
import { ApolloProvider } from "@apollo/client";
|
import { ApolloProvider } from "@apollo/client";
|
||||||
import { SplitFactoryProvider, SplitSdk } from "@splitsoftware/splitio-react";
|
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||||
import { ConfigProvider } from "antd";
|
import { ConfigProvider } from "antd";
|
||||||
import enLocale from "antd/es/locale/en_US";
|
import enLocale from "antd/es/locale/en_US";
|
||||||
import React from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||||
import client from "../utils/GraphQLClient";
|
import client from "../utils/GraphQLClient";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
import themeProvider from "./themeProvider";
|
import themeProvider from "./themeProvider";
|
||||||
import { Userpilot } from "userpilot";
|
|
||||||
|
|
||||||
// Initialize Userpilot
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
Userpilot.initialize("NX-69145f08");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Base Split configuration
|
||||||
const config = {
|
const config = {
|
||||||
core: {
|
core: {
|
||||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||||
key: "anon"
|
key: "anon" // Default key, overridden dynamically by SplitClientProvider
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
export const factory = SplitSdk(config);
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
function AppContainer() {
|
function AppContainer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -31,7 +40,6 @@ function AppContainer() {
|
|||||||
return (
|
return (
|
||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
//componentSize="small"
|
|
||||||
input={{ autoComplete: "new-password" }}
|
input={{ autoComplete: "new-password" }}
|
||||||
locale={enLocale}
|
locale={enLocale}
|
||||||
theme={themeProvider}
|
theme={themeProvider}
|
||||||
@@ -43,8 +51,10 @@ function AppContainer() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GlobalLoadingBar />
|
<GlobalLoadingBar />
|
||||||
<SplitFactoryProvider factory={factory}>
|
<SplitFactoryProvider config={config}>
|
||||||
|
<SplitClientProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</SplitClientProvider>
|
||||||
</SplitFactoryProvider>
|
</SplitFactoryProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||||
import { Button, Result } from "antd";
|
import { Button, Result } from "antd";
|
||||||
import LogRocket from "logrocket";
|
import LogRocket from "logrocket";
|
||||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
|
import DocumentEditorContainer from "../components/document-editor/document-editor.container";
|
||||||
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
|
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; // Component Imports
|
||||||
@@ -21,7 +21,7 @@ import "./App.styles.scss";
|
|||||||
import Eula from "../components/eula/eula.component";
|
import Eula from "../components/eula/eula.component";
|
||||||
import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
||||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||||
import { SocketProvider } from "../contexts/SocketIO/socketContext.jsx";
|
import { SocketProvider } from "../contexts/SocketIO/useSocket.jsx";
|
||||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||||
@@ -46,6 +46,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
const client = useSplitClient().client;
|
const client = useSplitClient().client;
|
||||||
const [listenersAdded, setListenersAdded] = useState(false);
|
const [listenersAdded, setListenersAdded] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
@@ -200,7 +201,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
path="/manage/*"
|
path="/manage/*"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SocketProvider bodyshop={bodyshop}>
|
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -212,7 +213,7 @@ export function App({ bodyshop, checkUserSession, currentUser, online, setOnline
|
|||||||
path="/tech/*"
|
path="/tech/*"
|
||||||
element={
|
element={
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<SocketProvider bodyshop={bodyshop}>
|
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -180,3 +180,13 @@
|
|||||||
.muted-button:hover {
|
.muted-button:hover {
|
||||||
color: darkgrey;
|
color: darkgrey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-alert-unordered-list {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.notification-alert-unordered-list-item {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useApolloClient } from "@apollo/client";
|
import { useApolloClient } from "@apollo/client";
|
||||||
import { getToken } from "@firebase/messaging";
|
import { getToken } from "@firebase/messaging";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import React, { useContext, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
import { messaging, requestForToken } from "../../firebase/firebase.utils";
|
||||||
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
import ChatPopupComponent from "../chat-popup/chat-popup.component";
|
||||||
import "./chat-affix.styles.scss";
|
import "./chat-affix.styles.scss";
|
||||||
@@ -12,7 +12,7 @@ import { registerMessagingHandlers, unregisterMessagingHandlers } from "./regist
|
|||||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Button } from "antd";
|
import { Button } from "antd";
|
||||||
import React, { useContext, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -18,7 +18,7 @@ export function ChatArchiveButton({ conversation, bodyshop }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
|
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const handleToggleArchive = async () => {
|
const handleToggleArchive = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Tag } from "antd";
|
import { Tag } from "antd";
|
||||||
import React, { useContext } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -18,7 +17,7 @@ const mapDispatchToProps = () => ({});
|
|||||||
|
|
||||||
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
export function ChatConversationTitleTags({ jobConversations, bodyshop }) {
|
||||||
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const handleRemoveTag = async (jobId) => {
|
const handleRemoveTag = async (jobId) => {
|
||||||
const convId = jobConversations[0].conversationid;
|
const convId = jobConversations[0].conversationid;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
import { gql, useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { GET_CONVERSATION_DETAILS, CONVERSATION_SUBSCRIPTION_BY_PK } from "../../graphql/conversations.queries";
|
import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||||
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import ChatConversationComponent from "./chat-conversation.component";
|
import ChatConversationComponent from "./chat-conversation.component";
|
||||||
@@ -16,7 +16,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
|
|
||||||
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
function ChatConversationContainer({ bodyshop, selectedConversation }) {
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
|
||||||
|
|
||||||
// Fetch conversation details
|
// Fetch conversation details
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { PlusOutlined } from "@ant-design/icons";
|
import { PlusOutlined } from "@ant-design/icons";
|
||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client";
|
||||||
import { Input, Spin, Tag, Tooltip } from "antd";
|
import { Input, Spin, Tag, Tooltip } from "antd";
|
||||||
import React, { useContext, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -20,7 +20,7 @@ export function ChatLabel({ conversation, bodyshop }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [value, setValue] = useState(conversation.label);
|
const [value, setValue] = useState(conversation.label);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { PlusCircleFilled } from "@ant-design/icons";
|
import { PlusCircleFilled } from "@ant-design/icons";
|
||||||
import { Button, Form, Popover } from "antd";
|
import { Button, Form, Popover } from "antd";
|
||||||
import React, { useContext } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||||
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-formatted/phone-form-item.component";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
//currentUser: selectCurrentUser
|
//currentUser: selectCurrentUser
|
||||||
@@ -18,7 +17,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export function ChatNewConversation({ openChatByPhone }) {
|
export function ChatNewConversation({ openChatByPhone }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
const handleFinish = (values) => {
|
||||||
openChatByPhone({ phone_num: values.phoneNumber, socket });
|
openChatByPhone({ phone_num: values.phoneNumber, socket });
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
import React, { useContext } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
|
||||||
@@ -8,7 +7,7 @@ import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
|||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
|
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
@@ -22,7 +21,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
|
|
||||||
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
|
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, jobid, openChatByPhone }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
if (!phone) return <></>;
|
if (!phone) return <></>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
|
||||||
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
import { useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
|
||||||
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||||
import React, { useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -12,8 +12,9 @@ import ChatConversationListComponent from "../chat-conversation-list/chat-conver
|
|||||||
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
|
import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
|
||||||
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
|
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
|
||||||
import "./chat-popup.styles.scss";
|
import "./chat-popup.styles.scss";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
selectedConversation: selectSelectedConversation,
|
selectedConversation: selectSelectedConversation,
|
||||||
@@ -27,7 +28,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
export function ChatPopupComponent({ chatVisible, selectedConversation, toggleChatVisible }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [pollInterval, setPollInterval] = useState(0);
|
const [pollInterval, setPollInterval] = useState(0);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const client = useApolloClient(); // Apollo Client instance for cache operations
|
const client = useApolloClient(); // Apollo Client instance for cache operations
|
||||||
|
|
||||||
// Lazy query for conversations
|
// Lazy query for conversations
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { PlusOutlined } from "@ant-design/icons";
|
|||||||
import { useLazyQuery, useMutation } from "@apollo/client";
|
import { useLazyQuery, useMutation } from "@apollo/client";
|
||||||
import { Tag } from "antd";
|
import { Tag } from "antd";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import React, { useContext, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
import { INSERT_CONVERSATION_TAG } from "../../graphql/job-conversations.queries";
|
||||||
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
|
import { SEARCH_FOR_JOBS } from "../../graphql/jobs.queries";
|
||||||
import ChatTagRo from "./chat-tag-ro.component";
|
import ChatTagRo from "./chat-tag-ro.component";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
@@ -22,7 +22,7 @@ const mapDispatchToProps = () => ({});
|
|||||||
export function ChatTagRoContainer({ conversation, bodyshop }) {
|
export function ChatTagRoContainer({ conversation, bodyshop }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
|
const [loadRo, { loading, data }] = useLazyQuery(SEARCH_FOR_JOBS);
|
||||||
|
|
||||||
|
|||||||
132
client/src/components/dashboard-grid/componentList.js
Normal file
132
client/src/components/dashboard-grid/componentList.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import i18next from "i18next";
|
||||||
|
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component.jsx";
|
||||||
|
import {
|
||||||
|
DashboardTotalProductionHours,
|
||||||
|
DashboardTotalProductionHoursGql
|
||||||
|
} from "../dashboard-components/total-production-hours/total-production-hours.component.jsx";
|
||||||
|
import DashboardProjectedMonthlySales, {
|
||||||
|
DashboardProjectedMonthlySalesGql
|
||||||
|
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component.jsx";
|
||||||
|
import DashboardMonthlyRevenueGraph, {
|
||||||
|
DashboardMonthlyRevenueGraphGql
|
||||||
|
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component.jsx";
|
||||||
|
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component.jsx";
|
||||||
|
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component.jsx";
|
||||||
|
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component.jsx";
|
||||||
|
import DashboardMonthlyEmployeeEfficiency, {
|
||||||
|
DashboardMonthlyEmployeeEfficiencyGql
|
||||||
|
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component.jsx";
|
||||||
|
import DashboardScheduledInToday, {
|
||||||
|
DashboardScheduledInTodayGql
|
||||||
|
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component.jsx";
|
||||||
|
import DashboardScheduledOutToday, {
|
||||||
|
DashboardScheduledOutTodayGql
|
||||||
|
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component.jsx";
|
||||||
|
import JobLifecycleDashboardComponent, {
|
||||||
|
JobLifecycleDashboardGQL
|
||||||
|
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component.jsx";
|
||||||
|
|
||||||
|
const componentList = {
|
||||||
|
ProductionDollars: {
|
||||||
|
label: i18next.t("dashboard.titles.productiondollars"),
|
||||||
|
component: DashboardTotalProductionDollars,
|
||||||
|
gqlFragment: null,
|
||||||
|
w: 1,
|
||||||
|
h: 1,
|
||||||
|
minW: 2,
|
||||||
|
minH: 1
|
||||||
|
},
|
||||||
|
ProductionHours: {
|
||||||
|
label: i18next.t("dashboard.titles.productionhours"),
|
||||||
|
component: DashboardTotalProductionHours,
|
||||||
|
gqlFragment: DashboardTotalProductionHoursGql,
|
||||||
|
w: 3,
|
||||||
|
h: 1,
|
||||||
|
minW: 3,
|
||||||
|
minH: 1
|
||||||
|
},
|
||||||
|
ProjectedMonthlySales: {
|
||||||
|
label: i18next.t("dashboard.titles.projectedmonthlysales"),
|
||||||
|
component: DashboardProjectedMonthlySales,
|
||||||
|
gqlFragment: DashboardProjectedMonthlySalesGql,
|
||||||
|
w: 2,
|
||||||
|
h: 1,
|
||||||
|
minW: 2,
|
||||||
|
minH: 1
|
||||||
|
},
|
||||||
|
MonthlyRevenueGraph: {
|
||||||
|
label: i18next.t("dashboard.titles.monthlyrevenuegraph"),
|
||||||
|
component: DashboardMonthlyRevenueGraph,
|
||||||
|
gqlFragment: DashboardMonthlyRevenueGraphGql,
|
||||||
|
w: 4,
|
||||||
|
h: 2,
|
||||||
|
minW: 4,
|
||||||
|
minH: 2
|
||||||
|
},
|
||||||
|
MonthlyJobCosting: {
|
||||||
|
label: i18next.t("dashboard.titles.monthlyjobcosting"),
|
||||||
|
component: DashboardMonthlyJobCosting,
|
||||||
|
gqlFragment: null,
|
||||||
|
minW: 6,
|
||||||
|
minH: 3,
|
||||||
|
w: 6,
|
||||||
|
h: 3
|
||||||
|
},
|
||||||
|
MonthlyPartsSales: {
|
||||||
|
label: i18next.t("dashboard.titles.monthlypartssales"),
|
||||||
|
component: DashboardMonthlyPartsSales,
|
||||||
|
gqlFragment: null,
|
||||||
|
minW: 2,
|
||||||
|
minH: 2,
|
||||||
|
w: 2,
|
||||||
|
h: 2
|
||||||
|
},
|
||||||
|
MonthlyLaborSales: {
|
||||||
|
label: i18next.t("dashboard.titles.monthlylaborsales"),
|
||||||
|
component: DashboardMonthlyLaborSales,
|
||||||
|
gqlFragment: null,
|
||||||
|
minW: 2,
|
||||||
|
minH: 2,
|
||||||
|
w: 2,
|
||||||
|
h: 2
|
||||||
|
},
|
||||||
|
// Typo in Efficency should be Efficiency, but changing it would reset users dashboard settings
|
||||||
|
MonthlyEmployeeEfficency: {
|
||||||
|
label: i18next.t("dashboard.titles.monthlyemployeeefficiency"),
|
||||||
|
component: DashboardMonthlyEmployeeEfficiency,
|
||||||
|
gqlFragment: DashboardMonthlyEmployeeEfficiencyGql,
|
||||||
|
minW: 2,
|
||||||
|
minH: 2,
|
||||||
|
w: 2,
|
||||||
|
h: 2
|
||||||
|
},
|
||||||
|
ScheduleInToday: {
|
||||||
|
label: i18next.t("dashboard.titles.scheduledintoday"),
|
||||||
|
component: DashboardScheduledInToday,
|
||||||
|
gqlFragment: DashboardScheduledInTodayGql,
|
||||||
|
minW: 6,
|
||||||
|
minH: 2,
|
||||||
|
w: 10,
|
||||||
|
h: 3
|
||||||
|
},
|
||||||
|
ScheduleOutToday: {
|
||||||
|
label: i18next.t("dashboard.titles.scheduledouttoday"),
|
||||||
|
component: DashboardScheduledOutToday,
|
||||||
|
gqlFragment: DashboardScheduledOutTodayGql,
|
||||||
|
minW: 6,
|
||||||
|
minH: 2,
|
||||||
|
w: 10,
|
||||||
|
h: 3
|
||||||
|
},
|
||||||
|
JobLifecycle: {
|
||||||
|
label: i18next.t("dashboard.titles.joblifecycle"),
|
||||||
|
component: JobLifecycleDashboardComponent,
|
||||||
|
gqlFragment: JobLifecycleDashboardGQL,
|
||||||
|
minW: 6,
|
||||||
|
minH: 3,
|
||||||
|
w: 6,
|
||||||
|
h: 3
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default componentList;
|
||||||
85
client/src/components/dashboard-grid/createDashboardQuery.js
Normal file
85
client/src/components/dashboard-grid/createDashboardQuery.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { gql } from "@apollo/client";
|
||||||
|
import dayjs from "../../utils/day.js";
|
||||||
|
import componentList from "./componentList.js";
|
||||||
|
|
||||||
|
const createDashboardQuery = (state) => {
|
||||||
|
const componentBasedAdditions =
|
||||||
|
state &&
|
||||||
|
Array.isArray(state.layout) &&
|
||||||
|
state.layout.map((item, index) => componentList[item.i].gqlFragment || "").join("");
|
||||||
|
return gql`
|
||||||
|
query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""}
|
||||||
|
monthly_sales: jobs(where: {_and: [
|
||||||
|
{ voided: {_eq: false}},
|
||||||
|
{date_invoiced: {_gte: "${dayjs()
|
||||||
|
.startOf("month")
|
||||||
|
.startOf("day")
|
||||||
|
.toISOString()}"}}, {date_invoiced: {_lte: "${dayjs().endOf("month").endOf("day").toISOString()}"}}]}) {
|
||||||
|
id
|
||||||
|
ro_number
|
||||||
|
date_invoiced
|
||||||
|
job_totals
|
||||||
|
rate_la1
|
||||||
|
rate_la2
|
||||||
|
rate_la3
|
||||||
|
rate_la4
|
||||||
|
rate_laa
|
||||||
|
rate_lab
|
||||||
|
rate_lad
|
||||||
|
rate_lae
|
||||||
|
rate_laf
|
||||||
|
rate_lag
|
||||||
|
rate_lam
|
||||||
|
rate_lar
|
||||||
|
rate_las
|
||||||
|
rate_lau
|
||||||
|
rate_ma2s
|
||||||
|
rate_ma2t
|
||||||
|
rate_ma3s
|
||||||
|
rate_mabl
|
||||||
|
rate_macs
|
||||||
|
rate_mahw
|
||||||
|
rate_mapa
|
||||||
|
rate_mash
|
||||||
|
rate_matd
|
||||||
|
joblines(where: { removed: { _eq: false } }) {
|
||||||
|
id
|
||||||
|
mod_lbr_ty
|
||||||
|
mod_lb_hrs
|
||||||
|
act_price
|
||||||
|
part_qty
|
||||||
|
part_type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
production_jobs: jobs(where: { inproduction: { _eq: true } }) {
|
||||||
|
id
|
||||||
|
ro_number
|
||||||
|
ins_co_nm
|
||||||
|
job_totals
|
||||||
|
joblines(where: { removed: { _eq: false } }) {
|
||||||
|
id
|
||||||
|
mod_lbr_ty
|
||||||
|
mod_lb_hrs
|
||||||
|
act_price
|
||||||
|
part_qty
|
||||||
|
part_type
|
||||||
|
}
|
||||||
|
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) {
|
||||||
|
aggregate {
|
||||||
|
sum {
|
||||||
|
mod_lb_hrs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) {
|
||||||
|
aggregate {
|
||||||
|
sum {
|
||||||
|
mod_lb_hrs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createDashboardQuery;
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import Icon, { SyncOutlined } from "@ant-design/icons";
|
import Icon, { SyncOutlined } from "@ant-design/icons";
|
||||||
import { gql, useMutation, useQuery } from "@apollo/client";
|
import { isEmpty, cloneDeep } from "lodash";
|
||||||
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
import { Button, Dropdown, Space } from "antd";
|
import { Button, Dropdown, Space } from "antd";
|
||||||
import { PageHeader } from "@ant-design/pro-layout";
|
import { PageHeader } from "@ant-design/pro-layout";
|
||||||
import i18next from "i18next";
|
import { useMemo, useState } from "react";
|
||||||
import _ from "lodash";
|
|
||||||
import dayjs from "../../utils/day";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { Responsive, WidthProvider } from "react-grid-layout";
|
import { Responsive, WidthProvider } from "react-grid-layout";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { MdClose } from "react-icons/md";
|
import { MdClose } from "react-icons/md";
|
||||||
@@ -15,38 +13,13 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
|||||||
import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
|
import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import DashboardMonthlyEmployeeEfficiency, {
|
|
||||||
DashboardMonthlyEmployeeEfficiencyGql
|
|
||||||
} from "../dashboard-components/monthly-employee-efficiency/monthly-employee-efficiency.component";
|
|
||||||
import DashboardMonthlyJobCosting from "../dashboard-components/monthly-job-costing/monthly-job-costing.component";
|
|
||||||
import DashboardMonthlyLaborSales from "../dashboard-components/monthly-labor-sales/monthly-labor-sales.component";
|
|
||||||
import DashboardMonthlyPartsSales from "../dashboard-components/monthly-parts-sales/monthly-parts-sales.component";
|
|
||||||
import DashboardMonthlyRevenueGraph, {
|
|
||||||
DashboardMonthlyRevenueGraphGql
|
|
||||||
} from "../dashboard-components/monthly-revenue-graph/monthly-revenue-graph.component";
|
|
||||||
import DashboardProjectedMonthlySales, {
|
|
||||||
DashboardProjectedMonthlySalesGql
|
|
||||||
} from "../dashboard-components/pojected-monthly-sales/projected-monthly-sales.component";
|
|
||||||
import DashboardTotalProductionDollars from "../dashboard-components/total-production-dollars/total-production-dollars.component";
|
|
||||||
import DashboardTotalProductionHours, {
|
|
||||||
DashboardTotalProductionHoursGql
|
|
||||||
} from "../dashboard-components/total-production-hours/total-production-hours.component";
|
|
||||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||||
//Combination of the following:
|
|
||||||
// /node_modules/react-grid-layout/css/styles.css
|
|
||||||
// /node_modules/react-resizable/css/styles.css
|
|
||||||
import DashboardScheduledInToday, {
|
|
||||||
DashboardScheduledInTodayGql
|
|
||||||
} from "../dashboard-components/scheduled-in-today/scheduled-in-today.component";
|
|
||||||
import DashboardScheduledOutToday, {
|
|
||||||
DashboardScheduledOutTodayGql
|
|
||||||
} from "../dashboard-components/scheduled-out-today/scheduled-out-today.component";
|
|
||||||
import JobLifecycleDashboardComponent, {
|
|
||||||
JobLifecycleDashboardGQL
|
|
||||||
} from "../dashboard-components/job-lifecycle/job-lifecycle-dashboard.component";
|
|
||||||
import "./dashboard-grid.styles.scss";
|
|
||||||
import { GenerateDashboardData } from "./dashboard-grid.utils";
|
import { GenerateDashboardData } from "./dashboard-grid.utils";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import componentList from "./componentList.js";
|
||||||
|
import createDashboardQuery from "./createDashboardQuery.js";
|
||||||
|
|
||||||
|
import "./dashboard-grid.styles.scss";
|
||||||
|
|
||||||
const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
const ResponsiveReactGridLayout = WidthProvider(Responsive);
|
||||||
|
|
||||||
@@ -54,6 +27,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||||
});
|
});
|
||||||
@@ -85,19 +59,21 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
|||||||
layout: { ...state, layout, layouts }
|
layout: { ...state, layout, layouts }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!!result.errors) {
|
|
||||||
notification["error"]({
|
if (!isEmpty(result?.errors)) {
|
||||||
|
notification.error({
|
||||||
message: t("dashboard.errors.updatinglayout", {
|
message: t("dashboard.errors.updatinglayout", {
|
||||||
message: JSON.stringify(result.errors)
|
message: JSON.stringify(result.errors)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveComponent = (key) => {
|
const handleRemoveComponent = (key) => {
|
||||||
logImEXEvent("dashboard_remove_component", { name: key });
|
logImEXEvent("dashboard_remove_component", { name: key });
|
||||||
const idxToRemove = state.items.findIndex((i) => i.i === key);
|
const idxToRemove = state.items.findIndex((i) => i.i === key);
|
||||||
|
|
||||||
const items = _.cloneDeep(state.items);
|
const items = cloneDeep(state.items);
|
||||||
|
|
||||||
items.splice(idxToRemove, 1);
|
items.splice(idxToRemove, 1);
|
||||||
setState({ ...state, items });
|
setState({ ...state, items });
|
||||||
@@ -120,7 +96,8 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const dashboarddata = React.useMemo(() => GenerateDashboardData(data), [data]);
|
const dashboardData = useMemo(() => GenerateDashboardData(data), [data]);
|
||||||
|
|
||||||
const existingLayoutKeys = state.items.map((i) => i.i);
|
const existingLayoutKeys = state.items.map((i) => i.i);
|
||||||
|
|
||||||
const menuItems = Object.keys(componentList).map((key) => ({
|
const menuItems = Object.keys(componentList).map((key) => ({
|
||||||
@@ -156,7 +133,6 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
|||||||
width="100%"
|
width="100%"
|
||||||
layouts={state.layouts}
|
layouts={state.layouts}
|
||||||
onLayoutChange={handleLayoutChange}
|
onLayoutChange={handleLayoutChange}
|
||||||
// onBreakpointChange={onBreakpointChange}
|
|
||||||
>
|
>
|
||||||
{state.items.map((item, index) => {
|
{state.items.map((item, index) => {
|
||||||
const TheComponent = componentList[item.i].component;
|
const TheComponent = componentList[item.i].component;
|
||||||
@@ -182,7 +158,7 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
|||||||
}}
|
}}
|
||||||
onClick={() => handleRemoveComponent(item.i)}
|
onClick={() => handleRemoveComponent(item.i)}
|
||||||
/>
|
/>
|
||||||
<TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboarddata} />
|
<TheComponent className="dashboard-card" bodyshop={bodyshop} data={dashboardData} />
|
||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -193,189 +169,3 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(DashboardGridComponent);
|
export default connect(mapStateToProps, mapDispatchToProps)(DashboardGridComponent);
|
||||||
|
|
||||||
const componentList = {
|
|
||||||
ProductionDollars: {
|
|
||||||
label: i18next.t("dashboard.titles.productiondollars"),
|
|
||||||
component: DashboardTotalProductionDollars,
|
|
||||||
gqlFragment: null,
|
|
||||||
w: 1,
|
|
||||||
h: 1,
|
|
||||||
minW: 2,
|
|
||||||
minH: 1
|
|
||||||
},
|
|
||||||
ProductionHours: {
|
|
||||||
label: i18next.t("dashboard.titles.productionhours"),
|
|
||||||
component: DashboardTotalProductionHours,
|
|
||||||
gqlFragment: DashboardTotalProductionHoursGql,
|
|
||||||
w: 3,
|
|
||||||
h: 1,
|
|
||||||
minW: 3,
|
|
||||||
minH: 1
|
|
||||||
},
|
|
||||||
ProjectedMonthlySales: {
|
|
||||||
label: i18next.t("dashboard.titles.projectedmonthlysales"),
|
|
||||||
component: DashboardProjectedMonthlySales,
|
|
||||||
gqlFragment: DashboardProjectedMonthlySalesGql,
|
|
||||||
w: 2,
|
|
||||||
h: 1,
|
|
||||||
minW: 2,
|
|
||||||
minH: 1
|
|
||||||
},
|
|
||||||
MonthlyRevenueGraph: {
|
|
||||||
label: i18next.t("dashboard.titles.monthlyrevenuegraph"),
|
|
||||||
component: DashboardMonthlyRevenueGraph,
|
|
||||||
gqlFragment: DashboardMonthlyRevenueGraphGql,
|
|
||||||
w: 4,
|
|
||||||
h: 2,
|
|
||||||
minW: 4,
|
|
||||||
minH: 2
|
|
||||||
},
|
|
||||||
MonthlyJobCosting: {
|
|
||||||
label: i18next.t("dashboard.titles.monthlyjobcosting"),
|
|
||||||
component: DashboardMonthlyJobCosting,
|
|
||||||
gqlFragment: null,
|
|
||||||
minW: 6,
|
|
||||||
minH: 3,
|
|
||||||
w: 6,
|
|
||||||
h: 3
|
|
||||||
},
|
|
||||||
MonthlyPartsSales: {
|
|
||||||
label: i18next.t("dashboard.titles.monthlypartssales"),
|
|
||||||
component: DashboardMonthlyPartsSales,
|
|
||||||
gqlFragment: null,
|
|
||||||
minW: 2,
|
|
||||||
minH: 2,
|
|
||||||
w: 2,
|
|
||||||
h: 2
|
|
||||||
},
|
|
||||||
MonthlyLaborSales: {
|
|
||||||
label: i18next.t("dashboard.titles.monthlylaborsales"),
|
|
||||||
component: DashboardMonthlyLaborSales,
|
|
||||||
gqlFragment: null,
|
|
||||||
minW: 2,
|
|
||||||
minH: 2,
|
|
||||||
w: 2,
|
|
||||||
h: 2
|
|
||||||
},
|
|
||||||
// Typo in Efficency should be Efficiency, but changing it would reset users dashboard settings
|
|
||||||
MonthlyEmployeeEfficency: {
|
|
||||||
label: i18next.t("dashboard.titles.monthlyemployeeefficiency"),
|
|
||||||
component: DashboardMonthlyEmployeeEfficiency,
|
|
||||||
gqlFragment: DashboardMonthlyEmployeeEfficiencyGql,
|
|
||||||
minW: 2,
|
|
||||||
minH: 2,
|
|
||||||
w: 2,
|
|
||||||
h: 2
|
|
||||||
},
|
|
||||||
ScheduleInToday: {
|
|
||||||
label: i18next.t("dashboard.titles.scheduledintoday"),
|
|
||||||
component: DashboardScheduledInToday,
|
|
||||||
gqlFragment: DashboardScheduledInTodayGql,
|
|
||||||
minW: 6,
|
|
||||||
minH: 2,
|
|
||||||
w: 10,
|
|
||||||
h: 3
|
|
||||||
},
|
|
||||||
ScheduleOutToday: {
|
|
||||||
label: i18next.t("dashboard.titles.scheduledouttoday"),
|
|
||||||
component: DashboardScheduledOutToday,
|
|
||||||
gqlFragment: DashboardScheduledOutTodayGql,
|
|
||||||
minW: 6,
|
|
||||||
minH: 2,
|
|
||||||
w: 10,
|
|
||||||
h: 3
|
|
||||||
},
|
|
||||||
JobLifecycle: {
|
|
||||||
label: i18next.t("dashboard.titles.joblifecycle"),
|
|
||||||
component: JobLifecycleDashboardComponent,
|
|
||||||
gqlFragment: JobLifecycleDashboardGQL,
|
|
||||||
minW: 6,
|
|
||||||
minH: 3,
|
|
||||||
w: 6,
|
|
||||||
h: 3
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createDashboardQuery = (state) => {
|
|
||||||
const componentBasedAdditions =
|
|
||||||
state &&
|
|
||||||
Array.isArray(state.layout) &&
|
|
||||||
state.layout.map((item, index) => componentList[item.i].gqlFragment || "").join("");
|
|
||||||
return gql`
|
|
||||||
query QUERY_DASHBOARD_DETAILS { ${componentBasedAdditions || ""}
|
|
||||||
monthly_sales: jobs(where: {_and: [
|
|
||||||
{ voided: {_eq: false}},
|
|
||||||
{date_invoiced: {_gte: "${dayjs()
|
|
||||||
.startOf("month")
|
|
||||||
.startOf("day")
|
|
||||||
.toISOString()}"}}, {date_invoiced: {_lte: "${dayjs()
|
|
||||||
.endOf("month")
|
|
||||||
.endOf("day")
|
|
||||||
.toISOString()}"}}]}) {
|
|
||||||
id
|
|
||||||
ro_number
|
|
||||||
date_invoiced
|
|
||||||
job_totals
|
|
||||||
rate_la1
|
|
||||||
rate_la2
|
|
||||||
rate_la3
|
|
||||||
rate_la4
|
|
||||||
rate_laa
|
|
||||||
rate_lab
|
|
||||||
rate_lad
|
|
||||||
rate_lae
|
|
||||||
rate_laf
|
|
||||||
rate_lag
|
|
||||||
rate_lam
|
|
||||||
rate_lar
|
|
||||||
rate_las
|
|
||||||
rate_lau
|
|
||||||
rate_ma2s
|
|
||||||
rate_ma2t
|
|
||||||
rate_ma3s
|
|
||||||
rate_mabl
|
|
||||||
rate_macs
|
|
||||||
rate_mahw
|
|
||||||
rate_mapa
|
|
||||||
rate_mash
|
|
||||||
rate_matd
|
|
||||||
joblines(where: { removed: { _eq: false } }) {
|
|
||||||
id
|
|
||||||
mod_lbr_ty
|
|
||||||
mod_lb_hrs
|
|
||||||
act_price
|
|
||||||
part_qty
|
|
||||||
part_type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
production_jobs: jobs(where: { inproduction: { _eq: true } }) {
|
|
||||||
id
|
|
||||||
ro_number
|
|
||||||
ins_co_nm
|
|
||||||
job_totals
|
|
||||||
joblines(where: { removed: { _eq: false } }) {
|
|
||||||
id
|
|
||||||
mod_lbr_ty
|
|
||||||
mod_lb_hrs
|
|
||||||
act_price
|
|
||||||
part_qty
|
|
||||||
part_type
|
|
||||||
}
|
|
||||||
labhrs: joblines_aggregate(where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } }) {
|
|
||||||
aggregate {
|
|
||||||
sum {
|
|
||||||
mod_lb_hrs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
larhrs: joblines_aggregate(where: { mod_lbr_ty: { _eq: "LAR" }, removed: { _eq: false } }) {
|
|
||||||
aggregate {
|
|
||||||
sum {
|
|
||||||
mod_lb_hrs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
<Row>
|
<Row>
|
||||||
<Col offset={6} span={12}>
|
<Col offset={6} span={12}>
|
||||||
<Collapse bordered={false}>
|
<Collapse bordered={false}>
|
||||||
<Collapse.Panel header={t("general.labels.errors")}>
|
<Collapse.Panel key="errors-panel" header={t("general.labels.errors")}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{this.state.error.message}</strong>
|
<strong>{this.state.error.message}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,9 +78,7 @@ const Eula = ({ currentEula, currentUser, acceptEula }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notification.error({
|
notification.error({
|
||||||
message: t("eula.errors.acceptance.message"),
|
message: t("eula.errors.acceptance.message"),
|
||||||
description: t("eula.errors.acceptance.description"),
|
description: t("eula.errors.acceptance.description")
|
||||||
placement: "bottomRight",
|
|
||||||
duration: 5000
|
|
||||||
});
|
});
|
||||||
console.log(`${t("eula.errors.acceptance.message")}`);
|
console.log(`${t("eula.errors.acceptance.message")}`);
|
||||||
console.dir({
|
console.dir({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Icon, {
|
import {
|
||||||
BankFilled,
|
BankFilled,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
|
BellFilled,
|
||||||
CarFilled,
|
CarFilled,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleFilled,
|
ClockCircleFilled,
|
||||||
@@ -25,8 +26,10 @@ import Icon, {
|
|||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
UserOutlined
|
UserOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import { Layout, Menu, Space } from "antd";
|
import { Badge, Layout, Menu, Spin } from "antd";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BsKanban } from "react-icons/bs";
|
import { BsKanban } from "react-icons/bs";
|
||||||
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
import { FaCalendarAlt, FaCarCrash, FaCreditCard, FaFileInvoiceDollar, FaTasks } from "react-icons/fa";
|
||||||
@@ -37,14 +40,19 @@ import { RiSurveyLine } from "react-icons/ri";
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
import { GET_UNREAD_COUNT } from "../../graphql/notifications.queries.js";
|
||||||
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
|
import { selectRecentItems, selectSelectedHeader } from "../../redux/application/application.selectors";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
import { signOutStart } from "../../redux/user/user.actions";
|
import { signOutStart } from "../../redux/user/user.actions";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
|
import day from "../../utils/day.js";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
||||||
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
import LockWrapper from "../lock-wrapper/lock-wrapper.component";
|
||||||
|
import NotificationCenterContainer from "../notification-center/notification-center.container.jsx";
|
||||||
|
|
||||||
|
// Redux mappings
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser,
|
currentUser: selectCurrentUser,
|
||||||
recentItems: selectRecentItems,
|
recentItems: selectRecentItems,
|
||||||
@@ -53,43 +61,13 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setBillEnterContext: (context) =>
|
setBillEnterContext: (context) => dispatch(setModalContext({ context, modal: "billEnter" })),
|
||||||
dispatch(
|
setTimeTicketContext: (context) => dispatch(setModalContext({ context, modal: "timeTicket" })),
|
||||||
setModalContext({
|
setPaymentContext: (context) => dispatch(setModalContext({ context, modal: "payment" })),
|
||||||
context: context,
|
setReportCenterContext: (context) => dispatch(setModalContext({ context, modal: "reportCenter" })),
|
||||||
modal: "billEnter"
|
|
||||||
})
|
|
||||||
),
|
|
||||||
setTimeTicketContext: (context) =>
|
|
||||||
dispatch(
|
|
||||||
setModalContext({
|
|
||||||
context: context,
|
|
||||||
modal: "timeTicket"
|
|
||||||
})
|
|
||||||
),
|
|
||||||
setPaymentContext: (context) => dispatch(setModalContext({ context: context, modal: "payment" })),
|
|
||||||
setReportCenterContext: (context) =>
|
|
||||||
dispatch(
|
|
||||||
setModalContext({
|
|
||||||
context: context,
|
|
||||||
modal: "reportCenter"
|
|
||||||
})
|
|
||||||
),
|
|
||||||
signOutStart: () => dispatch(signOutStart()),
|
signOutStart: () => dispatch(signOutStart()),
|
||||||
setCardPaymentContext: (context) =>
|
setCardPaymentContext: (context) => dispatch(setModalContext({ context, modal: "cardPayment" })),
|
||||||
dispatch(
|
setTaskUpsertContext: (context) => dispatch(setModalContext({ context, modal: "taskUpsert" }))
|
||||||
setModalContext({
|
|
||||||
context: context,
|
|
||||||
modal: "cardPayment"
|
|
||||||
})
|
|
||||||
),
|
|
||||||
setTaskUpsertContext: (context) =>
|
|
||||||
dispatch(
|
|
||||||
setModalContext({
|
|
||||||
context: context,
|
|
||||||
modal: "taskUpsert"
|
|
||||||
})
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function Header({
|
function Header({
|
||||||
@@ -115,24 +93,81 @@ function Header({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isConnected, scenarioNotificationsOn } = useSocket();
|
||||||
|
const [notificationVisible, setNotificationVisible] = useState(false);
|
||||||
|
const baseTitleRef = useRef(document.title || "");
|
||||||
|
const lastSetTitleRef = useRef("");
|
||||||
|
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||||
|
|
||||||
// const deleteBetaCookie = () => {
|
const {
|
||||||
// const cookieExists = document.cookie.split("; ").some((row) => row.startsWith(`betaSwitchImex=`));
|
data: unreadData,
|
||||||
// if (cookieExists) {
|
refetch: refetchUnread,
|
||||||
// const domain = window.location.hostname.split(".").slice(-2).join(".");
|
loading: unreadLoading
|
||||||
// document.cookie = `betaSwitchImex=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.${domain}`;
|
} = useQuery(GET_UNREAD_COUNT, {
|
||||||
// }
|
variables: { associationid: userAssociationId },
|
||||||
// };
|
fetchPolicy: "network-only",
|
||||||
//
|
pollInterval: isConnected ? 0 : day.duration(60, "seconds").asMilliseconds(),
|
||||||
// deleteBetaCookie();
|
skip: !userAssociationId || !scenarioNotificationsOn
|
||||||
|
});
|
||||||
|
|
||||||
const accountingChildren = [];
|
const unreadCount = unreadData?.notifications_aggregate?.aggregate?.count ?? 0;
|
||||||
|
|
||||||
accountingChildren.push(
|
useEffect(() => {
|
||||||
|
if (userAssociationId) {
|
||||||
|
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
||||||
|
}
|
||||||
|
}, [refetchUnread, userAssociationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConnected && !unreadLoading && userAssociationId) {
|
||||||
|
refetchUnread().catch((e) => console.error(`Error fetching unread notifications: ${e?.message}`));
|
||||||
|
}
|
||||||
|
}, [isConnected, unreadLoading, refetchUnread, userAssociationId]);
|
||||||
|
|
||||||
|
// Keep The unread count in the title.
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTitle = () => {
|
||||||
|
const currentTitle = document.title;
|
||||||
|
// Check if the current title differs from what we last set
|
||||||
|
if (currentTitle !== lastSetTitleRef.current) {
|
||||||
|
// Extract base title by removing any unread count prefix
|
||||||
|
const baseTitleMatch = currentTitle.match(/^\(\d+\)\s*(.*)$/);
|
||||||
|
baseTitleRef.current = baseTitleMatch ? baseTitleMatch[1] : currentTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply unread count to the base title
|
||||||
|
const newTitle = unreadCount > 0 ? `(${unreadCount}) ${baseTitleRef.current}` : baseTitleRef.current;
|
||||||
|
|
||||||
|
// Only update if the title has changed to avoid unnecessary DOM writes
|
||||||
|
if (document.title !== newTitle) {
|
||||||
|
document.title = newTitle;
|
||||||
|
lastSetTitleRef.current = newTitle; // Store what we set
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateTitle();
|
||||||
|
|
||||||
|
// Poll every 100ms to catch child component changes
|
||||||
|
const interval = setInterval(updateTitle, 100);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
document.title = baseTitleRef.current; // Reset to base title on unmount
|
||||||
|
};
|
||||||
|
}, [unreadCount]); // Re-run when unreadCount changes
|
||||||
|
|
||||||
|
const handleNotificationClick = (e) => {
|
||||||
|
setNotificationVisible(!notificationVisible);
|
||||||
|
if (handleMenuClick) handleMenuClick(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const accountingChildren = [
|
||||||
{
|
{
|
||||||
key: "bills",
|
key: "bills",
|
||||||
id: "header-accounting-bills",
|
id: "header-accounting-bills",
|
||||||
icon: <Icon component={FaFileInvoiceDollar} />,
|
icon: <FaFileInvoiceDollar />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/bills">
|
<Link to="/manage/bills">
|
||||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||||
@@ -144,92 +179,60 @@ function Header({
|
|||||||
{
|
{
|
||||||
key: "enterbills",
|
key: "enterbills",
|
||||||
id: "header-accounting-enterbills",
|
id: "header-accounting-enterbills",
|
||||||
icon: <Icon component={GiPayMoney} />,
|
icon: <GiPayMoney />,
|
||||||
label: (
|
label: (
|
||||||
<Space>
|
|
||||||
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
<LockWrapper featureName="bills" bodyshop={bodyshop}>
|
||||||
{t("menus.header.enterbills")}
|
{t("menus.header.enterbills")}
|
||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
onClick: () => {
|
onClick: () =>
|
||||||
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
|
HasFeatureAccess({ featureName: "bills", bodyshop }) &&
|
||||||
setBillEnterContext({
|
setBillEnterContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: {}
|
context: {}
|
||||||
});
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Simple_Inventory.treatment === "on") {
|
|
||||||
accountingChildren.push(
|
|
||||||
{
|
|
||||||
type: "divider"
|
|
||||||
},
|
},
|
||||||
|
...(Simple_Inventory.treatment === "on"
|
||||||
|
? [
|
||||||
|
{ type: "divider" },
|
||||||
{
|
{
|
||||||
key: "inventory",
|
key: "inventory",
|
||||||
id: "header-accounting-inventory",
|
id: "header-accounting-inventory",
|
||||||
icon: <Icon component={FaFileInvoiceDollar} />,
|
icon: <FaFileInvoiceDollar />,
|
||||||
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
label: <Link to="/manage/inventory">{t("menus.header.inventory")}</Link>
|
||||||
}
|
}
|
||||||
);
|
]
|
||||||
}
|
: []),
|
||||||
|
{ type: "divider" },
|
||||||
accountingChildren.push(
|
|
||||||
{
|
|
||||||
type: "divider"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "allpayments",
|
key: "allpayments",
|
||||||
id: "header-accounting-allpayments",
|
id: "header-accounting-allpayments",
|
||||||
icon: <BankFilled />,
|
icon: <BankFilled />,
|
||||||
label: (
|
label: <Link to="/manage/payments">{t("menus.header.allpayments")}</Link>
|
||||||
<Link to="/manage/payments">
|
|
||||||
<LockWrapper featureName="payments" bodyshop={bodyshop}>
|
|
||||||
{t("menus.header.allpayments")}
|
|
||||||
</LockWrapper>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enterpayments",
|
key: "enterpayments",
|
||||||
id: "header-accounting-enterpayments",
|
id: "header-accounting-enterpayments",
|
||||||
icon: <Icon component={FaCreditCard} />,
|
icon: <FaCreditCard />,
|
||||||
label: (
|
label: t("menus.header.enterpayment"),
|
||||||
<LockWrapper featureName="payments" bodyshop={bodyshop}>
|
onClick: () =>
|
||||||
{t("menus.header.enterpayment")}
|
|
||||||
</LockWrapper>
|
|
||||||
),
|
|
||||||
onClick: () => {
|
|
||||||
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
|
|
||||||
setPaymentContext({
|
setPaymentContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: null
|
context: null
|
||||||
});
|
})
|
||||||
}
|
},
|
||||||
}
|
...(ImEXPay.treatment === "on"
|
||||||
);
|
? [
|
||||||
|
{
|
||||||
if (ImEXPay.treatment === "on") {
|
|
||||||
accountingChildren.push({
|
|
||||||
key: "entercardpayments",
|
key: "entercardpayments",
|
||||||
id: "header-accounting-entercardpayments",
|
id: "header-accounting-entercardpayments",
|
||||||
icon: <Icon component={FaCreditCard} />,
|
icon: <FaCreditCard />,
|
||||||
label: t("menus.header.entercardpayment"),
|
label: t("menus.header.entercardpayment"),
|
||||||
onClick: () => {
|
onClick: () => setCardPaymentContext({ actions: {}, context: {} })
|
||||||
setCardPaymentContext({
|
|
||||||
actions: {},
|
|
||||||
context: {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
]
|
||||||
}
|
: []),
|
||||||
|
{ type: "divider" },
|
||||||
accountingChildren.push(
|
|
||||||
{
|
|
||||||
type: "divider"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "timetickets",
|
key: "timetickets",
|
||||||
id: "header-accounting-timetickets",
|
id: "header-accounting-timetickets",
|
||||||
@@ -241,45 +244,48 @@ function Header({
|
|||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
);
|
...(bodyshop?.md_tasks_presets?.use_approvals
|
||||||
|
? [
|
||||||
if (bodyshop?.md_tasks_presets?.use_approvals) {
|
{
|
||||||
accountingChildren.push({
|
|
||||||
key: "ttapprovals",
|
key: "ttapprovals",
|
||||||
id: "header-accounting-ttapprovals",
|
id: "header-accounting-ttapprovals",
|
||||||
icon: <FieldTimeOutlined />,
|
icon: <FieldTimeOutlined />,
|
||||||
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
label: <Link to="/manage/ttapprovals">{t("menus.header.ttapprovals")}</Link>
|
||||||
});
|
|
||||||
}
|
}
|
||||||
accountingChildren.push(
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
key: "entertimetickets",
|
key: "entertimetickets",
|
||||||
icon: <Icon component={GiPlayerTime} />,
|
id: "header-accounting-entertimetickets",
|
||||||
|
icon: <GiPlayerTime />,
|
||||||
label: (
|
label: (
|
||||||
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
|
<LockWrapper featureName="timetickets" bodyshop={bodyshop}>
|
||||||
{t("menus.header.entertimeticket")}
|
{t("menus.header.entertimeticket")}
|
||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
),
|
),
|
||||||
id: "header-accounting-entertimetickets",
|
onClick: () =>
|
||||||
onClick: () => {
|
|
||||||
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
|
HasFeatureAccess({ featureName: "timetickets", bodyshop }) &&
|
||||||
setTimeTicketContext({
|
setTimeTicketContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: {
|
context: {
|
||||||
created_by: currentUser.displayName
|
created_by: currentUser.displayName
|
||||||
? currentUser.email.concat(" | ", currentUser.displayName)
|
? `${currentUser.email} | ${currentUser.displayName}`
|
||||||
: currentUser.email
|
: currentUser.email
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
{ type: "divider" },
|
||||||
{
|
{
|
||||||
type: "divider"
|
key: "accountingexport",
|
||||||
}
|
id: "header-accounting-export",
|
||||||
);
|
icon: <ExportOutlined />,
|
||||||
|
label: (
|
||||||
const accountingExportChildren = [
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
|
{t("menus.header.export")}
|
||||||
|
</LockWrapper>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
{
|
{
|
||||||
key: "receivables",
|
key: "receivables",
|
||||||
id: "header-accounting-receivables",
|
id: "header-accounting-receivables",
|
||||||
@@ -290,11 +296,11 @@ function Header({
|
|||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
];
|
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) ||
|
||||||
|
DmsAp.treatment === "on"
|
||||||
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber)) || DmsAp.treatment === "on") {
|
? [
|
||||||
accountingExportChildren.push({
|
{
|
||||||
key: "payables",
|
key: "payables",
|
||||||
id: "header-accounting-payables",
|
id: "header-accounting-payables",
|
||||||
label: (
|
label: (
|
||||||
@@ -304,11 +310,12 @@ function Header({
|
|||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
if (!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))) {
|
: []),
|
||||||
accountingExportChildren.push({
|
...(!((bodyshop && bodyshop.cdk_dealerid) || (bodyshop && bodyshop.pbs_serialnumber))
|
||||||
|
? [
|
||||||
|
{
|
||||||
key: "payments",
|
key: "payments",
|
||||||
id: "header-accounting-payments",
|
id: "header-accounting-payments",
|
||||||
label: (
|
label: (
|
||||||
@@ -318,13 +325,10 @@ function Header({
|
|||||||
</LockWrapper>
|
</LockWrapper>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
accountingExportChildren.push(
|
: []),
|
||||||
{
|
{ type: "divider" },
|
||||||
type: "divider"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "exportlogs",
|
key: "exportlogs",
|
||||||
id: "header-accounting-exportlogs",
|
id: "header-accounting-exportlogs",
|
||||||
@@ -336,37 +340,28 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
);
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
accountingChildren.push({
|
// Left menu items (includes original navigation items)
|
||||||
key: "accountingexport",
|
const leftMenuItems = [
|
||||||
id: "header-accounting-export",
|
|
||||||
icon: <ExportOutlined />,
|
|
||||||
label: (
|
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
|
||||||
{t("menus.header.export")}
|
|
||||||
</LockWrapper>
|
|
||||||
),
|
|
||||||
children: accountingExportChildren
|
|
||||||
});
|
|
||||||
|
|
||||||
const menuItems = [
|
|
||||||
{
|
{
|
||||||
key: "home",
|
key: "home",
|
||||||
icon: <HomeFilled />,
|
|
||||||
id: "header-home",
|
id: "header-home",
|
||||||
|
icon: <HomeFilled />,
|
||||||
label: <Link to="/manage/">{t("menus.header.home")}</Link>
|
label: <Link to="/manage/">{t("menus.header.home")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "schedule",
|
key: "schedule",
|
||||||
id: "header-schedule",
|
id: "header-schedule",
|
||||||
icon: <Icon component={FaCalendarAlt} />,
|
icon: <FaCalendarAlt />,
|
||||||
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
label: <Link to="/manage/schedule">{t("menus.header.schedule")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "jobssubmenu",
|
key: "jobssubmenu",
|
||||||
id: "header-jobs",
|
id: "header-jobs",
|
||||||
icon: <Icon component={FaCarCrash} />,
|
icon: <FaCarCrash />,
|
||||||
label: t("menus.header.jobs"),
|
label: t("menus.header.jobs"),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -399,31 +394,24 @@ function Header({
|
|||||||
icon: <FileAddOutlined />,
|
icon: <FileAddOutlined />,
|
||||||
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
|
label: <Link to="/manage/jobs/new">{t("menus.header.newjob")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{ type: "divider" },
|
||||||
type: "divider",
|
|
||||||
id: "header-jobs-divider"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "alljobs",
|
key: "alljobs",
|
||||||
id: "header-all-jobs",
|
id: "header-all-jobs",
|
||||||
icon: <UnorderedListOutlined />,
|
icon: <UnorderedListOutlined />,
|
||||||
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
label: <Link to="/manage/jobs/all">{t("menus.header.alljobs")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{ type: "divider" },
|
||||||
type: "divider",
|
|
||||||
id: "header-jobs-divider2"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "productionlist",
|
key: "productionlist",
|
||||||
id: "header-production-list",
|
id: "header-production-list",
|
||||||
icon: <ScheduleOutlined />,
|
icon: <ScheduleOutlined />,
|
||||||
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
label: <Link to="/manage/production/list">{t("menus.header.productionlist")}</Link>
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "productionboard",
|
key: "productionboard",
|
||||||
id: "header-production-board",
|
id: "header-production-board",
|
||||||
icon: <Icon component={BsKanban} />,
|
icon: <BsKanban />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/production/board">
|
<Link to="/manage/production/board">
|
||||||
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
|
<LockWrapper featureName="visualboard" bodyshop={bodyshop}>
|
||||||
@@ -432,11 +420,7 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{ type: "divider" },
|
||||||
{
|
|
||||||
type: "divider",
|
|
||||||
id: "header-jobs-divider3"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "scoreboard",
|
key: "scoreboard",
|
||||||
id: "header-scoreboard",
|
id: "header-scoreboard",
|
||||||
@@ -453,8 +437,8 @@ function Header({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "customers",
|
key: "customers",
|
||||||
icon: <UserOutlined />,
|
|
||||||
id: "header-customers",
|
id: "header-customers",
|
||||||
|
icon: <UserOutlined />,
|
||||||
label: t("menus.header.customers"),
|
label: t("menus.header.customers"),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -519,7 +503,6 @@ function Header({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
...(accountingChildren.length > 0
|
...(accountingChildren.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -537,7 +520,6 @@ function Header({
|
|||||||
icon: <PhoneOutlined />,
|
icon: <PhoneOutlined />,
|
||||||
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
label: <Link to="/manage/phonebook">{t("menus.header.phonebook")}</Link>
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "temporarydocs",
|
key: "temporarydocs",
|
||||||
id: "header-temporarydocs",
|
id: "header-temporarydocs",
|
||||||
@@ -550,7 +532,6 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "tasks",
|
key: "tasks",
|
||||||
id: "tasks",
|
id: "tasks",
|
||||||
@@ -562,12 +543,7 @@ function Header({
|
|||||||
id: "header-create-task",
|
id: "header-create-task",
|
||||||
icon: <PlusCircleOutlined />,
|
icon: <PlusCircleOutlined />,
|
||||||
label: t("menus.header.create_task"),
|
label: t("menus.header.create_task"),
|
||||||
onClick: () => {
|
onClick: () => setTaskUpsertContext({ actions: {}, context: {} })
|
||||||
setTaskUpsertContext({
|
|
||||||
actions: {},
|
|
||||||
context: {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "mytasks",
|
key: "mytasks",
|
||||||
@@ -592,7 +568,7 @@ function Header({
|
|||||||
{
|
{
|
||||||
key: "shop",
|
key: "shop",
|
||||||
id: "header-shop",
|
id: "header-shop",
|
||||||
icon: <Icon component={GiSettingsKnobs} />,
|
icon: <GiSettingsKnobs />,
|
||||||
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
|
label: <Link to="/manage/shop?tab=info">{t("menus.header.shop_config")}</Link>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -610,24 +586,18 @@ function Header({
|
|||||||
id: "header-reportcenter",
|
id: "header-reportcenter",
|
||||||
icon: <BarChartOutlined />,
|
icon: <BarChartOutlined />,
|
||||||
label: t("menus.header.reportcenter"),
|
label: t("menus.header.reportcenter"),
|
||||||
onClick: () => {
|
onClick: () => setReportCenterContext({ actions: {}, context: {} })
|
||||||
setReportCenterContext({
|
|
||||||
actions: {},
|
|
||||||
context: {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "shop-vendors",
|
key: "shop-vendors",
|
||||||
id: "header-shop-vendors",
|
id: "header-shop-vendors",
|
||||||
icon: <Icon component={IoBusinessOutline} />,
|
icon: <IoBusinessOutline />,
|
||||||
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
label: <Link to="/manage/shop/vendors">{t("menus.header.shop_vendors")}</Link>
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "shop-csi",
|
key: "shop-csi",
|
||||||
id: "header-shop-csi",
|
id: "header-shop-csi",
|
||||||
icon: <Icon component={RiSurveyLine} />,
|
icon: <RiSurveyLine />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/shop/csi">
|
<Link to="/manage/shop/csi">
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
@@ -638,14 +608,27 @@ function Header({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "recent",
|
||||||
|
id: "header-recent",
|
||||||
|
icon: <ClockCircleFilled />,
|
||||||
|
label: t("menus.header.recent"),
|
||||||
|
children: recentItems.map((i, idx) => ({
|
||||||
|
key: idx,
|
||||||
|
id: `header-recent-${idx}`,
|
||||||
|
label: <Link to={i.url}>{i.label}</Link>
|
||||||
|
}))
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "user",
|
key: "user",
|
||||||
label: currentUser.displayName || currentUser.email || t("general.labels.unknown"),
|
id: "header-user",
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: t("menus.currentuser.profile"),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
key: "signout",
|
key: "signout",
|
||||||
id: "header-signout",
|
id: "header-signout",
|
||||||
icon: <Icon component={FiLogOut} />,
|
icon: <FiLogOut />,
|
||||||
danger: true,
|
danger: true,
|
||||||
label: t("user.actions.signout"),
|
label: t("user.actions.signout"),
|
||||||
onClick: () => signOutStart()
|
onClick: () => signOutStart()
|
||||||
@@ -653,33 +636,25 @@ function Header({
|
|||||||
{
|
{
|
||||||
key: "help",
|
key: "help",
|
||||||
id: "header-help",
|
id: "header-help",
|
||||||
icon: <Icon component={QuestionCircleFilled} />,
|
icon: <QuestionCircleFilled />,
|
||||||
label: t("menus.header.help"),
|
label: t("menus.header.help"),
|
||||||
onClick: () => {
|
onClick: () => window.open("https://help.imex.online/", "_blank")
|
||||||
window.open("https://help.imex.online/", "_blank");
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
...(InstanceRenderManager({
|
...(InstanceRenderManager({ imex: true, rome: false })
|
||||||
imex: true,
|
|
||||||
rome: false
|
|
||||||
})
|
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "rescue",
|
key: "rescue",
|
||||||
id: "header-rescue",
|
id: "header-rescue",
|
||||||
icon: <Icon component={CarFilled} />,
|
icon: <CarFilled />,
|
||||||
label: t("menus.header.rescueme"),
|
label: t("menus.header.rescueme"),
|
||||||
onClick: () => {
|
onClick: () => window.open("https://imexrescue.com/", "_blank")
|
||||||
window.open("https://imexrescue.com/", "_blank");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "shiftclock",
|
key: "shiftclock",
|
||||||
id: "header-shiftclock",
|
id: "header-shiftclock",
|
||||||
icon: <Icon component={GiPlayerTime} />,
|
icon: <GiPlayerTime />,
|
||||||
label: (
|
label: (
|
||||||
<Link to="/manage/shiftclock">
|
<Link to="/manage/shiftclock">
|
||||||
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
<LockWrapper featureName="export" bodyshop={bodyshop}>
|
||||||
@@ -688,64 +663,79 @@ function Header({
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: "profile",
|
key: "profile",
|
||||||
id: "header-profile",
|
id: "header-profile",
|
||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
label: <Link to="/manage/profile">{t("menus.currentuser.profile")}</Link>
|
||||||
}
|
}
|
||||||
// {
|
|
||||||
// key: 'langselecter',
|
|
||||||
// label: t("menus.currentuser.languageselector"),
|
|
||||||
// children: [
|
|
||||||
// {
|
|
||||||
// key: 'en-US',
|
|
||||||
// label: t("general.languages.english"),
|
|
||||||
// onClick: () => {
|
|
||||||
// window.location.href = "/?lang=en-US";
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'fr-CA',
|
|
||||||
// label: t("general.languages.french"),
|
|
||||||
// onClick: () => {
|
|
||||||
// window.location.href = "/?lang=fr-CA";
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// key: 'es-MX',
|
|
||||||
// label: t("general.languages.spanish"),
|
|
||||||
// onClick: () => {
|
|
||||||
// window.location.href = "/?lang=es-MX";
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// ]
|
|
||||||
// },
|
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "recent",
|
|
||||||
icon: <ClockCircleFilled />,
|
|
||||||
id: "header-recent",
|
|
||||||
children: recentItems.map((i, idx) => ({
|
|
||||||
key: idx,
|
|
||||||
id: `header-recent-${idx}`,
|
|
||||||
label: <Link to={i.url}>{i.label}</Link>
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Notifications item (always on the right)
|
||||||
|
const notificationItem = scenarioNotificationsOn
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "notifications",
|
||||||
|
id: "header-notifications",
|
||||||
|
icon: unreadLoading ? (
|
||||||
|
<Spin size="small" />
|
||||||
|
) : (
|
||||||
|
<Badge offset={[8, 0]} size="small" count={unreadCount}>
|
||||||
|
<BellFilled />
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
onClick: handleNotificationClick
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout.Header>
|
<Layout.Header style={{ padding: 0, background: "#001529" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden"
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Menu
|
<Menu
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
theme={"dark"}
|
theme="dark"
|
||||||
selectedKeys={[selectedHeader]}
|
selectedKeys={[selectedHeader]}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
subMenuCloseDelay={0.3}
|
subMenuCloseDelay={0.3}
|
||||||
items={menuItems}
|
items={leftMenuItems}
|
||||||
|
style={{
|
||||||
|
flex: "1 1 auto",
|
||||||
|
minWidth: 0,
|
||||||
|
overflowX: "auto",
|
||||||
|
borderBottom: "none",
|
||||||
|
background: "transparent"
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{scenarioNotificationsOn && (
|
||||||
|
<Menu
|
||||||
|
mode="horizontal"
|
||||||
|
theme="dark"
|
||||||
|
selectedKeys={[selectedHeader]}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
subMenuCloseDelay={0.3}
|
||||||
|
items={notificationItem}
|
||||||
|
style={{ flex: "0 0 auto", minWidth: 0, borderBottom: "none", background: "transparent" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{scenarioNotificationsOn && (
|
||||||
|
<NotificationCenterContainer
|
||||||
|
visible={notificationVisible}
|
||||||
|
onClose={() => setNotificationVisible(false)}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Layout.Header>
|
</Layout.Header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,7 @@
|
|||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import HeaderComponent from "./header.component";
|
import HeaderComponent from "./header.component";
|
||||||
|
|
||||||
// const mapDispatchToProps = (dispatch) => ({
|
|
||||||
// setUserLanguage: (language) => dispatch(setUserLanguage(language))
|
|
||||||
// });
|
|
||||||
|
|
||||||
// setUserLanguage was removed from signature because it is not used in the component, and it is throwing a deprecation warning
|
|
||||||
export function HeaderContainer() {
|
export function HeaderContainer() {
|
||||||
// Commented out the handleMenuClick function because it is not used in the component, and it is throwing a deprecation warning
|
|
||||||
|
|
||||||
/* const handleMenuClick = (e) => {
|
|
||||||
if (e.item.props.actiontype === "lang-select") {
|
|
||||||
i18next.changeLanguage(e.key, (err, t) => {
|
|
||||||
if (err) {
|
|
||||||
logImEXEvent("language_change_error", { error: err });
|
|
||||||
|
|
||||||
return console.log("Error encountered when changing languages.", err);
|
|
||||||
}
|
|
||||||
logImEXEvent("language_change", { language: e.key });
|
|
||||||
|
|
||||||
setUserLanguage(e.key);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};*/
|
|
||||||
// return <HeaderComponent handleMenuClick={handleMenuClick} />;
|
|
||||||
|
|
||||||
return <HeaderComponent />;
|
return <HeaderComponent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useMutation } from "@apollo/client";
|
|||||||
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
|
import { Button, Divider, Dropdown, Form, Input, Popover, Select, Space } from "antd";
|
||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import React, { useContext, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
import { UPDATE_APPOINTMENT } from "../../graphql/appointments.queries";
|
||||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
@@ -51,7 +51,7 @@ export function ScheduleEventComponent({
|
|||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
const [updateAppointment] = useMutation(UPDATE_APPOINTMENT);
|
||||||
const [title, setTitle] = useState(event.title);
|
const [title, setTitle] = useState(event.title);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const blockContent = (
|
const blockContent = (
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export function JobCloseRoGuardContainer({ job, jobRO, bodyshop, form }) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Collapse.Panel>
|
</Collapse.Panel>
|
||||||
|
|
||||||
<Collapse.Panel header={t("jobs.labels.performance")}>
|
<Collapse.Panel key="job-performance" header={t("jobs.labels.performance")}>
|
||||||
<Row gutter={[32, 32]}>
|
<Row gutter={[32, 32]}>
|
||||||
<Col className="ro-guard-col" span={24}>
|
<Col className="ro-guard-col" span={24}>
|
||||||
<JobCloseRoGuardTtLifecycle job={job} />
|
<JobCloseRoGuardTtLifecycle job={job} />
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { PrinterFilled } from "@ant-design/icons";
|
import { PauseCircleOutlined, PlayCircleOutlined, PrinterFilled } from "@ant-design/icons";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
import { Button, Card, Col, Divider, Drawer, Grid, Row, Space } from "antd";
|
import { Button, Card, Col, Divider, Drawer, Grid, Row, Space } from "antd";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { QUERY_JOB_CARD_DETAILS } from "../../graphql/jobs.queries";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
|
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||||
|
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import JobSyncButton from "../job-sync-button/job-sync-button.component";
|
import JobSyncButton from "../job-sync-button/job-sync-button.component";
|
||||||
|
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||||
import JobsDetailHeader from "../jobs-detail-header/jobs-detail-header.component";
|
import JobsDetailHeader from "../jobs-detail-header/jobs-detail-header.component";
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import JobDetailCardsDamageComponent from "./job-detail-cards.damage.component";
|
import JobDetailCardsDamageComponent from "./job-detail-cards.damage.component";
|
||||||
@@ -27,7 +31,15 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" }))
|
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })),
|
||||||
|
insertAuditTrail: ({ jobid, operation, type }) =>
|
||||||
|
dispatch(
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid,
|
||||||
|
operation,
|
||||||
|
type
|
||||||
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
const span = {
|
const span = {
|
||||||
@@ -36,7 +48,9 @@ const span = {
|
|||||||
xxl: { span: 8 }
|
xxl: { span: 8 }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
export function JobDetailCards({ bodyshop, setPrintCenterContext, insertAuditTrail }) {
|
||||||
|
const { scenarioNotificationsOn } = useSocket();
|
||||||
|
const [updateJob] = useMutation(UPDATE_JOB);
|
||||||
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
|
||||||
.filter((screen) => !!screen[1])
|
.filter((screen) => !!screen[1])
|
||||||
.slice(-1)[0];
|
.slice(-1)[0];
|
||||||
@@ -78,12 +92,39 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
|||||||
{data ? (
|
{data ? (
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>{data.jobs_by_pk.ro_number || t("general.labels.na")}</Link>
|
<Space>
|
||||||
|
{scenarioNotificationsOn && <JobWatcherToggleContainer job={data.jobs_by_pk} />}
|
||||||
|
<Link to={`/manage/jobs/${data.jobs_by_pk.id}`}>
|
||||||
|
{data.jobs_by_pk.ro_number || t("general.labels.na")}
|
||||||
|
</Link>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<JobSyncButton job={data.jobs_by_pk} />
|
<JobSyncButton job={data.jobs_by_pk} />
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
logImEXEvent("production_toggle_alert");
|
||||||
|
updateJob({
|
||||||
|
variables: {
|
||||||
|
jobId: data.jobs_by_pk.id,
|
||||||
|
job: {
|
||||||
|
suspended: !data.jobs_by_pk.suspended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: data.jobs_by_pk.id,
|
||||||
|
operation: AuditTrailMapping.jobsuspend(
|
||||||
|
data.jobs_by_pk.suspended ? !data.jobs_by_pk.suspended : true
|
||||||
|
),
|
||||||
|
type: "jobsuspend"
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
icon={data.jobs_by_pk.suspended ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
|
||||||
|
>
|
||||||
|
{data.jobs_by_pk.suspended ? t("production.actions.unsuspend") : t("production.actions.suspend")}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPrintCenterContext({
|
setPrintCenterContext({
|
||||||
@@ -95,8 +136,8 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
icon={<PrinterFilled />}
|
||||||
>
|
>
|
||||||
<PrinterFilled />
|
|
||||||
{t("jobs.actions.printCenter")}
|
{t("jobs.actions.printCenter")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link to={`/manage/jobs/${data.jobs_by_pk.id}?tab=repairdata`}>
|
<Link to={`/manage/jobs/${data.jobs_by_pk.id}?tab=repairdata`}>
|
||||||
@@ -122,7 +163,11 @@ export function JobDetailCards({ bodyshop, setPrintCenterContext }) {
|
|||||||
</Col>
|
</Col>
|
||||||
{!bodyshop.uselocalmediaserver && (
|
{!bodyshop.uselocalmediaserver && (
|
||||||
<Col {...span}>
|
<Col {...span}>
|
||||||
<JobDetailCardsDocumentsComponent loading={loading} data={data ? data.jobs_by_pk : null} bodyshop={bodyshop} />
|
<JobDetailCardsDocumentsComponent
|
||||||
|
loading={loading}
|
||||||
|
data={data ? data.jobs_by_pk : null}
|
||||||
|
bodyshop={bodyshop}
|
||||||
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<Col {...span}>
|
<Col {...span}>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function JobsTotalsTableComponent({ jobRO, currentUser, job }) {
|
|||||||
<Card title="DEVELOPMENT USE ONLY">
|
<Card title="DEVELOPMENT USE ONLY">
|
||||||
<JobCalculateTotals job={job} disabled={jobRO} />
|
<JobCalculateTotals job={job} disabled={jobRO} />
|
||||||
<Collapse>
|
<Collapse>
|
||||||
<Collapse.Panel header="JSON Tree Totals">
|
<Collapse.Panel key="json-totals" header="JSON Tree Totals">
|
||||||
<div>
|
<div>
|
||||||
<pre>
|
<pre>
|
||||||
{JSON.stringify(
|
{JSON.stringify(
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { EyeFilled, EyeOutlined, UserOutlined } from "@ant-design/icons";
|
||||||
|
import { Avatar, Button, Divider, List, Popover, Select, Tooltip, Typography } from "antd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import EmployeeSearchSelectComponent from "../../components/employee-search-select/employee-search-select.component.jsx";
|
||||||
|
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component.jsx";
|
||||||
|
import { BiSolidTrash } from "react-icons/bi";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export default function JobWatcherToggleComponent({
|
||||||
|
jobWatchers,
|
||||||
|
isWatching,
|
||||||
|
watcherLoading,
|
||||||
|
adding,
|
||||||
|
removing,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
selectedWatcher,
|
||||||
|
setSelectedWatcher,
|
||||||
|
selectedTeam,
|
||||||
|
bodyshop,
|
||||||
|
Enhanced_Payroll,
|
||||||
|
handleToggleSelf,
|
||||||
|
handleRemoveWatcher,
|
||||||
|
handleWatcherSelect,
|
||||||
|
handleTeamSelect
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleRenderItem = (watcher) => {
|
||||||
|
// Check if watcher is defined and has user_email
|
||||||
|
if (!watcher || !watcher.user_email) {
|
||||||
|
return null; // Skip rendering invalid watchers
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = bodyshop?.employees?.find((e) => e.user_email === watcher.user_email);
|
||||||
|
const displayName = employee ? `${employee.first_name} ${employee.last_name}` : watcher.user_email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
danger
|
||||||
|
size="medium"
|
||||||
|
icon={<BiSolidTrash />}
|
||||||
|
onClick={() => handleRemoveWatcher(watcher.user_email)}
|
||||||
|
disabled={adding || removing} // Optional: Disable button during mutations
|
||||||
|
>
|
||||||
|
{t("notifications.actions.remove")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<Avatar icon={<UserOutlined />} />}
|
||||||
|
title={<Text>{displayName}</Text>}
|
||||||
|
description={watcher.user_email}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const popoverContent = (
|
||||||
|
<div style={{ width: "30em" }}>
|
||||||
|
<List>
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type={isWatching ? "primary" : "default"}
|
||||||
|
danger={!isWatching}
|
||||||
|
icon={isWatching ? <EyeOutlined /> : <EyeFilled />}
|
||||||
|
size="medium"
|
||||||
|
onClick={handleToggleSelf}
|
||||||
|
loading={adding || removing}
|
||||||
|
>
|
||||||
|
{isWatching ? t("notifications.labels.unwatch") : t("notifications.labels.watch")}
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta>
|
||||||
|
<Text type="secondary" style={{ marginBottom: 8, display: "block" }}>
|
||||||
|
{t("notifications.labels.watching-issue")}
|
||||||
|
</Text>
|
||||||
|
</List.Item.Meta>
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
{watcherLoading ? (
|
||||||
|
<LoadingSpinner />
|
||||||
|
) : jobWatchers && jobWatchers.length > 0 ? (
|
||||||
|
<List dataSource={jobWatchers} renderItem={handleRenderItem} />
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">{t("notifications.labels.no-watchers")}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Text type="secondary">{t("notifications.labels.add-watchers")}</Text>
|
||||||
|
<EmployeeSearchSelectComponent
|
||||||
|
style={{ minWidth: "100%" }}
|
||||||
|
options={
|
||||||
|
bodyshop?.employees?.filter((e) =>
|
||||||
|
jobWatchers.every((w) => w.user_email !== e.user_email && e.active && e.user_email)
|
||||||
|
) || []
|
||||||
|
}
|
||||||
|
placeholder={t("notifications.labels.employee-search")}
|
||||||
|
value={selectedWatcher}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedWatcher(value);
|
||||||
|
handleWatcherSelect(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Enhanced_Payroll && bodyshop?.employee_teams?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Text type="secondary">{t("notifications.labels.add-watchers-team")}</Text>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
style={{ minWidth: "100%" }}
|
||||||
|
placeholder={t("notifications.labels.teams-search")}
|
||||||
|
value={selectedTeam}
|
||||||
|
onChange={handleTeamSelect}
|
||||||
|
options={
|
||||||
|
bodyshop?.employee_teams?.map((team) => {
|
||||||
|
const teamMembers = team.employee_team_members
|
||||||
|
.map((member) => {
|
||||||
|
const employee = bodyshop?.employees?.find((e) => e.id === member.employeeid);
|
||||||
|
return employee?.user_email && employee?.active ? employee.user_email : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return {
|
||||||
|
value: JSON.stringify(teamMembers),
|
||||||
|
label: team.name
|
||||||
|
};
|
||||||
|
}) || []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover placement="rightTop" content={popoverContent} trigger="click" open={open} onOpenChange={setOpen}>
|
||||||
|
<Tooltip title={t("notifications.tooltips.job-watchers")}>
|
||||||
|
<Button
|
||||||
|
shape="circle"
|
||||||
|
type={isWatching ? "primary" : "default"}
|
||||||
|
icon={isWatching ? <EyeFilled /> : <EyeOutlined />}
|
||||||
|
loading={watcherLoading}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
|
import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../graphql/jobs.queries.js";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||||
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
|
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||||
|
|
||||||
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
bodyshop: selectBodyshop,
|
||||||
|
currentUser: selectCurrentUser
|
||||||
|
});
|
||||||
|
|
||||||
|
function JobWatcherToggleContainer({ job, currentUser, bodyshop }) {
|
||||||
|
const {
|
||||||
|
treatments: { Enhanced_Payroll }
|
||||||
|
} = useSplitTreatments({
|
||||||
|
attributes: {},
|
||||||
|
names: ["Enhanced_Payroll"],
|
||||||
|
splitKey: bodyshop && bodyshop.imexshopid
|
||||||
|
});
|
||||||
|
|
||||||
|
const userEmail = currentUser.email;
|
||||||
|
const jobid = job.id;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedWatcher, setSelectedWatcher] = useState(null);
|
||||||
|
const [selectedTeam, setSelectedTeam] = useState(null);
|
||||||
|
|
||||||
|
// Fetch current watchers with refetch capability
|
||||||
|
const {
|
||||||
|
data: watcherData,
|
||||||
|
loading: watcherLoading,
|
||||||
|
refetch
|
||||||
|
} = useQuery(GET_JOB_WATCHERS, {
|
||||||
|
variables: { jobid },
|
||||||
|
fetchPolicy: "cache-and-network" // Ensure fresh data from server
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refetch jobWatchers when the popover opens (open changes to true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
refetch().catch((err) =>
|
||||||
|
console.error(`Something went wrong fetching Notification Watchers on popover open: ${err?.message}`, {
|
||||||
|
stack: err?.stack
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, refetch]);
|
||||||
|
|
||||||
|
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
|
||||||
|
const isWatching = jobWatchers.some((w) => w.user_email === userEmail);
|
||||||
|
|
||||||
|
const [addWatcher, { loading: adding }] = useMutation(ADD_JOB_WATCHER, {
|
||||||
|
onCompleted: () =>
|
||||||
|
refetch().catch((err) =>
|
||||||
|
console.error(`Something went wrong fetching Notification Watchers after add: ${err?.message}`, {
|
||||||
|
stack: err?.stack
|
||||||
|
})
|
||||||
|
),
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.graphQLErrors && err.graphQLErrors.length > 0) {
|
||||||
|
const errorMessage = err.graphQLErrors[0].message;
|
||||||
|
if (
|
||||||
|
errorMessage.includes("Uniqueness violation") ||
|
||||||
|
errorMessage.includes("idx_job_watchers_jobid_user_email_unique")
|
||||||
|
) {
|
||||||
|
console.warn("Watcher already exists for this job and user.");
|
||||||
|
refetch().catch((err) =>
|
||||||
|
console.error(
|
||||||
|
`Something went wrong fetching Notification Watchers after uniqueness violation: ${err?.message}`,
|
||||||
|
{ stack: err?.stack }
|
||||||
|
)
|
||||||
|
); // Sync with server to ensure UI reflects actual state
|
||||||
|
} else {
|
||||||
|
console.error(`Error adding job watcher: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`Unexpected error adding job watcher: ${err.message || JSON.stringify(err)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update(cache, { data }) {
|
||||||
|
if (!data || !data.insert_job_watchers_one) {
|
||||||
|
console.warn("No data or insert_job_watchers_one returned from mutation, skipping cache update.");
|
||||||
|
refetch().catch((err) =>
|
||||||
|
console.error(`Something went wrong updating Notification Watchers after add: ${err?.message}`, {
|
||||||
|
stack: err?.stack
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insert_job_watchers_one = data.insert_job_watchers_one;
|
||||||
|
const existingData = cache.readQuery({
|
||||||
|
query: GET_JOB_WATCHERS,
|
||||||
|
variables: { jobid }
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_JOB_WATCHERS,
|
||||||
|
variables: { jobid },
|
||||||
|
data: {
|
||||||
|
...existingData,
|
||||||
|
job_watchers: [...(existingData?.job_watchers || []), insert_job_watchers_one]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [removeWatcher, { loading: removing }] = useMutation(REMOVE_JOB_WATCHER, {
|
||||||
|
onCompleted: () =>
|
||||||
|
refetch().catch((err) =>
|
||||||
|
console.error(`Something went wrong fetching Notification Watchers after remove: ${err?.message}`, {
|
||||||
|
stack: err?.stack
|
||||||
|
})
|
||||||
|
), // Refetch to sync with server after success
|
||||||
|
onError: (err) => console.error(`Error removing job watcher: ${err.message}`),
|
||||||
|
update(cache, { data: { delete_job_watchers } }) {
|
||||||
|
const existingData = cache.readQuery({
|
||||||
|
query: GET_JOB_WATCHERS,
|
||||||
|
variables: { jobid }
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedWatcher = delete_job_watchers.returning[0];
|
||||||
|
const updatedWatchers = deletedWatcher
|
||||||
|
? (existingData?.job_watchers || []).filter((watcher) => watcher.user_email !== deletedWatcher.user_email)
|
||||||
|
: existingData?.job_watchers || [];
|
||||||
|
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_JOB_WATCHERS,
|
||||||
|
variables: { jobid },
|
||||||
|
data: {
|
||||||
|
...existingData,
|
||||||
|
job_watchers: updatedWatchers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleSelf = useCallback(async () => {
|
||||||
|
if (adding || removing) return;
|
||||||
|
if (isWatching) {
|
||||||
|
await removeWatcher({ variables: { jobid, userEmail } });
|
||||||
|
} else {
|
||||||
|
await addWatcher({ variables: { jobid, userEmail } });
|
||||||
|
}
|
||||||
|
}, [isWatching, addWatcher, removeWatcher, jobid, userEmail, adding, removing]);
|
||||||
|
|
||||||
|
const handleRemoveWatcher = useCallback(
|
||||||
|
async (email) => {
|
||||||
|
if (removing) return;
|
||||||
|
await removeWatcher({ variables: { jobid, userEmail: email } });
|
||||||
|
},
|
||||||
|
[removeWatcher, jobid, removing]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWatcherSelect = useCallback(
|
||||||
|
async (selectedUser) => {
|
||||||
|
if (adding || removing) return;
|
||||||
|
const employee = bodyshop.employees.find((e) => e.id === selectedUser);
|
||||||
|
if (!employee) return;
|
||||||
|
|
||||||
|
const email = employee.user_email;
|
||||||
|
const isAlreadyWatching = jobWatchers.some((w) => w.user_email === email);
|
||||||
|
|
||||||
|
if (isAlreadyWatching) {
|
||||||
|
await handleRemoveWatcher(email);
|
||||||
|
} else {
|
||||||
|
await addWatcher({ variables: { jobid, userEmail: email } });
|
||||||
|
}
|
||||||
|
setSelectedWatcher(null);
|
||||||
|
},
|
||||||
|
[jobWatchers, addWatcher, handleRemoveWatcher, jobid, bodyshop, adding, removing]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTeamSelect = useCallback(
|
||||||
|
async (team) => {
|
||||||
|
if (adding) return;
|
||||||
|
const selectedTeamMembers = JSON.parse(team);
|
||||||
|
const newWatchers = selectedTeamMembers.filter(
|
||||||
|
(email) => !jobWatchers.some((watcher) => watcher.user_email === email)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newWatchers.length === 0) {
|
||||||
|
console.warn("All selected team members are already watchers.");
|
||||||
|
setSelectedTeam(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(newWatchers.map((email) => addWatcher({ variables: { jobid, userEmail: email } })));
|
||||||
|
},
|
||||||
|
[jobWatchers, addWatcher, jobid, adding]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JobWatcherToggleComponent
|
||||||
|
jobWatchers={jobWatchers}
|
||||||
|
isWatching={isWatching}
|
||||||
|
watcherLoading={watcherLoading}
|
||||||
|
adding={adding}
|
||||||
|
removing={removing}
|
||||||
|
open={open}
|
||||||
|
setOpen={setOpen}
|
||||||
|
selectedWatcher={selectedWatcher}
|
||||||
|
setSelectedWatcher={setSelectedWatcher}
|
||||||
|
selectedTeam={selectedTeam}
|
||||||
|
setSelectedTeam={setSelectedTeam}
|
||||||
|
bodyshop={bodyshop}
|
||||||
|
Enhanced_Payroll={Enhanced_Payroll}
|
||||||
|
handleToggleSelf={handleToggleSelf}
|
||||||
|
handleRemoveWatcher={handleRemoveWatcher}
|
||||||
|
handleWatcherSelect={handleWatcherSelect}
|
||||||
|
handleTeamSelect={handleTeamSelect}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(JobWatcherToggleContainer);
|
||||||
@@ -4,12 +4,12 @@ import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
|||||||
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
|
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import parsePhoneNumber from "libphonenumber-js";
|
import parsePhoneNumber from "libphonenumber-js";
|
||||||
import { useContext, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
|
import { auth, logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
|
import { CANCEL_APPOINTMENTS_BY_JOB_ID, INSERT_MANUAL_APPT } from "../../graphql/appointments.queries";
|
||||||
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
|
import { GET_CURRENT_QUESTIONSET_ID, INSERT_CSI } from "../../graphql/csi.queries";
|
||||||
@@ -28,11 +28,11 @@ import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
|
|||||||
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
|
||||||
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||||
|
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
||||||
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
|
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
|
||||||
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
|
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
|
||||||
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
|
import JobsDetailHeaderActionsToggleProduction from "./jobs-detail-header-actions.toggle-production";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -130,7 +130,7 @@ export function JobsDetailHeaderActions({
|
|||||||
const [updateJob] = useMutation(UPDATE_JOB);
|
const [updateJob] = useMutation(UPDATE_JOB);
|
||||||
const [voidJob] = useMutation(VOID_JOB);
|
const [voidJob] = useMutation(VOID_JOB);
|
||||||
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
|
const [cancelAllAppointments] = useMutation(CANCEL_APPOINTMENTS_BY_JOB_ID);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -775,11 +775,10 @@ export function JobsDetailHeaderActions({
|
|||||||
key: "enterpayments",
|
key: "enterpayments",
|
||||||
id: "job-actions-enterpayments",
|
id: "job-actions-enterpayments",
|
||||||
disabled: !job.converted,
|
disabled: !job.converted,
|
||||||
label: <LockerWrapperComponent featureName="payments">{t("menus.header.enterpayment")}</LockerWrapperComponent>,
|
label: t("menus.header.enterpayment"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
logImEXEvent("job_header_enter_payment");
|
logImEXEvent("job_header_enter_payment");
|
||||||
|
|
||||||
HasFeatureAccess({ featureName: "payments", bodyshop }) &&
|
|
||||||
setPaymentContext({
|
setPaymentContext({
|
||||||
actions: {},
|
actions: {},
|
||||||
context: { jobid: job.id }
|
context: { jobid: job.id }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { connect } from "react-redux";
|
|||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import CABCpvrtCalculator from "../ca-bc-pvrt-calculator/ca-bc-pvrt-calculator.component";
|
import CABCpvrtCalculator from "../ca-bc-pvrt-calculator/ca-bc-pvrt-calculator.component";
|
||||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import JobsDetailRatesChangeButton from "../jobs-detail-rates-change-button/jobs-detail-rates-change-button.component";
|
import JobsDetailRatesChangeButton from "../jobs-detail-rates-change-button/jobs-detail-rates-change-button.component";
|
||||||
@@ -14,9 +15,8 @@ import JobsDetailRatesLabor from "./jobs-detail-rates.labor.component";
|
|||||||
import JobsDetailRatesMaterials from "./jobs-detail-rates.materials.component";
|
import JobsDetailRatesMaterials from "./jobs-detail-rates.materials.component";
|
||||||
import JobsDetailRatesOther from "./jobs-detail-rates.other.component";
|
import JobsDetailRatesOther from "./jobs-detail-rates.other.component";
|
||||||
import JobsDetailRatesParts from "./jobs-detail-rates.parts.component";
|
import JobsDetailRatesParts from "./jobs-detail-rates.parts.component";
|
||||||
import JobsDetailRatesTaxes from "./jobs-detail-rates.taxes.component";
|
|
||||||
import JobsDetailRatesProfileOVerride from "./jobs-detail-rates.profile-override.component";
|
import JobsDetailRatesProfileOVerride from "./jobs-detail-rates.profile-override.component";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import JobsDetailRatesTaxes from "./jobs-detail-rates.taxes.component";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
jobRO: selectJobReadOnly,
|
jobRO: selectJobReadOnly,
|
||||||
@@ -66,14 +66,48 @@ export function JobsDetailRates({ jobRO, form, job, bodyshop }) {
|
|||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
<Form.Item label={t("jobs.fields.auto_add_ats")} name="auto_add_ats" valuePropName="checked">
|
<Form.Item label={t("jobs.fields.auto_add_ats")} name="auto_add_ats" valuePropName="checked">
|
||||||
<Switch disabled={jobRO} />
|
<Switch
|
||||||
|
disabled={jobRO}
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
form.setFieldsValue({ flat_rate_ats: false });
|
||||||
|
form.setFieldsValue({ rate_ats: form.getFieldValue('rate_ats') || bodyshop.shoprates.rate_ats });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.auto_add_ats !== cur.auto_add_ats}>
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.auto_add_ats !== cur.auto_add_ats}>
|
||||||
{() => {
|
{() => {
|
||||||
if (form.getFieldValue("auto_add_ats"))
|
if (form.getFieldValue("auto_add_ats"))
|
||||||
return (
|
return (
|
||||||
<Form.Item label={t("jobs.fields.rate_ats")} name="rate_ats" initialValue={bodyshop.shoprates.rate_atp}>
|
<Form.Item label={t("jobs.fields.rate_ats")} name="rate_ats">
|
||||||
|
<CurrencyInput disabled={jobRO} />
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t("jobs.fields.flat_rate_ats")} name="flat_rate_ats" valuePropName="checked">
|
||||||
|
<Switch
|
||||||
|
disabled={jobRO}
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
form.setFieldsValue({ auto_add_ats: false });
|
||||||
|
form.setFieldsValue({ rate_ats_flat: form.getFieldValue('rate_ats_flat') || bodyshop.shoprates.rate_ats_flat });
|
||||||
|
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.flat_rate_ats !== cur.flat_rate_ats}>
|
||||||
|
{() => {
|
||||||
|
if (form.getFieldValue("flat_rate_ats"))
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
label={t("jobs.fields.rate_ats_flat")}
|
||||||
|
name="rate_ats_flat"
|
||||||
|
>
|
||||||
<CurrencyInput disabled={jobRO} />
|
<CurrencyInput disabled={jobRO} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Virtuoso } from "react-virtuoso";
|
||||||
|
import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
|
||||||
|
import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import "./notification-center.styles.scss";
|
||||||
|
import day from "../../utils/day.js";
|
||||||
|
import { forwardRef, useRef, useEffect } from "react";
|
||||||
|
import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification Center Component
|
||||||
|
* @type {React.ForwardRefExoticComponent<React.PropsWithoutRef<{readonly visible?: *, readonly onClose?: *, readonly notifications?: *, readonly loading?: *, readonly showUnreadOnly?: *, readonly toggleUnreadOnly?: *, readonly markAllRead?: *, readonly loadMore?: *, readonly onNotificationClick?: *, readonly unreadCount?: *}> & React.RefAttributes<unknown>>}
|
||||||
|
*/
|
||||||
|
const NotificationCenterComponent = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
notifications,
|
||||||
|
loading,
|
||||||
|
showUnreadOnly,
|
||||||
|
toggleUnreadOnly,
|
||||||
|
markAllRead,
|
||||||
|
loadMore,
|
||||||
|
onNotificationClick,
|
||||||
|
unreadCount
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const virtuosoRef = useRef(null);
|
||||||
|
|
||||||
|
// Scroll to top when showUnreadOnly changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (virtuosoRef.current) {
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [showUnreadOnly]);
|
||||||
|
|
||||||
|
const renderNotification = (index, notification) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!notification.read) {
|
||||||
|
onNotificationClick(notification.id);
|
||||||
|
}
|
||||||
|
navigate(`/manage/jobs/${notification.jobid}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${notification.id}-${index}`}
|
||||||
|
className={`notification-item ${notification.read ? "notification-read" : "notification-unread"}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Badge dot={!notification.read}>
|
||||||
|
<div className="notification-content">
|
||||||
|
<Title level={5} className="notification-title">
|
||||||
|
<span className="ro-number">
|
||||||
|
{t("notifications.labels.ro-number", { ro_number: notification.roNumber || t("general.labels.na") })}
|
||||||
|
</span>
|
||||||
|
<Text type="secondary" className="relative-time" title={DateTimeFormat(notification.created_at)}>
|
||||||
|
{day(notification.created_at).fromNow()}
|
||||||
|
</Text>
|
||||||
|
</Title>
|
||||||
|
<Text strong={!notification.read} className="notification-body">
|
||||||
|
<ul>
|
||||||
|
{notification.scenarioText.map((text, idx) => (
|
||||||
|
<li key={`${notification.id}-${idx}`}>{text}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`notification-center ${visible ? "visible" : ""}`} ref={ref}>
|
||||||
|
<div className="notification-header">
|
||||||
|
<Space direction="horizontal">
|
||||||
|
<h3>{t("notifications.labels.notification-center")}</h3>
|
||||||
|
{loading && <Spin spinning={loading} size="small"></Spin>}
|
||||||
|
</Space>
|
||||||
|
<div className="notification-controls">
|
||||||
|
<Tooltip title={t("notifications.labels.show-unread-only")}>
|
||||||
|
<Space size={4} align="center" className="notification-toggle">
|
||||||
|
{showUnreadOnly ? (
|
||||||
|
<EyeFilled className="notification-toggle-icon" />
|
||||||
|
) : (
|
||||||
|
<EyeOutlined className="notification-toggle-icon" />
|
||||||
|
)}
|
||||||
|
<Switch checked={showUnreadOnly} onChange={(checked) => toggleUnreadOnly(checked)} size="small" />
|
||||||
|
</Space>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={t("notifications.labels.mark-all-read")}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={!unreadCount ? <CheckCircleFilled /> : <CheckCircleOutlined />}
|
||||||
|
onClick={markAllRead}
|
||||||
|
disabled={!unreadCount}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
style={{ height: "400px", width: "100%" }}
|
||||||
|
data={notifications}
|
||||||
|
totalCount={notifications.length}
|
||||||
|
endReached={loadMore}
|
||||||
|
itemContent={renderNotification}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NotificationCenterComponent;
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import NotificationCenterComponent from "./notification-center.component";
|
||||||
|
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
|
||||||
|
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { selectBodyshop } from "../../redux/user/user.selectors.js";
|
||||||
|
import day from "../../utils/day.js";
|
||||||
|
|
||||||
|
// This will be used to poll for notifications when the socket is disconnected
|
||||||
|
const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification Center Container
|
||||||
|
* @param visible
|
||||||
|
* @param onClose
|
||||||
|
* @param bodyshop
|
||||||
|
* @param unreadCount
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount }) => {
|
||||||
|
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { isConnected, markNotificationRead, markAllNotificationsRead } = useSocket();
|
||||||
|
const notificationRef = useRef(null);
|
||||||
|
|
||||||
|
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||||
|
|
||||||
|
const baseWhereClause = useMemo(() => {
|
||||||
|
return { associationid: { _eq: userAssociationId } };
|
||||||
|
}, [userAssociationId]);
|
||||||
|
|
||||||
|
const whereClause = useMemo(() => {
|
||||||
|
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
|
||||||
|
}, [baseWhereClause, showUnreadOnly]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
fetchMore,
|
||||||
|
loading: queryLoading,
|
||||||
|
refetch
|
||||||
|
} = useQuery(GET_NOTIFICATIONS, {
|
||||||
|
variables: {
|
||||||
|
limit: INITIAL_NOTIFICATIONS,
|
||||||
|
offset: 0,
|
||||||
|
where: whereClause
|
||||||
|
},
|
||||||
|
fetchPolicy: "cache-and-network",
|
||||||
|
notifyOnNetworkStatusChange: true,
|
||||||
|
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||||
|
skip: !userAssociationId,
|
||||||
|
onError: (err) => {
|
||||||
|
console.error(`Error polling Notifications: ${err?.message || ""}`);
|
||||||
|
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
// Prevent open + close behavior from the header
|
||||||
|
if (event.target.closest("#header-notifications")) return;
|
||||||
|
if (visible && notificationRef.current && !notificationRef.current.contains(event.target)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [visible, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.notifications) {
|
||||||
|
const processedNotifications = data.notifications
|
||||||
|
.map((notif) => {
|
||||||
|
let scenarioText;
|
||||||
|
let scenarioMeta;
|
||||||
|
try {
|
||||||
|
scenarioText = notif.scenario_text ? JSON.parse(notif.scenario_text) : [];
|
||||||
|
scenarioMeta = notif.scenario_meta ? JSON.parse(notif.scenario_meta) : {};
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing JSON for notification:", notif.id, e);
|
||||||
|
scenarioText = [notif.fcm_text || "Invalid notification data"];
|
||||||
|
scenarioMeta = {};
|
||||||
|
}
|
||||||
|
if (!Array.isArray(scenarioText)) scenarioText = [scenarioText];
|
||||||
|
const roNumber = notif.job.ro_number;
|
||||||
|
if (!Array.isArray(scenarioMeta)) scenarioMeta = [scenarioMeta];
|
||||||
|
return {
|
||||||
|
id: notif.id,
|
||||||
|
jobid: notif.jobid,
|
||||||
|
associationid: notif.associationid,
|
||||||
|
scenarioText,
|
||||||
|
scenarioMeta,
|
||||||
|
roNumber,
|
||||||
|
created_at: notif.created_at,
|
||||||
|
read: notif.read,
|
||||||
|
__typename: notif.__typename
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||||
|
setNotifications(processedNotifications);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!queryLoading && data?.notifications.length) {
|
||||||
|
setIsLoading(true); // Show spinner during fetchMore
|
||||||
|
fetchMore({
|
||||||
|
variables: { offset: data.notifications.length, where: whereClause },
|
||||||
|
updateQuery: (prev, { fetchMoreResult }) => {
|
||||||
|
if (!fetchMoreResult) return prev;
|
||||||
|
return {
|
||||||
|
notifications: [...prev.notifications, ...fetchMoreResult.notifications]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Fetch more error:", err);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false)); // Hide spinner when done
|
||||||
|
}
|
||||||
|
}, [data?.notifications?.length, fetchMore, queryLoading, whereClause]);
|
||||||
|
|
||||||
|
const handleToggleUnreadOnly = (value) => {
|
||||||
|
setShowUnreadOnly(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
markAllNotificationsRead()
|
||||||
|
.then(() => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
setNotifications((prev) => {
|
||||||
|
const updatedNotifications = prev.map((notif) =>
|
||||||
|
notif.read === null && notif.associationid === userAssociationId
|
||||||
|
? {
|
||||||
|
...notif,
|
||||||
|
read: timestamp
|
||||||
|
}
|
||||||
|
: notif
|
||||||
|
);
|
||||||
|
// Filter out read notifications if in unread only mode
|
||||||
|
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(`Error marking all notifications read: ${e?.message || ""}`))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [markAllNotificationsRead, userAssociationId, showUnreadOnly]);
|
||||||
|
|
||||||
|
const handleNotificationClick = useCallback(
|
||||||
|
(notificationId) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
markNotificationRead({ variables: { id: notificationId } })
|
||||||
|
.then(() => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
setNotifications((prev) => {
|
||||||
|
const updatedNotifications = prev.map((notif) =>
|
||||||
|
notif.id === notificationId && !notif.read ? { ...notif, read: timestamp } : notif
|
||||||
|
);
|
||||||
|
// Filter out the read notification if in unread only mode
|
||||||
|
return showUnreadOnly ? updatedNotifications.filter((notif) => !notif.read) : updatedNotifications;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
},
|
||||||
|
[markNotificationRead, showUnreadOnly]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && !isConnected) {
|
||||||
|
setIsLoading(true);
|
||||||
|
refetch()
|
||||||
|
.catch((err) => console.error(`Error re-fetching notifications: ${err?.message || ""}`))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}
|
||||||
|
}, [visible, isConnected, refetch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationCenterComponent
|
||||||
|
ref={notificationRef}
|
||||||
|
visible={visible}
|
||||||
|
onClose={onClose}
|
||||||
|
notifications={notifications}
|
||||||
|
loading={isLoading}
|
||||||
|
showUnreadOnly={showUnreadOnly}
|
||||||
|
toggleUnreadOnly={handleToggleUnreadOnly}
|
||||||
|
markAllRead={handleMarkAllRead}
|
||||||
|
loadMore={loadMore}
|
||||||
|
onNotificationClick={handleNotificationClick}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
bodyshop: selectBodyshop
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, null)(NotificationCenterContainer);
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
.notification-center {
|
||||||
|
position: absolute;
|
||||||
|
top: 64px;
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 400px;
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08), 0 3px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
padding: 4px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
// Styles for the eye icon and switch (custom classes)
|
||||||
|
.notification-toggle {
|
||||||
|
align-items: center; // Ensure vertical alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-toggle-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1677ff;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-switch {
|
||||||
|
&.ant-switch-small {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
|
||||||
|
.ant-switch-handle {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-switch-checked {
|
||||||
|
background-color: #1677ff;
|
||||||
|
.ant-switch-handle {
|
||||||
|
left: calc(100% - 14px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles for the "Mark All Read" button (restore original link button style)
|
||||||
|
.ant-btn-link {
|
||||||
|
padding: 0;
|
||||||
|
color: #1677ff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #69b1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
color: rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: #0958d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-read {
|
||||||
|
background: #fff;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-unread {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.ro-number {
|
||||||
|
margin: 0;
|
||||||
|
color: #1677ff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative-time {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body {
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-badge {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-alert {
|
||||||
|
margin: 8px;
|
||||||
|
background: #fff1f0;
|
||||||
|
color: rgba(0, 0, 0, 0.85);
|
||||||
|
border: 1px solid #ffa39e;
|
||||||
|
|
||||||
|
.ant-alert-message {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||||
|
import { Checkbox, Form } from "antd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ColumnHeaderCheckbox
|
||||||
|
* @param channel
|
||||||
|
* @param form
|
||||||
|
* @param disabled
|
||||||
|
* @param onHeaderChange
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
const ColumnHeaderCheckbox = ({ channel, form, disabled = false, onHeaderChange }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Subscribe to all form values so that this component re-renders on changes.
|
||||||
|
const formValues = Form.useWatch([], form) || {};
|
||||||
|
|
||||||
|
// Determine if all scenarios for this channel are checked.
|
||||||
|
const allChecked =
|
||||||
|
notificationScenarios.length > 0 && notificationScenarios.every((scenario) => formValues[scenario]?.[channel]);
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
// Get current form values.
|
||||||
|
const currentValues = form.getFieldsValue();
|
||||||
|
// Update each scenario for this channel.
|
||||||
|
const newValues = { ...currentValues };
|
||||||
|
notificationScenarios.forEach((scenario) => {
|
||||||
|
newValues[scenario] = { ...newValues[scenario], [channel]: checked };
|
||||||
|
});
|
||||||
|
// Update form values.
|
||||||
|
form.setFieldsValue(newValues);
|
||||||
|
// Manually mark the form as dirty.
|
||||||
|
if (onHeaderChange) {
|
||||||
|
onHeaderChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Checkbox onChange={onChange} checked={allChecked} disabled={disabled}>
|
||||||
|
{t(`notifications.channels.${channel}`)}
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ColumnHeaderCheckbox.propTypes = {
|
||||||
|
channel: PropTypes.oneOf(["app", "email", "fcm"]).isRequired,
|
||||||
|
form: PropTypes.object.isRequired,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
onHeaderChange: PropTypes.func
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColumnHeaderCheckbox;
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, Card, Checkbox, Form, Space, Table } from "antd";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import { createStructuredSelector } from "reselect";
|
||||||
|
import { selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
|
import AlertComponent from "../alert/alert.component";
|
||||||
|
import { QUERY_NOTIFICATION_SETTINGS, UPDATE_NOTIFICATION_SETTINGS } from "../../graphql/user.queries.js";
|
||||||
|
import { notificationScenarios } from "../../utils/jobNotificationScenarios.js";
|
||||||
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component.jsx";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import ColumnHeaderCheckbox from "../notification-settings/column-header-checkbox.component.jsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications Settings Form
|
||||||
|
* @param currentUser
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
const NotificationSettingsForm = ({ currentUser }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [initialValues, setInitialValues] = useState({});
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
|
// Fetch notification settings.
|
||||||
|
const { loading, error, data } = useQuery(QUERY_NOTIFICATION_SETTINGS, {
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
nextFetchPolicy: "network-only",
|
||||||
|
variables: { email: currentUser.email },
|
||||||
|
skip: !currentUser
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateNotificationSettings, { loading: saving }] = useMutation(UPDATE_NOTIFICATION_SETTINGS);
|
||||||
|
|
||||||
|
// Populate form with fetched data.
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.associations?.length > 0) {
|
||||||
|
const settings = data.associations[0].notification_settings || {};
|
||||||
|
// Ensure each scenario has an object with { app, email, fcm }.
|
||||||
|
const formattedValues = notificationScenarios.reduce((acc, scenario) => {
|
||||||
|
acc[scenario] = settings[scenario] ?? { app: false, email: false, fcm: false };
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
setInitialValues(formattedValues);
|
||||||
|
form.setFieldsValue(formattedValues);
|
||||||
|
setIsDirty(false); // Reset dirty state when new data loads.
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const handleSave = async (values) => {
|
||||||
|
if (data?.associations?.length > 0) {
|
||||||
|
const userId = data.associations[0].id;
|
||||||
|
// Save the updated notification settings.
|
||||||
|
const result = await updateNotificationSettings({ variables: { id: userId, ns: values } });
|
||||||
|
if (!result?.errors) {
|
||||||
|
notification.success({ message: t("notifications.labels.notification-settings-success") });
|
||||||
|
setInitialValues(values);
|
||||||
|
setIsDirty(false);
|
||||||
|
} else {
|
||||||
|
notification.error({ message: t("notifications.labels.notification-settings-failure") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark the form as dirty on any manual change.
|
||||||
|
const handleFormChange = () => {
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) return <AlertComponent type="error" message={error.message} />;
|
||||||
|
if (loading) return <LoadingSpinner />;
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t("notifications.labels.scenario"),
|
||||||
|
dataIndex: "scenarioLabel",
|
||||||
|
key: "scenario",
|
||||||
|
render: (_, record) => t(`notifications.scenarios.${record.key}`),
|
||||||
|
width: "90%"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <ColumnHeaderCheckbox channel="app" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||||
|
dataIndex: "app",
|
||||||
|
key: "app",
|
||||||
|
align: "center",
|
||||||
|
render: (_, record) => (
|
||||||
|
<Form.Item name={[record.key, "app"]} valuePropName="checked" noStyle>
|
||||||
|
<Checkbox />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <ColumnHeaderCheckbox channel="email" form={form} onHeaderChange={() => setIsDirty(true)} />,
|
||||||
|
dataIndex: "email",
|
||||||
|
key: "email",
|
||||||
|
align: "center",
|
||||||
|
render: (_, record) => (
|
||||||
|
<Form.Item name={[record.key, "email"]} valuePropName="checked" noStyle>
|
||||||
|
<Checkbox />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// TODO: Disabled for now until FCM is implemented.
|
||||||
|
// {
|
||||||
|
// title: <ColumnHeaderCheckbox channel="fcm" form={form} disabled onHeaderChange={() => setIsDirty(true)} />,
|
||||||
|
// dataIndex: "fcm",
|
||||||
|
// key: "fcm",
|
||||||
|
// align: "center",
|
||||||
|
// render: (_, record) => (
|
||||||
|
// <Form.Item name={[record.key, "fcm"]} valuePropName="checked" noStyle>
|
||||||
|
// <Checkbox disabled />
|
||||||
|
// </Form.Item>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dataSource = notificationScenarios.map((scenario) => ({ key: scenario }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={handleSave}
|
||||||
|
onValuesChange={handleFormChange}
|
||||||
|
initialValues={initialValues}
|
||||||
|
autoComplete="off"
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
title={t("notifications.labels.notificationscenarios")}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Button type="default" onClick={handleReset} disabled={!isDirty}>
|
||||||
|
{t("general.actions.clear")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="primary" htmlType="submit" disabled={!isDirty} loading={saving}>
|
||||||
|
{t("notifications.labels.save")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table dataSource={dataSource} columns={columns} pagination={false} bordered rowKey="key" />
|
||||||
|
</Card>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NotificationSettingsForm.propTypes = {
|
||||||
|
currentUser: PropTypes.shape({
|
||||||
|
email: PropTypes.string.isRequired
|
||||||
|
}).isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = createStructuredSelector({
|
||||||
|
currentUser: selectCurrentUser
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(NotificationSettingsForm);
|
||||||
@@ -2,15 +2,15 @@ import { CopyFilled } from "@ant-design/icons";
|
|||||||
import { Button, Form, message, Popover, Space } from "antd";
|
import { Button, Form, message, Popover, Space } from "antd";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Dinero from "dinero.js";
|
import Dinero from "dinero.js";
|
||||||
import { parsePhoneNumber } from "libphonenumber-js";
|
import { parsePhoneNumberWithError, ParseError } from "libphonenumber-js";
|
||||||
import React, { useContext, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -29,22 +29,34 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [paymentLink, setPaymentLink] = useState(null);
|
const [paymentLink, setPaymentLink] = useState(null);
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
|
|
||||||
const handleFinish = async ({ amount }) => {
|
const handleFinish = async ({ amount }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
let p;
|
let p;
|
||||||
try {
|
try {
|
||||||
p = parsePhoneNumber(job.ownr_ph1 || "", "CA");
|
// Updated to use parsePhoneNumberWithError
|
||||||
|
p = parsePhoneNumberWithError(job.ownr_ph1 || "", "CA");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Unable to parse phone number");
|
if (error instanceof ParseError) {
|
||||||
|
// Handle specific parsing errors
|
||||||
|
console.log(`Phone number parsing failed: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
// Handle other unexpected errors
|
||||||
|
console.log("Unexpected error while parsing phone number:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await axios.post("/intellipay/generate_payment_url", {
|
const response = await axios.post("/intellipay/generate_payment_url", {
|
||||||
bodyshop,
|
bodyshop,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
account: job.ro_number,
|
account: job.ro_number,
|
||||||
comment: btoa(JSON.stringify({ payments: [{ jobid: job.id, amount }], userEmail: currentUser.email }))
|
comment: btoa(
|
||||||
|
JSON.stringify({
|
||||||
|
payments: [{ jobid: job.id, amount }],
|
||||||
|
userEmail: currentUser.email
|
||||||
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPaymentLink(response.data.shorUrl);
|
setPaymentLink(response.data.shorUrl);
|
||||||
@@ -106,7 +118,20 @@ export function PaymentsGenerateLink({ bodyshop, currentUser, callback, job, ope
|
|||||||
</Space>
|
</Space>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const p = parsePhoneNumber(job.ownr_ph1, "CA");
|
let p;
|
||||||
|
try {
|
||||||
|
// Updated second instance of phone parsing
|
||||||
|
p = parsePhoneNumberWithError(job.ownr_ph1, "CA");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ParseError) {
|
||||||
|
// Handle specific parsing errors
|
||||||
|
console.log(`Phone number parsing failed: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
// Handle other unexpected errors
|
||||||
|
console.log("Unexpected error while parsing phone number:", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
openChatByPhone({
|
openChatByPhone({
|
||||||
phone_num: p.formatInternational(),
|
phone_num: p.formatInternational(),
|
||||||
jobid: job.id,
|
jobid: job.id,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useApolloClient } from "@apollo/client";
|
|||||||
import { Button, Skeleton, Space } from "antd";
|
import { Button, Skeleton, Space } from "antd";
|
||||||
import cloneDeep from "lodash/cloneDeep";
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useContext, useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -12,7 +12,7 @@ import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
|||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -22,7 +22,7 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
|
function ProductionBoardKanbanContainer({ bodyshop, currentUser, subscriptionType = "direct" }) {
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useContext(SocketContext); // Get the socket from context
|
const { socket } = useSocket();
|
||||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||||
const disconnectTime = useRef(null); // To track disconnection time
|
const disconnectTime = useRef(null); // To track disconnection time
|
||||||
const acceptableReconnectTime = 2000; // 2 seconds threshold
|
const acceptableReconnectTime = 2000; // 2 seconds threshold
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { PrinterFilled } from "@ant-design/icons";
|
import { PauseCircleOutlined, PlayCircleOutlined, PrinterFilled } from "@ant-design/icons";
|
||||||
import { useQuery } from "@apollo/client";
|
|
||||||
import { Button, Descriptions, Drawer, Space } from "antd";
|
|
||||||
import { PageHeader } from "@ant-design/pro-layout";
|
import { PageHeader } from "@ant-design/pro-layout";
|
||||||
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
|
import { Button, Descriptions, Drawer, Space } from "antd";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { QUERY_JOB_CARD_DETAILS } from "../../graphql/jobs.queries";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
import { logImEXEvent } from "../../firebase/firebase.utils.js";
|
||||||
|
import { QUERY_JOB_CARD_DETAILS, UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||||
|
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||||
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
import CurrencyFormatter from "../../utils/CurrencyFormatter";
|
||||||
import { DateFormatter } from "../../utils/DateFormatter";
|
import { DateFormatter } from "../../utils/DateFormatter";
|
||||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||||
@@ -24,6 +27,7 @@ import JobDetailCardsPartsComponent from "../job-detail-cards/job-detail-cards.p
|
|||||||
import CardTemplate from "../job-detail-cards/job-detail-cards.template.component";
|
import CardTemplate from "../job-detail-cards/job-detail-cards.template.component";
|
||||||
import JobEmployeeAssignments from "../job-employee-assignments/job-employee-assignments.container";
|
import JobEmployeeAssignments from "../job-employee-assignments/job-employee-assignments.container";
|
||||||
import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add-button.component";
|
import ScoreboardAddButton from "../job-scoreboard-add-button/job-scoreboard-add-button.component";
|
||||||
|
import JobWatcherToggleContainer from "../job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||||
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||||
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
|
||||||
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
|
import ProductionRemoveButton from "../production-remove-button/production-remove-button.component";
|
||||||
@@ -33,14 +37,23 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
technician: selectTechnician
|
technician: selectTechnician
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" }))
|
setPrintCenterContext: (context) => dispatch(setModalContext({ context: context, modal: "printCenter" })),
|
||||||
|
insertAuditTrail: ({ jobid, operation, type }) =>
|
||||||
|
dispatch(
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid,
|
||||||
|
operation,
|
||||||
|
type
|
||||||
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(ProductionListDetail);
|
export default connect(mapStateToProps, mapDispatchToProps)(ProductionListDetail);
|
||||||
|
|
||||||
export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, technician }) {
|
export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, technician, insertAuditTrail }) {
|
||||||
const search = queryString.parse(useLocation().search);
|
const search = queryString.parse(useLocation().search);
|
||||||
const history = useNavigate();
|
const history = useNavigate();
|
||||||
const { selected } = search;
|
const { selected } = search;
|
||||||
|
const { scenarioNotificationsOn } = useSocket();
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theJob = jobs.find((j) => j.id === selected) || {};
|
const theJob = jobs.find((j) => j.id === selected) || {};
|
||||||
@@ -55,15 +68,44 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
|||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
const [updateJob] = useMutation(UPDATE_JOB);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={theJob.ro_number}
|
title={
|
||||||
|
<Space>
|
||||||
|
{!technician && scenarioNotificationsOn && <JobWatcherToggleContainer job={theJob} />}
|
||||||
|
{theJob.ro_number}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
|
{!technician ? <ProductionRemoveButton jobId={theJob.id} /> : null}
|
||||||
|
{!technician && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
logImEXEvent("production_toggle_alert");
|
||||||
|
updateJob({
|
||||||
|
variables: {
|
||||||
|
jobId: theJob.id,
|
||||||
|
job: {
|
||||||
|
suspended: !theJob.suspended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: theJob.id,
|
||||||
|
operation: AuditTrailMapping.jobsuspend(theJob.suspended ? !theJob.suspended : true),
|
||||||
|
type: "jobsuspend"
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
icon={theJob.suspended ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
|
||||||
|
>
|
||||||
|
{theJob.suspended ? t("production.actions.unsuspend") : t("production.actions.suspend")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPrintCenterContext({
|
setPrintCenterContext({
|
||||||
@@ -75,8 +117,8 @@ export function ProductionListDetail({ bodyshop, jobs, setPrintCenterContext, te
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
icon={<PrinterFilled />}
|
||||||
>
|
>
|
||||||
<PrinterFilled />
|
|
||||||
{t("jobs.actions.printCenter")}
|
{t("jobs.actions.printCenter")}
|
||||||
</Button>
|
</Button>
|
||||||
{!technician ? <ScoreboardAddButton job={data ? data.jobs_by_pk : {}} /> : null}
|
{!technician ? <ScoreboardAddButton job={data ? data.jobs_by_pk : {}} /> : null}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
import { useApolloClient, useQuery, useSubscription } from "@apollo/client";
|
||||||
import React, { useContext, useEffect, useState, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
QUERY_EXACT_JOB_IN_PRODUCTION,
|
QUERY_EXACT_JOB_IN_PRODUCTION,
|
||||||
QUERY_EXACT_JOBS_IN_PRODUCTION,
|
QUERY_EXACT_JOBS_IN_PRODUCTION,
|
||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
import ProductionListTable from "./production-list-table.component";
|
import ProductionListTable from "./production-list-table.component";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
|
||||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useContext(SocketContext);
|
const { socket } = useSocket();
|
||||||
const [joblist, setJoblist] = useState([]);
|
const [joblist, setJoblist] = useState([]);
|
||||||
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
const reconnectTimeout = useRef(null); // To store the reconnect timeout
|
||||||
const disconnectTime = useRef(null); // To store the time of disconnection
|
const disconnectTime = useRef(null); // To store the time of disconnection
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function ProductionRemoveButton({ jobId }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button loading={loading} onClick={handleRemoveFromProd} type={"danger"}>
|
<Button loading={loading} onClick={handleRemoveFromProd} type="default" danger>
|
||||||
{t("production.actions.remove")}
|
{t("production.actions.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button, Card, Col, Form, Input } from "antd";
|
import { Button, Card, Col, Form, Input } from "antd";
|
||||||
import { LockOutlined } from "@ant-design/icons";
|
import { LockOutlined } from "@ant-design/icons";
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -9,6 +8,8 @@ import { selectCurrentUser } from "../../redux/user/user.selectors";
|
|||||||
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
import { logImEXEvent, updateCurrentPassword } from "../../firebase/firebase.utils";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser
|
currentUser: selectCurrentUser
|
||||||
@@ -22,6 +23,7 @@ export default connect(
|
|||||||
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
|
)(function ProfileMyComponent({ currentUser, updateUserDetails }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
const { scenarioNotificationsOn } = useSocket();
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
const handleFinish = (values) => {
|
||||||
logImEXEvent("profile_update");
|
logImEXEvent("profile_update");
|
||||||
@@ -117,6 +119,11 @@ export default connect(
|
|||||||
</Card>
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
|
{scenarioNotificationsOn && (
|
||||||
|
<Col span={24}>
|
||||||
|
<NotificationSettingsForm />
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { QUERY_ALL_ASSOCIATIONS, UPDATE_ACTIVE_ASSOCIATION } from "../../graphql
|
|||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import ProfileShopsComponent from "./profile-shops.component";
|
import ProfileShopsComponent from "./profile-shops.component";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { getToken } from "firebase/messaging";
|
import { getToken } from "@firebase/messaging";
|
||||||
|
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DeleteFilled } from "@ant-design/icons";
|
import { DeleteFilled } from "@ant-design/icons";
|
||||||
import { Button, Form, Input } from "antd";
|
import { Button, Form, Input, Space } from "antd";
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||||
@@ -10,14 +9,23 @@ export default function ShopInfoLaborRates({ form }) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
|
<LayoutFormRow header={t("bodyshop.labels.shoprates")}>
|
||||||
|
<Form.Item label={t("jobs.fields.rate_ats")} name={["shoprates", "rate_ats"]}>
|
||||||
|
<CurrencyInput min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t("jobs.fields.rate_ats_flat")} name={["shoprates", "rate_ats_flat"]}>
|
||||||
|
<CurrencyInput min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
|
<LayoutFormRow header={t("bodyshop.labels.laborrates")}>
|
||||||
<Form.List name={["md_labor_rates"]}>
|
<Form.List name={["md_labor_rates"]}>
|
||||||
{(fields, { add, remove, move }) => {
|
{(fields, { add, remove, move }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<Form.Item key={field.key}>
|
<Form.Item key={field.key}>
|
||||||
<LayoutFormRow>
|
<LayoutFormRow noDivider={index === 0}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label={t("jobs.fields.labor_rate_desc")}
|
label={t("jobs.fields.labor_rate_desc")}
|
||||||
key={`${index}rate_label`}
|
key={`${index}rate_label`}
|
||||||
@@ -306,12 +314,14 @@ export default function ShopInfoLaborRates({ form }) {
|
|||||||
>
|
>
|
||||||
<CurrencyInput min={0} />
|
<CurrencyInput min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
<DeleteFilled
|
<DeleteFilled
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
remove(field.name);
|
remove(field.name);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FormListMoveArrows move={move} index={index} total={fields.rate_length} />
|
<FormListMoveArrows move={move} index={index} total={fields.rate_length} />
|
||||||
|
</Space>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
@@ -330,6 +340,7 @@ export default function ShopInfoLaborRates({ form }) {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
</div>
|
</LayoutFormRow>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Alert, Form, Switch } from "antd";
|
import { Alert, Form, Select, Switch } from "antd";
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||||
|
|
||||||
@@ -39,6 +38,92 @@ export function ShopInfoIntellipay({bodyshop, form}) {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</LayoutFormRow>
|
</LayoutFormRow>
|
||||||
|
<LayoutFormRow header={t("bodyshop.fields.intellipay_config.payment_type")}>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.visa")}
|
||||||
|
name={["intellipay_config", "payment_map", "visa"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.mast")}
|
||||||
|
name={["intellipay_config", "payment_map", "mast"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.amex")}
|
||||||
|
name={["intellipay_config", "payment_map", "amex"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.disc")}
|
||||||
|
name={["intellipay_config", "payment_map", "disc"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.dnrs")}
|
||||||
|
name={["intellipay_config", "payment_map", "dnrs"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.jcb")}
|
||||||
|
name={["intellipay_config", "payment_map", "jcb"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label={t("bodyshop.fields.intellipay_config.payment_map.intr")}
|
||||||
|
name={["intellipay_config", "payment_map", "intr"]}
|
||||||
|
>
|
||||||
|
<Select showSearch>
|
||||||
|
{bodyshop.md_payment_types.map((item, idx) => (
|
||||||
|
<Select.Option key={idx} value={item}>
|
||||||
|
{item}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</LayoutFormRow>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AlertOutlined } from "@ant-design/icons";
|
import { AlertOutlined } from "@ant-design/icons";
|
||||||
import { Alert, Button, Col, Row, Space } from "antd";
|
import { Alert, Button, Col, Row, Space } from "antd";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -81,8 +81,7 @@ export function UpdateAlert({ updateAvailable }) {
|
|||||||
imex: "$t(titles.imexonline)",
|
imex: "$t(titles.imexonline)",
|
||||||
rome: "$t(titles.romeonline)"
|
rome: "$t(titles.romeonline)"
|
||||||
})
|
})
|
||||||
}),
|
})
|
||||||
placement: "bottomRight"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (needRefresh && timerStarted && timeLeft <= 0) {
|
if (needRefresh && timerStarted && timeLeft <= 0) {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// NotificationProvider.jsx
|
import { createContext, useContext } from "react";
|
||||||
import React, { createContext, useContext } from "react";
|
|
||||||
import { notification } from "antd";
|
import { notification } from "antd";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +21,11 @@ export const useNotification = () => {
|
|||||||
* - Provide `api` via the NotificationContext.
|
* - Provide `api` via the NotificationContext.
|
||||||
*/
|
*/
|
||||||
export const NotificationProvider = ({ children }) => {
|
export const NotificationProvider = ({ children }) => {
|
||||||
const [api, contextHolder] = notification.useNotification();
|
const [api, contextHolder] = notification.useNotification({
|
||||||
|
placement: "bottomRight",
|
||||||
|
bottom: 70,
|
||||||
|
showProgress: true
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider value={api}>
|
<NotificationContext.Provider value={api}>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import React, { createContext } from "react";
|
|
||||||
import useSocket from "./useSocket"; // Import the custom hook
|
|
||||||
|
|
||||||
// Create the SocketContext
|
|
||||||
const SocketContext = createContext(null);
|
|
||||||
|
|
||||||
export const SocketProvider = ({ children, bodyshop }) => {
|
|
||||||
const { socket, clientId } = useSocket(bodyshop);
|
|
||||||
|
|
||||||
return <SocketContext.Provider value={{ socket, clientId }}> {children}</SocketContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SocketContext;
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import SocketIO from "socket.io-client";
|
|
||||||
import { auth } from "../../firebase/firebase.utils";
|
|
||||||
import { store } from "../../redux/store";
|
|
||||||
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
|
||||||
|
|
||||||
const useSocket = (bodyshop) => {
|
|
||||||
const socketRef = useRef(null);
|
|
||||||
const [clientId, setClientId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeSocket = async (token) => {
|
|
||||||
if (!bodyshop || !bodyshop.id) return;
|
|
||||||
|
|
||||||
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
|
|
||||||
|
|
||||||
const socketInstance = SocketIO(endpoint, {
|
|
||||||
path: "/wss",
|
|
||||||
withCredentials: true,
|
|
||||||
auth: { token },
|
|
||||||
reconnectionAttempts: Infinity,
|
|
||||||
reconnectionDelay: 2000,
|
|
||||||
reconnectionDelayMax: 10000
|
|
||||||
});
|
|
||||||
|
|
||||||
socketRef.current = socketInstance;
|
|
||||||
|
|
||||||
// Handle socket events
|
|
||||||
const handleBodyshopMessage = (message) => {
|
|
||||||
if (!message || !message.type) return;
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case "alert-update":
|
|
||||||
store.dispatch(addAlerts(message.payload));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!import.meta.env.DEV) return;
|
|
||||||
console.log(`Received message for bodyshop ${bodyshop.id}:`, message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConnect = () => {
|
|
||||||
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
|
||||||
setClientId(socketInstance.id);
|
|
||||||
store.dispatch(setWssStatus("connected"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReconnect = () => {
|
|
||||||
store.dispatch(setWssStatus("connected"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConnectionError = (err) => {
|
|
||||||
console.error("Socket connection error:", err);
|
|
||||||
|
|
||||||
// Handle token expiration
|
|
||||||
if (err.message.includes("auth/id-token-expired")) {
|
|
||||||
console.warn("Token expired, refreshing...");
|
|
||||||
auth.currentUser?.getIdToken(true).then((newToken) => {
|
|
||||||
socketInstance.auth = { token: newToken }; // Update socket auth
|
|
||||||
socketInstance.connect(); // Retry connection
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
store.dispatch(setWssStatus("error"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = (reason) => {
|
|
||||||
console.warn("Socket disconnected:", reason);
|
|
||||||
store.dispatch(setWssStatus("disconnected"));
|
|
||||||
|
|
||||||
// Manually trigger reconnection if necessary
|
|
||||||
if (!socketInstance.connected && reason !== "io server disconnect") {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (socketInstance.disconnected) {
|
|
||||||
console.log("Manually triggering reconnection...");
|
|
||||||
socketInstance.connect();
|
|
||||||
}
|
|
||||||
}, 2000); // Retry after 2 seconds
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register event handlers
|
|
||||||
socketInstance.on("connect", handleConnect);
|
|
||||||
socketInstance.on("reconnect", handleReconnect);
|
|
||||||
socketInstance.on("connect_error", handleConnectionError);
|
|
||||||
socketInstance.on("disconnect", handleDisconnect);
|
|
||||||
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
|
||||||
if (user) {
|
|
||||||
const token = await user.getIdToken();
|
|
||||||
|
|
||||||
if (socketRef.current) {
|
|
||||||
// Update token if socket exists
|
|
||||||
socketRef.current.emit("update-token", token);
|
|
||||||
} else {
|
|
||||||
// Initialize socket if not already connected
|
|
||||||
initializeSocket(token);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User is not authenticated
|
|
||||||
if (socketRef.current) {
|
|
||||||
socketRef.current.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up on unmount
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
if (socketRef.current) {
|
|
||||||
socketRef.current.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [bodyshop]);
|
|
||||||
|
|
||||||
return { socket: socketRef.current, clientId };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useSocket;
|
|
||||||
500
client/src/contexts/SocketIO/useSocket.jsx
Normal file
500
client/src/contexts/SocketIO/useSocket.jsx
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import SocketIO from "socket.io-client";
|
||||||
|
import { auth } from "../../firebase/firebase.utils";
|
||||||
|
import { store } from "../../redux/store";
|
||||||
|
import { addAlerts, setWssStatus } from "../../redux/application/application.actions";
|
||||||
|
import client from "../../utils/GraphQLClient";
|
||||||
|
import { useNotification } from "../Notifications/notificationContext.jsx";
|
||||||
|
import {
|
||||||
|
GET_NOTIFICATIONS,
|
||||||
|
GET_UNREAD_COUNT,
|
||||||
|
MARK_ALL_NOTIFICATIONS_READ,
|
||||||
|
MARK_NOTIFICATION_READ,
|
||||||
|
UPDATE_NOTIFICATIONS_READ_FRAGMENT
|
||||||
|
} from "../../graphql/notifications.queries.js";
|
||||||
|
import { useMutation } from "@apollo/client";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSplitTreatments } from "@splitsoftware/splitio-react";
|
||||||
|
|
||||||
|
const SocketContext = createContext(null);
|
||||||
|
|
||||||
|
const INITIAL_NOTIFICATIONS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Socket Provider - Scenario Notifications / Web Socket related items
|
||||||
|
* @param children
|
||||||
|
* @param bodyshop
|
||||||
|
* @param navigate
|
||||||
|
* @param currentUser
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
const [clientId, setClientId] = useState(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const notification = useNotification();
|
||||||
|
const userAssociationId = bodyshop?.associations?.[0]?.id;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
treatments: { Realtime_Notifications_UI }
|
||||||
|
} = useSplitTreatments({
|
||||||
|
attributes: {},
|
||||||
|
names: ["Realtime_Notifications_UI"],
|
||||||
|
splitKey: bodyshop?.imexshopid
|
||||||
|
});
|
||||||
|
|
||||||
|
const [markNotificationRead] = useMutation(MARK_NOTIFICATION_READ, {
|
||||||
|
update: (cache, { data: { update_notifications } }) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const updatedNotification = update_notifications.returning[0];
|
||||||
|
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
notifications(existing = [], { readField }) {
|
||||||
|
return existing.map((notif) =>
|
||||||
|
readField("id", notif) === updatedNotification.id
|
||||||
|
? {
|
||||||
|
...notif,
|
||||||
|
read: timestamp
|
||||||
|
}
|
||||||
|
: notif
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCountQuery = cache.readQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unreadCountQuery?.notifications_aggregate?.aggregate?.count > 0) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId },
|
||||||
|
data: {
|
||||||
|
notifications_aggregate: {
|
||||||
|
...unreadCountQuery.notifications_aggregate,
|
||||||
|
aggregate: {
|
||||||
|
...unreadCountQuery.notifications_aggregate.aggregate,
|
||||||
|
count: unreadCountQuery.notifications_aggregate.aggregate.count - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socketRef.current && isConnected) {
|
||||||
|
socketRef.current.emit("sync-notification-read", {
|
||||||
|
email: currentUser?.email,
|
||||||
|
bodyshopId: bodyshop.id,
|
||||||
|
notificationId: updatedNotification.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
console.error("MARK_NOTIFICATION_READ error:", {
|
||||||
|
message: err?.message,
|
||||||
|
stack: err?.stack
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const [markAllNotificationsRead] = useMutation(MARK_ALL_NOTIFICATIONS_READ, {
|
||||||
|
variables: { associationid: userAssociationId },
|
||||||
|
update: (cache) => {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
notifications(existing = [], { readField }) {
|
||||||
|
return existing.map((notif) =>
|
||||||
|
readField("read", notif) === null && readField("associationid", notif) === userAssociationId
|
||||||
|
? { ...notif, read: timestamp }
|
||||||
|
: notif
|
||||||
|
);
|
||||||
|
},
|
||||||
|
notifications_aggregate() {
|
||||||
|
return { aggregate: { count: 0, __typename: "notifications_aggregate_fields" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseWhereClause = { associationid: { _eq: userAssociationId } };
|
||||||
|
const cachedNotifications = cache.readQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedNotifications?.notifications) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: { limit: INITIAL_NOTIFICATIONS, offset: 0, where: baseWhereClause },
|
||||||
|
data: {
|
||||||
|
notifications: cachedNotifications.notifications.map((notif) =>
|
||||||
|
notif.read === null ? { ...notif, read: timestamp } : notif
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socketRef.current && isConnected) {
|
||||||
|
socketRef.current.emit("sync-all-notifications-read", {
|
||||||
|
email: currentUser?.email,
|
||||||
|
bodyshopId: bodyshop.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err)
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeSocket = async (token) => {
|
||||||
|
if (!bodyshop || !bodyshop.id || socketRef.current) return;
|
||||||
|
|
||||||
|
const endpoint = import.meta.env.PROD ? import.meta.env.VITE_APP_AXIOS_BASE_API_URL : "";
|
||||||
|
const socketInstance = SocketIO(endpoint, {
|
||||||
|
path: "/wss",
|
||||||
|
withCredentials: true,
|
||||||
|
auth: { token, bodyshopId: bodyshop.id },
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
reconnectionDelayMax: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
socketRef.current = socketInstance;
|
||||||
|
|
||||||
|
const handleBodyshopMessage = (message) => {
|
||||||
|
if (!message || !message.type) return;
|
||||||
|
switch (message.type) {
|
||||||
|
case "alert-update":
|
||||||
|
store.dispatch(addAlerts(message.payload));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
socketInstance.emit("join-bodyshop-room", bodyshop.id);
|
||||||
|
setClientId(socketInstance.id);
|
||||||
|
setIsConnected(true);
|
||||||
|
store.dispatch(setWssStatus("connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReconnect = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
store.dispatch(setWssStatus("connected"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectionError = (err) => {
|
||||||
|
console.error("Socket connection error:", err);
|
||||||
|
setIsConnected(false);
|
||||||
|
if (err.message.includes("auth/id-token-expired")) {
|
||||||
|
console.warn("Token expired, refreshing...");
|
||||||
|
auth.currentUser?.getIdToken(true).then((newToken) => {
|
||||||
|
socketInstance.auth = { token: newToken };
|
||||||
|
socketInstance.connect();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
store.dispatch(setWssStatus("error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = (reason) => {
|
||||||
|
console.warn("Socket disconnected:", reason);
|
||||||
|
setIsConnected(false);
|
||||||
|
store.dispatch(setWssStatus("disconnected"));
|
||||||
|
if (!socketInstance.connected && reason !== "io server disconnect") {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (socketInstance.disconnected) {
|
||||||
|
console.log("Manually triggering reconnection...");
|
||||||
|
socketInstance.connect();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotification = (data) => {
|
||||||
|
// Scenario Notifications have been disabled, bail.
|
||||||
|
if (Realtime_Notifications_UI?.treatment !== "on") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { jobId, jobRoNumber, notificationId, associationId, notifications } = data;
|
||||||
|
if (associationId !== userAssociationId) return;
|
||||||
|
|
||||||
|
const newNotification = {
|
||||||
|
__typename: "notifications",
|
||||||
|
id: notificationId,
|
||||||
|
jobid: jobId,
|
||||||
|
associationid: associationId,
|
||||||
|
scenario_text: JSON.stringify(notifications.map((notif) => notif.body)),
|
||||||
|
fcm_text: notifications.map((notif) => notif.body).join(". ") + ".",
|
||||||
|
scenario_meta: JSON.stringify(notifications.map((notif) => notif.variables || {})),
|
||||||
|
created_at: new Date(notifications[0].timestamp).toISOString(),
|
||||||
|
read: null,
|
||||||
|
job: { ro_number: jobRoNumber }
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseVariables = {
|
||||||
|
limit: INITIAL_NOTIFICATIONS,
|
||||||
|
offset: 0,
|
||||||
|
where: { associationid: { _eq: userAssociationId } }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingNotifications =
|
||||||
|
client.cache.readQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: baseVariables
|
||||||
|
})?.notifications || [];
|
||||||
|
if (!existingNotifications.some((n) => n.id === newNotification.id)) {
|
||||||
|
client.cache.writeQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: baseVariables,
|
||||||
|
data: {
|
||||||
|
notifications: [newNotification, ...existingNotifications].sort(
|
||||||
|
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
broadcast: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadVariables = {
|
||||||
|
...baseVariables,
|
||||||
|
where: { ...baseVariables.where, read: { _is_null: true } }
|
||||||
|
};
|
||||||
|
const unreadNotifications =
|
||||||
|
client.cache.readQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: unreadVariables
|
||||||
|
})?.notifications || [];
|
||||||
|
if (newNotification.read === null && !unreadNotifications.some((n) => n.id === newNotification.id)) {
|
||||||
|
client.cache.writeQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: unreadVariables,
|
||||||
|
data: {
|
||||||
|
notifications: [newNotification, ...unreadNotifications].sort(
|
||||||
|
(a, b) => new Date(b.created_at) - new Date(a.created_at)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
broadcast: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
client.cache.modify({
|
||||||
|
id: "ROOT_QUERY",
|
||||||
|
fields: {
|
||||||
|
notifications_aggregate(existing = { aggregate: { count: 0 } }) {
|
||||||
|
return {
|
||||||
|
...existing,
|
||||||
|
aggregate: {
|
||||||
|
...existing.aggregate,
|
||||||
|
count: existing.aggregate.count + (newNotification.read === null ? 1 : 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.info({
|
||||||
|
message: (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
markNotificationRead({ variables: { id: notificationId } })
|
||||||
|
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||||
|
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("notifications.labels.notification-popup-title", {
|
||||||
|
ro_number: jobRoNumber || t("general.labels.na")
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<ul
|
||||||
|
className="notification-alert-unordered-list"
|
||||||
|
onClick={() => {
|
||||||
|
markNotificationRead({ variables: { id: notificationId } })
|
||||||
|
.then(() => navigate(`/manage/jobs/${jobId}`))
|
||||||
|
.catch((e) => console.error(`Error marking notification read: ${e?.message || ""}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notifications.map((notif, index) => (
|
||||||
|
<li className="notification-alert-unordered-list-item" key={index}>
|
||||||
|
{notif.body}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling new notification: ${error?.message || ""}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncNotificationRead = ({ notificationId, timestamp }) => {
|
||||||
|
// Scenario Notifications have been disabled, bail.
|
||||||
|
if (Realtime_Notifications_UI?.treatment !== "on") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notificationRef = client.cache.identify({
|
||||||
|
__typename: "notifications",
|
||||||
|
id: notificationId
|
||||||
|
});
|
||||||
|
client.cache.writeFragment({
|
||||||
|
id: notificationRef,
|
||||||
|
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
||||||
|
data: { read: timestamp }
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCountData = client.cache.readQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId }
|
||||||
|
});
|
||||||
|
if (unreadCountData?.notifications_aggregate?.aggregate?.count > 0) {
|
||||||
|
const newCount = Math.max(unreadCountData.notifications_aggregate.aggregate.count - 1, 0);
|
||||||
|
client.cache.writeQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId },
|
||||||
|
data: {
|
||||||
|
notifications_aggregate: {
|
||||||
|
__typename: "notifications_aggregate",
|
||||||
|
aggregate: {
|
||||||
|
__typename: "notifications_aggregate_fields",
|
||||||
|
count: newCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in handleSyncNotificationRead:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncAllNotificationsRead = ({ timestamp }) => {
|
||||||
|
// Scenario Notifications have been disabled, bail.
|
||||||
|
if (Realtime_Notifications_UI?.treatment !== "on") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryVars = {
|
||||||
|
limit: INITIAL_NOTIFICATIONS,
|
||||||
|
offset: 0,
|
||||||
|
where: { associationid: { _eq: userAssociationId } }
|
||||||
|
};
|
||||||
|
const cachedData = client.cache.readQuery({
|
||||||
|
query: GET_NOTIFICATIONS,
|
||||||
|
variables: queryVars
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cachedData?.notifications) {
|
||||||
|
cachedData.notifications.forEach((notif) => {
|
||||||
|
if (!notif.read) {
|
||||||
|
const notifRef = client.cache.identify({ __typename: "notifications", id: notif.id });
|
||||||
|
client.cache.writeFragment({
|
||||||
|
id: notifRef,
|
||||||
|
fragment: UPDATE_NOTIFICATIONS_READ_FRAGMENT,
|
||||||
|
data: { read: timestamp }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
client.cache.writeQuery({
|
||||||
|
query: GET_UNREAD_COUNT,
|
||||||
|
variables: { associationid: userAssociationId },
|
||||||
|
data: {
|
||||||
|
notifications_aggregate: {
|
||||||
|
__typename: "notifications_aggregate",
|
||||||
|
aggregate: {
|
||||||
|
__typename: "notifications_aggregate_fields",
|
||||||
|
count: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error In HandleSyncAllNotificationsRead: ${error?.message || ""}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socketInstance.on("connect", handleConnect);
|
||||||
|
socketInstance.on("reconnect", handleReconnect);
|
||||||
|
socketInstance.on("connect_error", handleConnectionError);
|
||||||
|
socketInstance.on("disconnect", handleDisconnect);
|
||||||
|
socketInstance.on("bodyshop-message", handleBodyshopMessage);
|
||||||
|
socketInstance.on("notification", handleNotification);
|
||||||
|
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
||||||
|
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = auth.onIdTokenChanged(async (user) => {
|
||||||
|
if (user) {
|
||||||
|
const token = await user.getIdToken();
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
|
||||||
|
} else {
|
||||||
|
initializeSocket(token).catch((err) =>
|
||||||
|
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
if (socketRef.current) {
|
||||||
|
socketRef.current.disconnect();
|
||||||
|
socketRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
bodyshop,
|
||||||
|
notification,
|
||||||
|
userAssociationId,
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
navigate,
|
||||||
|
currentUser,
|
||||||
|
Realtime_Notifications_UI,
|
||||||
|
t
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider
|
||||||
|
value={{
|
||||||
|
socket: socketRef.current,
|
||||||
|
clientId,
|
||||||
|
isConnected,
|
||||||
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
scenarioNotificationsOn: Realtime_Notifications_UI?.treatment === "on"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSocket = () => {
|
||||||
|
const context = useContext(SocketContext);
|
||||||
|
// NOTE: Not sure if we absolutely require this, does cause slipups on dev env
|
||||||
|
if (!context) throw new Error("useSocket must be used within a SocketProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SocketContext, SocketProvider, INITIAL_NOTIFICATIONS, useSocket };
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getAnalytics, logEvent } from "firebase/analytics";
|
import { getAnalytics, logEvent } from "@firebase/analytics";
|
||||||
import { initializeApp } from "firebase/app";
|
import { initializeApp } from "@firebase/app";
|
||||||
import { getAuth, updatePassword, updateProfile } from "firebase/auth";
|
import { getAuth, updatePassword, updateProfile } from "@firebase/auth";
|
||||||
import { getFirestore } from "firebase/firestore";
|
import { getFirestore } from "@firebase/firestore";
|
||||||
import { getMessaging, getToken, onMessage } from "firebase/messaging";
|
import { getMessaging, getToken, onMessage } from "@firebase/messaging";
|
||||||
import { store } from "../redux/store";
|
import { store } from "../redux/store";
|
||||||
|
|
||||||
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { onError } from "@apollo/client/link/error";
|
import { onError } from "@apollo/client/link/error";
|
||||||
//https://stackoverflow.com/questions/57163454/refreshing-a-token-with-apollo-client-firebase-auth
|
//https://stackoverflow.com/questions/57163454/refreshing-a-token-with-apollo-client-firebase-auth
|
||||||
import * as Sentry from "@sentry/react";
|
|
||||||
|
|
||||||
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
|
||||||
if (graphQLErrors) {
|
if (graphQLErrors) {
|
||||||
graphQLErrors.forEach(({ message, locations, path }) => {
|
graphQLErrors.forEach(({ message, locations, path }) => {
|
||||||
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
|
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`);
|
||||||
Sentry.captureException({ message, locations, path });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (networkError) console.log(`[Network error]: ${JSON.stringify(networkError)}`);
|
if (networkError) console.log(`[Network error]: ${JSON.stringify(networkError)}`);
|
||||||
|
|||||||
@@ -349,3 +349,13 @@ export const QUERY_STRIPE_ID = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
|
||||||
|
query GetActiveEmployeesInShop($shopid: uuid!) {
|
||||||
|
associations(where: { shopid: { _eq: $shopid } }) {
|
||||||
|
id
|
||||||
|
useremail
|
||||||
|
shopid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -509,6 +509,7 @@ export const GET_JOB_BY_PK = gql`
|
|||||||
est_ct_ln
|
est_ct_ln
|
||||||
est_ea
|
est_ea
|
||||||
est_ph1
|
est_ph1
|
||||||
|
flat_rate_ats
|
||||||
federal_tax_rate
|
federal_tax_rate
|
||||||
id
|
id
|
||||||
inproduction
|
inproduction
|
||||||
@@ -524,6 +525,10 @@ export const GET_JOB_BY_PK = gql`
|
|||||||
invoice_final_note
|
invoice_final_note
|
||||||
iouparent
|
iouparent
|
||||||
job_totals
|
job_totals
|
||||||
|
job_watchers {
|
||||||
|
id
|
||||||
|
user_email
|
||||||
|
}
|
||||||
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
joblines(where: { removed: { _eq: false } }, order_by: { line_no: asc }) {
|
||||||
act_price
|
act_price
|
||||||
act_price_before_ppc
|
act_price_before_ppc
|
||||||
@@ -645,6 +650,7 @@ export const GET_JOB_BY_PK = gql`
|
|||||||
policy_no
|
policy_no
|
||||||
production_vars
|
production_vars
|
||||||
rate_ats
|
rate_ats
|
||||||
|
rate_ats_flat
|
||||||
rate_la1
|
rate_la1
|
||||||
rate_la2
|
rate_la2
|
||||||
rate_la3
|
rate_la3
|
||||||
@@ -2567,3 +2573,34 @@ export const GET_JOB_BY_PK_QUICK_INTAKE = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_JOB_WATCHERS = gql`
|
||||||
|
query GET_JOB_WATCHERS($jobid: uuid!) {
|
||||||
|
job_watchers(where: { jobid: { _eq: $jobid } }) {
|
||||||
|
id
|
||||||
|
user_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ADD_JOB_WATCHER = gql`
|
||||||
|
mutation ADD_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
|
||||||
|
insert_job_watchers_one(object: { jobid: $jobid, user_email: $userEmail }) {
|
||||||
|
id
|
||||||
|
jobid
|
||||||
|
user_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REMOVE_JOB_WATCHER = gql`
|
||||||
|
mutation REMOVE_JOB_WATCHER($jobid: uuid!, $userEmail: String!) {
|
||||||
|
delete_job_watchers(where: { jobid: { _eq: $jobid }, user_email: { _eq: $userEmail } }) {
|
||||||
|
affected_rows
|
||||||
|
returning {
|
||||||
|
id
|
||||||
|
user_email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
58
client/src/graphql/notifications.queries.js
Normal file
58
client/src/graphql/notifications.queries.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { gql } from "@apollo/client";
|
||||||
|
|
||||||
|
export const GET_NOTIFICATIONS = gql`
|
||||||
|
query GetNotifications($limit: Int!, $offset: Int!, $where: notifications_bool_exp) {
|
||||||
|
notifications(limit: $limit, offset: $offset, order_by: { created_at: desc }, where: $where) {
|
||||||
|
id
|
||||||
|
jobid
|
||||||
|
associationid
|
||||||
|
scenario_text
|
||||||
|
fcm_text
|
||||||
|
scenario_meta
|
||||||
|
created_at
|
||||||
|
read
|
||||||
|
job {
|
||||||
|
id
|
||||||
|
ro_number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GET_UNREAD_COUNT = gql`
|
||||||
|
query GetUnreadCount($associationid: uuid!) {
|
||||||
|
notifications_aggregate(where: { read: { _is_null: true }, associationid: { _eq: $associationid } }) {
|
||||||
|
aggregate {
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_ALL_NOTIFICATIONS_READ = gql`
|
||||||
|
mutation MarkAllNotificationsRead($associationid: uuid!) {
|
||||||
|
update_notifications(
|
||||||
|
where: { read: { _is_null: true }, associationid: { _eq: $associationid } }
|
||||||
|
_set: { read: "now()" }
|
||||||
|
) {
|
||||||
|
affected_rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MARK_NOTIFICATION_READ = gql`
|
||||||
|
mutation MarkNotificationRead($id: uuid!) {
|
||||||
|
update_notifications(where: { id: { _eq: $id } }, _set: { read: "now()" }) {
|
||||||
|
returning {
|
||||||
|
id
|
||||||
|
read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_NOTIFICATIONS_READ_FRAGMENT = gql`
|
||||||
|
fragment UpdateNotificationRead on notifications {
|
||||||
|
read
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -85,3 +85,21 @@ export const UPDATE_KANBAN_SETTINGS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const QUERY_NOTIFICATION_SETTINGS = gql`
|
||||||
|
query QUERY_NOTIFICATION_SETTINGS($email: String!) {
|
||||||
|
associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) {
|
||||||
|
id
|
||||||
|
notification_settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_NOTIFICATION_SETTINGS = gql`
|
||||||
|
mutation UPDATE_NOTIFICATION_SETTINGS($id: uuid!, $ns: jsonb) {
|
||||||
|
update_associations_by_pk(pk_columns: { id: $id }, _set: { notification_settings: $ns }) {
|
||||||
|
id
|
||||||
|
notification_settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import "./utils/sentry"; //Must be first.
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
|
import { ConfigProvider } from "antd";
|
||||||
import Dinero from "dinero.js";
|
import Dinero from "dinero.js";
|
||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
|
import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from "react-router-dom";
|
||||||
import { PersistGate } from "redux-persist/integration/react";
|
import { PersistGate } from "redux-persist/integration/react";
|
||||||
|
import { registerSW } from "virtual:pwa-register";
|
||||||
import AppContainer from "./App/App.container";
|
import AppContainer from "./App/App.container";
|
||||||
import LoadingSpinner from "./components/loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "./components/loading-spinner/loading-spinner.component";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
@@ -12,56 +14,18 @@ import { persistor, store } from "./redux/store";
|
|||||||
import reportWebVitals from "./reportWebVitals";
|
import reportWebVitals from "./reportWebVitals";
|
||||||
import "./translations/i18n";
|
import "./translations/i18n";
|
||||||
import "./utils/CleanAxios";
|
import "./utils/CleanAxios";
|
||||||
import { ConfigProvider } from "antd";
|
|
||||||
import InstanceRenderManager from "./utils/instanceRenderMgr";
|
|
||||||
import { registerSW } from "virtual:pwa-register";
|
|
||||||
|
|
||||||
window.global ||= window;
|
window.global ||= window;
|
||||||
|
|
||||||
registerSW({ immediate: true });
|
registerSW({ immediate: true });
|
||||||
//import { BrowserTracing } from "@sentry/tracing";
|
|
||||||
//import "antd/dist/antd.css";
|
|
||||||
// import "antd/dist/antd.less";
|
|
||||||
|
|
||||||
// Dinero.defaultCurrency = "CAD";
|
// Dinero.defaultCurrency = "CAD";
|
||||||
// Dinero.globalLocale = "en-CA";
|
// Dinero.globalLocale = "en-CA";
|
||||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||||
|
|
||||||
if (import.meta.env.PROD) {
|
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
|
||||||
Sentry.init({
|
|
||||||
dsn: InstanceRenderManager({
|
|
||||||
imex: "https://fd7e89369b6b4bdc9c6c4c9f22fa4ee4@o492140.ingest.sentry.io/5651027",
|
|
||||||
rome: "https://a6acc91c073e414196014b8484627a61@o492140.ingest.sentry.io/4504561071161344"
|
|
||||||
}),
|
|
||||||
|
|
||||||
ignoreErrors: [
|
const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
|
||||||
"ResizeObserver loop",
|
|
||||||
"ResizeObserver loop limit exceeded",
|
|
||||||
"Module specifier, 'fs' does not start",
|
|
||||||
"Module specifier, 'zlib' does not start with"
|
|
||||||
],
|
|
||||||
integrations: [
|
|
||||||
Sentry.replayIntegration({
|
|
||||||
maskAllText: false,
|
|
||||||
blockAllMedia: true
|
|
||||||
}),
|
|
||||||
Sentry.browserTracingIntegration()
|
|
||||||
],
|
|
||||||
tracePropagationTargets: [
|
|
||||||
"api.imex.online",
|
|
||||||
"api.test.imex.online",
|
|
||||||
"db.imex.online",
|
|
||||||
"api.romeonline.io",
|
|
||||||
"api.test.romeonline.io",
|
|
||||||
"db.romeonline.io"
|
|
||||||
],
|
|
||||||
tracesSampleRate: 1.0,
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
environment: import.meta.env.MODE
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = createBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
let styles =
|
let styles =
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import { DateTimeFormat } from "../../utils/DateFormatter";
|
|||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
|
import JobWatcherToggleContainer from "../../components/job-watcher-toggle/job-watcher-toggle.container.jsx";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -102,6 +104,7 @@ export function JobsDetailPage({
|
|||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
const { scenarioNotificationsOn } = useSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
//form.setFieldsValue(transormJobToForm(job));
|
//form.setFieldsValue(transormJobToForm(job));
|
||||||
@@ -319,7 +322,13 @@ export function JobsDetailPage({
|
|||||||
>
|
>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
// onBack={() => window.history.back()}
|
// onBack={() => window.history.back()}
|
||||||
title={job.ro_number || t("general.labels.na")}
|
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
{scenarioNotificationsOn && <JobWatcherToggleContainer job={job} />}
|
||||||
|
{job.ro_number || t("general.labels.na")}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
extra={menuExtra}
|
extra={menuExtra}
|
||||||
/>
|
/>
|
||||||
<JobsDetailHeader job={job} />
|
<JobsDetailHeader job={job} />
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export default connect(mapStateToProps, null)(LandingPage);
|
|||||||
|
|
||||||
export function LandingPage({ currentUser }) {
|
export function LandingPage({ currentUser }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
console.log("Main");
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigate("/manage/jobs");
|
navigate("/manage/jobs");
|
||||||
}, [currentUser, navigate]);
|
}, [currentUser, navigate]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FloatButton, Layout, Spin } from "antd";
|
import { FloatButton, Layout, Spin } from "antd";
|
||||||
|
|
||||||
// import preval from "preval.macro";
|
// import preval from "preval.macro";
|
||||||
import React, { lazy, Suspense, useContext, useEffect, useState } from "react";
|
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Link, Route, Routes } from "react-router-dom";
|
import { Link, Route, Routes } from "react-router-dom";
|
||||||
@@ -20,7 +20,7 @@ import PartnerPingComponent from "../../components/partner-ping/partner-ping.com
|
|||||||
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
|
import PrintCenterModalContainer from "../../components/print-center-modal/print-center-modal.container";
|
||||||
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
||||||
import { requestForToken } from "../../firebase/firebase.utils";
|
import { requestForToken } from "../../firebase/firebase.utils";
|
||||||
import SocketContext from "../../contexts/SocketIO/socketContext.jsx";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.jsx";
|
||||||
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectInstanceConflict } from "../../redux/user/user.selectors";
|
||||||
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
import UpdateAlert from "../../components/update-alert/update-alert.component";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||||
@@ -29,6 +29,7 @@ import WssStatusDisplayComponent from "../../components/wss-status-display/wss-s
|
|||||||
import { selectAlerts } from "../../redux/application/application.selectors.js";
|
import { selectAlerts } from "../../redux/application/application.selectors.js";
|
||||||
import { addAlerts } from "../../redux/application/application.actions.js";
|
import { addAlerts } from "../../redux/application/application.actions.js";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
|
||||||
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
const JobsPage = lazy(() => import("../jobs/jobs.page"));
|
||||||
|
|
||||||
const CardPaymentModalContainer = lazy(
|
const CardPaymentModalContainer = lazy(
|
||||||
@@ -122,7 +123,7 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [chatVisible] = useState(false);
|
const [chatVisible] = useState(false);
|
||||||
const { socket, clientId } = useContext(SocketContext);
|
const { socket, clientId } = useSocket();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
// State to track displayed alerts
|
// State to track displayed alerts
|
||||||
@@ -142,11 +143,11 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
|||||||
const fetchedAlerts = await response.json();
|
const fetchedAlerts = await response.json();
|
||||||
setAlerts(fetchedAlerts);
|
setAlerts(fetchedAlerts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching alerts:", error);
|
console.warn("Error fetching alerts:", error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAlerts();
|
fetchAlerts().catch((err) => `Error fetching Bodyshop Alerts: ${err?.message || ""}`);
|
||||||
}, [setAlerts]);
|
}, [setAlerts]);
|
||||||
|
|
||||||
// Use useEffect to watch for new alerts
|
// Use useEffect to watch for new alerts
|
||||||
@@ -166,7 +167,6 @@ export function Manage({ conflict, bodyshop, alerts, setAlerts }) {
|
|||||||
description: alert.description,
|
description: alert.description,
|
||||||
type: alert.type || "info",
|
type: alert.type || "info",
|
||||||
duration: 0,
|
duration: 0,
|
||||||
placement: "bottomRight",
|
|
||||||
closable: true,
|
closable: true,
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
// When the notification is closed, update displayed alerts state and localStorage
|
// When the notification is closed, update displayed alerts state and localStorage
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import React, { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
@@ -10,23 +10,17 @@ import PaymentsListPaginated from "../../components/payments-list-paginated/paym
|
|||||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||||
import { QUERY_ALL_PAYMENTS_PAGINATED } from "../../graphql/payments.queries";
|
import { QUERY_ALL_PAYMENTS_PAGINATED } from "../../graphql/payments.queries";
|
||||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
|
||||||
import { pageLimit } from "../../utils/config";
|
import { pageLimit } from "../../utils/config";
|
||||||
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
|
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
|
|
||||||
import { Card } from "antd";
|
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({});
|
||||||
bodyshop: selectBodyshop
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
|
export function AllJobs({ setBreadcrumbs, setSelectedHeader }) {
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
const { page, sortcolumn, sortorder, searchObj } = searchParams;
|
const { page, sortcolumn, sortorder, searchObj } = searchParams;
|
||||||
|
|
||||||
@@ -60,15 +54,6 @@ export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
|
|||||||
|
|
||||||
if (error) return <AlertComponent message={error.message} type="error" />;
|
if (error) return <AlertComponent message={error.message} type="error" />;
|
||||||
return (
|
return (
|
||||||
<FeatureWrapperComponent
|
|
||||||
featureName="payments"
|
|
||||||
noauth={
|
|
||||||
<Card>
|
|
||||||
<UpsellComponent upsell={upsellEnum().payments.general} />
|
|
||||||
</Card>
|
|
||||||
}
|
|
||||||
z
|
|
||||||
>
|
|
||||||
<RbacWrapper action="payments:list">
|
<RbacWrapper action="payments:list">
|
||||||
<PaymentsListPaginated
|
<PaymentsListPaginated
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
@@ -78,7 +63,6 @@ export function AllJobs({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
|
|||||||
payments={data ? data.payments : []}
|
payments={data ? data.payments : []}
|
||||||
/>
|
/>
|
||||||
</RbacWrapper>
|
</RbacWrapper>
|
||||||
</FeatureWrapperComponent>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,3 +118,8 @@ export const setCurrentEula = (eula) => ({
|
|||||||
export const acceptEula = () => ({
|
export const acceptEula = () => ({
|
||||||
type: UserActionTypes.EULA_ACCEPTED
|
type: UserActionTypes.EULA_ACCEPTED
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setImexShopId = (imexshopid) => ({
|
||||||
|
type: UserActionTypes.SET_IMEX_SHOP_ID,
|
||||||
|
payload: imexshopid
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ const userReducer = (state = INITIAL_STATE, action) => {
|
|||||||
};
|
};
|
||||||
case UserActionTypes.SET_AUTH_LEVEL:
|
case UserActionTypes.SET_AUTH_LEVEL:
|
||||||
return { ...state, authLevel: action.payload };
|
return { ...state, authLevel: action.payload };
|
||||||
|
case UserActionTypes.SET_IMEX_SHOP_ID:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
imexshopid: action.payload
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,19 @@ import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
|||||||
import * as Sentry from "@sentry/browser";
|
import * as Sentry from "@sentry/browser";
|
||||||
import { notification } from "antd";
|
import { notification } from "antd";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { setUserId, setUserProperties } from "firebase/analytics";
|
import { setUserId, setUserProperties } from "@firebase/analytics";
|
||||||
import {
|
import {
|
||||||
checkActionCode,
|
checkActionCode,
|
||||||
confirmPasswordReset,
|
confirmPasswordReset,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
signInWithEmailAndPassword,
|
signInWithEmailAndPassword,
|
||||||
signOut
|
signOut
|
||||||
} from "firebase/auth";
|
} from "@firebase/auth";
|
||||||
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "firebase/firestore";
|
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
|
||||||
import { getToken } from "firebase/messaging";
|
import { getToken } from "@firebase/messaging";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import LogRocket from "logrocket";
|
import LogRocket from "logrocket";
|
||||||
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
|
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
|
||||||
import { factory } from "../../App/App.container";
|
|
||||||
import {
|
import {
|
||||||
analytics,
|
analytics,
|
||||||
auth,
|
auth,
|
||||||
@@ -35,6 +34,7 @@ import {
|
|||||||
sendPasswordResetFailure,
|
sendPasswordResetFailure,
|
||||||
sendPasswordResetSuccess,
|
sendPasswordResetSuccess,
|
||||||
setAuthlevel,
|
setAuthlevel,
|
||||||
|
setImexShopId,
|
||||||
setInstanceConflict,
|
setInstanceConflict,
|
||||||
setInstanceId,
|
setInstanceId,
|
||||||
setLocalFingerprint,
|
setLocalFingerprint,
|
||||||
@@ -318,7 +318,8 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
|||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory.client(payload.imexshopid);
|
// Dispatch the imexshopid to Redux store
|
||||||
|
yield put(setImexShopId(payload.imexshopid));
|
||||||
|
|
||||||
const authRecord = payload.associations.filter((a) => a.useremail.toLowerCase() === userEmail.toLowerCase());
|
const authRecord = payload.associations.filter((a) => a.useremail.toLowerCase() === userEmail.toLowerCase());
|
||||||
|
|
||||||
@@ -351,7 +352,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) {
|
|||||||
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
|
? window.$crisp.push(["set", "session:segments", [["allAccess"]]])
|
||||||
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
|
: window.$crisp.push(["set", "session:segments", [["basic"]]]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Couldnt find $crisp.");
|
console.warn("Couldnt find $crisp.", error.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
yield put(signInFailure(error.message));
|
yield put(signInFailure(error.message));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const UserActionTypes = {
|
|||||||
CHECK_ACTION_CODE_SUCCESS: "CHECK_ACTION_CODE_SUCCESS",
|
CHECK_ACTION_CODE_SUCCESS: "CHECK_ACTION_CODE_SUCCESS",
|
||||||
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
|
CHECK_ACTION_CODE_FAILURE: "CHECK_ACTION_CODE_FAILURE",
|
||||||
SET_CURRENT_EULA: "SET_CURRENT_EULA",
|
SET_CURRENT_EULA: "SET_CURRENT_EULA",
|
||||||
EULA_ACCEPTED: "EULA_ACCEPTED"
|
EULA_ACCEPTED: "EULA_ACCEPTED",
|
||||||
|
SET_IMEX_SHOP_ID: "SET_IMEX_SHOP_ID"
|
||||||
};
|
};
|
||||||
export default UserActionTypes;
|
export default UserActionTypes;
|
||||||
|
|||||||
@@ -334,7 +334,17 @@
|
|||||||
},
|
},
|
||||||
"intellipay_config": {
|
"intellipay_config": {
|
||||||
"cash_discount_percentage": "Cash Discount %",
|
"cash_discount_percentage": "Cash Discount %",
|
||||||
"enable_cash_discount": "Enable Cash Discounting"
|
"enable_cash_discount": "Enable Cash Discounting",
|
||||||
|
"payment_type": "Payment Type Map",
|
||||||
|
"payment_map": {
|
||||||
|
"amex": "American Express",
|
||||||
|
"disc": "Discover",
|
||||||
|
"dnrs": "Diners",
|
||||||
|
"intr": "Interac",
|
||||||
|
"jcb": "JCB",
|
||||||
|
"mast": "MasterCard",
|
||||||
|
"visa": "Visa"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"invoice_federal_tax_rate": "Invoices - Federal Tax Rate",
|
"invoice_federal_tax_rate": "Invoices - Federal Tax Rate",
|
||||||
"invoice_local_tax_rate": "Invoices - Local Tax Rate",
|
"invoice_local_tax_rate": "Invoices - Local Tax Rate",
|
||||||
@@ -712,6 +722,7 @@
|
|||||||
"scoreboardsetup": "Scoreboard Setup",
|
"scoreboardsetup": "Scoreboard Setup",
|
||||||
"shop_enabled_features": "Shop Enabled Features",
|
"shop_enabled_features": "Shop Enabled Features",
|
||||||
"shopinfo": "Shop Information",
|
"shopinfo": "Shop Information",
|
||||||
|
"shoprates": "Shop Rates",
|
||||||
"speedprint": "Speed Print Configuration",
|
"speedprint": "Speed Print Configuration",
|
||||||
"ssbuckets": "Job Size Definitions",
|
"ssbuckets": "Job Size Definitions",
|
||||||
"systemsettings": "System Settings",
|
"systemsettings": "System Settings",
|
||||||
@@ -1648,7 +1659,7 @@
|
|||||||
"08": "Left Rear Side",
|
"08": "Left Rear Side",
|
||||||
"09": "Left Side"
|
"09": "Left Side"
|
||||||
},
|
},
|
||||||
"auto_add_ats": "Automatically Add/Update ATS",
|
"auto_add_ats": "Automatically Add/Update ATS?",
|
||||||
"ca_bc_pvrt": "PVRT",
|
"ca_bc_pvrt": "PVRT",
|
||||||
"ca_customer_gst": "Customer Portion of GST",
|
"ca_customer_gst": "Customer Portion of GST",
|
||||||
"ca_gst_registrant": "GST Registrant",
|
"ca_gst_registrant": "GST Registrant",
|
||||||
@@ -1747,6 +1758,7 @@
|
|||||||
"est_ct_ln": "Estimator Last Name",
|
"est_ct_ln": "Estimator Last Name",
|
||||||
"est_ea": "Estimator Email",
|
"est_ea": "Estimator Email",
|
||||||
"est_ph1": "Estimator Phone #",
|
"est_ph1": "Estimator Phone #",
|
||||||
|
"flat_rate_ats": "Flat Rate ATS?",
|
||||||
"federal_tax_payable": "Federal Tax Payable",
|
"federal_tax_payable": "Federal Tax Payable",
|
||||||
"federal_tax_rate": "Federal Tax Rate",
|
"federal_tax_rate": "Federal Tax Rate",
|
||||||
"ins_addr1": "Insurance Co. Address",
|
"ins_addr1": "Insurance Co. Address",
|
||||||
@@ -1858,6 +1870,7 @@
|
|||||||
},
|
},
|
||||||
"queued_for_parts": "Queued for Parts",
|
"queued_for_parts": "Queued for Parts",
|
||||||
"rate_ats": "ATS Rate",
|
"rate_ats": "ATS Rate",
|
||||||
|
"rate_ats_flat": "ATS Flat Rate",
|
||||||
"rate_la1": "LA1",
|
"rate_la1": "LA1",
|
||||||
"rate_la2": "LA2",
|
"rate_la2": "LA2",
|
||||||
"rate_la3": "LA3",
|
"rate_la3": "LA3",
|
||||||
@@ -3766,6 +3779,60 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"unique_vendor_name": "You must enter a unique vendor name."
|
"unique_vendor_name": "You must enter a unique vendor name."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"labels": {
|
||||||
|
"notification-center": "Notification Center",
|
||||||
|
"scenario": "Scenario",
|
||||||
|
"notificationscenarios": "Job Notification Scenarios",
|
||||||
|
"save": "Save Scenarios",
|
||||||
|
"watching-issue": "Watching",
|
||||||
|
"add-watchers": "Add Watchers",
|
||||||
|
"employee-search": "Search for an Employee",
|
||||||
|
"teams-search": "Search for a Team",
|
||||||
|
"add-watchers-team": "Add Team Members",
|
||||||
|
"new-notification-title": "New Notification:",
|
||||||
|
"show-unread-only": "Show Unread Only",
|
||||||
|
"mark-all-read": "Mark All Read",
|
||||||
|
"notification-popup-title": "Changes for Job #{{ro_number}}",
|
||||||
|
"ro-number": "RO #{{ro_number}}",
|
||||||
|
"no-watchers": "No Watchers",
|
||||||
|
"notification-settings-success": "Notification Settings saved successfully.",
|
||||||
|
"notification-settings-failure": "Error saving Notification Settings. {{error}}",
|
||||||
|
"watch": "Watch",
|
||||||
|
"unwatch": "Unwatch"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"remove": "Remove"
|
||||||
|
},
|
||||||
|
"aria": {
|
||||||
|
"toggle": "Toggle Watching Job"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"job-watchers": "Job Watchers"
|
||||||
|
},
|
||||||
|
"scenarios": {
|
||||||
|
"job-assigned-to-me": "Job Assigned to Me",
|
||||||
|
"bill-posted": "Bill Posted",
|
||||||
|
"critical-parts-status-changed": "Critical Parts Status Changed",
|
||||||
|
"part-marked-back-ordered": "Part Marked Back Ordered",
|
||||||
|
"new-note-added": "New Note Added",
|
||||||
|
"supplement-imported": "Supplement Imported",
|
||||||
|
"schedule-dates-changed": "Schedule Dates Changed",
|
||||||
|
"tasks-updated-created": "Tasks Updated / Created",
|
||||||
|
"new-media-added-reassigned": "New Media Added or Reassigned",
|
||||||
|
"new-time-ticket-posted": "New Time Ticket Posted",
|
||||||
|
"intake-delivery-checklist-completed": "Intake or Delivery Checklist Completed",
|
||||||
|
"job-added-to-production": "Job Added to Production",
|
||||||
|
"job-status-change": "Job Status Changed",
|
||||||
|
"payment-collected-completed": "Payment Collected / Completed",
|
||||||
|
"alternate-transport-changed": "Alternate Transport Changed"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"app": "App",
|
||||||
|
"email": "Email",
|
||||||
|
"fcm": "Push"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,7 +334,17 @@
|
|||||||
},
|
},
|
||||||
"intellipay_config": {
|
"intellipay_config": {
|
||||||
"cash_discount_percentage": "",
|
"cash_discount_percentage": "",
|
||||||
"enable_cash_discount": ""
|
"enable_cash_discount": "",
|
||||||
|
"payment_type": "",
|
||||||
|
"payment_map": {
|
||||||
|
"amex": "American Express",
|
||||||
|
"disc": "Discover",
|
||||||
|
"dnrs": "Diners",
|
||||||
|
"intr": "Interac",
|
||||||
|
"jcb": "JCB",
|
||||||
|
"mast": "MasterCard",
|
||||||
|
"visa": "Visa"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"invoice_federal_tax_rate": "",
|
"invoice_federal_tax_rate": "",
|
||||||
"invoice_local_tax_rate": "",
|
"invoice_local_tax_rate": "",
|
||||||
@@ -712,6 +722,7 @@
|
|||||||
"scoreboardsetup": "",
|
"scoreboardsetup": "",
|
||||||
"shop_enabled_features": "",
|
"shop_enabled_features": "",
|
||||||
"shopinfo": "",
|
"shopinfo": "",
|
||||||
|
"shoprates": "",
|
||||||
"speedprint": "",
|
"speedprint": "",
|
||||||
"ssbuckets": "",
|
"ssbuckets": "",
|
||||||
"systemsettings": "",
|
"systemsettings": "",
|
||||||
@@ -1747,6 +1758,7 @@
|
|||||||
"est_ct_ln": "Apellido del tasador",
|
"est_ct_ln": "Apellido del tasador",
|
||||||
"est_ea": "Correo electrónico del tasador",
|
"est_ea": "Correo electrónico del tasador",
|
||||||
"est_ph1": "Número de teléfono del tasador",
|
"est_ph1": "Número de teléfono del tasador",
|
||||||
|
"flat_rate_ats": "",
|
||||||
"federal_tax_payable": "Impuesto federal por pagar",
|
"federal_tax_payable": "Impuesto federal por pagar",
|
||||||
"federal_tax_rate": "",
|
"federal_tax_rate": "",
|
||||||
"ins_addr1": "Dirección de Insurance Co.",
|
"ins_addr1": "Dirección de Insurance Co.",
|
||||||
@@ -1858,6 +1870,7 @@
|
|||||||
},
|
},
|
||||||
"queued_for_parts": "",
|
"queued_for_parts": "",
|
||||||
"rate_ats": "",
|
"rate_ats": "",
|
||||||
|
"rate_ats_flat": "",
|
||||||
"rate_la1": "Tarifa LA1",
|
"rate_la1": "Tarifa LA1",
|
||||||
"rate_la2": "Tarifa LA2",
|
"rate_la2": "Tarifa LA2",
|
||||||
"rate_la3": "Tarifa LA3",
|
"rate_la3": "Tarifa LA3",
|
||||||
@@ -3766,6 +3779,60 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"unique_vendor_name": ""
|
"unique_vendor_name": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"labels": {
|
||||||
|
"notification-center": "",
|
||||||
|
"scenario": "",
|
||||||
|
"notificationscenarios": "",
|
||||||
|
"save": "",
|
||||||
|
"watching-issue": "",
|
||||||
|
"add-watchers": "",
|
||||||
|
"employee-search": "",
|
||||||
|
"teams-search": "",
|
||||||
|
"add-watchers-team": "",
|
||||||
|
"new-notification-title": "",
|
||||||
|
"show-unread-only": "",
|
||||||
|
"mark-all-read": "",
|
||||||
|
"notification-popup-title": "",
|
||||||
|
"ro-number": "",
|
||||||
|
"no-watchers": "",
|
||||||
|
"notification-settings-success": "",
|
||||||
|
"notification-settings-failure": "",
|
||||||
|
"watch": "",
|
||||||
|
"unwatch": ""
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"remove": ""
|
||||||
|
},
|
||||||
|
"aria": {
|
||||||
|
"toggle": ""
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"job-watchers": ""
|
||||||
|
},
|
||||||
|
"scenarios": {
|
||||||
|
"job-assigned-to-me": "",
|
||||||
|
"bill-posted": "",
|
||||||
|
"critical-parts-status-changed": "",
|
||||||
|
"part-marked-back-ordered": "",
|
||||||
|
"new-note-added": "",
|
||||||
|
"supplement-imported": "",
|
||||||
|
"schedule-dates-changed": "",
|
||||||
|
"tasks-updated-created": "",
|
||||||
|
"new-media-added-reassigned": "",
|
||||||
|
"new-time-ticket-posted": "",
|
||||||
|
"intake-delivery-checklist-completed": "",
|
||||||
|
"job-added-to-production": "",
|
||||||
|
"job-status-change": "",
|
||||||
|
"payment-collected-completed": "",
|
||||||
|
"alternate-transport-changed": ""
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"app": "",
|
||||||
|
"email": "",
|
||||||
|
"fcm": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,7 +334,17 @@
|
|||||||
},
|
},
|
||||||
"intellipay_config": {
|
"intellipay_config": {
|
||||||
"cash_discount_percentage": "",
|
"cash_discount_percentage": "",
|
||||||
"enable_cash_discount": ""
|
"enable_cash_discount": "",
|
||||||
|
"payment_type": "",
|
||||||
|
"payment_map": {
|
||||||
|
"amex": "American Express",
|
||||||
|
"disc": "Discover",
|
||||||
|
"dnrs": "Diners",
|
||||||
|
"intr": "Interac",
|
||||||
|
"jcb": "JCB",
|
||||||
|
"mast": "MasterCard",
|
||||||
|
"visa": "Visa"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"invoice_federal_tax_rate": "",
|
"invoice_federal_tax_rate": "",
|
||||||
"invoice_local_tax_rate": "",
|
"invoice_local_tax_rate": "",
|
||||||
@@ -712,6 +722,7 @@
|
|||||||
"scoreboardsetup": "",
|
"scoreboardsetup": "",
|
||||||
"shop_enabled_features": "",
|
"shop_enabled_features": "",
|
||||||
"shopinfo": "",
|
"shopinfo": "",
|
||||||
|
"shoprates": "",
|
||||||
"speedprint": "",
|
"speedprint": "",
|
||||||
"ssbuckets": "",
|
"ssbuckets": "",
|
||||||
"systemsettings": "",
|
"systemsettings": "",
|
||||||
@@ -1747,6 +1758,7 @@
|
|||||||
"est_ct_ln": "Nom de l'évaluateur",
|
"est_ct_ln": "Nom de l'évaluateur",
|
||||||
"est_ea": "Courriel de l'évaluateur",
|
"est_ea": "Courriel de l'évaluateur",
|
||||||
"est_ph1": "Numéro de téléphone de l'évaluateur",
|
"est_ph1": "Numéro de téléphone de l'évaluateur",
|
||||||
|
"flat_rate_ats": "",
|
||||||
"federal_tax_payable": "Impôt fédéral à payer",
|
"federal_tax_payable": "Impôt fédéral à payer",
|
||||||
"federal_tax_rate": "",
|
"federal_tax_rate": "",
|
||||||
"ins_addr1": "Adresse Insurance Co.",
|
"ins_addr1": "Adresse Insurance Co.",
|
||||||
@@ -1858,6 +1870,7 @@
|
|||||||
},
|
},
|
||||||
"queued_for_parts": "",
|
"queued_for_parts": "",
|
||||||
"rate_ats": "",
|
"rate_ats": "",
|
||||||
|
"rate_ats_flat": "",
|
||||||
"rate_la1": "Taux LA1",
|
"rate_la1": "Taux LA1",
|
||||||
"rate_la2": "Taux LA2",
|
"rate_la2": "Taux LA2",
|
||||||
"rate_la3": "Taux LA3",
|
"rate_la3": "Taux LA3",
|
||||||
@@ -3766,6 +3779,60 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"unique_vendor_name": ""
|
"unique_vendor_name": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"labels": {
|
||||||
|
"notification-center": "",
|
||||||
|
"scenario": "",
|
||||||
|
"notificationscenarios": "",
|
||||||
|
"save": "",
|
||||||
|
"watching-issue": "",
|
||||||
|
"add-watchers": "",
|
||||||
|
"employee-search": "",
|
||||||
|
"teams-search": "",
|
||||||
|
"add-watchers-team": "",
|
||||||
|
"new-notification-title": "",
|
||||||
|
"show-unread-only": "",
|
||||||
|
"mark-all-read": "",
|
||||||
|
"notification-popup-title": "",
|
||||||
|
"ro-number": "",
|
||||||
|
"no-watchers": "",
|
||||||
|
"notification-settings-success": "",
|
||||||
|
"notification-settings-failure": "",
|
||||||
|
"watch": "",
|
||||||
|
"unwatch": ""
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"remove": ""
|
||||||
|
},
|
||||||
|
"aria": {
|
||||||
|
"toggle": ""
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"job-watchers": ""
|
||||||
|
},
|
||||||
|
"scenarios": {
|
||||||
|
"job-assigned-to-me": "",
|
||||||
|
"bill-posted": "",
|
||||||
|
"critical-parts-status-changed": "",
|
||||||
|
"part-marked-back-ordered": "",
|
||||||
|
"new-note-added": "",
|
||||||
|
"supplement-imported": "",
|
||||||
|
"schedule-dates-changed": "",
|
||||||
|
"tasks-updated-created": "",
|
||||||
|
"new-media-added-reassigned": "",
|
||||||
|
"new-time-ticket-posted": "",
|
||||||
|
"intake-delivery-checklist-completed": "",
|
||||||
|
"job-added-to-production": "",
|
||||||
|
"job-status-change": "",
|
||||||
|
"payment-collected-completed": "",
|
||||||
|
"alternate-transport-changed": ""
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"app": "",
|
||||||
|
"email": "",
|
||||||
|
"fcm": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user