Skip to content

Commit 76ef2c1

Browse files
feat: Add support for opt-in and opt-out in Workflow History V2 feature flag (#1132)
* Update return type of HISTORY_PAGE_V2_ENABLED feature flag from simple boolean to enum, to support special behaviour for opting in to and opting out from Workflow History V2 * Create new hook useIsWorkflowHistoryV2Selected that uses the feature flag value and combines it with local storage to decide if History V2 is selected * Load isWorkflowHistoryV2Selected into Workflow History Context * Move history v2 selected flag check to separate WorkflowHistoryComponent Signed-off-by: Adhitya Mamallan <adhitya.mamallan@uber.com>
1 parent a3a14b6 commit 76ef2c1

18 files changed

Lines changed: 425 additions & 94 deletions

src/config/dynamic/dynamic.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import extendedDomainInfoEnabled from './resolvers/extended-domain-info-enabled'
1717
import { type ExtendedDomainInfoEnabledConfig } from './resolvers/extended-domain-info-enabled.types';
1818
import failoverHistoryEnabled from './resolvers/failover-history-enabled';
1919
import historyPageV2Enabled from './resolvers/history-page-v2-enabled';
20+
import { type HistoryPageV2EnabledConfigValue } from './resolvers/history-page-v2-enabled.types';
2021
import workflowActionsEnabled from './resolvers/workflow-actions-enabled';
2122
import {
2223
type WorkflowActionsEnabledResolverParams,
@@ -76,7 +77,7 @@ const dynamicConfigs: {
7677
>;
7778
HISTORY_PAGE_V2_ENABLED: ConfigAsyncResolverDefinition<
7879
undefined,
79-
boolean,
80+
HistoryPageV2EnabledConfigValue,
8081
'request',
8182
true
8283
>;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG = [
2+
'DISABLED',
3+
'OPT_IN',
4+
'OPT_OUT',
5+
'ENABLED',
6+
] as const;
7+
8+
export default HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG;
Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
1+
import HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG from './history-page-v2-enabled-values.config';
2+
import { type HistoryPageV2EnabledConfigValue } from './history-page-v2-enabled.types';
3+
14
/**
2-
* WIP: Returns whether the new Workflow History (V2) page is enabled
5+
* WIP: Returns the configuration value for the new Workflow History (V2) page
6+
*
7+
* To configure the new Workflow History (V2) page, set the CADENCE_HISTORY_PAGE_V2_ENABLED env variable to one of the following:
8+
* - `DISABLED` - disable the feature entirely (default if env var is not set or invalid)
9+
* - `OPT_IN` - allow users to view the new page using a button on the old page
10+
* - `OPT_OUT` - default to the new page with an option to fall back to the old page
11+
* - `ENABLED` - completely enable the new page with no option to fall back
312
*
4-
* To enable the new Workflow History (V2) page, set the CADENCE_HISTORY_PAGE_V2_ENABLED env variable to true.
513
* For further customization, override the implementation of this resolver.
614
*
7-
* @returns {Promise<boolean>} Whether Workflow History (V2) page is enabled.
15+
* @returns {Promise<HistoryPageV2EnabledConfigValue>} The configuration value for Workflow History (V2) page.
816
*/
9-
export default async function historyPageV2Enabled(): Promise<boolean> {
10-
return process.env.CADENCE_HISTORY_PAGE_V2_ENABLED === 'true';
17+
export default async function historyPageV2Enabled(): Promise<HistoryPageV2EnabledConfigValue> {
18+
const envValue = process.env.CADENCE_HISTORY_PAGE_V2_ENABLED;
19+
20+
if (HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG.includes(envValue as any)) {
21+
return envValue as HistoryPageV2EnabledConfigValue;
22+
}
23+
24+
return 'DISABLED';
1125
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import type HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG from './history-page-v2-enabled-values.config';
2+
3+
export type HistoryPageV2EnabledConfigValue =
4+
(typeof HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG)[number];

src/config/dynamic/resolvers/schemas/resolver-schemas.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod';
22

33
import { type ResolverSchemas } from '../../../../utils/config/config.types';
4+
import HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG from '../history-page-v2-enabled-values.config';
45
import WORKFLOW_ACTIONS_DISABLED_VALUES_CONFIG from '../workflow-actions-disabled-values.config';
56

67
const workflowActionsEnabledValueSchema = z.enum([
@@ -72,7 +73,7 @@ const resolverSchemas: ResolverSchemas = {
7273
},
7374
HISTORY_PAGE_V2_ENABLED: {
7475
args: z.undefined(),
75-
returnType: z.boolean(),
76+
returnType: z.enum(HISTORY_PAGE_V2_ENABLED_VALUES_CONFIG),
7677
},
7778
};
7879

src/utils/config/__fixtures__/resolved-config-values.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,6 @@ const mockResolvedConfigValues: LoadedConfigResolvedValues = {
4343
WORKFLOW_DIAGNOSTICS_ENABLED: false,
4444
ARCHIVAL_DEFAULT_SEARCH_ENABLED: false,
4545
FAILOVER_HISTORY_ENABLED: false,
46-
HISTORY_PAGE_V2_ENABLED: false,
46+
HISTORY_PAGE_V2_ENABLED: 'DISABLED',
4747
};
4848
export default mockResolvedConfigValues;

src/views/workflow-history-v2/__tests__/workflow-history-v2.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,8 @@ async function setup({
452452
value={{
453453
ungroupedViewUserPreference: ungroupedViewPreference ?? null,
454454
setUngroupedViewUserPreference: mockSetUngroupedViewUserPreference,
455+
isWorkflowHistoryV2Selected: false,
456+
setIsWorkflowHistoryV2Selected: jest.fn(),
455457
}}
456458
>
457459
<WorkflowHistoryV2
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import React, { Suspense } from 'react';
2+
3+
import { renderHook, waitFor, act } from '@/test-utils/rtl';
4+
5+
import { type UseSuspenseConfigValueResult } from '@/hooks/use-config-value/use-config-value.types';
6+
import useSuspenseConfigValue from '@/hooks/use-config-value/use-suspense-config-value';
7+
import * as localStorageModule from '@/utils/local-storage';
8+
import workflowHistoryUserPreferencesConfig from '@/views/workflow-history/config/workflow-history-user-preferences.config';
9+
10+
import useIsWorkflowHistoryV2Selected from '../use-is-workflow-history-v2-selected';
11+
12+
jest.mock('@/hooks/use-config-value/use-suspense-config-value');
13+
jest.mock('@/utils/local-storage', () => ({
14+
getLocalStorageValue: jest.fn(),
15+
setLocalStorageValue: jest.fn(),
16+
clearLocalStorageValue: jest.fn(),
17+
}));
18+
19+
const mockUseSuspenseConfigValue =
20+
useSuspenseConfigValue as jest.MockedFunction<any>;
21+
22+
describe(useIsWorkflowHistoryV2Selected.name, () => {
23+
afterEach(() => {
24+
jest.restoreAllMocks();
25+
});
26+
27+
it('should return true when config value is ENABLED', async () => {
28+
const { result } = setup({ configValue: 'ENABLED' });
29+
30+
await waitFor(() => {
31+
expect(result.current[0]).toBe(true);
32+
});
33+
});
34+
35+
it('should return true when config value is OPT_OUT', async () => {
36+
const { result } = setup({ configValue: 'OPT_OUT' });
37+
38+
await waitFor(() => {
39+
expect(result.current[0]).toBe(true);
40+
});
41+
});
42+
43+
it('should return false when config value is DISABLED', async () => {
44+
const { result } = setup({ configValue: 'DISABLED' });
45+
46+
await waitFor(() => {
47+
expect(result.current[0]).toBe(false);
48+
});
49+
});
50+
51+
it('should return false when config value is OPT_IN and localStorage has no value', async () => {
52+
const { result } = setup({ configValue: 'OPT_IN' });
53+
54+
await waitFor(() => {
55+
expect(result.current[0]).toBe(false);
56+
});
57+
});
58+
59+
it('should return true when config value is OPT_IN and localStorage has true', async () => {
60+
const { result } = setup({
61+
configValue: 'OPT_IN',
62+
localStorageValue: true,
63+
});
64+
65+
await waitFor(() => {
66+
expect(result.current[0]).toBe(true);
67+
});
68+
});
69+
70+
it('should return false when config value is OPT_IN and localStorage has false', async () => {
71+
const { result } = setup({
72+
configValue: 'OPT_IN',
73+
localStorageValue: false,
74+
});
75+
76+
await waitFor(() => {
77+
expect(result.current[0]).toBe(false);
78+
});
79+
});
80+
81+
it('should not allow setting value when config is DISABLED', async () => {
82+
const { result, mockSetLocalStorageValue } = setup({
83+
configValue: 'DISABLED',
84+
});
85+
86+
await waitFor(() => {
87+
expect(result.current[0]).toBe(false);
88+
});
89+
90+
act(() => {
91+
result.current[1](true);
92+
});
93+
94+
expect(result.current[0]).toBe(false);
95+
expect(mockSetLocalStorageValue).not.toHaveBeenCalled();
96+
});
97+
98+
it('should not allow setting value when config is ENABLED', async () => {
99+
const { result, mockSetLocalStorageValue } = setup({
100+
configValue: 'ENABLED',
101+
});
102+
103+
await waitFor(() => {
104+
expect(result.current[0]).toBe(true);
105+
});
106+
107+
act(() => {
108+
result.current[1](false);
109+
});
110+
111+
expect(result.current[0]).toBe(true);
112+
expect(mockSetLocalStorageValue).not.toHaveBeenCalled();
113+
});
114+
115+
it('should allow setting value when config is OPT_OUT', async () => {
116+
const { result, mockSetLocalStorageValue } = setup({
117+
configValue: 'OPT_OUT',
118+
});
119+
120+
await waitFor(() => {
121+
expect(result.current[0]).toBe(true);
122+
});
123+
124+
act(() => {
125+
result.current[1](false);
126+
});
127+
128+
expect(result.current[0]).toBe(false);
129+
expect(mockSetLocalStorageValue).not.toHaveBeenCalled();
130+
});
131+
132+
it('should allow setting value and update localStorage when config is OPT_IN', async () => {
133+
const { result, mockSetLocalStorageValue } = setup({
134+
configValue: 'OPT_IN',
135+
localStorageValue: false,
136+
});
137+
138+
await waitFor(() => {
139+
expect(result.current[0]).toBe(false);
140+
});
141+
142+
act(() => {
143+
result.current[1](true);
144+
});
145+
146+
expect(result.current[0]).toBe(true);
147+
expect(mockSetLocalStorageValue).toHaveBeenCalledWith(
148+
workflowHistoryUserPreferencesConfig.historyV2ViewEnabled.key,
149+
'true'
150+
);
151+
});
152+
153+
it('should update localStorage when setting value to false in OPT_IN mode', async () => {
154+
const { result, mockSetLocalStorageValue } = setup({
155+
configValue: 'OPT_IN',
156+
localStorageValue: true,
157+
});
158+
159+
await waitFor(() => {
160+
expect(result.current[0]).toBe(true);
161+
});
162+
163+
act(() => {
164+
result.current[1](false);
165+
});
166+
167+
expect(result.current[0]).toBe(false);
168+
expect(mockSetLocalStorageValue).toHaveBeenCalledWith(
169+
workflowHistoryUserPreferencesConfig.historyV2ViewEnabled.key,
170+
'false'
171+
);
172+
});
173+
});
174+
175+
function setup({
176+
configValue,
177+
localStorageValue,
178+
}: {
179+
configValue: 'DISABLED' | 'OPT_IN' | 'OPT_OUT' | 'ENABLED';
180+
localStorageValue?: boolean | null;
181+
}) {
182+
mockUseSuspenseConfigValue.mockReturnValue({
183+
data: configValue,
184+
} satisfies Pick<
185+
UseSuspenseConfigValueResult<'HISTORY_PAGE_V2_ENABLED'>,
186+
'data'
187+
>);
188+
189+
const mockGetLocalStorageValue = jest.fn(() => localStorageValue ?? null);
190+
const mockSetLocalStorageValue = jest.fn();
191+
192+
jest
193+
.spyOn(localStorageModule, 'getLocalStorageValue')
194+
.mockImplementation(mockGetLocalStorageValue);
195+
jest
196+
.spyOn(localStorageModule, 'setLocalStorageValue')
197+
.mockImplementation(mockSetLocalStorageValue);
198+
199+
const { result } = renderHook(
200+
() => useIsWorkflowHistoryV2Selected(),
201+
undefined,
202+
{
203+
wrapper: ({ children }: { children: React.ReactNode }) => (
204+
<Suspense>{children}</Suspense>
205+
),
206+
}
207+
);
208+
209+
return { result, mockGetLocalStorageValue, mockSetLocalStorageValue };
210+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useCallback, useState } from 'react';
2+
3+
import useSuspenseConfigValue from '@/hooks/use-config-value/use-suspense-config-value';
4+
import {
5+
getLocalStorageValue,
6+
setLocalStorageValue,
7+
} from '@/utils/local-storage';
8+
import workflowHistoryUserPreferencesConfig from '@/views/workflow-history/config/workflow-history-user-preferences.config';
9+
10+
/**
11+
* Manages Workflow History V2 selected state based on config and localStorage.
12+
*
13+
* @returns A tuple containing:
14+
* - `isWorkflowHistoryV2Selected`: boolean indicating whether Workflow History V2 is selected
15+
* - `setIsWorkflowHistoryV2Selected`: function to update the selected state
16+
*
17+
* Behavior by config mode:
18+
* - `DISABLED`: Always returns `false`. Setter has no effect.
19+
* - `ENABLED`: Always returns `true`. Setter has no effect.
20+
* - `OPT_OUT`: Always starts with `true`. Setter updates state but does not persist to localStorage.
21+
* - `OPT_IN`: Reads initial state from localStorage (defaults to `false`). Setter updates both state and localStorage.
22+
*/
23+
export default function useIsWorkflowHistoryV2Selected(): [
24+
boolean,
25+
(v: boolean) => void,
26+
] {
27+
const { data: historyPageV2Config } = useSuspenseConfigValue(
28+
'HISTORY_PAGE_V2_ENABLED'
29+
);
30+
31+
const [isSelected, setIsSelected] = useState(() => {
32+
switch (historyPageV2Config) {
33+
case 'DISABLED':
34+
return false;
35+
case 'ENABLED':
36+
case 'OPT_OUT':
37+
return true;
38+
case 'OPT_IN':
39+
const userPreference = getLocalStorageValue(
40+
workflowHistoryUserPreferencesConfig.historyV2ViewEnabled.key,
41+
workflowHistoryUserPreferencesConfig.historyV2ViewEnabled.schema
42+
);
43+
return userPreference ?? false;
44+
}
45+
});
46+
47+
const setIsWorkflowHistoryV2Selected = useCallback(
48+
(v: boolean) => {
49+
if (
50+
historyPageV2Config === 'DISABLED' ||
51+
historyPageV2Config === 'ENABLED'
52+
) {
53+
return;
54+
}
55+
56+
setIsSelected(v);
57+
58+
// We only save user preferences to localStorage for OPT_IN mode, not for OPT_OUT,
59+
// so that we can learn when users explicitly choose to stick with the old workflow history view.
60+
// This helps us gather feedback from those who opt to not try the new experience.
61+
if (historyPageV2Config === 'OPT_IN') {
62+
setLocalStorageValue(
63+
workflowHistoryUserPreferencesConfig.historyV2ViewEnabled.key,
64+
String(v)
65+
);
66+
}
67+
},
68+
[historyPageV2Config]
69+
);
70+
71+
return [isSelected, setIsWorkflowHistoryV2Selected];
72+
}

src/views/workflow-history/__tests__/workflow-history.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ async function setup({
298298
value={{
299299
ungroupedViewUserPreference: ungroupedViewPreference ?? null,
300300
setUngroupedViewUserPreference: mockSetUngroupedViewUserPreference,
301+
isWorkflowHistoryV2Selected: false,
302+
setIsWorkflowHistoryV2Selected: jest.fn(),
301303
}}
302304
>
303305
<WorkflowHistory

0 commit comments

Comments
 (0)