diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index e458b3d8..0da6b63a 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -7,7 +7,7 @@ import Knock from "../../knock"; import { DEFAULT_GROUP_KEY, - SelectionResult, + // SelectionResult, byKey, checkStateIfThrottled, findDefaultGroup, @@ -46,6 +46,9 @@ import { SelectFilterParams, SelectGuideOpts, SelectGuidesOpts, + SelectQueryLimit, + SelectionResult, + // SelectQueryParams, StepMessageState, StoreState, TargetParams, @@ -150,7 +153,16 @@ const safeJsonParseDebugParams = (value: string): DebugState => { } }; -const select = (state: StoreState, filters: SelectFilterParams = {}) => { +type SelectQueryMetadata = { + limit: SelectQueryLimit; + opts: SelectGuideOpts; +}; + +const select = ( + state: StoreState, + filters: SelectFilterParams, + metadata: SelectQueryMetadata, +) => { // A map of selected guides as values, with its order index as keys. const result = new SelectionResult(); @@ -175,7 +187,8 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => { result.set(index, guide); } - result.metadata = { guideGroup: defaultGroup }; + result.metadata = { guideGroup: defaultGroup, filters, ...metadata }; + return result; }; @@ -617,14 +630,35 @@ export class KnockGuideClient { `[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`, ); - const selectedGuide = this.selectGuide(state, filters, opts); + // 1. First, call selectGuide() using the same filters to ensure we have a + // group stage open and respect throttling. This isn't the real query, but + // rather it's a shortcut ahead of handling the actual query result below. + const selectedGuide = this.selectGuide(state, filters, { + ...opts, + // Don't record this result, not the actual query result we need. + recordSelectQuery: false, + }); + + // 2. Now make the actual select query with the provided filters and opts, + // and record the result (as needed). By default, we only record the result + // while in debugging. + const { recordSelectQuery = !!state.debug?.debugging } = opts; + const metadata: SelectQueryMetadata = { + limit: "all", + opts: { ...opts, recordSelectQuery }, + }; + const result = select(state, filters, metadata); + this.maybeRecordSelectResult(result); + + // 3. Stop if there is not at least one guide to return. if (!selectedGuide) { return []; } // There should be at least one guide to return here now. - const guides = [...select(state, filters).values()]; + const guides = [...result.values()]; + // 4. If throttled, filter out any throttled guides. if (!opts.includeThrottled && checkStateIfThrottled(state)) { const unthrottledGuides = guides.filter( (g) => g.bypass_global_group_limit, @@ -657,32 +691,6 @@ export class KnockGuideClient { return undefined; } - const result = select(state, filters); - - if (result.size === 0) { - this.knock.log("[Guide] Selection found zero result"); - return undefined; - } - - const [index, guide] = [...result][0]!; - this.knock.log( - `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`, - ); - - // If a guide ignores the group limit, then return immediately to render - // always. - if (guide.bypass_global_group_limit) { - this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`); - return guide; - } - - // Check if inside the throttle window (i.e. throttled) and if so stop and - // return undefined unless explicitly given the option to include throttled. - if (!opts.includeThrottled && checkStateIfThrottled(state)) { - this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); - return undefined; - } - // Starting here to the end of this method represents the core logic of how // "group stage" works. It provides a mechanism for 1) figuring out which // guide components are about to render on a page, 2) determining which @@ -716,6 +724,42 @@ export class KnockGuideClient { this.stage = this.openGroupStage(); // Assign here to make tsc happy } + // Must come AFTER we ensure a group stage exists above, so we can record + // select queries. By default, we only record the result while in debugging. + const { recordSelectQuery = !!state.debug?.debugging } = opts; + const metadata: SelectQueryMetadata = { + limit: "one", + opts: { ...opts, recordSelectQuery }, + }; + const result = select(state, filters, metadata); + this.maybeRecordSelectResult(result); + + if (result.size === 0) { + this.knock.log("[Guide] Selection found zero result"); + return undefined; + } + + const [index, guide] = [...result][0]!; + this.knock.log( + `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`, + ); + + // If a guide ignores the group limit, then return immediately to render + // always. + if (guide.bypass_global_group_limit) { + this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`); + return guide; + } + + // Check if inside the throttle window (i.e. throttled) and if so stop and + // return undefined unless explicitly given the option to include throttled. + const throttled = !opts.includeThrottled && checkStateIfThrottled(state); + + // if (!opts.includeThrottled && checkStateIfThrottled(state)) { + // this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); + // return undefined; + // } + switch (this.stage.status) { case "open": { this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`); @@ -727,6 +771,11 @@ export class KnockGuideClient { this.knock.log(`[Guide] Patching the group stage: ${guide.key}`); this.stage.ordered[index] = guide.key; + if (throttled) { + this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); + return undefined; + } + const ret = this.stage.resolved === guide.key ? guide : undefined; this.knock.log( `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`, @@ -735,6 +784,11 @@ export class KnockGuideClient { } case "closed": { + if (throttled) { + this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); + return undefined; + } + const ret = this.stage.resolved === guide.key ? guide : undefined; this.knock.log( `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`, @@ -744,6 +798,70 @@ export class KnockGuideClient { } } + private maybeRecordSelectResult(result: SelectionResult) { + if (!result.metadata) return; + + const { opts, filters, limit } = result.metadata; + if (!opts.recordSelectQuery) return; + if (!filters.key && !filters.type) return; + if (!this.stage || this.stage.status === "closed") return; + + // Deep merge to accumulate the results. + const queriedByKey = this.stage.results.key || {}; + if (filters.key) { + queriedByKey[filters.key] = { + ...(queriedByKey[filters.key] || {}), + ...{ [limit]: result }, + }; + } + const queriedByType = this.stage.results.type || {}; + if (filters.type) { + queriedByType[filters.type] = { + ...(queriedByType[filters.type] || {}), + ...{ [limit]: result }, + }; + } + + this.stage = { + ...this.stage, + results: { key: queriedByKey, type: queriedByType }, + }; + } + + // private maybeRecordSelectQuery( + // params: SelectQueryParams, + // opts: SelectGuideOpts, + // ) { + // if (!opts.recordSelectQuery) return; + // if (!this.stage || this.stage.status === "closed") return; + // if (!params.key && !params.type) return; + // + // // Deep merge into the query logs: + // const queriesByKey = this.stage.queries.key || {}; + // if (params.key) { + // queriesByKey[params.key] = { + // ...(queriesByKey[params.key] || {}), + // ...{ [params.limit]: opts }, + // }; + // } + // const queriesByType = this.stage.queries.type || {}; + // if (params.type) { + // queriesByType[params.type] = { + // ...(queriesByType[params.type] || {}), + // ...{ [params.limit]: opts }, + // }; + // } + // + // this.stage = { + // ...this.stage, + // queries: { key: queriesByKey, type: queriesByType }, + // }; + // } + + getStage() { + return this.stage; + } + private openGroupStage() { this.knock.log("[Guide] Opening a new group stage"); @@ -759,6 +877,7 @@ export class KnockGuideClient { this.stage = { status: "open", ordered: [], + results: {}, timeoutId, }; diff --git a/packages/client/src/clients/guide/helpers.ts b/packages/client/src/clients/guide/helpers.ts index 771bb749..90cf7ddc 100644 --- a/packages/client/src/clients/guide/helpers.ts +++ b/packages/client/src/clients/guide/helpers.ts @@ -3,23 +3,14 @@ import { GuideActivationUrlRuleData, GuideData, GuideGroupData, - KnockGuide, + // KnockGuide, KnockGuideActivationUrlPattern, SelectFilterParams, + // SelectGuideOpts, + // SelectQueryLimit, StoreState, } from "./types"; -// Extends the map class to allow having metadata on it, which is used to record -// the guide group context for the selection result (though currently only a -// default global group is supported). -export class SelectionResult extends Map { - metadata: { guideGroup: GuideGroupData } | undefined; - - constructor() { - super(); - } -} - export const formatGroupStage = (stage: GroupStage) => { return `status=${stage.status}, resolved=${stage.resolved}`; }; @@ -233,3 +224,18 @@ export const predicateUrlPatterns = ( } }, predicateDefault); }; + +// export type SelectorIdParams = { +// filters: SelectFilterParams; +// limit: "one" | "all"; +// }; +// +// export const formatSelectorId = ({ filters, limit }: SelectorIdParams) => { +// +// // change to query params format +// // key="asdf"&type +// +// +// // Keep this ordering: limit, key, and type. +// return JSON.stringify({ limit, key: filters.key, type: filters.type }); +// }; diff --git a/packages/client/src/clients/guide/index.ts b/packages/client/src/clients/guide/index.ts index 7d551916..5741773c 100644 --- a/packages/client/src/clients/guide/index.ts +++ b/packages/client/src/clients/guide/index.ts @@ -3,6 +3,7 @@ export { DEBUG_QUERY_PARAMS, checkActivatable, } from "./client"; +export { checkStateIfThrottled } from "./helpers"; export type { KnockGuide, KnockGuideStep, @@ -12,4 +13,6 @@ export type { SelectGuideOpts as KnockSelectGuideOpts, SelectGuidesOpts as KnockSelectGuidesOpts, StoreState as KnockGuideClientStoreState, + GroupStage as KnockGuideClientGroupStage, + SelectionResult as KnockGuideSelectionResult, } from "./types"; diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index 0327d827..e6bb8210 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -1,5 +1,24 @@ import { GenericData } from "@knocklabs/types"; +type SelectionResultMetadata = { + guideGroup: GuideGroupData; + // Additional info about the underlying select query behind the result. + filters: SelectFilterParams; + limit: SelectQueryLimit; + opts: SelectGuideOpts; +}; + +// Extends the map class to allow having metadata on it, which is used to record +// the guide group context for the selection result (though currently only a +// default global group is supported). +export class SelectionResult extends Map { + metadata: SelectionResultMetadata | undefined; + + constructor() { + super(); + } +} + // // Fetch guides API // @@ -231,6 +250,8 @@ export type SelectFilterParams = { export type SelectGuideOpts = { includeThrottled?: boolean; + // XXX: record result + recordSelectQuery?: boolean; }; export type SelectGuidesOpts = SelectGuideOpts; @@ -247,9 +268,27 @@ export type ConstructorOpts = { throttleCheckInterval?: number; }; +// i.e. useGuide vs useGuides +export type SelectQueryLimit = "one" | "all"; + +// export type SelectQueryParams = SelectFilterParams & { +// limit: "one" | "all"; +// }; + +type SelectionResultByLimit = { + one?: SelectionResult; + all?: SelectionResult; +}; + +type RecordedSelectionResults = { + key?: Record; + type?: Record; +}; + export type GroupStage = { status: "open" | "closed" | "patch"; ordered: Array; resolved?: KnockGuide["key"]; timeoutId: ReturnType | null; + results: RecordedSelectionResults; }; diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx index bd54daf8..6242a594 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx @@ -6,6 +6,7 @@ import { Text } from "@telegraph/typography"; import { CheckCircle2, CircleDashed, + Code2, Eye, LocateFixed, UserCircle2, @@ -48,8 +49,33 @@ export const GuideRow = ({ guide, orderIndex }: Props) => { {guide.__typename === "Guide" && ( +