Skip to content

Commit

Permalink
feat(headless): Refactor the Headless Notify Trigger controller to ma…
Browse files Browse the repository at this point in the history
…ke it compatible with the insight use case (#4362)

[SFINT-5468](https://coveord.atlassian.net/browse/SFINT-5468)


### IN THIS PR:

We want to refactor the Headless Notify trigger controller to make it
compatible with the insight use case. We therefore will have the Core
Triggers controller and have 2 controllers (1 for insight usecase and 1
for search usecase)

We are doing the following steps:

1. Created an insight-trigger-analytics-actions.ts file and added the
`logNotifyTrigger` action in the file.
2. Added insight-trigger-analytics-actions.test.ts file to test the
`logNotifyTrigger` action described above.
3. Modified the Core controller + tests
4. Added a insightTriggerController + tests
5. Modified the search trigger controller + tests
6. updated exports files in index


### DEMO IN HIP:


https://github.com/user-attachments/assets/25d25833-7083-45e4-ab2c-98d7638aeba5



### TESTS:

HEADLESS-CORE-NOTIFY-TRIGGER:
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/4513965e-11df-459e-92d7-0645615d4ec8">

HEADLESS-NOTIFY-TRIGGER:
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/bd01ceed-8543-4ed2-b7de-a033554a1683">

HEADLESS-INSIGHT-NOTIFY-TRIGGER:
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/489251e1-dc23-4254-893d-3013610cd291">

INSIGHT-TRIGGER-ANALYTICS-ACTIONS:
<img width="300" alt="image"
src="https://github.com/user-attachments/assets/435cd04d-2566-47fd-9772-d766ab80b250">






[SFINT-5468]:
https://coveord.atlassian.net/browse/SFINT-5468?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
  • Loading branch information
SimonMilord authored Oct 2, 2024
1 parent 1f45d27 commit e8e81e1
Show file tree
Hide file tree
Showing 12 changed files with 413 additions and 170 deletions.
2 changes: 2 additions & 0 deletions packages/headless/src/api/analytics/insight-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
QuerySection,
SearchHubSection,
SearchSection,
TriggerSection,
} from '../../state/state-sections.js';
import {getOrganizationEndpoint} from '../platform-client.js';
import {PreprocessRequest} from '../preprocess-request.js';
Expand All @@ -36,6 +37,7 @@ export type StateNeededByInsightAnalyticsProvider = ConfigurationSection &
SearchSection &
PipelineSection &
QuerySection &
TriggerSection &
SectionNeededForFacetMetadata &
GeneratedAnswerSection
>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {Mock} from 'vitest';
import {logNotifyTrigger} from '../../../features/triggers/trigger-analytics-actions.js';
import {triggerReducer as triggers} from '../../../features/triggers/triggers-slice.js';
import {
buildMockSearchEngine,
MockedSearchEngine,
} from '../../../test/mock-engine-v2.js';
import {createMockState} from '../../../test/mock-state.js';
import {NotifyTrigger} from '../../core/triggers/headless-core-notify-trigger.js';
import {buildCoreNotifyTrigger} from './headless-core-notify-trigger.js';

vi.mock('../../../features/triggers/trigger-analytics-actions');

describe('NotifyTrigger', () => {
let engine: MockedSearchEngine;
let notifyTrigger: NotifyTrigger;

function initNotifyTrigger() {
notifyTrigger = buildCoreNotifyTrigger(engine, {
options: {
logNotifyTriggerActionCreator: logNotifyTrigger,
},
});
}

function setEngineTriggersState(notifications: string[]) {
engine.state.triggers!.notifications = notifications;
}

function registeredListeners() {
return (engine.subscribe as Mock).mock.calls.map((args) => args[0]);
}

beforeEach(() => {
vi.resetAllMocks();
engine = buildMockSearchEngine(createMockState());
initNotifyTrigger();
});

it('initializes', () => {
expect(notifyTrigger).toBeTruthy();
});

it('it adds the correct reducers to the engine', () => {
expect(engine.addReducers).toHaveBeenCalledWith({
triggers,
});
});

it('exposes a #subscribe method', () => {
expect(notifyTrigger.subscribe).toBeTruthy();
});

describe('when the #engine.state.triggers.notifications is not updated', () => {
const listener = vi.fn();
beforeEach(() => {
engine = buildMockSearchEngine(createMockState());
initNotifyTrigger();
notifyTrigger.subscribe(listener);
const [firstListener] = registeredListeners();
firstListener();
});

it('it does not call the listener', () => {
expect(listener).toHaveBeenCalledTimes(0);
});

it('it does not dispatch #logNotifyTrigger', () => {
expect(logNotifyTrigger).not.toHaveBeenCalled();
});
});

describe('when the #engine.state.triggers.notifications is updated', () => {
const listener = vi.fn();
beforeEach(() => {
engine = buildMockSearchEngine(createMockState());
initNotifyTrigger();
notifyTrigger.subscribe(listener);
setEngineTriggersState(['hello']);
const [firstListener] = registeredListeners();
firstListener();
});

it('it calls the listener', () => {
expect(listener).toHaveBeenCalledTimes(1);
});

it('it dispatches #logNotifyTrigger', () => {
expect(logNotifyTrigger).toHaveBeenCalled();
});
});

describe('when the #engine.state.triggers.notifications is updated with an empty array', () => {
const listener = vi.fn();
beforeEach(() => {
engine = buildMockSearchEngine(createMockState());
initNotifyTrigger();
notifyTrigger.subscribe(listener);
setEngineTriggersState([]);
const [firstListener] = registeredListeners();
firstListener();
});

it('it does not call the listener', () => {
expect(listener).toHaveBeenCalledTimes(0);
});

it('it does not dispatch #logNotifyTrigger', () => {
expect(logNotifyTrigger).not.toHaveBeenCalled();
});
});

describe('when a non-empty #engine.state.triggers.notifications is updated with an empty array', () => {
const listener = vi.fn();
beforeEach(() => {
engine = buildMockSearchEngine(createMockState());
setEngineTriggersState(['hello', 'world']);
initNotifyTrigger();
notifyTrigger.subscribe(listener);
setEngineTriggersState([]);
const [firstListener] = registeredListeners();
firstListener();
});

it('it calls the listener', () => {
expect(listener).toHaveBeenCalledTimes(1);
});

it('it dispatches #logNotifyTrigger', () => {
expect(logNotifyTrigger).toHaveBeenCalled();
});
});

describe('when a non-empty #engine.state.triggers.notifications is updated with the same array', () => {
const listener = vi.fn();
beforeEach(() => {
engine = buildMockSearchEngine(createMockState());
setEngineTriggersState(['hello', 'world']);
initNotifyTrigger();
notifyTrigger.subscribe(listener);
setEngineTriggersState(['hello', 'world']);
const [firstListener] = registeredListeners();
firstListener();
});

it('it does not call the listener', () => {
expect(listener).toHaveBeenCalledTimes(0);
});

it('it does not dispatch #logNotifyTrigger', () => {
expect(logNotifyTrigger).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import {Controller} from '../../controller/headless-controller.js';
import {CoreEngine} from '../../../app/engine.js';
import {
InsightAction,
LegacySearchAction,
} from '../../../features/analytics/analytics-utils.js';
import {triggerReducer as triggers} from '../../../features/triggers/triggers-slice.js';
import {TriggerSection} from '../../../state/state-sections.js';
import {arrayEqual} from '../../../utils/compare-utils.js';
import {loadReducerError} from '../../../utils/errors.js';
import {
buildController,
Controller,
} from '../../controller/headless-controller.js';

/**
* The `NotifyTrigger` controller handles notify triggers. A [Notify trigger](https://docs.coveo.com/en/3413#notify) query pipeline rule lets you define a message to be displayed to the end user when a certain condition is met.
*/
export interface NotifyTrigger extends Controller {
/**
* the state of the `NotifyTrigger` controller.
* The state of the `NotifyTrigger` controller.
*/
state: NotifyTriggerState;
}
Expand All @@ -19,3 +31,69 @@ export interface NotifyTriggerState {
*/
notifications: string[];
}

export interface NotifyTriggerProps {
options: NotifyTriggerOptions;
}

export interface NotifyTriggerOptions {
logNotifyTriggerActionCreator: () => InsightAction | LegacySearchAction;
}

/**
* Creates a core `NotifyTrigger` controller instance.
*
* @param engine - The headless engine.
* @returns A `NotifyTrigger` controller instance.
*/
export function buildCoreNotifyTrigger(
engine: CoreEngine,
props: NotifyTriggerProps
): NotifyTrigger {
const logNotifyTrigger = props.options.logNotifyTriggerActionCreator;

if (!loadNotifyTriggerReducers(engine)) {
throw loadReducerError;
}

const controller = buildController(engine);
const {dispatch} = engine;

const getState = () => engine.state;

let previousNotifications = getState().triggers.notifications;

return {
...controller,

subscribe(listener: () => void) {
const strictListener = () => {
const hasChanged = !arrayEqual(
previousNotifications,
this.state.notifications
);
previousNotifications = this.state.notifications;

if (hasChanged) {
listener();
dispatch(logNotifyTrigger());
}
};
strictListener();
return engine.subscribe(strictListener);
},

get state() {
return {
notifications: getState().triggers.notifications,
};
},
};
}

function loadNotifyTriggerReducers(
engine: CoreEngine
): engine is CoreEngine<TriggerSection> {
engine.addReducers({triggers});
return true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
buildMockInsightEngine,
MockedInsightEngine,
} from '../../../test/mock-engine-v2.js';
import {buildMockInsightState} from '../../../test/mock-insight-state.js';
import {
NotifyTrigger,
buildNotifyTrigger,
} from './headless-insight-notify-trigger.js';

vi.mock('../../../features/insight-search/insight-search-actions');

describe('NotifyTrigger', () => {
let engine: MockedInsightEngine;
let notifyTrigger: NotifyTrigger;

function initNotifyTrigger() {
notifyTrigger = buildNotifyTrigger(engine);
}

beforeEach(() => {
engine = buildMockInsightEngine(buildMockInsightState());
initNotifyTrigger();
});

it('initializes', () => {
expect(notifyTrigger).toBeTruthy();
});

it('exposes a #subscribe method', () => {
expect(notifyTrigger.subscribe).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {InsightEngine} from '../../../app/insight-engine/insight-engine.js';
import {logNotifyTrigger} from '../../../features/triggers/insight-trigger-analytics-actions.js';
import {
buildCoreNotifyTrigger,
NotifyTrigger,
NotifyTriggerState,
} from '../../core/triggers/headless-core-notify-trigger.js';

export type {NotifyTrigger, NotifyTriggerState};

/**
* Creates an insight `NotifyTrigger` controller instance.
*
* @param engine - The insight engine.
* @returns A `NotifyTrigger` controller instance.
* */
export function buildNotifyTrigger(engine: InsightEngine): NotifyTrigger {
return buildCoreNotifyTrigger(engine, {
options: {
logNotifyTriggerActionCreator: logNotifyTrigger,
},
});
}
Loading

0 comments on commit e8e81e1

Please sign in to comment.