feature/IO-3701-Harness-Replacement - Implement

This commit is contained in:
Dave
2026-05-20 14:41:24 -04:00
parent 84ec68f142
commit deb2fc28ce
100 changed files with 4813 additions and 773 deletions

View 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.

View 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
};

View 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);
});
});