Skip to content

Commit 681b7fa

Browse files
authored
feat: add selectable status indicator to toolbar v2 (#848)
1 parent 04a8f16 commit 681b7fa

8 files changed

Lines changed: 1908 additions & 60 deletions

File tree

packages/client/src/clients/guide/client.ts

Lines changed: 117 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import Knock from "../../knock";
77

88
import {
99
DEFAULT_GROUP_KEY,
10-
SelectionResult,
1110
byKey,
1211
checkStateIfThrottled,
1312
findDefaultGroup,
@@ -46,6 +45,8 @@ import {
4645
SelectFilterParams,
4746
SelectGuideOpts,
4847
SelectGuidesOpts,
48+
SelectQueryLimit,
49+
SelectionResult,
4950
StepMessageState,
5051
StoreState,
5152
TargetParams,
@@ -150,7 +151,16 @@ const safeJsonParseDebugParams = (value: string): DebugState => {
150151
}
151152
};
152153

153-
const select = (state: StoreState, filters: SelectFilterParams = {}) => {
154+
type SelectQueryMetadata = {
155+
limit: SelectQueryLimit;
156+
opts: SelectGuideOpts;
157+
};
158+
159+
const select = (
160+
state: StoreState,
161+
filters: SelectFilterParams,
162+
metadata: SelectQueryMetadata,
163+
) => {
154164
// A map of selected guides as values, with its order index as keys.
155165
const result = new SelectionResult();
156166

@@ -175,7 +185,8 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => {
175185
result.set(index, guide);
176186
}
177187

178-
result.metadata = { guideGroup: defaultGroup };
188+
result.metadata = { guideGroup: defaultGroup, filters, ...metadata };
189+
179190
return result;
180191
};
181192

@@ -617,14 +628,35 @@ export class KnockGuideClient {
617628
`[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`,
618629
);
619630

620-
const selectedGuide = this.selectGuide(state, filters, opts);
621-
if (!selectedGuide) {
631+
// 1. First, call selectGuide() using the same filters to ensure we have a
632+
// group stage open and respect throttling. This isn't the real query, but
633+
// rather it's a shortcut ahead of handling the actual query result below.
634+
const selectedGuide = this.selectGuide(state, filters, {
635+
...opts,
636+
// Don't record this result, not the actual query result we need.
637+
recordSelectQuery: false,
638+
});
639+
640+
// 2. Now make the actual select query with the provided filters and opts,
641+
// and record the result (as needed). By default, we only record the result
642+
// while in debugging.
643+
const { recordSelectQuery = !!state.debug?.debugging } = opts;
644+
const metadata: SelectQueryMetadata = {
645+
limit: "all",
646+
opts: { ...opts, recordSelectQuery },
647+
};
648+
const result = select(state, filters, metadata);
649+
this.maybeRecordSelectResult(result);
650+
651+
// 3. Stop if there is not at least one guide to return.
652+
if (!selectedGuide && !opts.includeThrottled) {
622653
return [];
623654
}
624655

625656
// There should be at least one guide to return here now.
626-
const guides = [...select(state, filters).values()];
657+
const guides = [...result.values()];
627658

659+
// 4. If throttled, filter out any throttled guides.
628660
if (!opts.includeThrottled && checkStateIfThrottled(state)) {
629661
const unthrottledGuides = guides.filter(
630662
(g) => g.bypass_global_group_limit,
@@ -657,32 +689,6 @@ export class KnockGuideClient {
657689
return undefined;
658690
}
659691

660-
const result = select(state, filters);
661-
662-
if (result.size === 0) {
663-
this.knock.log("[Guide] Selection found zero result");
664-
return undefined;
665-
}
666-
667-
const [index, guide] = [...result][0]!;
668-
this.knock.log(
669-
`[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
670-
);
671-
672-
// If a guide ignores the group limit, then return immediately to render
673-
// always.
674-
if (guide.bypass_global_group_limit) {
675-
this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
676-
return guide;
677-
}
678-
679-
// Check if inside the throttle window (i.e. throttled) and if so stop and
680-
// return undefined unless explicitly given the option to include throttled.
681-
if (!opts.includeThrottled && checkStateIfThrottled(state)) {
682-
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
683-
return undefined;
684-
}
685-
686692
// Starting here to the end of this method represents the core logic of how
687693
// "group stage" works. It provides a mechanism for 1) figuring out which
688694
// guide components are about to render on a page, 2) determining which
@@ -716,6 +722,35 @@ export class KnockGuideClient {
716722
this.stage = this.openGroupStage(); // Assign here to make tsc happy
717723
}
718724

725+
// Must come AFTER we ensure a group stage exists above, so we can record
726+
// select queries. By default, we only record the result while in debugging.
727+
const { recordSelectQuery = !!state.debug?.debugging } = opts;
728+
const metadata: SelectQueryMetadata = {
729+
limit: "one",
730+
opts: { ...opts, recordSelectQuery },
731+
};
732+
const result = select(state, filters, metadata);
733+
this.maybeRecordSelectResult(result);
734+
735+
if (result.size === 0) {
736+
this.knock.log("[Guide] Selection found zero result");
737+
return undefined;
738+
}
739+
740+
const [index, guide] = [...result][0]!;
741+
this.knock.log(
742+
`[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
743+
);
744+
745+
// If a guide ignores the group limit, then return immediately to render
746+
// always.
747+
if (guide.bypass_global_group_limit) {
748+
this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
749+
return guide;
750+
}
751+
752+
const throttled = !opts.includeThrottled && checkStateIfThrottled(state);
753+
719754
switch (this.stage.status) {
720755
case "open": {
721756
this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`);
@@ -725,8 +760,16 @@ export class KnockGuideClient {
725760

726761
case "patch": {
727762
this.knock.log(`[Guide] Patching the group stage: ${guide.key}`);
763+
// Refresh the ordered queue in the group stage while continuing to
764+
// render the currently resolved guide while in patch window, so that
765+
// we can re-resolve when the group stage closes.
728766
this.stage.ordered[index] = guide.key;
729767

768+
if (throttled) {
769+
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
770+
return undefined;
771+
}
772+
730773
const ret = this.stage.resolved === guide.key ? guide : undefined;
731774
this.knock.log(
732775
`[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -735,6 +778,11 @@ export class KnockGuideClient {
735778
}
736779

737780
case "closed": {
781+
if (throttled) {
782+
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
783+
return undefined;
784+
}
785+
738786
const ret = this.stage.resolved === guide.key ? guide : undefined;
739787
this.knock.log(
740788
`[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
@@ -744,6 +792,42 @@ export class KnockGuideClient {
744792
}
745793
}
746794

795+
// Record select query results by accumulating them by 1) key or type first,
796+
// and then 2) "one" or "all".
797+
private maybeRecordSelectResult(result: SelectionResult) {
798+
if (!result.metadata) return;
799+
800+
const { opts, filters, limit } = result.metadata;
801+
if (!opts.recordSelectQuery) return;
802+
if (!filters.key && !filters.type) return;
803+
if (!this.stage || this.stage.status === "closed") return;
804+
805+
// Deep merge to accumulate the results.
806+
const queriedByKey = this.stage.results.key || {};
807+
if (filters.key) {
808+
queriedByKey[filters.key] = {
809+
...(queriedByKey[filters.key] || {}),
810+
...{ [limit]: result },
811+
};
812+
}
813+
const queriedByType = this.stage.results.type || {};
814+
if (filters.type) {
815+
queriedByType[filters.type] = {
816+
...(queriedByType[filters.type] || {}),
817+
...{ [limit]: result },
818+
};
819+
}
820+
821+
this.stage = {
822+
...this.stage,
823+
results: { key: queriedByKey, type: queriedByType },
824+
};
825+
}
826+
827+
getStage() {
828+
return this.stage;
829+
}
830+
747831
private openGroupStage() {
748832
this.knock.log("[Guide] Opening a new group stage");
749833

@@ -759,6 +843,7 @@ export class KnockGuideClient {
759843
this.stage = {
760844
status: "open",
761845
ordered: [],
846+
results: {},
762847
timeoutId,
763848
};
764849

packages/client/src/clients/guide/helpers.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,11 @@ import {
33
GuideActivationUrlRuleData,
44
GuideData,
55
GuideGroupData,
6-
KnockGuide,
76
KnockGuideActivationUrlPattern,
87
SelectFilterParams,
98
StoreState,
109
} from "./types";
1110

12-
// Extends the map class to allow having metadata on it, which is used to record
13-
// the guide group context for the selection result (though currently only a
14-
// default global group is supported).
15-
export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
16-
metadata: { guideGroup: GuideGroupData } | undefined;
17-
18-
constructor() {
19-
super();
20-
}
21-
}
22-
2311
export const formatGroupStage = (stage: GroupStage) => {
2412
return `status=${stage.status}, resolved=${stage.resolved}`;
2513
};

packages/client/src/clients/guide/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export {
33
DEBUG_QUERY_PARAMS,
44
checkActivatable,
55
} from "./client";
6+
export { checkStateIfThrottled } from "./helpers";
67
export type {
78
KnockGuide,
89
KnockGuideStep,
@@ -12,4 +13,6 @@ export type {
1213
SelectGuideOpts as KnockSelectGuideOpts,
1314
SelectGuidesOpts as KnockSelectGuidesOpts,
1415
StoreState as KnockGuideClientStoreState,
16+
GroupStage as KnockGuideClientGroupStage,
17+
SelectionResult as KnockGuideSelectionResult,
1518
} from "./types";

packages/client/src/clients/guide/types.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import { GenericData } from "@knocklabs/types";
22

3+
// i.e. useGuide vs useGuides
4+
export type SelectQueryLimit = "one" | "all";
5+
6+
type SelectionResultMetadata = {
7+
guideGroup: GuideGroupData;
8+
// Additional info about the underlying select query behind the result.
9+
filters: SelectFilterParams;
10+
limit: SelectQueryLimit;
11+
opts: SelectGuideOpts;
12+
};
13+
14+
// Extends the map class to allow having metadata on it, which is used to record
15+
// the guide group context for the selection result (though currently only a
16+
// default global group is supported).
17+
export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
18+
metadata: SelectionResultMetadata | undefined;
19+
20+
constructor() {
21+
super();
22+
}
23+
}
24+
325
//
426
// Fetch guides API
527
//
@@ -237,6 +259,7 @@ export type SelectFilterParams = {
237259

238260
export type SelectGuideOpts = {
239261
includeThrottled?: boolean;
262+
recordSelectQuery?: boolean;
240263
};
241264

242265
export type SelectGuidesOpts = SelectGuideOpts;
@@ -253,9 +276,20 @@ export type ConstructorOpts = {
253276
throttleCheckInterval?: number;
254277
};
255278

279+
type SelectionResultByLimit = {
280+
one?: SelectionResult;
281+
all?: SelectionResult;
282+
};
283+
284+
type RecordedSelectionResults = {
285+
key?: Record<KnockGuide["key"], SelectionResultByLimit>;
286+
type?: Record<KnockGuide["type"], SelectionResultByLimit>;
287+
};
288+
256289
export type GroupStage = {
257290
status: "open" | "closed" | "patch";
258291
ordered: Array<KnockGuide["key"]>;
259292
resolved?: KnockGuide["key"];
260293
timeoutId: ReturnType<typeof setTimeout> | null;
294+
results: RecordedSelectionResults;
261295
};

0 commit comments

Comments
 (0)