Compare commits
93 Commits
feature/IO
...
test-AIO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
045f36e294 | ||
|
|
c7c6dfcd7d | ||
|
|
c1c0b35c8f | ||
|
|
c024fdd57b | ||
|
|
a4ccacf83a | ||
|
|
aa3b303fe9 | ||
|
|
fdaf50d778 | ||
|
|
468ed23f73 | ||
|
|
322ebd3bc7 | ||
|
|
b887cfed01 | ||
|
|
0f800c5a4c | ||
|
|
6cce92b0fd | ||
|
|
60ab04cb38 | ||
|
|
345a470731 | ||
|
|
0025e113c6 | ||
|
|
dc435b2bb0 | ||
|
|
fd72d244e7 | ||
|
|
87bb472271 | ||
|
|
825959880e | ||
|
|
c40fea0ec9 | ||
|
|
ebdf427b58 | ||
|
|
b3fdd68276 | ||
|
|
30e5027c8c | ||
|
|
3e63c58b9b | ||
|
|
938cef1f6b | ||
|
|
7e2df3e341 | ||
|
|
45d095a7a3 | ||
|
|
709b6ef1d6 | ||
|
|
4e98df6694 | ||
|
|
b920bb4437 | ||
|
|
e36a110e81 | ||
|
|
719d1b6479 | ||
|
|
29ded5efbf | ||
|
|
551e0f0592 | ||
|
|
4d299bb226 | ||
|
|
ae9b68a0bc | ||
|
|
cf8df89e30 | ||
|
|
bfd6cc83af | ||
|
|
99b65e8186 | ||
|
|
f8fd2ee64c | ||
|
|
8240ea9a64 | ||
|
|
ebde2f1581 | ||
|
|
85b3b88538 | ||
|
|
426283ffee | ||
|
|
a45808eb94 | ||
|
|
a2389b1f26 | ||
|
|
4fc86ccaa3 | ||
|
|
519997a8be | ||
|
|
ab606a4266 | ||
|
|
c4c36b7fd0 | ||
|
|
deb2fc28ce | ||
|
|
a67946c5a3 | ||
|
|
da317704c4 | ||
|
|
e43923b7a0 | ||
|
|
e9ef429729 | ||
|
|
db01ad9155 | ||
|
|
8bf7fbd1f1 | ||
|
|
c37037ef21 | ||
|
|
6050aebcd5 | ||
|
|
77d0f5ab38 | ||
|
|
a0692f8c69 | ||
|
|
4f76aeb06f | ||
|
|
302a42089f | ||
|
|
906265c4b2 | ||
|
|
388b042037 | ||
|
|
73eb76a230 | ||
|
|
d5e9b79f75 | ||
|
|
56d0c009e2 | ||
|
|
79030f6b36 | ||
|
|
5e78cdd8ae | ||
|
|
8f4ac866f1 | ||
|
|
9ad2a53bec | ||
|
|
6590f8961b | ||
|
|
7df71b8f44 | ||
|
|
4776b03a21 | ||
|
|
20943f74e9 | ||
|
|
4af312854e | ||
|
|
ff084f6fb8 | ||
|
|
5c9e4517a6 | ||
|
|
190217ffce | ||
|
|
28dc1d4533 | ||
|
|
a97e03e0b1 | ||
|
|
e30353cab6 | ||
|
|
c9b9f67170 | ||
|
|
4a47f543b2 | ||
|
|
3b60aa89f1 | ||
|
|
20d2572087 | ||
|
|
ac4c09af60 | ||
|
|
6a60af9dfe | ||
|
|
dfb6f02864 | ||
|
|
48bb494e0f | ||
|
|
9b74cba56b | ||
|
|
6fc8124268 |
@@ -7,6 +7,7 @@ _reference
|
||||
client
|
||||
redis/dockerdata
|
||||
hasura
|
||||
harness-feature-flags-export
|
||||
node_modules
|
||||
# Files to exclude
|
||||
.ebignore
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
/client
|
||||
/firebase
|
||||
/hasura
|
||||
/harness-feature-flags-export
|
||||
/jsreport
|
||||
/node_modules
|
||||
.env.local
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -17,6 +17,9 @@ jsreport/auth-server/node_modules
|
||||
client/coverage
|
||||
admin/coverage
|
||||
|
||||
# Generated Harness/Split feature flag export artifacts
|
||||
/harness-feature-flags-export/
|
||||
|
||||
# production
|
||||
/build
|
||||
client/build
|
||||
@@ -153,4 +156,5 @@ docker_data
|
||||
|
||||
.terraform
|
||||
|
||||
terraform.tfvars
|
||||
terraform.tfvars
|
||||
terraform.exe
|
||||
|
||||
1297
_reference/feature-flags.md
Normal file
1297
_reference/feature-flags.md
Normal file
File diff suppressed because it is too large
Load Diff
3193
client/package-lock.json
generated
3193
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,62 +8,61 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "^2.38.0",
|
||||
"@amplitude/analytics-browser": "^2.42.4",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.6",
|
||||
"@apollo/client": "^4.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@documenso/embed-react": "^0.5.1",
|
||||
"@documenso/embed-react": "^0.6.1",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||
"@firebase/analytics": "^0.10.21",
|
||||
"@firebase/app": "^0.14.10",
|
||||
"@firebase/auth": "^1.12.2",
|
||||
"@firebase/firestore": "^4.13.0",
|
||||
"@firebase/messaging": "^0.12.25",
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@firebase/analytics": "^0.10.22",
|
||||
"@firebase/app": "^0.14.12",
|
||||
"@firebase/auth": "^1.13.1",
|
||||
"@firebase/firestore": "^4.14.1",
|
||||
"@firebase/messaging": "^0.12.26",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@sentry/cli": "^3.3.5",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"@sentry/cli": "^3.4.3",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.63",
|
||||
"antd": "^6.3.5",
|
||||
"antd": "^6.4.3",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.14.0",
|
||||
"axios": "^1.16.1",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dayjs-business-days2": "^1.3.3",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"env-cmd": "^11.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphql": "^16.13.2",
|
||||
"graphql": "^16.14.0",
|
||||
"graphql-ws": "^6.0.8",
|
||||
"i18next": "^25.10.10",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.12.41",
|
||||
"libphonenumber-js": "^1.13.3",
|
||||
"lightningcss": "^1.32.0",
|
||||
"logrocket": "^12.1.0",
|
||||
"logrocket": "^12.1.1",
|
||||
"markerjs2": "^2.32.7",
|
||||
"memoize-one": "^6.0.0",
|
||||
"normalize-url": "^8.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"phone": "^3.1.71",
|
||||
"posthog-js": "^1.364.4",
|
||||
"posthog-js": "^1.376.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.3.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
"react": "^19.2.4",
|
||||
"react": "^19.2.6",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.1.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-cookie": "^8.1.2",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"react-i18next": "^16.6.6",
|
||||
@@ -73,22 +72,22 @@
|
||||
"react-number-format": "^5.4.5",
|
||||
"react-popopo": "^2.1.9",
|
||||
"react-product-fruits": "^2.2.62",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react-sticky": "^6.0.3",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"recharts": "^3.8.1",
|
||||
"redux": "^5.0.1",
|
||||
"redux-actions": "^3.0.3",
|
||||
"redux-persist": "^6.0.0",
|
||||
"redux-saga": "^1.4.2",
|
||||
"redux-saga": "^1.5.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"reselect": "^5.1.1",
|
||||
"reselect": "^5.2.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.98.0",
|
||||
"sass": "^1.100.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.12",
|
||||
"styled-components": "^6.4.2",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^5.2.0"
|
||||
},
|
||||
@@ -138,14 +137,14 @@
|
||||
"@rollup/rollup-linux-x64-gnu": "4.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/icons": "^6.2.3",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@babel/preset-react": "^7.28.5",
|
||||
"@dotenvx/dotenvx": "^1.59.1",
|
||||
"@babel/preset-react": "^7.29.7",
|
||||
"@dotenvx/dotenvx": "^1.68.1",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -157,21 +156,21 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"globals": "^17.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"memfs": "^4.57.1",
|
||||
"memfs": "^4.57.2",
|
||||
"os-browserify": "^0.3.0",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright": "^1.60.0",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-babel": "^1.6.0",
|
||||
"vite-plugin-babel": "^1.7.3",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.26.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-node-polyfills": "^0.28.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-style-import": "^2.0.0",
|
||||
"vitest": "^4.1.2",
|
||||
"workbox-window": "^7.4.0"
|
||||
"vitest": "^4.1.7",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5593,29 +5593,6 @@ Demo: https://rawgit.com/Sphinxxxx/color-conversion/master/demo/index.html
|
||||
|
||||
-----------
|
||||
|
||||
The following NPM packages may be included in this product:
|
||||
|
||||
- @splitsoftware/splitio-commons@1.6.1
|
||||
- @splitsoftware/splitio-react@1.7.1
|
||||
|
||||
These packages each contain the following license and notice below:
|
||||
|
||||
Copyright © 2022 Split Software, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
-----------
|
||||
|
||||
The following NPM packages may be included in this product:
|
||||
|
||||
- @stripe/react-stripe-js@1.9.0
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { FeatureFlagProvider, useFeatureFlagClient } from "../feature-flags/splitio-react-replacement";
|
||||
import { ConfigProvider } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -16,23 +16,21 @@ import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
function FeatureFlagClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
const featureFlagClient = useFeatureFlagClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
if (import.meta.env.DEV && featureFlagClient && imexshopid) {
|
||||
console.log(`Feature flag client initialized with key: ${imexshopid}, isReady: ${featureFlagClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
}, [featureFlagClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -124,11 +122,11 @@ function AppContainer() {
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<FeatureFlagProvider config={config}>
|
||||
<FeatureFlagClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</FeatureFlagClientProvider>
|
||||
</FeatureFlagProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { ConfigProvider, Grid } from "antd";
|
||||
import enLocale from "antd/es/locale/en_US";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CookiesProvider } from "react-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
|
||||
import { setDarkMode } from "../redux/application/application.actions";
|
||||
import { selectDarkMode } from "../redux/application/application.selectors";
|
||||
import { selectCurrentUser } from "../redux/user/user.selectors.js";
|
||||
import { signOutStart } from "../redux/user/user.actions";
|
||||
import client from "../utils/GraphQLClient";
|
||||
import App from "./App";
|
||||
import getTheme from "./themeProvider";
|
||||
|
||||
// Base Split configuration
|
||||
const config = {
|
||||
core: {
|
||||
authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
|
||||
key: "anon"
|
||||
}
|
||||
};
|
||||
|
||||
function SplitClientProvider({ children }) {
|
||||
const imexshopid = useSelector((state) => state.user.imexshopid);
|
||||
const splitClient = useSplitClient({ key: imexshopid || "anon" });
|
||||
|
||||
useEffect(() => {
|
||||
if (import.meta.env.DEV && splitClient && imexshopid) {
|
||||
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
|
||||
}
|
||||
}, [splitClient, imexshopid]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AppContainer() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const currentUser = useSelector(selectCurrentUser);
|
||||
const isDarkMode = useSelector(selectDarkMode);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isPhone = !screens.md;
|
||||
const isUltraWide = Boolean(screens.xxxl);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
const baseTheme = getTheme(isDarkMode);
|
||||
|
||||
return {
|
||||
...baseTheme,
|
||||
token: {
|
||||
...(baseTheme.token || {}),
|
||||
screenXXXL: 2160
|
||||
},
|
||||
components: {
|
||||
...(baseTheme.components || {}),
|
||||
Table: {
|
||||
...(baseTheme.components?.Table || {}),
|
||||
cellFontSizeSM: isPhone ? 12 : 13,
|
||||
cellFontSizeMD: isPhone ? 13 : isUltraWide ? 15 : 14,
|
||||
cellFontSize: isUltraWide ? 15 : 14,
|
||||
cellPaddingInlineSM: isPhone ? 8 : 10,
|
||||
cellPaddingInlineMD: isPhone ? 10 : 14,
|
||||
cellPaddingInline: isUltraWide ? 20 : 16,
|
||||
cellPaddingBlockSM: isPhone ? 8 : 10,
|
||||
cellPaddingBlockMD: isPhone ? 10 : 12,
|
||||
cellPaddingBlock: isUltraWide ? 14 : 12,
|
||||
selectionColumnWidth: isPhone ? 44 : 52
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDarkMode, isPhone, isUltraWide]);
|
||||
|
||||
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
|
||||
const antdTable = useMemo(() => ({ scroll: { x: "max-content" } }), []);
|
||||
const antdPagination = useMemo(
|
||||
() => ({
|
||||
showSizeChanger: !isPhone,
|
||||
totalBoundaryShowSizeChanger: 100
|
||||
}),
|
||||
[isPhone]
|
||||
);
|
||||
|
||||
const antdForm = useMemo(
|
||||
() => ({
|
||||
validateMessages: {
|
||||
required: t("general.validation.required", { label: "${label}" })
|
||||
}
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Global seamless logout listener with redirect to /signin
|
||||
useEffect(() => {
|
||||
const handleSeamlessLogout = (event) => {
|
||||
if (event.data?.type !== "seamlessLogoutRequest") return;
|
||||
|
||||
// Only accept messages from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
|
||||
const targetOrigin = event.origin || "*";
|
||||
|
||||
if (currentUser?.authorized !== true) {
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(signOutStart());
|
||||
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleSeamlessLogout);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleSeamlessLogout);
|
||||
};
|
||||
}, [dispatch, currentUser?.authorized]);
|
||||
|
||||
// Update data-theme attribute (no cleanup to avoid transient style churn)
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Sync darkMode with localStorage
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
|
||||
if (!uid) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `dark-mode-${uid}`;
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (raw == null) {
|
||||
dispatch(setDarkMode(false));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
|
||||
} catch {
|
||||
dispatch(setDarkMode(false));
|
||||
}
|
||||
}, [currentUser?.uid, dispatch]);
|
||||
|
||||
// Persist darkMode
|
||||
useEffect(() => {
|
||||
const uid = currentUser?.uid;
|
||||
if (!uid) return;
|
||||
|
||||
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
|
||||
}, [isDarkMode, currentUser?.uid]);
|
||||
|
||||
return (
|
||||
<CookiesProvider>
|
||||
<ApolloProvider client={client}>
|
||||
<ConfigProvider
|
||||
input={antdInput}
|
||||
locale={enLocale}
|
||||
theme={theme}
|
||||
form={antdForm}
|
||||
table={antdTable}
|
||||
pagination={antdPagination}
|
||||
componentSize={isPhone ? "small" : isUltraWide ? "large" : "middle"}
|
||||
popupOverflow="viewport"
|
||||
>
|
||||
<GlobalLoadingBar />
|
||||
<SplitFactoryProvider config={config}>
|
||||
<SplitClientProvider>
|
||||
<App />
|
||||
</SplitClientProvider>
|
||||
</SplitFactoryProvider>
|
||||
</ConfigProvider>
|
||||
</ApolloProvider>
|
||||
</CookiesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sentry.withProfiler(AppContainer);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSplitClient } from "@splitsoftware/splitio-react";
|
||||
import { useSplitClient } from "../feature-flags/splitio-react-replacement";
|
||||
import { Button, Result } from "antd";
|
||||
//import LogRocket from "logrocket";
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
@@ -225,13 +225,22 @@ export function App({
|
||||
path="/parts/*"
|
||||
element={
|
||||
<ErrorBoundary>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route
|
||||
path="/edit/*"
|
||||
element={
|
||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||
</SocketProvider>
|
||||
}
|
||||
>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -512,7 +512,52 @@
|
||||
|
||||
|
||||
.esignature-embed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-width: 0;
|
||||
}
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.esignature-modal {
|
||||
.ant-modal {
|
||||
top: 16px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.esignature-modal-frame {
|
||||
width: 100%;
|
||||
height: calc(100vh - 150px);
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px), (max-height: 560px) {
|
||||
.esignature-modal {
|
||||
.ant-modal {
|
||||
top: 8px;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
max-height: calc(100vh - 16px);
|
||||
}
|
||||
}
|
||||
|
||||
.esignature-modal-frame {
|
||||
height: calc(100vh - 132px);
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ const defaultTheme = (isDarkMode) => ({
|
||||
isDarkMode
|
||||
),
|
||||
colorError: isDarkMode ? "#ff4d4f" : "#f5222d",
|
||||
colorShadow: isDarkMode ? "rgba(0, 0, 0, 0.7)" : "#000000",
|
||||
colorBgBase: isDarkMode ? "#1f1f1f" : "#ffffff" // Align with Ant Design dark mode
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Divider, Form, Modal, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Icon, { UploadOutlined } from "@ant-design/icons";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,6 +36,7 @@ export function BillEnterModalLinesComponent({
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||
const firstFieldRefs = useRef({});
|
||||
const lineDescriptionRefs = useRef({});
|
||||
|
||||
const CONTROL_HEIGHT = 32;
|
||||
|
||||
@@ -94,6 +95,23 @@ export function BillEnterModalLinesComponent({
|
||||
});
|
||||
};
|
||||
|
||||
const focusLineDescription = (index) => {
|
||||
const lineDescription = lineDescriptionRefs.current[index];
|
||||
|
||||
if (typeof lineDescription?.focus === "function") {
|
||||
lineDescription.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
lineDescription?.resizableTextArea?.textArea?.focus?.({ preventScroll: true });
|
||||
};
|
||||
|
||||
const focusJobLineSelect = (index) => {
|
||||
window.setTimeout(() => {
|
||||
firstFieldRefs.current[index]?.focus?.({ preventScroll: true });
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
||||
const autofillActualCost = (index) => {
|
||||
if (bodyshop.accountingconfig?.disableBillCostCalculation) return;
|
||||
@@ -195,6 +213,12 @@ export function BillEnterModalLinesComponent({
|
||||
minHeight: `${CONTROL_HEIGHT}px`
|
||||
}}
|
||||
allowRemoved={form.getFieldValue("is_credit_memo") || false}
|
||||
onInputKeyDown={(event) => {
|
||||
if (event.key !== "Tab" || event.shiftKey || event.defaultPrevented) return;
|
||||
|
||||
event.preventDefault();
|
||||
focusLineDescription(index);
|
||||
}}
|
||||
onSelect={(value, opt) => {
|
||||
// IMPORTANT:
|
||||
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
|
||||
@@ -221,6 +245,7 @@ export function BillEnterModalLinesComponent({
|
||||
};
|
||||
})
|
||||
});
|
||||
focusJobLineSelect(index);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -236,7 +261,16 @@ export function BillEnterModalLinesComponent({
|
||||
label: t("billlines.fields.line_desc"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
|
||||
formInput: (record, index) => (
|
||||
<Input.TextArea
|
||||
ref={(el) => {
|
||||
lineDescriptionRefs.current[index] = el;
|
||||
}}
|
||||
disabled={disabled}
|
||||
autoSize
|
||||
tabIndex={0}
|
||||
/>
|
||||
)
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import GlobalSearch from "../global-search/global-search.component";
|
||||
import GlobalSearchOs from "../global-search/global-search-os.component";
|
||||
import "./breadcrumbs.styles.scss";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
breadcrumbs: selectBreadcrumbs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PictureFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Badge, Popover } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
currentUser: selectCurrentUser,
|
||||
|
||||
@@ -26,7 +26,7 @@ import DmsCdkMakesRefetch from "../dms-cdk-makes/dms-cdk-makes.refetch.component
|
||||
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import { DMS_MAP } from "../../utils/dmsUtils";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
/**
|
||||
* CDK-like DMS post form:
|
||||
|
||||
@@ -9,7 +9,7 @@ import AlertComponent from "../alert/alert.component";
|
||||
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
|
||||
import JobsDocumentsLocalGalleryExternalComponent from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import JobsDocumentImgproxyGalleryExternal from "../jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -24,16 +24,28 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
)
|
||||
});
|
||||
|
||||
export function EsignatureCustomDocument({ bodyshop, jobId, setEsignatureContext }) {
|
||||
export function EsignatureCustomDocument({
|
||||
bodyshop,
|
||||
disabled = false,
|
||||
jobId,
|
||||
setEsignatureContext,
|
||||
showUnavailable = false
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const notification = useNotification();
|
||||
const { t } = useTranslation();
|
||||
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||
const isDisabled = disabled || !esignatureEnabled;
|
||||
|
||||
if (!hasDocumensoApiKey(bodyshop)) {
|
||||
if (!esignatureEnabled && !showUnavailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uploadCustomDocument = async ({ file, onError, onSuccess }) => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("document", file);
|
||||
formData.append("jobid", jobId);
|
||||
@@ -78,11 +90,12 @@ export function EsignatureCustomDocument({ bodyshop, jobId, setEsignatureContext
|
||||
return Upload.LIST_IGNORE;
|
||||
}}
|
||||
customRequest={uploadCustomDocument}
|
||||
disabled={isDisabled}
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
multiple={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={loading}>
|
||||
<Button disabled={isDisabled} icon={<UploadOutlined />} loading={loading}>
|
||||
{t("esignature.actions.upload_document")}
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
@@ -26,6 +26,7 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
|
||||
const { open, context } = esignatureModal;
|
||||
const { token, envelopeId, documentId, jobid } = context;
|
||||
const [distributing, setDistributing] = useState(false);
|
||||
const hasToken = Boolean(token);
|
||||
|
||||
if (!hasDocumensoApiKey(bodyshop)) {
|
||||
return null;
|
||||
@@ -39,6 +40,10 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
|
||||
rome: t("jobs.labels.esignature_rome")
|
||||
})}
|
||||
onOk={async () => {
|
||||
if (!hasToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDistributing(true);
|
||||
await axios.post("/esign/distribute", {
|
||||
@@ -58,6 +63,11 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
|
||||
setDistributing(false);
|
||||
}}
|
||||
onCancel={async () => {
|
||||
if (!hasToken) {
|
||||
toggleModalVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post("/esign/delete", {
|
||||
documentId,
|
||||
@@ -73,13 +83,15 @@ export function EsignatureModalContainer({ esignatureModal, toggleModalVisible,
|
||||
});
|
||||
}
|
||||
}}
|
||||
okButtonProps={{ loading: distributing }}
|
||||
okButtonProps={{ loading: distributing, style: hasToken ? undefined : { display: "none" } }}
|
||||
okText={t("esignature.actions.distribute")}
|
||||
destroyOnHidden
|
||||
width={"80%"}
|
||||
width="calc(100vw - 32px)"
|
||||
wrapClassName="esignature-modal"
|
||||
styles={{ body: { overflow: "hidden", padding: 0 } }}
|
||||
>
|
||||
<div style={{ height: "80vh", width: "100%" }}>
|
||||
{token ? (
|
||||
<div className="esignature-modal-frame">
|
||||
{hasToken ? (
|
||||
<EmbedUpdateDocumentV1
|
||||
presignToken={token}
|
||||
host="https://sign.imex.online"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from "react-redux";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import WssStatusDisplayComponent from "../../components/wss-status-display/wss-status-display.component.jsx";
|
||||
import { useTreatment } from "../../feature-flags/splitio-react-replacement.jsx";
|
||||
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||
|
||||
@@ -16,6 +17,12 @@ const mapStateToProps = createStructuredSelector({
|
||||
|
||||
export function GlobalFooter({ isPartsEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const testFlagTreatment = useTreatment({ name: "TEST_FLAG" });
|
||||
const testFlagEnabled = testFlagTreatment === "on";
|
||||
|
||||
const testFlagIndicator = testFlagEnabled ? (
|
||||
<div style={{ fontWeight: 600, marginTop: 4 }}>Test Feature Flag Enabled</div>
|
||||
) : null;
|
||||
|
||||
if (isPartsEntry) {
|
||||
return (
|
||||
@@ -38,6 +45,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||
Disclaimer & Notices
|
||||
</Link>
|
||||
{testFlagIndicator}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
@@ -74,6 +82,7 @@ export function GlobalFooter({ isPartsEntry }) {
|
||||
<Link to="/disclaimer" target="_blank" style={{ color: "#ccc" }}>
|
||||
Disclaimer & Notices
|
||||
</Link>
|
||||
{testFlagIndicator}
|
||||
</div>
|
||||
</Footer>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { BellFilled } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Badge, Layout, Menu, Spin, Tooltip } from "antd";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { GET_LINE_TICKET_BY_PK } from "../../graphql/jobs-lines.queries";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Popconfirm } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -30,7 +30,7 @@ import JobLinesBillRefernece from "../job-lines-bill-reference/job-lines-bill-re
|
||||
// import AllocationsAssignmentContainer from "../allocations-assignment/allocations-assignment.container";
|
||||
// import AllocationsBulkAssignmentContainer from "../allocations-bulk-assignment/allocations-bulk-assignment.container";
|
||||
// import AllocationsEmployeeLabelContainer from "../allocations-employee-label/allocations-employee-label.container";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import _ from "lodash";
|
||||
import { FaTasks } from "react-icons/fa";
|
||||
import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
|
||||
|
||||
@@ -63,7 +63,9 @@ export function JobLineDispatchButton({
|
||||
}
|
||||
}
|
||||
//joblineids: selectedLines.map((l) => l.id),
|
||||
}
|
||||
},
|
||||
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
|
||||
awaitRefetchQueries: true
|
||||
});
|
||||
if (result.errors) {
|
||||
console.log("🚀 ~ handleConvert ~ result.errors:", result.errors);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import Axios from "axios";
|
||||
import Dinero from "dinero.js";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -16,7 +16,7 @@ import DataLabel from "../data-label/data-label.component";
|
||||
import PaymentExpandedRowComponent from "../payment-expanded-row/payment-expanded-row.component";
|
||||
import PaymentsGenerateLink from "../payments-generate-link/payments-generate-link.component";
|
||||
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ADD_JOB_WATCHER, GET_JOB_WATCHERS, REMOVE_JOB_WATCHER } from "../../gra
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import JobWatcherToggleComponent from "./job-watcher-toggle.component.jsx";
|
||||
import { useIsEmployee } from "../../utils/useIsEmployee.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useApolloClient, useLazyQuery, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { gql } from "@apollo/client";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Col, Row } from "antd";
|
||||
import Axios from "axios";
|
||||
import _ from "lodash";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DownCircleFilled } from "@ant-design/icons";
|
||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
|
||||
import axios from "axios";
|
||||
import parsePhoneNumber from "libphonenumber-js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import LaborAllocationsTableComponent from "../labor-allocations-table/labor-all
|
||||
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
|
||||
import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
jobRO: selectJobReadOnly,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import cleanAxios from "../../utils/CleanAxios";
|
||||
import formatBytes from "../../utils/formatbytes";
|
||||
//import yauzl from "yauzl";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -7,7 +7,7 @@ import JobDocuments from "./jobs-documents-gallery.component";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -14,7 +14,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass }) => {
|
||||
const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass, locked }) => {
|
||||
let renderedChildren = children;
|
||||
|
||||
//Mark the child prop as disabled.
|
||||
@@ -36,11 +36,13 @@ const LockWrapper = ({ featureName, bodyshop, children, disabled = true, bypass
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
return HasFeatureAccess({ featureName: featureName, bodyshop }) ? (
|
||||
const hasAccess = typeof locked === "boolean" ? !locked : HasFeatureAccess({ featureName: featureName, bodyshop });
|
||||
|
||||
return hasAccess ? (
|
||||
children
|
||||
) : (
|
||||
<Space>
|
||||
{!HasFeatureAccess({ featureName: featureName, bodyshop }) && <LockOutlined style={{ color: "tomato" }} />}
|
||||
<LockOutlined style={{ color: "tomato" }} />
|
||||
{renderedChildren}
|
||||
</Space>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled, DownOutlined, WarningFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Checkbox, Divider, Dropdown, Form, Input, InputNumber, Radio, Select, Space, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import PartsOrderModalComponent from "./parts-order-modal.component";
|
||||
import axios from "axios";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import _ from "lodash";
|
||||
import { UPDATE_JOB } from "../../graphql/jobs.queries";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, Input, Radio, Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, Row, Space, Tooltip, Typography } from "antd";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Card, Col, Input, Row, Space, Typography } from "antd";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { CloseOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Card, Col, Input, Row, Space, Typography, Tooltip } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,12 +11,14 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import Jobd3RdPartyModal from "../job-3rd-party-modal/job-3rd-party-modal.component";
|
||||
import EsignatureCustomDocument from "../esignature-custom-document/esignature-custom-document.component";
|
||||
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
|
||||
import PrintCenterItem from "../print-center-item/print-center-item.component";
|
||||
import PrintCenterJobsLabels from "../print-center-jobs-labels/print-center-jobs-labels.component";
|
||||
import PrintCenterSpeedPrint from "../print-center-speed-print/print-center-speed-print.component";
|
||||
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { hasDocumensoApiKey } from "../../utils/esignature.js";
|
||||
import useLocalStorage from "../../utils/useLocalStorage";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
printCenterModal: selectPrintCenter,
|
||||
@@ -27,6 +30,10 @@ const mapDispatchToProps = () => ({});
|
||||
|
||||
export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technician }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [esignatureBannerDismissed, setEsignatureBannerDismissed] = useLocalStorage(
|
||||
"print_center_esignature_banner_dismissed",
|
||||
false
|
||||
);
|
||||
const { id: jobId, job } = printCenterModal.context;
|
||||
const tempList = TemplateList("job", {});
|
||||
const { t } = useTranslation();
|
||||
@@ -41,6 +48,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
const dmsMode = getDmsMode(bodyshop, "off");
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
const esignatureEnabled = hasDocumensoApiKey(bodyshop);
|
||||
const showEsignatureBanner = !esignatureEnabled && !esignatureBannerDismissed;
|
||||
|
||||
const Templates = !hasDMSKey
|
||||
? Object.keys(tempList)
|
||||
@@ -50,7 +58,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
.filter(
|
||||
(temp) =>
|
||||
(!temp.regions ||
|
||||
(temp.regions && temp.regions[bodyshop.region_config]) ||
|
||||
temp.regions?.[bodyshop.region_config] ||
|
||||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
|
||||
(!temp.dms || temp.dms === false)
|
||||
)
|
||||
@@ -62,7 +70,7 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
.filter(
|
||||
(temp) =>
|
||||
!temp.regions ||
|
||||
(temp.regions && temp.regions[bodyshop.region_config]) ||
|
||||
temp.regions?.[bodyshop.region_config] ||
|
||||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
|
||||
)
|
||||
.filter((temp) => !isReynoldsMode || !temp.excludedDmsModes?.includes(dmsMode))
|
||||
@@ -91,6 +99,23 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showEsignatureBanner && (
|
||||
<Alert
|
||||
action={
|
||||
<Button
|
||||
aria-label={t("general.actions.close")}
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => setEsignatureBannerDismissed(true)}
|
||||
size="small"
|
||||
type="text"
|
||||
/>
|
||||
}
|
||||
banner
|
||||
title={t("printcenter.banners.esignature_promo")}
|
||||
type="info"
|
||||
className="print-center-esignature-banner"
|
||||
/>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col lg={8} md={12} sm={24}>
|
||||
<PrintCenterSpeedPrint jobId={jobId} />
|
||||
@@ -100,7 +125,13 @@ export function PrintCenterJobsComponent({ printCenterModal, bodyshop, technicia
|
||||
extra={
|
||||
<Space wrap>
|
||||
<PrintCenterJobsLabels jobId={jobId} />
|
||||
{esignatureEnabled && <EsignatureCustomDocument jobId={jobId} />}
|
||||
<Tooltip title={!esignatureEnabled ? t("esignature.tooltips.contact_sales") : null}>
|
||||
<span>
|
||||
<LockWrapperComponent locked={!esignatureEnabled} bodyshop={bodyshop}>
|
||||
<EsignatureCustomDocument jobId={jobId} showUnavailable />
|
||||
</LockWrapperComponent>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Jobd3RdPartyModal jobId={jobId} job={job} />
|
||||
<Input.Search onChange={(e) => setSearch(e.target.value)} value={search} enterButton />
|
||||
</Space>
|
||||
|
||||
@@ -5,3 +5,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.print-center-esignature-banner {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { QUERY_KANBAN_SETTINGS } from "../../graphql/user.queries";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import ProductionBoardKanbanComponent from "./production-board-kanban.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
technician: selectTechnician,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { UPDATE_ACTIVE_PROD_LIST_VIEW } from "../../graphql/associations.queries";
|
||||
import { UPDATE_SHOP } from "../../graphql/bodyshop.queries";
|
||||
import ProductionListColumns from "../production-list-columns/production-list-columns.data";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { isFunction } from "lodash";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../graphql/jobs.queries";
|
||||
import ProductionListTable from "./production-list-table.component";
|
||||
import _ from "lodash";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
|
||||
export default function ProductionListTableContainer({ bodyshop, subscriptionType = "direct" }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, DatePicker, Form, Input, Radio, Row, Typography } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import queryString from "query-string";
|
||||
@@ -12,11 +12,11 @@ import { createStructuredSelector } from "reselect";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import {
|
||||
CHECK_EMPLOYEE_EMAIL,
|
||||
CHECK_EMPLOYEE_NUMBER,
|
||||
DELETE_VACATION,
|
||||
INSERT_EMPLOYEES,
|
||||
QUERY_EMPLOYEE_BY_ID,
|
||||
QUERY_USERS_BY_EMAIL,
|
||||
UPDATE_EMPLOYEE
|
||||
} from "../../graphql/employees.queries";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
@@ -174,9 +174,10 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
||||
|
||||
const handleFinish = async (values) => {
|
||||
const submitAction = saveAndResetSubmitAction();
|
||||
const userEmail = typeof values.user_email === "string" ? values.user_email.trim() : values.user_email;
|
||||
const normalizedValues = {
|
||||
...values,
|
||||
user_email: values.user_email === "" ? null : values.user_email
|
||||
user_email: userEmail === "" ? null : userEmail
|
||||
};
|
||||
|
||||
if (search.employeeId && search.employeeId !== "new") {
|
||||
@@ -491,18 +492,29 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
async validator(rule, value) {
|
||||
const user_email = getFieldValue("user_email");
|
||||
const user_email = typeof value === "string" ? value.trim() : getFieldValue("user_email");
|
||||
|
||||
if (user_email && value) {
|
||||
const response = await client.query({
|
||||
query: QUERY_USERS_BY_EMAIL,
|
||||
query: CHECK_EMPLOYEE_EMAIL,
|
||||
variables: {
|
||||
email: user_email
|
||||
email: user_email,
|
||||
shopId: bodyshop.id
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.users.length === 1) {
|
||||
return Promise.resolve();
|
||||
const matchingEmployees = response.data.employees_aggregate.nodes;
|
||||
const currentEmployeeId = form.getFieldValue("id") ?? search.employeeId;
|
||||
|
||||
if (
|
||||
response.data.employees_aggregate.aggregate.count === 0 ||
|
||||
matchingEmployees.every((employee) => employee.id === currentEmployeeId)
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.reject(t("employees.validation.unique_user_email"));
|
||||
}
|
||||
return Promise.reject(t("bodyshop.validation.useremailmustexist"));
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CHECK_EMPLOYEE_EMAIL,
|
||||
DELETE_VACATION,
|
||||
INSERT_EMPLOYEES,
|
||||
QUERY_EMPLOYEE_BY_ID,
|
||||
@@ -16,6 +17,7 @@ const updateEmployeeMock = vi.fn();
|
||||
const deleteVacationMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const apolloClientQueryMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
const notification = {
|
||||
error: vi.fn(),
|
||||
@@ -33,7 +35,7 @@ vi.mock("@apollo/client/react", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@splitsoftware/splitio-react", () => ({
|
||||
vi.mock("../../feature-flags/splitio-react-replacement", () => ({
|
||||
useTreatmentsWithConfig: () => ({
|
||||
treatments: {
|
||||
Enhanced_Payroll: {
|
||||
@@ -87,6 +89,10 @@ vi.mock("react-i18next", () => ({
|
||||
return "Employee number must be unique";
|
||||
}
|
||||
|
||||
if (key === "employees.validation.unique_user_email") {
|
||||
return "User email already assigned";
|
||||
}
|
||||
|
||||
if (key === "bodyshop.validation.useremailmustexist") {
|
||||
return "User email must exist";
|
||||
}
|
||||
@@ -203,18 +209,20 @@ describe("ShopEmployeesFormComponent", () => {
|
||||
return [vi.fn()];
|
||||
});
|
||||
|
||||
useApolloClient.mockReturnValue({
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
employees_aggregate: {
|
||||
aggregate: {
|
||||
count: 0
|
||||
},
|
||||
nodes: []
|
||||
apolloClientQueryMock.mockResolvedValue({
|
||||
data: {
|
||||
employees_aggregate: {
|
||||
aggregate: {
|
||||
count: 0
|
||||
},
|
||||
users: []
|
||||
}
|
||||
})
|
||||
nodes: []
|
||||
},
|
||||
users: []
|
||||
}
|
||||
});
|
||||
|
||||
useApolloClient.mockReturnValue({
|
||||
query: apolloClientQueryMock
|
||||
});
|
||||
|
||||
insertEmployeesMock.mockResolvedValue({
|
||||
@@ -356,4 +364,59 @@ describe("ShopEmployeesFormComponent", () => {
|
||||
title: "Saved"
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks saving when the user email belongs to another employee in the shop", async () => {
|
||||
apolloClientQueryMock.mockImplementation(({ query }) => {
|
||||
if (query === CHECK_EMPLOYEE_EMAIL) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
users: [{ email: "jamie@example.com" }],
|
||||
employees_aggregate: {
|
||||
aggregate: {
|
||||
count: 1
|
||||
},
|
||||
nodes: [{ id: "other-employee" }]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
employees_aggregate: {
|
||||
aggregate: {
|
||||
count: 0
|
||||
},
|
||||
nodes: []
|
||||
},
|
||||
users: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
|
||||
target: { value: "Jamie" }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
|
||||
target: { value: "Rivera" }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
|
||||
target: { value: "42" }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
|
||||
target: { value: "1234" }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
|
||||
target: { value: "2026-04-20" }
|
||||
});
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "User Email" }), {
|
||||
target: { value: "jamie@example.com" }
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save Employee" }));
|
||||
|
||||
expect(await screen.findByText("User email already assigned")).toBeInTheDocument();
|
||||
expect(insertEmployeesMock).not.toHaveBeenCalled();
|
||||
expect(notification.success).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Tabs } from "antd";
|
||||
import queryString from "query-string";
|
||||
import { useRef } from "react";
|
||||
|
||||
@@ -4,7 +4,7 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Form, InputNumber } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Col, DatePicker, Divider, Form, Input, InputNumber, Radio, Row, Select, Space, Switch } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -157,36 +157,36 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
|
||||
</Col>
|
||||
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
||||
ClosingPeriod.treatment === "on" && (
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<Form.Item
|
||||
key="ClosingPeriod"
|
||||
name={["accountingconfig", "ClosingPeriod"]}
|
||||
label={t("bodyshop.fields.closingperiod")}
|
||||
>
|
||||
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<Form.Item
|
||||
key="ClosingPeriod"
|
||||
name={["accountingconfig", "ClosingPeriod"]}
|
||||
label={t("bodyshop.fields.closingperiod")}
|
||||
>
|
||||
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
||||
ADPPayroll.treatment === "on" && (
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<Form.Item
|
||||
key="companyCode"
|
||||
name={["accountingconfig", "companyCode"]}
|
||||
label={t("bodyshop.fields.companycode")}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<Form.Item
|
||||
key="companyCode"
|
||||
name={["accountingconfig", "companyCode"]}
|
||||
label={t("bodyshop.fields.companycode")}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
{HasFeatureAccess({ featureName: "export", bodyshop }) &&
|
||||
ADPPayroll.treatment === "on" && (
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<Form.Item key="batchID" name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
{HasFeatureAccess({ featureName: "export", bodyshop }) && !hasDMSKey && (
|
||||
<>
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
@@ -512,6 +512,15 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
|
||||
>
|
||||
<InputNumber min={0} max={100} suffix="%" />
|
||||
</Form.Item>
|
||||
{bodyshop.cdk_dealerid && (
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.dms.disablecontact")}
|
||||
valuePropName="checked"
|
||||
name={["cdk_configuration", "disablecontact"]}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.pbs_serialnumber && (
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.dms.disablecontactvehiclecreation")}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DEFAULT_TRANSLUCENT_CARD_COLOR, getTintedCardSurfaceStyles } from "./sh
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -7,7 +7,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import JobSearchSelect from "../job-search-select/job-search-select.component";
|
||||
import JobsDetailLaborContainer from "../jobs-detail-labor/jobs-detail-labor.container";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import TechJobPrintTickets from "../tech-job-print-tickets/tech-job-print-tickets.component";
|
||||
import TechClockInComponent from "./tech-job-clock-in-form.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { CalculateAllocationsTotals } from "../labor-allocations-table/labor-allocations-table.utility";
|
||||
import TechJobClockoutDelete from "../tech-job-clock-out-delete/tech-job-clock-out-delete.component";
|
||||
import { LaborAllocationContainer } from "../time-ticket-modal/time-ticket-modal.component";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createStructuredSelector } from "reselect";
|
||||
import { techLogout } from "../../redux/tech/tech.actions";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { BsKanban } from "react-icons/bs";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { setModalContext } from "../../redux/modals/modals.actions";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EditFilled, SyncOutlined } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Card, Checkbox, Space } from "antd";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLazyQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Form, Modal, Space } from "antd";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -44,9 +44,7 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
||||
key: "description",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
|
||||
record.v_model_desc || ""
|
||||
} ${record.v_color || ""}`}</span>
|
||||
<span>{`${record.v_model_yr || ""} ${record.v_color || ""} ${record.v_make_desc || ""} ${record.v_model_desc || ""} `}</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -111,7 +109,13 @@ export default function VehiclesListComponent({ loading, vehicles, total, refetc
|
||||
>
|
||||
<ResponsiveTable
|
||||
loading={loading}
|
||||
pagination={{ placement: "top", pageSize: currentPageSize, current: currentPage, showSizeChanger: true, total: total }}
|
||||
pagination={{
|
||||
placement: "top",
|
||||
pageSize: currentPageSize,
|
||||
current: currentPage,
|
||||
showSizeChanger: true,
|
||||
total: total
|
||||
}}
|
||||
columns={columns}
|
||||
mobileColumnKeys={["v_vin", "description", "plate_no"]}
|
||||
rowKey="id"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteFilled } from "@ant-design/icons";
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from "antd";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "../../graphql/notifications.queries.js";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { FEATURE_FLAGS_CHANGED_EVENT, useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
|
||||
|
||||
const LIMIT = INITIAL_NOTIFICATIONS;
|
||||
@@ -280,6 +280,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeatureFlagsChanged = (payload) => {
|
||||
window.dispatchEvent(new CustomEvent(FEATURE_FLAGS_CHANGED_EVENT, { detail: payload }));
|
||||
};
|
||||
|
||||
const syncCurrentTokenToSocket = async () => {
|
||||
try {
|
||||
if (!auth.currentUser || !bodyshop?.id) return;
|
||||
@@ -574,6 +578,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
|
||||
socketInstance.on("notification", handleNotification);
|
||||
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
|
||||
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
|
||||
socketInstance.on(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||
socketInstance.on("token-updated", handleTokenUpdated);
|
||||
|
||||
if (tokenSyncIntervalRef.current) {
|
||||
|
||||
71
client/src/feature-flags/README.md
Normal file
71
client/src/feature-flags/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Feature Flags
|
||||
|
||||
The app imports feature-flag hooks from `src/feature-flags/splitio-react-replacement.jsx`. That module keeps the old
|
||||
Split-shaped component and hook API intact while removing the runtime dependency on Split.
|
||||
|
||||
Code should import this local module directly. We no longer rely on a Vite alias for the old Split package.
|
||||
|
||||
## Current storage contract
|
||||
|
||||
The compatibility layer reads the active shop from Redux, then fetches DB-backed assignments from:
|
||||
|
||||
```text
|
||||
GET /feature-flags/bodyshops/:bodyshopId
|
||||
```
|
||||
|
||||
That endpoint verifies the Firebase user can access the bodyshop through Hasura permissions, then returns cached Redis
|
||||
data when present or refreshes from `feature_flags` + `bodyshop_feature_flags`.
|
||||
|
||||
On successful backend responses, the client stores the last-known flag payload in browser `localStorage` for the active
|
||||
bodyshop. If the backend cannot be reached later, the client uses that bodyshop-scoped browser cache for up to 24 hours.
|
||||
If there is no browser cache, unknown flags resolve to `"off"`.
|
||||
|
||||
Recommended backend payload shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"flags": {
|
||||
"Enhanced_Payroll": {
|
||||
"treatment": "on",
|
||||
"config": null,
|
||||
"activeDate": null,
|
||||
"deactiveDate": null
|
||||
},
|
||||
"Demo_Feature": {
|
||||
"treatment": "on",
|
||||
"config": null,
|
||||
"activeDate": "2026-06-01T13:00:00-04:00",
|
||||
"deactiveDate": "2026-06-05T17:00:00-04:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported values:
|
||||
|
||||
- `true`, `"true"`, `1`, `"on"` -> treatment `"on"`
|
||||
- `false`, `"false"`, `0`, `"off"` -> treatment `"off"`
|
||||
- ISO-ish future date strings -> `"on"` until the date passes
|
||||
- `{ "treatment": "on" | "off" | "control" | "any-custom-treatment", "config": ... }`
|
||||
- Scheduled demo windows using `activeDate` and `deactiveDate`
|
||||
|
||||
Unknown flags default to `"off"`.
|
||||
|
||||
## Backend registry
|
||||
|
||||
Canonical feature flag definitions live in the Hasura-backed `feature_flags` table and are exposed to the admin panel
|
||||
through `GET /adm/feature-flags`.
|
||||
|
||||
Per-shop assignments live in `bodyshop_feature_flags`. The admin panel reads them through
|
||||
`GET /adm/bodyshops/:bodyshopId/feature-flags` and saves them through `POST /adm/updateshop`.
|
||||
|
||||
Hasura invalidates the Redis cache through `/feature-flags/cache/invalidate` when `bodyshop_feature_flags` or
|
||||
`feature_flags` changes. Assignment changes clear the affected shop cache for the current cache version; definition
|
||||
changes increment a global feature flag cache version so old per-shop cache entries become invisible and expire by TTL.
|
||||
|
||||
The backend also emits `feature-flags-changed` over the existing Socket.IO connection. `SocketProvider` bridges that
|
||||
socket message to a browser event, and `SplitFactoryProvider` refetches flags when the event is global or matches the
|
||||
active bodyshop. This keeps already-open tabs in sync with admin edits and Hasura-triggered invalidation.
|
||||
|
||||
For manual frontend testing, the global footer displays `Test Feature Flag Enabled` when `TEST_FLAG` resolves to
|
||||
the `on` treatment.
|
||||
411
client/src/feature-flags/splitio-react-replacement.jsx
Normal file
411
client/src/feature-flags/splitio-react-replacement.jsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import axios from "axios";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { selectBodyshop } from "../redux/user/user.selectors";
|
||||
|
||||
const FeatureFlagContext = createContext({
|
||||
config: {},
|
||||
factory: null,
|
||||
flags: {},
|
||||
isReady: true,
|
||||
source: "local"
|
||||
});
|
||||
|
||||
const OFF_TREATMENT = Object.freeze({ treatment: "off", config: null });
|
||||
const LOCAL_STORAGE_PREFIX = "bodyshop-feature-flags";
|
||||
const LOCAL_STORAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const FEATURE_FLAGS_REFRESH_DEBOUNCE_MS = 150;
|
||||
const MAX_SCHEDULE_REFRESH_DELAY_MS = 2_147_483_647;
|
||||
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
const hasSchedule = (value) => value.activeDate != null || value.deactiveDate != null;
|
||||
|
||||
export const FEATURE_FLAGS_CHANGED_EVENT = "feature-flags-changed";
|
||||
|
||||
/**
|
||||
* Parses optional schedule timestamps into comparable epoch milliseconds.
|
||||
*/
|
||||
const parseDate = (value) => {
|
||||
if (value == null || value === "") return null;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether a scheduled feature flag assignment is active at the current time.
|
||||
*/
|
||||
const isWithinSchedule = (value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return true;
|
||||
|
||||
const now = Date.now();
|
||||
const startsAt = parseDate(value.activeDate);
|
||||
const endsAt = parseDate(value.deactiveDate);
|
||||
|
||||
if (startsAt != null && now < startsAt) return false;
|
||||
if (endsAt != null && now >= endsAt) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes backend config values into the object/string/null shape Split hooks expect.
|
||||
*/
|
||||
const normalizeConfig = (config) => {
|
||||
if (config == null || config === "") return null;
|
||||
if (typeof config === "string") {
|
||||
try {
|
||||
return JSON.parse(config);
|
||||
} catch {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts legacy boolean-ish values and custom treatment strings into a stable treatment value.
|
||||
*/
|
||||
const normalizeTreatment = (value) => {
|
||||
if (typeof value === "boolean") return value ? "on" : "off";
|
||||
if (typeof value === "number") return value > 0 ? "on" : "off";
|
||||
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
const lowered = normalized.toLowerCase();
|
||||
|
||||
if (lowered === "true") return "on";
|
||||
if (lowered === "false") return "off";
|
||||
if (lowered === "on" || lowered === "off" || lowered === "control") return lowered;
|
||||
|
||||
const dateValue = Date.parse(normalized);
|
||||
if (!Number.isNaN(dateValue)) return dateValue > Date.now() ? "on" : "off";
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return value ? "on" : "off";
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts any supported backend flag value into a Split-compatible treatment/config pair.
|
||||
*/
|
||||
const normalizeFlagValue = (value) => {
|
||||
if (value == null) return OFF_TREATMENT;
|
||||
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
if (!isWithinSchedule(value)) return OFF_TREATMENT;
|
||||
|
||||
if (hasOwn(value, "treatment")) {
|
||||
return {
|
||||
treatment: normalizeTreatment(value.treatment),
|
||||
config: normalizeConfig(value.config)
|
||||
};
|
||||
}
|
||||
|
||||
if (hasOwn(value, "enabled")) {
|
||||
return {
|
||||
treatment: normalizeTreatment(value.enabled),
|
||||
config: normalizeConfig(value.config)
|
||||
};
|
||||
}
|
||||
|
||||
if (hasSchedule(value)) {
|
||||
return {
|
||||
treatment: "on",
|
||||
config: normalizeConfig(value.config)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
treatment: normalizeTreatment(value),
|
||||
config: null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether a socket/browser feature-flag change event applies to the active bodyshop.
|
||||
*/
|
||||
const isFeatureFlagChangeRelevant = (detail, bodyshopId) => {
|
||||
if (!detail || detail.scope === "global") return true;
|
||||
if (!bodyshopId) return false;
|
||||
return String(detail.bodyshopId) === String(bodyshopId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the next scheduled flag boundary that should force a local re-render.
|
||||
*/
|
||||
const getNextScheduleRefreshDelay = (flags = {}, now = Date.now()) => {
|
||||
const nextTimestamp = Object.values(flags).reduce((next, value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return next;
|
||||
|
||||
const timestamps = [parseDate(value.activeDate), parseDate(value.deactiveDate)].filter(
|
||||
(timestamp) => timestamp != null && timestamp > now
|
||||
);
|
||||
if (!timestamps.length) return next;
|
||||
|
||||
const candidate = Math.min(...timestamps);
|
||||
return next == null ? candidate : Math.min(next, candidate);
|
||||
}, null);
|
||||
|
||||
if (nextTimestamp == null) return null;
|
||||
|
||||
return Math.min(Math.max(nextTimestamp - now + 50, 0), MAX_SCHEDULE_REFRESH_DELAY_MS);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether browser localStorage can be used in the current runtime.
|
||||
*/
|
||||
const isBrowserStorageAvailable = () => typeof window !== "undefined" && window.localStorage;
|
||||
|
||||
/**
|
||||
* Builds the browser cache key for one bodyshop's feature flags.
|
||||
*/
|
||||
const getLocalStorageKey = (bodyshopId) => `${LOCAL_STORAGE_PREFIX}:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Reads a bodyshop-scoped last-known-good flag payload from browser storage.
|
||||
*/
|
||||
const readCachedFeatureFlags = (bodyshopId, now = Date.now()) => {
|
||||
if (!bodyshopId || !isBrowserStorageAvailable()) return null;
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(getLocalStorageKey(bodyshopId));
|
||||
if (!rawValue) return null;
|
||||
|
||||
const parsed = JSON.parse(rawValue);
|
||||
if (!parsed?.flags || typeof parsed.flags !== "object" || Array.isArray(parsed.flags)) return null;
|
||||
const cachedAt = Date.parse(parsed.cachedAt);
|
||||
if (!parsed.cachedAt || Number.isNaN(cachedAt) || now - cachedAt > LOCAL_STORAGE_MAX_AGE_MS) return null;
|
||||
|
||||
return parsed.flags;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a successful backend flag payload for short-term browser fallback.
|
||||
*/
|
||||
const writeCachedFeatureFlags = (bodyshopId, flags) => {
|
||||
if (!bodyshopId || !flags || !isBrowserStorageAvailable()) return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
getLocalStorageKey(bodyshopId),
|
||||
JSON.stringify({
|
||||
cachedAt: new Date().toISOString(),
|
||||
flags
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// localStorage may be unavailable, full, or blocked. Runtime flags still work without the browser cache.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the local client object that mimics the Split client surface used by the app.
|
||||
*/
|
||||
const createFeatureFlagClient = ({ bodyshop, key, backendFlags }) => {
|
||||
const attributes = {};
|
||||
|
||||
const getTreatmentWithConfig = (name) => normalizeFlagValue(backendFlags?.[name]);
|
||||
|
||||
return {
|
||||
client: null,
|
||||
isReady: true,
|
||||
isReadyFromCache: true,
|
||||
key: key || bodyshop?.imexshopid || "anon",
|
||||
getTreatment: (name) => getTreatmentWithConfig(name).treatment,
|
||||
getTreatmentWithConfig,
|
||||
getTreatments: (names = []) =>
|
||||
names.reduce((acc, name) => {
|
||||
acc[name] = getTreatmentWithConfig(name).treatment;
|
||||
return acc;
|
||||
}, {}),
|
||||
getTreatmentsWithConfig: (names = []) =>
|
||||
names.reduce((acc, name) => {
|
||||
acc[name] = getTreatmentWithConfig(name);
|
||||
return acc;
|
||||
}, {}),
|
||||
setAttribute: (name, value) => {
|
||||
attributes[name] = value;
|
||||
return true;
|
||||
},
|
||||
setAttributes: (values = {}) => {
|
||||
Object.assign(attributes, values);
|
||||
return true;
|
||||
},
|
||||
getAttribute: (name) => attributes[name],
|
||||
getAttributes: () => ({ ...attributes }),
|
||||
ready: () => Promise.resolve(),
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
destroy: () => {}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides database-backed feature flags through a Split-shaped React context.
|
||||
*/
|
||||
export function SplitFactoryProvider({ children, config, factory }) {
|
||||
const bodyshop = useSelector(selectBodyshop);
|
||||
const [state, setState] = useState({ flags: {}, isReady: true, source: "local" });
|
||||
const loadIdRef = useRef(0);
|
||||
const refreshTimerRef = useRef(null);
|
||||
|
||||
const loadFeatureFlags = useCallback(async () => {
|
||||
const loadId = (loadIdRef.current += 1);
|
||||
|
||||
if (!bodyshop?.id) {
|
||||
setState({ flags: {}, isReady: true, source: "local" });
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => ({ ...current, isReady: false }));
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(`/feature-flags/bodyshops/${bodyshop.id}`);
|
||||
if (loadId !== loadIdRef.current) return;
|
||||
const flags = data.flags || {};
|
||||
writeCachedFeatureFlags(bodyshop.id, flags);
|
||||
setState({
|
||||
flags,
|
||||
isReady: true,
|
||||
source: data.source || "database"
|
||||
});
|
||||
} catch (error) {
|
||||
if (loadId !== loadIdRef.current) return;
|
||||
const cachedFlags = readCachedFeatureFlags(bodyshop.id);
|
||||
console.warn("Feature flags backend fetch failed; falling back to last-known browser cache.", error);
|
||||
setState({
|
||||
flags: cachedFlags || {},
|
||||
isReady: true,
|
||||
source: cachedFlags ? "browser-cache" : "local"
|
||||
});
|
||||
}
|
||||
}, [bodyshop?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatureFlags();
|
||||
|
||||
return () => {
|
||||
loadIdRef.current += 1;
|
||||
};
|
||||
}, [loadFeatureFlags]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bodyshop?.id) return undefined;
|
||||
|
||||
const handleFeatureFlagsChanged = (event) => {
|
||||
if (!isFeatureFlagChangeRelevant(event.detail, bodyshop.id)) return;
|
||||
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
loadFeatureFlags();
|
||||
}, FEATURE_FLAGS_REFRESH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
window.addEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(FEATURE_FLAGS_CHANGED_EVENT, handleFeatureFlagsChanged);
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [bodyshop?.id, loadFeatureFlags]);
|
||||
|
||||
useEffect(() => {
|
||||
const delay = getNextScheduleRefreshDelay(state.flags);
|
||||
if (delay == null) return undefined;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setState((current) => ({ ...current, flags: { ...current.flags } }));
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [state.flags]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ config, factory, flags: state.flags, isReady: state.isReady, source: state.source }),
|
||||
[config, factory, state.flags, state.isReady, state.source]
|
||||
);
|
||||
return <FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Split-compatible client backed by the local feature flag context.
|
||||
*/
|
||||
export function useSplitClient(options = {}) {
|
||||
const bodyshop = useSelector(selectBodyshop);
|
||||
const context = useContext(FeatureFlagContext);
|
||||
|
||||
const client = useMemo(() => {
|
||||
const nextClient = createFeatureFlagClient({
|
||||
bodyshop,
|
||||
key: options.key,
|
||||
backendFlags: context.flags
|
||||
});
|
||||
nextClient.client = nextClient;
|
||||
nextClient.isReady = context.isReady;
|
||||
nextClient.isReadyFromCache = context.source === "redis" || context.source === "browser-cache";
|
||||
return nextClient;
|
||||
}, [bodyshop, options.key, context.flags, context.isReady, context.source]);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns treatment/config pairs for several feature flags.
|
||||
*/
|
||||
export function useTreatmentsWithConfig({ names = [] } = {}) {
|
||||
const client = useSplitClient();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
treatments: client.getTreatmentsWithConfig(names),
|
||||
isReady: client.isReady,
|
||||
isReadyFromCache: client.isReadyFromCache,
|
||||
lastUpdate: Date.now()
|
||||
}),
|
||||
[client, names]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the treatment string for one feature flag.
|
||||
*/
|
||||
export function useTreatment({ name } = {}) {
|
||||
const client = useSplitClient();
|
||||
return client.getTreatment(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the treatment/config pair for one feature flag.
|
||||
*/
|
||||
export function useTreatmentWithConfig({ name } = {}) {
|
||||
const client = useSplitClient();
|
||||
return client.getTreatmentWithConfig(name);
|
||||
}
|
||||
|
||||
export const FeatureFlagProvider = SplitFactoryProvider;
|
||||
export const useFeatureFlagClient = useSplitClient;
|
||||
export const SplitContext = FeatureFlagContext;
|
||||
export const useSplitContext = () => useContext(FeatureFlagContext);
|
||||
|
||||
export const __featureFlagTesting = {
|
||||
createFeatureFlagClient,
|
||||
getNextScheduleRefreshDelay,
|
||||
getLocalStorageKey,
|
||||
isFeatureFlagChangeRelevant,
|
||||
normalizeFlagValue,
|
||||
readCachedFeatureFlags,
|
||||
writeCachedFeatureFlags
|
||||
};
|
||||
166
client/src/feature-flags/splitio-react-replacement.test.jsx
Normal file
166
client/src/feature-flags/splitio-react-replacement.test.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __featureFlagTesting } from "./splitio-react-replacement";
|
||||
|
||||
const {
|
||||
createFeatureFlagClient,
|
||||
getNextScheduleRefreshDelay,
|
||||
getLocalStorageKey,
|
||||
isFeatureFlagChangeRelevant,
|
||||
normalizeFlagValue,
|
||||
readCachedFeatureFlags,
|
||||
writeCachedFeatureFlags
|
||||
} = __featureFlagTesting;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("splitio-react-replacement feature flag normalization", () => {
|
||||
it("returns off for unknown or null values", () => {
|
||||
expect(normalizeFlagValue(null)).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue(undefined)).toEqual({ treatment: "off", config: null });
|
||||
});
|
||||
|
||||
it("normalizes primitive values into Split-like treatments", () => {
|
||||
expect(normalizeFlagValue(true)).toEqual({ treatment: "on", config: null });
|
||||
expect(normalizeFlagValue(false)).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue(1)).toEqual({ treatment: "on", config: null });
|
||||
expect(normalizeFlagValue(0)).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue("true")).toEqual({ treatment: "on", config: null });
|
||||
expect(normalizeFlagValue("false")).toEqual({ treatment: "off", config: null });
|
||||
expect(normalizeFlagValue("variant-a")).toEqual({ treatment: "variant-a", config: null });
|
||||
});
|
||||
|
||||
it("preserves custom treatments and parses JSON config strings", () => {
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "demo",
|
||||
config: "{\"limit\":25}"
|
||||
})
|
||||
).toEqual({
|
||||
treatment: "demo",
|
||||
config: { limit: 25 }
|
||||
});
|
||||
});
|
||||
|
||||
it("respects activeDate and deactiveDate windows", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
|
||||
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "on",
|
||||
activeDate: "2026-05-19T14:59:00.000Z",
|
||||
deactiveDate: "2026-05-19T15:01:00.000Z"
|
||||
})
|
||||
).toEqual({ treatment: "on", config: null });
|
||||
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "on",
|
||||
activeDate: "2026-05-19T15:01:00.000Z"
|
||||
})
|
||||
).toEqual({ treatment: "off", config: null });
|
||||
|
||||
expect(
|
||||
normalizeFlagValue({
|
||||
treatment: "on",
|
||||
deactiveDate: "2026-05-19T15:00:00.000Z"
|
||||
})
|
||||
).toEqual({ treatment: "off", config: null });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitio-react-replacement feature flag client", () => {
|
||||
it("uses backend flags", () => {
|
||||
const client = createFeatureFlagClient({
|
||||
bodyshop: {
|
||||
imexshopid: "APPLE"
|
||||
},
|
||||
backendFlags: {
|
||||
Enhanced_Payroll: { treatment: "on" }
|
||||
}
|
||||
});
|
||||
|
||||
expect(client.getTreatment("Enhanced_Payroll")).toBe("on");
|
||||
});
|
||||
|
||||
it("ignores old bodyshop feature JSON fallback values", () => {
|
||||
const client = createFeatureFlagClient({
|
||||
bodyshop: {
|
||||
imexshopid: "APPLE",
|
||||
features: {
|
||||
featureFlags: {
|
||||
Enhanced_Payroll: { treatment: "on" }
|
||||
}
|
||||
}
|
||||
},
|
||||
backendFlags: {}
|
||||
});
|
||||
|
||||
expect(client.getTreatment("Enhanced_Payroll")).toBe("off");
|
||||
});
|
||||
|
||||
it("returns off for flags that are not present in any source", () => {
|
||||
const client = createFeatureFlagClient({
|
||||
bodyshop: { imexshopid: "APPLE", features: {} },
|
||||
backendFlags: {}
|
||||
});
|
||||
|
||||
expect(client.getTreatment("Missing_Flag")).toBe("off");
|
||||
});
|
||||
|
||||
it("uses a bodyshop-scoped browser cache key", () => {
|
||||
expect(getLocalStorageKey("shop-1")).toBe("bodyshop-feature-flags:shop-1");
|
||||
});
|
||||
|
||||
it("stores and reads last-known backend flags from browser storage", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
|
||||
|
||||
writeCachedFeatureFlags("shop-1", {
|
||||
Enhanced_Payroll: { treatment: "on", config: null }
|
||||
});
|
||||
|
||||
expect(readCachedFeatureFlags("shop-1")).toEqual({
|
||||
Enhanced_Payroll: { treatment: "on", config: null }
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores expired browser cached flags", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-19T15:00:00.000Z"));
|
||||
|
||||
writeCachedFeatureFlags("shop-1", {
|
||||
Enhanced_Payroll: { treatment: "on", config: null }
|
||||
});
|
||||
|
||||
expect(readCachedFeatureFlags("shop-1", Date.parse("2026-05-20T15:00:01.000Z"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitio-react-replacement live refresh helpers", () => {
|
||||
it("matches global and bodyshop-scoped socket changes", () => {
|
||||
expect(isFeatureFlagChangeRelevant({ scope: "global" }, "shop-1")).toBe(true);
|
||||
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-1", scope: "bodyshop" }, "shop-1")).toBe(true);
|
||||
expect(isFeatureFlagChangeRelevant({ bodyshopId: "shop-2", scope: "bodyshop" }, "shop-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the next active/deactive date boundary that needs a refresh", () => {
|
||||
const now = Date.parse("2026-05-19T15:00:00.000Z");
|
||||
|
||||
expect(
|
||||
getNextScheduleRefreshDelay(
|
||||
{
|
||||
Demo: { treatment: "on", activeDate: "2026-05-19T15:05:00.000Z" },
|
||||
Expiring: { treatment: "on", deactiveDate: "2026-05-19T15:02:00.000Z" },
|
||||
Expired: { treatment: "on", deactiveDate: "2026-05-19T14:59:00.000Z" }
|
||||
},
|
||||
now
|
||||
)
|
||||
).toBe(120050);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,22 @@ export const CHECK_EMPLOYEE_NUMBER = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const CHECK_EMPLOYEE_EMAIL = gql`
|
||||
query CHECK_EMPLOYEE_EMAIL($email: String!, $shopId: uuid!) {
|
||||
users(where: { email: { _ilike: $email } }) {
|
||||
email
|
||||
}
|
||||
employees_aggregate(where: { user_email: { _ilike: $email }, shopid: { _eq: $shopId } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const QUERY_ACTIVE_EMPLOYEES = gql`
|
||||
query QUERY_ACTIVE_EMPLOYEES {
|
||||
employees(where: { active: { _eq: true } }) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createStructuredSelector } from "reselect";
|
||||
import queryString from "query-string";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Col, Result, Row, Select, Space, Switch } from "antd";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { some } from "lodash";
|
||||
import axios from "axios";
|
||||
import AlertComponent from "../../components/alert/alert.component";
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import Dinero from "dinero.js";
|
||||
import { Link } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
|
||||
@@ -2,7 +2,7 @@ import ProductionBoardKanbanContainer from "../../components/production-board-ka
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
|
||||
@@ -3,7 +3,7 @@ import ProductionListTable from "../../components/production-list-table/producti
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
|
||||
@@ -13,7 +13,7 @@ import { GET_UNACCEPTED_PARTS_DISPATCH } from "../../graphql/parts-dispatch.quer
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { alphaSort } from "../../utils/sorters";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
|
||||
@@ -4,7 +4,7 @@ import JobsDocumentsContainer from "../../components/jobs-documents-gallery/jobs
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
|
||||
import { QUERY_TEMPORARY_DOCS } from "../../graphql/documents.queries";
|
||||
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import JobsDocumentsLocalGallery from "../../components/jobs-documents-local-gallery/jobs-documents-local-gallery.container";
|
||||
|
||||
@@ -18,7 +18,7 @@ import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/appli
|
||||
import TimeTicketsCommit from "../../components/time-tickets-commit/time-tickets-commit.component";
|
||||
import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { useTreatmentsWithConfig } from "../../feature-flags/splitio-react-replacement";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
||||
//import { setUserId, setUserProperties } from "@firebase/analytics";
|
||||
import {
|
||||
checkActionCode,
|
||||
confirmPasswordReset,
|
||||
@@ -9,14 +8,11 @@ import {
|
||||
} from "@firebase/auth";
|
||||
import { arrayUnion, doc, getDoc, setDoc, updateDoc } from "@firebase/firestore";
|
||||
import { getToken } from "@firebase/messaging";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { notification } from "antd";
|
||||
import axios from "axios";
|
||||
import i18next from "i18next";
|
||||
//import LogRocket from "logrocket";
|
||||
import { all, call, delay, put, select, takeLatest } from "redux-saga/effects";
|
||||
import {
|
||||
//analytics,
|
||||
auth,
|
||||
firestore,
|
||||
getCurrentUser,
|
||||
@@ -49,9 +45,13 @@ import {
|
||||
validatePasswordResetSuccess
|
||||
} from "./user.actions";
|
||||
import UserActionTypes from "./user.types";
|
||||
//import posthog from "posthog-js";
|
||||
import { bodyshopHasDmsKey, determineDMSTypeByBodyshop, DMS_MAP } from "../../utils/dmsUtils";
|
||||
|
||||
//import { setUserId, setUserProperties } from "@firebase/analytics";
|
||||
//import * as Sentry from "@sentry/react";
|
||||
//import LogRocket from "logrocket";
|
||||
//import posthog from "posthog-js";
|
||||
|
||||
const fpPromise = FingerprintJS.load();
|
||||
|
||||
export function* onEmailSignInStart() {
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"cashierid": "Cashier ID",
|
||||
"default_journal": "Default Journal",
|
||||
"disablebillwip": "Disable bill WIP for A/P Posting",
|
||||
"disablecontact": "Disable Contact Updates/Creation",
|
||||
"disablecontactvehiclecreation": "Disable Contact & Vehicle Updates/Creation",
|
||||
"dms_acctnumber": "DMS Account #",
|
||||
"dms_control_override": "Static Control # Override",
|
||||
@@ -1350,7 +1351,8 @@
|
||||
"vacationadded": "Employee vacation added."
|
||||
},
|
||||
"validation": {
|
||||
"unique_employee_number": "You must enter a unique employee number."
|
||||
"unique_employee_number": "You must enter a unique employee number.",
|
||||
"unique_user_email": "This email is already assigned to another employee."
|
||||
}
|
||||
},
|
||||
"esignature": {
|
||||
@@ -1366,6 +1368,9 @@
|
||||
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
|
||||
"upload_title": "Unable to prepare document for e-signature"
|
||||
},
|
||||
"tooltips": {
|
||||
"contact_sales": "E-signatures are not enabled for this shop. Contact sales to add this feature."
|
||||
},
|
||||
"fields": {
|
||||
"completed": "Completed?",
|
||||
"completed_at": "Completed At",
|
||||
@@ -3050,6 +3055,9 @@
|
||||
"appointments": {
|
||||
"appointment_confirmation": "Appointment Confirmation"
|
||||
},
|
||||
"banners": {
|
||||
"esignature_promo": "Tired of getting paper signatures? Try E-Signatures today. Contact support to add this feature."
|
||||
},
|
||||
"bills": {
|
||||
"inhouse_invoice": "In House Invoice"
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"cashierid": "",
|
||||
"default_journal": "",
|
||||
"disablebillwip": "",
|
||||
"disablecontact": "",
|
||||
"disablecontactvehiclecreation": "",
|
||||
"dms_acctnumber": "",
|
||||
"dms_control_override": "",
|
||||
@@ -1350,7 +1351,8 @@
|
||||
"vacationadded": ""
|
||||
},
|
||||
"validation": {
|
||||
"unique_employee_number": ""
|
||||
"unique_employee_number": "",
|
||||
"unique_user_email": "Este correo electrónico ya está asignado a otro empleado."
|
||||
}
|
||||
},
|
||||
"esignature": {
|
||||
@@ -1366,6 +1368,9 @@
|
||||
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
|
||||
"upload_title": "Unable to prepare document for e-signature"
|
||||
},
|
||||
"tooltips": {
|
||||
"contact_sales": "Las firmas electronicas no estan habilitadas para este taller. Contacte a ventas para agregar esta funcion."
|
||||
},
|
||||
"fields": {
|
||||
"completed": "",
|
||||
"completed_at": "",
|
||||
@@ -3050,6 +3055,9 @@
|
||||
"appointments": {
|
||||
"appointment_confirmation": ""
|
||||
},
|
||||
"banners": {
|
||||
"esignature_promo": "¿Cansado de obtener firmas en papel? Prueba las firmas electrónicas hoy. Contacta a ventas para agregar esta función."
|
||||
},
|
||||
"bills": {
|
||||
"inhouse_invoice": ""
|
||||
},
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
"cashierid": "",
|
||||
"default_journal": "",
|
||||
"disablebillwip": "",
|
||||
"disablecontact": "",
|
||||
"disablecontactvehiclecreation": "",
|
||||
"dms_acctnumber": "",
|
||||
"dms_control_override": "",
|
||||
@@ -1350,7 +1351,8 @@
|
||||
"vacationadded": ""
|
||||
},
|
||||
"validation": {
|
||||
"unique_employee_number": ""
|
||||
"unique_employee_number": "",
|
||||
"unique_user_email": "Cette adresse courriel est déjà assignée à un autre employé."
|
||||
}
|
||||
},
|
||||
"esignature": {
|
||||
@@ -1366,6 +1368,9 @@
|
||||
"pdf_only": "Only PDF documents can be uploaded for e-signature.",
|
||||
"upload_title": "Unable to prepare document for e-signature"
|
||||
},
|
||||
"tooltips": {
|
||||
"contact_sales": "Les signatures electroniques ne sont pas activees pour cet atelier. Contactez les ventes pour ajouter cette fonctionnalite."
|
||||
},
|
||||
"fields": {
|
||||
"completed": "",
|
||||
"completed_at": "",
|
||||
@@ -3050,6 +3055,9 @@
|
||||
"appointments": {
|
||||
"appointment_confirmation": ""
|
||||
},
|
||||
"banners": {
|
||||
"esignature_promo": "Vous en avez assez des signatures papier? Essayez les signatures électroniques dès aujourd'hui. Communiquez avec les ventes pour ajouter cette fonction."
|
||||
},
|
||||
"bills": {
|
||||
"inhouse_invoice": ""
|
||||
},
|
||||
|
||||
@@ -236,10 +236,8 @@ export default defineConfig(({ command, mode }) => {
|
||||
redux: ["redux"],
|
||||
lodash: ["lodash"],
|
||||
"@sentry/react": ["@sentry/react"],
|
||||
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
|
||||
logrocket: ["logrocket"],
|
||||
"feature-flags": ["src/feature-flags/splitio-react-replacement.jsx"],
|
||||
firebase: [
|
||||
"@firebase/analytics",
|
||||
"@firebase/app",
|
||||
"@firebase/firestore",
|
||||
"@firebase/auth",
|
||||
|
||||
2
documenso/terraform/.terraform.lock.hcl
generated
2
documenso/terraform/.terraform.lock.hcl
generated
@@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "6.38.0"
|
||||
constraints = "~> 6.0"
|
||||
hashes = [
|
||||
"h1:IMf41BcW9huOeVcrt6XjQqadYR2xD8zkUpGLLERJ4NM=",
|
||||
"h1:RDoKIzXmt7H1mNFcNIyRT+nA/gTJyO3+iW9QGN5I2eQ=",
|
||||
"zh:143f118ae71059a7a7026c6b950da23fef04a06e2362ffa688bef75e43e869ed",
|
||||
"zh:29ee220a017306effd877e1280f8b2934dc957e16e0e72ca0222e5514d0db522",
|
||||
@@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.8.1"
|
||||
constraints = "~> 3.6"
|
||||
hashes = [
|
||||
"h1:osH3aBqEARwOz3VBJKdpFKJJCNIdgRC6k8vPojkLmlY=",
|
||||
"h1:u8AKlWVDTH5r9YLSeswoVEjiY72Rt4/ch7U+61ZDkiQ=",
|
||||
"zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4",
|
||||
"zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.14.3",
|
||||
"serial": 105,
|
||||
"terraform_version": "1.15.4",
|
||||
"serial": 111,
|
||||
"lineage": "2b49a6da-17c7-01da-d62f-9a13def4b683",
|
||||
"outputs": {
|
||||
"application_url": {
|
||||
@@ -21,7 +21,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"postgres_engine_version": {
|
||||
"value": "17.9",
|
||||
"value": "17.10",
|
||||
"type": "string"
|
||||
},
|
||||
"secrets_manager_secret_name": {
|
||||
@@ -118,7 +118,7 @@
|
||||
"filter": null,
|
||||
"has_major_target": null,
|
||||
"has_minor_target": null,
|
||||
"id": "17.9",
|
||||
"id": "17.10",
|
||||
"include_all": null,
|
||||
"latest": true,
|
||||
"parameter_group_family": "postgres17",
|
||||
@@ -144,15 +144,15 @@
|
||||
"supports_parallel_query": false,
|
||||
"supports_read_replica": true,
|
||||
"valid_major_targets": [
|
||||
"18.3"
|
||||
"18.4"
|
||||
],
|
||||
"valid_minor_targets": [],
|
||||
"valid_upgrade_targets": [
|
||||
"18.3"
|
||||
"18.4"
|
||||
],
|
||||
"version": "17.9",
|
||||
"version_actual": "17.9",
|
||||
"version_description": "PostgreSQL 17.9-R1"
|
||||
"version": "17.10",
|
||||
"version_actual": "17.10",
|
||||
"version_description": "PostgreSQL 17.10-R1"
|
||||
},
|
||||
"sensitive_attributes": [],
|
||||
"identity_schema_version": 0
|
||||
@@ -1085,7 +1085,7 @@
|
||||
"endpoint": "documenso-postgres.cfo5pnykioqq.ca-central-1.rds.amazonaws.com:5432",
|
||||
"engine": "postgres",
|
||||
"engine_lifecycle_support": "open-source-rds-extended-support",
|
||||
"engine_version": "17.9",
|
||||
"engine_version": "17.10",
|
||||
"engine_version_actual": "17.9",
|
||||
"final_snapshot_identifier": "documenso-final-03443461",
|
||||
"hosted_zone_id": "Z1JG78A3UK1DU3",
|
||||
@@ -1096,7 +1096,7 @@
|
||||
"instance_class": "db.t4g.micro",
|
||||
"iops": 3000,
|
||||
"kms_key_id": "arn:aws:kms:ca-central-1:714144183158:key/1237b672-91b3-4d23-958d-1877c5d22eb9",
|
||||
"latest_restorable_time": "2026-05-01T17:49:36Z",
|
||||
"latest_restorable_time": "2026-05-25T20:16:55Z",
|
||||
"license_model": "postgresql-license",
|
||||
"listener_endpoint": [],
|
||||
"maintenance_window": "tue:03:10-tue:03:40",
|
||||
@@ -1384,7 +1384,7 @@
|
||||
"Application": "documenso",
|
||||
"ManagedBy": "Terraform"
|
||||
},
|
||||
"task_definition": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:8",
|
||||
"task_definition": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:9",
|
||||
"timeouts": null,
|
||||
"triggers": {},
|
||||
"volume_configuration": [],
|
||||
@@ -1451,9 +1451,9 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"attributes": {
|
||||
"arn": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:8",
|
||||
"arn": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task:9",
|
||||
"arn_without_revision": "arn:aws:ecs:ca-central-1:714144183158:task-definition/documenso-task",
|
||||
"container_definitions": "[{\"environment\":[{\"name\":\"NEXT_PRIVATE_INTERNAL_WEBAPP_URL\",\"value\":\"http://127.0.0.1:3000\"},{\"name\":\"NEXT_PRIVATE_SMTP_HOST\",\"value\":\"email-smtp.ca-central-1.amazonaws.com\"},{\"name\":\"NEXT_PRIVATE_SMTP_PORT\",\"value\":\"587\"},{\"name\":\"NEXT_PRIVATE_SMTP_SECURE\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_SMTP_TRANSPORT\",\"value\":\"smtp-auth\"},{\"name\":\"NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_BUCKET\",\"value\":\"documenso-714144183158-ca-central-1\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_REGION\",\"value\":\"ca-central-1\"},{\"name\":\"NEXT_PUBLIC_DISABLE_SIGNUP\",\"value\":\"true\"},{\"name\":\"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT\",\"value\":\"10\"},{\"name\":\"NEXT_PUBLIC_UPLOAD_TRANSPORT\",\"value\":\"s3\"},{\"name\":\"NEXT_PUBLIC_WEBAPP_URL\",\"value\":\"https://sign.imex.online\"},{\"name\":\"PORT\",\"value\":\"3000\"}],\"essential\":true,\"image\":\"documenso/documenso:2.10.0\",\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/documenso\",\"awslogs-region\":\"ca-central-1\",\"awslogs-stream-prefix\":\"documenso\"}},\"mountPoints\":[],\"name\":\"documenso\",\"portMappings\":[{\"containerPort\":3000,\"hostPort\":3000,\"protocol\":\"tcp\"}],\"secrets\":[{\"name\":\"NEXTAUTH_SECRET\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXTAUTH_SECRET::\"},{\"name\":\"NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS::\"},{\"name\":\"NEXT_PRIVATE_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DIRECT_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DIRECT_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_PASSPHRASE\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_PASSPHRASE::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_ADDRESS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_ADDRESS::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_NAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_NAME::\"},{\"name\":\"NEXT_PRIVATE_SMTP_PASSWORD\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_PASSWORD::\"},{\"name\":\"NEXT_PRIVATE_SMTP_USERNAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_USERNAME::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY::\"}],\"systemControls\":[],\"volumesFrom\":[]}]",
|
||||
"container_definitions": "[{\"environment\":[{\"name\":\"NEXT_PRIVATE_INTERNAL_WEBAPP_URL\",\"value\":\"http://127.0.0.1:3000\"},{\"name\":\"NEXT_PRIVATE_SMTP_HOST\",\"value\":\"email-smtp.ca-central-1.amazonaws.com\"},{\"name\":\"NEXT_PRIVATE_SMTP_PORT\",\"value\":\"587\"},{\"name\":\"NEXT_PRIVATE_SMTP_SECURE\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_SMTP_TRANSPORT\",\"value\":\"smtp-auth\"},{\"name\":\"NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS\",\"value\":\"false\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_BUCKET\",\"value\":\"documenso-714144183158-ca-central-1\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_REGION\",\"value\":\"ca-central-1\"},{\"name\":\"NEXT_PUBLIC_DISABLE_SIGNUP\",\"value\":\"true\"},{\"name\":\"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT\",\"value\":\"10\"},{\"name\":\"NEXT_PUBLIC_UPLOAD_TRANSPORT\",\"value\":\"s3\"},{\"name\":\"NEXT_PUBLIC_WEBAPP_URL\",\"value\":\"https://sign.imex.online\"},{\"name\":\"PORT\",\"value\":\"3000\"}],\"essential\":true,\"image\":\"documenso/documenso:2.11.0\",\"logConfiguration\":{\"logDriver\":\"awslogs\",\"options\":{\"awslogs-group\":\"/ecs/documenso\",\"awslogs-region\":\"ca-central-1\",\"awslogs-stream-prefix\":\"documenso\"}},\"mountPoints\":[],\"name\":\"documenso\",\"portMappings\":[{\"containerPort\":3000,\"hostPort\":3000,\"protocol\":\"tcp\"}],\"secrets\":[{\"name\":\"NEXTAUTH_SECRET\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXTAUTH_SECRET::\"},{\"name\":\"NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS::\"},{\"name\":\"NEXT_PRIVATE_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DIRECT_DATABASE_URL\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DIRECT_DATABASE_URL::\"},{\"name\":\"NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_KEY::\"},{\"name\":\"NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS::\"},{\"name\":\"NEXT_PRIVATE_SIGNING_PASSPHRASE\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SIGNING_PASSPHRASE::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_ADDRESS\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_ADDRESS::\"},{\"name\":\"NEXT_PRIVATE_SMTP_FROM_NAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_FROM_NAME::\"},{\"name\":\"NEXT_PRIVATE_SMTP_PASSWORD\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_PASSWORD::\"},{\"name\":\"NEXT_PRIVATE_SMTP_USERNAME\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_SMTP_USERNAME::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID::\"},{\"name\":\"NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY\",\"valueFrom\":\"arn:aws:secretsmanager:ca-central-1:714144183158:secret:documenso/sign-imex-online/app-DNl1NE:NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY::\"}],\"systemControls\":[],\"volumesFrom\":[]}]",
|
||||
"cpu": "512",
|
||||
"enable_fault_injection": false,
|
||||
"ephemeral_storage": [],
|
||||
@@ -1470,7 +1470,7 @@
|
||||
"requires_compatibilities": [
|
||||
"FARGATE"
|
||||
],
|
||||
"revision": 8,
|
||||
"revision": 9,
|
||||
"runtime_platform": [],
|
||||
"skip_destroy": false,
|
||||
"tags": {
|
||||
@@ -1498,7 +1498,7 @@
|
||||
"account_id": "714144183158",
|
||||
"family": "documenso-task",
|
||||
"region": "ca-central-1",
|
||||
"revision": 8
|
||||
"revision": 9
|
||||
},
|
||||
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjEifQ==",
|
||||
"dependencies": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 4,
|
||||
"terraform_version": "1.14.3",
|
||||
"serial": 101,
|
||||
"terraform_version": "1.15.4",
|
||||
"serial": 105,
|
||||
"lineage": "2b49a6da-17c7-01da-d62f-9a13def4b683",
|
||||
"outputs": {
|
||||
"application_url": {
|
||||
@@ -1096,7 +1096,7 @@
|
||||
"instance_class": "db.t4g.micro",
|
||||
"iops": 3000,
|
||||
"kms_key_id": "arn:aws:kms:ca-central-1:714144183158:key/1237b672-91b3-4d23-958d-1877c5d22eb9",
|
||||
"latest_restorable_time": "2026-05-01T15:49:30Z",
|
||||
"latest_restorable_time": "2026-05-01T17:49:36Z",
|
||||
"license_model": "postgresql-license",
|
||||
"listener_endpoint": [],
|
||||
"maintenance_window": "tue:03:10-tue:03:40",
|
||||
@@ -3551,7 +3551,7 @@
|
||||
],
|
||||
"description": "WAF protection for Documenso",
|
||||
"id": "04577153-2a1a-462c-94b8-b0a1804755bb",
|
||||
"lock_token": "e71f2816-492c-4afc-acc2-3700795c2657",
|
||||
"lock_token": "417061f1-deea-4ac2-b932-9bea49265444",
|
||||
"name": "documenso-web-acl",
|
||||
"name_prefix": "",
|
||||
"region": "ca-central-1",
|
||||
@@ -3693,7 +3693,24 @@
|
||||
{
|
||||
"managed_rule_group_configs": [],
|
||||
"name": "AWSManagedRulesCommonRuleSet",
|
||||
"rule_action_override": [],
|
||||
"rule_action_override": [
|
||||
{
|
||||
"action_to_use": [
|
||||
{
|
||||
"allow": [],
|
||||
"block": [],
|
||||
"captcha": [],
|
||||
"challenge": [],
|
||||
"count": [
|
||||
{
|
||||
"custom_request_handling": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"name": "SizeRestrictions_BODY"
|
||||
}
|
||||
],
|
||||
"scope_down_statement": [],
|
||||
"vendor_name": "AWS",
|
||||
"version": ""
|
||||
|
||||
@@ -846,6 +846,13 @@
|
||||
table:
|
||||
name: exportlog
|
||||
schema: public
|
||||
- name: feature_flags
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: bodyshopid
|
||||
table:
|
||||
name: bodyshop_feature_flags
|
||||
schema: public
|
||||
- name: inventories
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
@@ -2739,6 +2746,114 @@
|
||||
- end_date
|
||||
- content
|
||||
filter: {}
|
||||
- table:
|
||||
name: bodyshop_feature_flags
|
||||
schema: public
|
||||
object_relationships:
|
||||
- name: bodyshop
|
||||
using:
|
||||
foreign_key_constraint_on: bodyshopid
|
||||
- name: feature_flag
|
||||
using:
|
||||
foreign_key_constraint_on: name
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- id
|
||||
- bodyshopid
|
||||
- name
|
||||
- treatment
|
||||
- config
|
||||
- activeDate
|
||||
- deactiveDate
|
||||
- created_at
|
||||
- updated_at
|
||||
filter:
|
||||
_and:
|
||||
- bodyshop:
|
||||
associations:
|
||||
_and:
|
||||
- user:
|
||||
authid:
|
||||
_eq: X-Hasura-User-Id
|
||||
- active:
|
||||
_eq: true
|
||||
- feature_flag:
|
||||
active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: cache_bodyshop_feature_flags
|
||||
definition:
|
||||
delete:
|
||||
columns: '*'
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/feature-flags/cache/invalidate'
|
||||
version: 2
|
||||
- table:
|
||||
name: feature_flags
|
||||
schema: public
|
||||
array_relationships:
|
||||
- name: bodyshop_feature_flags
|
||||
using:
|
||||
foreign_key_constraint_on:
|
||||
column: name
|
||||
table:
|
||||
name: bodyshop_feature_flags
|
||||
schema: public
|
||||
select_permissions:
|
||||
- role: user
|
||||
permission:
|
||||
columns:
|
||||
- name
|
||||
- description
|
||||
- default_treatment
|
||||
- active
|
||||
- created_at
|
||||
- updated_at
|
||||
filter:
|
||||
active:
|
||||
_eq: true
|
||||
event_triggers:
|
||||
- name: cache_feature_flags
|
||||
definition:
|
||||
delete:
|
||||
columns: '*'
|
||||
enable_manual: false
|
||||
insert:
|
||||
columns: '*'
|
||||
update:
|
||||
columns: '*'
|
||||
retry_conf:
|
||||
interval_sec: 10
|
||||
num_retries: 0
|
||||
timeout_sec: 60
|
||||
webhook_from_env: HASURA_API_URL
|
||||
headers:
|
||||
- name: event-secret
|
||||
value_from_env: EVENT_SECRET
|
||||
request_transform:
|
||||
method: POST
|
||||
query_params: {}
|
||||
template_engine: Kriti
|
||||
url: '{{$base_url}}/feature-flags/cache/invalidate'
|
||||
version: 2
|
||||
- table:
|
||||
name: exportlog
|
||||
schema: public
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS "public"."feature_flags";
|
||||
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "public"."feature_flags" (
|
||||
"name" text NOT NULL,
|
||||
"description" text NULL,
|
||||
"default_treatment" text NOT NULL DEFAULT 'off',
|
||||
"active" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("name"),
|
||||
CONSTRAINT "feature_flags_default_treatment_check" CHECK (length(btrim("default_treatment")) > 0)
|
||||
);
|
||||
|
||||
INSERT INTO "public"."feature_flags" ("name", "description")
|
||||
VALUES
|
||||
('ADPPayroll', 'Enable ADP payroll flows and reporting.'),
|
||||
('Allow_Negative_Jobline_Price', 'Allow negative pricing on job lines.'),
|
||||
('Autohouse_Detail_line', 'Enable Autohouse detail line handling.'),
|
||||
('Bill_OCR_AI', 'Enable AI bill OCR entry.'),
|
||||
('ClosingPeriod', 'Enable closing period accounting restrictions.'),
|
||||
('CriticalPartsScanning', 'Enable critical parts scanning workflows.'),
|
||||
('Direct_Media_Download', 'Enable direct media downloads.'),
|
||||
('DmsAp', 'Enable DMS accounts payable workflows.'),
|
||||
('Enhanced_Payroll', 'Enable enhanced payroll and labor allocation features.'),
|
||||
('Extended_Bill_Posting', 'Enable extended bill posting.'),
|
||||
('Fortellis', 'Enable Fortellis-backed DMS flows.'),
|
||||
('IOU_Tracking', 'Enable IOU tracking.'),
|
||||
('ImEXPay', 'Enable ImEX Pay workflows.'),
|
||||
('Imgproxy', 'Enable imgproxy-backed media rendering.'),
|
||||
('LogRocket_Tracking', 'Enable LogRocket tracking.'),
|
||||
('NewPhotoViewer', 'Enable the newer photo viewer experience.'),
|
||||
('OEConnection', 'Enable OEConnection parts ordering.'),
|
||||
('OEConnection_PriceChange', 'Enable OEConnection price changes.'),
|
||||
('OpenSearch', 'Enable OpenSearch global search.'),
|
||||
('OpenSearch_PaginatedScreens', 'Enable OpenSearch on paginated screens.'),
|
||||
('Production_List_Status_Colors', 'Enable status colors on production list.'),
|
||||
('Production_Use_View', 'Enable production view selection.'),
|
||||
('Qb_Multi_Ar', 'Enable QuickBooks multi-AR payment options.'),
|
||||
('Realtime_Notifications_UI', 'Enable realtime notification UI.'),
|
||||
('Share_To_Teams', 'Enable sharing workflows to Microsoft Teams.'),
|
||||
('Simple_Inventory', 'Enable simple inventory workflows.'),
|
||||
('TEST_FLAG', 'Manual test flag used to verify frontend feature flag plumbing.'),
|
||||
('Use_Graphql_RR', 'Enable GraphQL-backed Rome/RR flows.'),
|
||||
('Websocket_Production', 'Toggle websocket production board/list behavior.')
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TRIGGER IF EXISTS "set_public_feature_flags_updated_at" ON "public"."feature_flags";
|
||||
DROP TRIGGER IF EXISTS "set_public_bodyshop_feature_flags_updated_at" ON "public"."bodyshop_feature_flags";
|
||||
DROP TABLE IF EXISTS "public"."bodyshop_feature_flags";
|
||||
@@ -0,0 +1,89 @@
|
||||
CREATE TABLE "public"."bodyshop_feature_flags" (
|
||||
"id" uuid NOT NULL DEFAULT public.gen_random_uuid(),
|
||||
"bodyshopid" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"treatment" text NOT NULL DEFAULT 'off',
|
||||
"config" jsonb NULL,
|
||||
"activeDate" timestamptz NULL,
|
||||
"deactiveDate" timestamptz NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "bodyshop_feature_flags_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "bodyshop_feature_flags_bodyshopid_name_key" UNIQUE ("bodyshopid", "name"),
|
||||
CONSTRAINT "bodyshop_feature_flags_bodyshopid_fkey" FOREIGN KEY ("bodyshopid") REFERENCES "public"."bodyshops" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "bodyshop_feature_flags_name_fkey" FOREIGN KEY ("name") REFERENCES "public"."feature_flags" ("name") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK (length(btrim("treatment")) > 0),
|
||||
CONSTRAINT "bodyshop_feature_flags_dates_check" CHECK ("deactiveDate" IS NULL OR "activeDate" IS NULL OR "deactiveDate" > "activeDate")
|
||||
);
|
||||
|
||||
CREATE INDEX "bodyshop_feature_flags_bodyshopid_idx" ON "public"."bodyshop_feature_flags" ("bodyshopid");
|
||||
CREATE INDEX "bodyshop_feature_flags_name_idx" ON "public"."bodyshop_feature_flags" ("name");
|
||||
|
||||
INSERT INTO "public"."bodyshop_feature_flags" (
|
||||
"bodyshopid",
|
||||
"name",
|
||||
"treatment",
|
||||
"config",
|
||||
"activeDate",
|
||||
"deactiveDate"
|
||||
)
|
||||
SELECT
|
||||
"bodyshops"."id",
|
||||
"feature_flag"."name",
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
AND nullif(btrim("feature_flag"."value" ->> 'treatment'), '') IS NOT NULL
|
||||
THEN btrim("feature_flag"."value" ->> 'treatment')
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'boolean'
|
||||
THEN CASE WHEN ("feature_flag"."value" #>> '{}')::boolean THEN 'on' ELSE 'off' END
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'string'
|
||||
AND nullif(btrim("feature_flag"."value" #>> '{}'), '') IS NOT NULL
|
||||
THEN btrim("feature_flag"."value" #>> '{}')
|
||||
ELSE 'on'
|
||||
END,
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
THEN "feature_flag"."value" -> 'config'
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
AND "feature_flag"."value" ->> 'activeDate' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
|
||||
THEN ("feature_flag"."value" ->> 'activeDate')::timestamptz
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN jsonb_typeof("feature_flag"."value") = 'object'
|
||||
AND "feature_flag"."value" ->> 'deactiveDate' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
|
||||
THEN ("feature_flag"."value" ->> 'deactiveDate')::timestamptz
|
||||
ELSE NULL
|
||||
END
|
||||
FROM "public"."bodyshops"
|
||||
CROSS JOIN LATERAL jsonb_each(
|
||||
CASE
|
||||
WHEN jsonb_typeof(COALESCE("bodyshops"."features" -> 'featureFlags', '{}'::jsonb)) = 'object'
|
||||
THEN COALESCE("bodyshops"."features" -> 'featureFlags', '{}'::jsonb)
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) AS "feature_flag"("name", "value")
|
||||
INNER JOIN "public"."feature_flags" ON "feature_flags"."name" = "feature_flag"."name"
|
||||
ON CONFLICT ("bodyshopid", "name") DO UPDATE
|
||||
SET
|
||||
"treatment" = EXCLUDED."treatment",
|
||||
"config" = EXCLUDED."config",
|
||||
"activeDate" = EXCLUDED."activeDate",
|
||||
"deactiveDate" = EXCLUDED."deactiveDate";
|
||||
|
||||
CREATE TRIGGER "set_public_bodyshop_feature_flags_updated_at"
|
||||
BEFORE UPDATE ON "public"."bodyshop_feature_flags"
|
||||
FOR EACH ROW EXECUTE FUNCTION "public"."set_current_timestamp_updated_at"();
|
||||
|
||||
COMMENT ON TRIGGER "set_public_bodyshop_feature_flags_updated_at" ON "public"."bodyshop_feature_flags"
|
||||
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
|
||||
|
||||
CREATE TRIGGER "set_public_feature_flags_updated_at"
|
||||
BEFORE UPDATE ON "public"."feature_flags"
|
||||
FOR EACH ROW EXECUTE FUNCTION "public"."set_current_timestamp_updated_at"();
|
||||
|
||||
COMMENT ON TRIGGER "set_public_feature_flags_updated_at" ON "public"."feature_flags"
|
||||
IS 'trigger to set value of column "updated_at" to current timestamp on row update';
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "feature_flags_default_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
ADD CONSTRAINT "feature_flags_default_treatment_check" CHECK ("default_treatment" IN ('on', 'off', 'control'));
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "bodyshop_feature_flags_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
ADD CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK ("treatment" IN ('on', 'off', 'control'));
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "feature_flags_default_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."feature_flags"
|
||||
ADD CONSTRAINT "feature_flags_default_treatment_check" CHECK (length(btrim("default_treatment")) > 0);
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
DROP CONSTRAINT IF EXISTS "bodyshop_feature_flags_treatment_check";
|
||||
|
||||
ALTER TABLE "public"."bodyshop_feature_flags"
|
||||
ADD CONSTRAINT "bodyshop_feature_flags_treatment_check" CHECK (length(btrim("treatment")) > 0);
|
||||
@@ -0,0 +1,2 @@
|
||||
DELETE FROM "public"."feature_flags"
|
||||
WHERE "name" = 'TEST_FLAG';
|
||||
@@ -0,0 +1,3 @@
|
||||
INSERT INTO "public"."feature_flags" ("name", "description")
|
||||
VALUES ('TEST_FLAG', 'Manual test flag used to verify frontend feature flag plumbing.')
|
||||
ON CONFLICT ("name") DO NOTHING;
|
||||
2166
package-lock.json
generated
2166
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user