diff --git a/package-lock.json b/package-lock.json index 58a220f327..06c1064c50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "react-hot-toast": "^2.2.0", "react-icons": "^5.0.0", "react-image": "^4.1.0", + "react-joyride": "^3.1.0", "react-loading-icons": "^1.0.8", "react-mentions": "^4.4.0", "react-responsive": "^9.0.2", @@ -3293,6 +3294,22 @@ "integrity": "sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==", "dev": true }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@flanksource/icons": { "version": "1.0.46", "resolved": "https://registry.npmjs.org/@flanksource/icons/-/icons-1.0.46.tgz", @@ -3303,27 +3320,24 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", - "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, - "node_modules/@floating-ui/dom/node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" - }, "node_modules/@floating-ui/react": { "version": "0.26.22", "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.22.tgz", @@ -3339,26 +3353,50 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.1.tgz", - "integrity": "sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, - "node_modules/@floating-ui/react/node_modules/@floating-ui/utils": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.7.tgz", - "integrity": "sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==" - }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@gilbarbara/deep-equal": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.4.1.tgz", + "integrity": "sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w==", + "license": "MIT" + }, + "node_modules/@gilbarbara/hooks": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@gilbarbara/hooks/-/hooks-0.11.0.tgz", + "integrity": "sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.4.1" + }, + "peerDependencies": { + "react": "16.8 - 19" + } + }, + "node_modules/@gilbarbara/types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@gilbarbara/types/-/types-0.2.2.tgz", + "integrity": "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.1.0" + } }, "node_modules/@headlessui/react": { "version": "2.1.2", @@ -25996,6 +26034,12 @@ "node": ">=8" } }, + "node_modules/is-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-2.0.0.tgz", + "integrity": "sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==", + "license": "MIT" + }, "node_modules/is-map": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", @@ -37453,6 +37497,16 @@ "react-dom": ">=16.8" } }, + "node_modules/react-innertext": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", + "integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=0.0.0 <=99", + "react": ">=0.0.0 <=99" + } + }, "node_modules/react-inspector": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz", @@ -37467,6 +37521,37 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-joyride": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-3.1.0.tgz", + "integrity": "sha512-+UEDpNsYSHhhSW/OQcNl6+oODYx20EP6TykSD45if0MqAAZMYD+3DU64w9wP3fBjQswvq5BgK99w3rw6ing69g==", + "license": "MIT", + "dependencies": { + "@fastify/deepmerge": "^3.2.1", + "@floating-ui/react-dom": "^2.1.8", + "@gilbarbara/deep-equal": "^0.4.1", + "@gilbarbara/hooks": "^0.11.0", + "@gilbarbara/types": "^0.2.2", + "is-lite": "^2.0.0", + "react-innertext": "^1.1.5", + "scroll": "^3.0.1", + "scrollparent": "^2.1.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "16.8 - 19", + "react-dom": "16.8 - 19" + } + }, + "node_modules/react-joyride/node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-loading-icons": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/react-loading-icons/-/react-loading-icons-1.1.0.tgz", @@ -38966,6 +39051,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/scroll": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz", + "integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==", + "license": "MIT" + }, + "node_modules/scrollparent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", + "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==", + "license": "ISC" + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", diff --git a/package.json b/package.json index 8f88b481da..81b0074880 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "react-hot-toast": "^2.2.0", "react-icons": "^5.0.0", "react-image": "^4.1.0", + "react-joyride": "^3.1.0", "react-loading-icons": "^1.0.8", "react-mentions": "^4.4.0", "react-responsive": "^9.0.2", diff --git a/src/App.tsx b/src/App.tsx index 1f6a4fe412..f543be6e22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ import { } from "./context/UserAccessContext/UserAccessContext"; import { tables } from "./context/UserAccessContext/permissions"; +import { GettingStartedPage } from "./pages/GettingStartedPage"; import { ArtifactsPage } from "./pages/Settings/ArtifactsPage"; import { PermissionsPage } from "./pages/Settings/PermissionsPage"; import { PermissionsSubjectsPage } from "./pages/Settings/PermissionsSubjectsPage"; @@ -719,6 +720,10 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { /> + + } /> + + []>( + IncidentCommander.get( + `/person_touchpoints?person_id=eq.${personId}&select=key` + ) + ); +} + +export function recordPersonTouchpoint(personId: string, key: string) { + return IncidentCommander.post( + "/person_touchpoints", + { person_id: personId, key }, + { headers: { Prefer: "resolution=merge-duplicates,return=minimal" } } + ); +} diff --git a/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx b/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx index abb6d2adc2..2cfd38cf54 100644 --- a/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx +++ b/src/components/Authentication/Kratos/KratosUserProfileDropdown.tsx @@ -23,6 +23,7 @@ type UserProfileDropdownProps = { openCliSetupModal: () => void; openResourceSelectorSearchModal: () => void; openScopeImpersonationModal: () => void; + openGettingStarted: () => void; showScopeImpersonation?: boolean; }; @@ -32,6 +33,7 @@ export function KratosUserProfileDropdown({ openCliSetupModal, openResourceSelectorSearchModal, openScopeImpersonationModal, + openGettingStarted, showScopeImpersonation = false }: UserProfileDropdownProps) { const { user } = useUser(); @@ -93,6 +95,14 @@ export function KratosUserProfileDropdown({ ))} + + + + ))} + + + ); +} diff --git a/src/components/GuidedTour/TourTooltip.tsx b/src/components/GuidedTour/TourTooltip.tsx new file mode 100644 index 0000000000..662b1f3fe9 --- /dev/null +++ b/src/components/GuidedTour/TourTooltip.tsx @@ -0,0 +1,75 @@ +// ABOUTME: Custom react-joyride tooltip rendered with the app's design system. +// ABOUTME: Shows the step title, body, progress and Back/Next controls per step.buttons. +import { IoClose } from "react-icons/io5"; +import { type TooltipRenderProps } from "react-joyride"; +import { Button } from "../../ui/Buttons/Button"; +import { type TourStepData } from "./guidedTourSteps"; + +export function TourTooltip({ + backProps, + closeProps, + primaryProps, + index, + isLastStep, + size, + step, + tooltipProps +}: TooltipRenderProps) { + const buttons = step.buttons ?? []; + const showBack = buttons.includes("back"); + const showPrimary = buttons.includes("primary"); + const docLink = (step.data as TourStepData | undefined)?.docLink; + + return ( +
+ + + {step.title && ( +

+ {step.title} +

+ )} + +
+ {step.content} +
+ + {docLink && ( + + Learn more → + + )} + +
+ + {index + 1} / {size} + +
+ {showBack && ( + + )} + {showPrimary && ( + + )} +
+
+
+ ); +} diff --git a/src/components/GuidedTour/__tests__/guidedTourSteps.unit.test.ts b/src/components/GuidedTour/__tests__/guidedTourSteps.unit.test.ts new file mode 100644 index 0000000000..2dea8e15fb --- /dev/null +++ b/src/components/GuidedTour/__tests__/guidedTourSteps.unit.test.ts @@ -0,0 +1,388 @@ +// ABOUTME: Unit tests for the pure advance logic of the interactive tour. +// ABOUTME: Covers navigation/param gating, target-not-found handling, and event reduction. +import { ACTIONS, EVENTS, STATUS, type Step } from "react-joyride"; +import { + buildTourSteps, + buildTouchpointSteps, + findConfigClassTarget, + findPlaybookCardTarget, + reduceTourEvent, + resolveAutoAdvance, + resolveTargetNotFound, + tourSteps, + tourTarget, + type TourStepData +} from "../guidedTourSteps"; +import { allTouchpointIds } from "../touchpoints"; + +const stepKeys = (steps: Step[]) => + steps.map((s) => (s.data as TourStepData).key); + +const ctx = (pathname: string, search = "") => ({ + pathname, + params: new URLSearchParams(search) +}); + +describe("tourTarget", () => { + it("builds a data-tour attribute selector", () => { + expect(tourTarget("health")).toBe('[data-tour="health"]'); + }); +}); + +describe("resolveAutoAdvance", () => { + const steps: Step[] = [ + { target: "a", content: "x", data: { advanceOnNavigateTo: "/" } }, + { target: "a", content: "y" }, + { target: "b", content: "z", data: { advanceOnParam: "checkId" } } + ]; + + it("advances when the user navigates to the gated path", () => { + expect(resolveAutoAdvance(steps, 0, ctx("/"))).toBe(1); + }); + + it("does not advance when the path does not match", () => { + expect(resolveAutoAdvance(steps, 0, ctx("/health"))).toBeNull(); + }); + + it("advances when the gated param is present", () => { + expect(resolveAutoAdvance(steps, 2, ctx("/health", "checkId=abc"))).toBe( + "finish" + ); + }); + + it("does not advance when the gated param is absent", () => { + expect(resolveAutoAdvance(steps, 2, ctx("/health"))).toBeNull(); + }); + + it("does not advance for a step without gating data", () => { + expect(resolveAutoAdvance(steps, 1, ctx("/"))).toBeNull(); + }); + + it("advances when the path matches the gated pattern", () => { + const pathSteps: Step[] = [ + { + target: "a", + content: "x", + data: { advanceOnPathMatch: /^\/catalog\/[0-9a-f]{8}-/i } + }, + { target: "b", content: "y" } + ]; + expect( + resolveAutoAdvance( + pathSteps, + 0, + ctx("/catalog/0f9b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d") + ) + ).toBe(1); + }); + + it("does not advance when the path does not match the pattern", () => { + const pathSteps: Step[] = [ + { + target: "a", + content: "x", + data: { advanceOnPathMatch: /^\/catalog\/[0-9a-f]{8}-/i } + }, + { target: "b", content: "y" } + ]; + expect( + resolveAutoAdvance(pathSteps, 0, ctx("/catalog/changes")) + ).toBeNull(); + }); +}); + +describe("resolveTargetNotFound", () => { + const steps: Step[] = [ + { target: "a", content: "x", data: { onMissing: "finish" } }, + { target: "b", content: "y", data: { onMissing: "skip" } }, + { target: "c", content: "z", data: { onMissing: "skip" } } + ]; + + it("ends the tour when onMissing is finish", () => { + expect(resolveTargetNotFound(steps, 0)).toEqual({ + run: false, + stepIndex: 0 + }); + }); + + it("skips to the next step when onMissing is skip", () => { + expect(resolveTargetNotFound(steps, 1)).toEqual({ + run: true, + stepIndex: 2 + }); + }); + + it("ends the tour when skipping past the last step", () => { + expect(resolveTargetNotFound(steps, 2)).toEqual({ + run: false, + stepIndex: 0 + }); + }); + + it("defaults to skip when onMissing is unset", () => { + const noData: Step[] = [ + { target: "a", content: "x" }, + { target: "b", content: "y" } + ]; + expect(resolveTargetNotFound(noData, 0)).toEqual({ + run: true, + stepIndex: 1 + }); + }); + + it("jumps to the next section when onMissing is skipSection", () => { + const sectioned: Step[] = [ + { target: "a", content: "x", data: { onMissing: "skipSection" } }, + { target: "b", content: "y" }, + { target: "c", content: "z", data: { sectionStart: true } } + ]; + expect(resolveTargetNotFound(sectioned, 0)).toEqual({ + run: true, + stepIndex: 2 + }); + }); + + it("ends the tour when skipSection has no following section", () => { + const sectioned: Step[] = [ + { target: "a", content: "x", data: { sectionStart: true } }, + { target: "b", content: "y", data: { onMissing: "skipSection" } } + ]; + expect(resolveTargetNotFound(sectioned, 1)).toEqual({ + run: false, + stepIndex: 0 + }); + }); +}); + +describe("reduceTourEvent", () => { + const base = { + action: ACTIONS.NEXT, + type: EVENTS.STEP_AFTER, + status: STATUS.RUNNING, + index: 1 + }; + + it("advances on a next-button step:after event", () => { + expect(reduceTourEvent(base)).toEqual({ run: true, stepIndex: 2 }); + }); + + it("goes back on a prev-button step:after event", () => { + expect(reduceTourEvent({ ...base, action: ACTIONS.PREV })).toEqual({ + run: true, + stepIndex: 0 + }); + }); + + it("ends the tour when skipped", () => { + expect( + reduceTourEvent({ ...base, action: ACTIONS.SKIP, status: STATUS.SKIPPED }) + ).toEqual({ run: false, stepIndex: 0 }); + }); + + it("ends the tour when closed", () => { + expect(reduceTourEvent({ ...base, action: ACTIONS.CLOSE })).toEqual({ + run: false, + stepIndex: 0 + }); + }); + + it("ends the tour when finished", () => { + expect(reduceTourEvent({ ...base, status: STATUS.FINISHED })).toEqual({ + run: false, + stepIndex: 0 + }); + }); + + it("ignores unrelated events", () => { + expect(reduceTourEvent({ ...base, type: EVENTS.STEP_BEFORE })).toBeNull(); + }); +}); + +describe("tourSteps", () => { + it("gates the health step on navigation and the check step on the modal param", () => { + const health = tourSteps.find((s) => s.target === tourTarget("health")); + expect((health?.data as TourStepData).advanceOnNavigateTo).toBe("/health"); + + const checkRow = tourSteps.find((s) => typeof s.target === "function"); + expect((checkRow?.data as TourStepData).advanceOnParam).toBe("checkId"); + expect((checkRow?.data as TourStepData).onMissing).toBe("skipSection"); + }); + + it("gates the catalog nav step on navigation and the type step on the configType param", () => { + const catalog = tourSteps.find((s) => s.target === tourTarget("catalog")); + expect((catalog?.data as TourStepData).advanceOnNavigateTo).toBe( + "/catalog" + ); + + const typeStep = tourSteps.find( + (s) => (s.data as TourStepData)?.advanceOnParam === "configType" + ); + expect(typeStep).toBeDefined(); + expect((typeStep?.data as TourStepData).onMissing).toBe("skipSection"); + }); + + it("makes the user expand a config-class group before picking a type", () => { + const classStep = tourSteps.find((s) => s.target === findConfigClassTarget); + expect((classStep?.data as TourStepData).advanceOnTargetClick).toBe(true); + expect((classStep?.data as TourStepData).onMissing).toBe("skip"); + + const classIndex = tourSteps.findIndex( + (s) => s.target === findConfigClassTarget + ); + const typeIndex = tourSteps.findIndex( + (s) => (s.data as TourStepData)?.advanceOnParam === "configType" + ); + expect(classIndex).toBeGreaterThanOrEqual(0); + expect(classIndex).toBeLessThan(typeIndex); + }); + + it("gates the catalog item step on navigating to a resource detail path", () => { + const itemStep = tourSteps.find( + (s) => (s.data as TourStepData)?.advanceOnPathMatch + ); + expect(itemStep).toBeDefined(); + const pattern = (itemStep?.data as TourStepData).advanceOnPathMatch!; + expect(pattern.test("/catalog/0f9b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d")).toBe( + true + ); + expect(pattern.test("/catalog/changes")).toBe(false); + }); + + it("walks the playbooks section from the nav to a past run's details", () => { + const playbooks = buildTourSteps("playbooks"); + expect((playbooks[0].data as TourStepData).advanceOnNavigateTo).toBe( + "/playbooks" + ); + + const card = playbooks.find((s) => s.target === findPlaybookCardTarget); + expect((card?.data as TourStepData).advanceOnTargetClick).toBe(true); + expect((card?.data as TourStepData).clickTarget).toBeInstanceOf(Function); + + const runsTab = playbooks.find((s) => s.target === tourTarget("tab-Runs")); + expect((runsTab?.data as TourStepData).advanceOnNavigateTo).toBe( + "/playbooks/runs" + ); + }); + + it("centers the catalog spec tooltip so it stays on-screen", () => { + const spec = tourSteps.find( + (s) => (s.data as TourStepData)?.touchpoint === "catalog.view-spec" + ); + expect(spec?.placement).toBe("center"); + }); + + it("ends the views section with a documentation link", () => { + const views = buildTourSteps("views"); + const explain = views.find((s) => (s.data as TourStepData)?.docLink); + expect((explain?.data as TourStepData).docLink).toContain( + "flanksource.com/docs/guide/views" + ); + }); + + it("gates the relationships and playbooks tabs on clicking them and skips when absent", () => { + const relationships = tourSteps.find( + (s) => s.target === tourTarget("tab-Relationships") + ); + expect((relationships?.data as TourStepData).advanceOnTargetClick).toBe( + true + ); + expect((relationships?.data as TourStepData).onMissing).toBe("skip"); + + const playbooks = tourSteps.find( + (s) => s.target === tourTarget("tab-Playbooks") + ); + expect((playbooks?.data as TourStepData).advanceOnTargetClick).toBe(true); + expect((playbooks?.data as TourStepData).onMissing).toBe("skip"); + }); +}); + +describe("buildTouchpointSteps", () => { + it("takes the minimum catalog path, skipping spec and relationships", () => { + expect(stepKeys(buildTouchpointSteps("catalog.view-playbooks"))).toEqual([ + "catalog.view", + "catalog.expand", + "catalog.open-type", + "catalog.view-item", + "catalog.view-playbooks" + ]); + }); + + it("reaches a past run without opening the run modal", () => { + expect(stepKeys(buildTouchpointSteps("playbooks.view-run"))).toEqual([ + "playbooks.view", + "playbooks.runs-tab", + "playbooks.open-run", + "playbooks.view-run" + ]); + }); + + it("reaches the run button via the card, skipping the params step", () => { + expect(stepKeys(buildTouchpointSteps("playbooks.run"))).toEqual([ + "playbooks.view", + "playbooks.open-card", + "playbooks.run" + ]); + }); + + it("reaches a check's graph through opening the check", () => { + expect(stepKeys(buildTouchpointSteps("health.view-graph"))).toEqual([ + "health.view", + "health.open-check", + "health.view-graph" + ]); + }); + + it("returns the single step when a touchpoint has no prerequisites", () => { + expect(stepKeys(buildTouchpointSteps("views.open"))).toEqual([ + "views.open" + ]); + }); + + it("returns an empty walk for an unknown touchpoint", () => { + expect(buildTouchpointSteps("nope.nope")).toEqual([]); + }); + + it("resolves every checklist touchpoint to a walk ending at that touchpoint", () => { + for (const id of allTouchpointIds) { + const steps = buildTouchpointSteps(id); + expect(steps.length).toBeGreaterThan(0); + expect((steps[steps.length - 1].data as TourStepData).touchpoint).toBe( + id + ); + } + }); +}); + +describe("buildTourSteps", () => { + it("returns only the chosen section's steps", () => { + const health = buildTourSteps("health"); + expect(health.length).toBeGreaterThan(0); + expect((health[0].data as TourStepData).advanceOnNavigateTo).toBe( + "/health" + ); + expect(health.some((s) => s.target === tourTarget("catalog"))).toBe(false); + }); + + it("concatenates every section, in order, for the full tour", () => { + const full = buildTourSteps("full"); + const sectioned = + buildTourSteps("health").length + + buildTourSteps("catalog").length + + buildTourSteps("playbooks").length + + buildTourSteps("views").length; + // The full tour additionally opens with the dashboard intro steps. + expect(full.length).toBeGreaterThan(sectioned); + expect(full[0].target).toBe(tourTarget("dashboard")); + }); + + it("omits the playbooks section when the user can't run playbooks", () => { + const withPlaybooks = buildTourSteps("full", { canRunPlaybooks: true }); + const withoutPlaybooks = buildTourSteps("full", { + canRunPlaybooks: false + }); + expect(withoutPlaybooks.length).toBe( + withPlaybooks.length - buildTourSteps("playbooks").length + ); + expect(buildTourSteps("playbooks", { canRunPlaybooks: false })).toEqual([]); + }); +}); diff --git a/src/components/GuidedTour/guidedTourState.ts b/src/components/GuidedTour/guidedTourState.ts new file mode 100644 index 0000000000..82c71e788c --- /dev/null +++ b/src/components/GuidedTour/guidedTourState.ts @@ -0,0 +1,78 @@ +// ABOUTME: Jotai atoms holding the interactive product tour's run flag, steps and current step. +// ABOUTME: Shared between the menu trigger, the section picker and the GuidedTour renderer. +import { useUser } from "@flanksource-ui/context"; +import { hasPlaybookRunPermission } from "@flanksource-ui/utils/playbookPermissions"; +import { atom, useSetAtom } from "jotai"; +import { type Step } from "react-joyride"; +import { + buildTourSteps, + buildTouchpointSteps, + type TourSection +} from "./guidedTourSteps"; + +export const tourRunAtom = atom(false); +export const tourStepIndexAtom = atom(0); +export const tourMenuOpenAtom = atom(false); +export const tourStepsAtom = atom([]); + +/** + * Whether the user may run playbooks, gating the playbooks walkthrough. + */ +export function useCanRunPlaybooks() { + const { permissions, roles } = useUser(); + return ( + roles.includes("admin") || + roles.includes("editor") || + hasPlaybookRunPermission(permissions) + ); +} + +/** + * Returns a callback that opens the tour section picker. + */ +export function useStartTour() { + const setMenuOpen = useSetAtom(tourMenuOpenAtom); + + return () => setMenuOpen(true); +} + +/** + * Returns a callback that starts the tour for a chosen section (or the full + * tour), closing the picker. + */ +export function useStartTourSection() { + const setMenuOpen = useSetAtom(tourMenuOpenAtom); + const setSteps = useSetAtom(tourStepsAtom); + const setStepIndex = useSetAtom(tourStepIndexAtom); + const setRun = useSetAtom(tourRunAtom); + const canRunPlaybooks = useCanRunPlaybooks(); + + return (section: TourSection) => { + setSteps(buildTourSteps(section, { canRunPlaybooks })); + setStepIndex(0); + setMenuOpen(false); + setRun(true); + }; +} + +/** + * Returns a callback that starts the minimal guided walk reaching a single + * checklist touchpoint. + */ +export function useStartTouchpoint() { + const setMenuOpen = useSetAtom(tourMenuOpenAtom); + const setSteps = useSetAtom(tourStepsAtom); + const setStepIndex = useSetAtom(tourStepIndexAtom); + const setRun = useSetAtom(tourRunAtom); + + return (touchpoint: string) => { + const steps = buildTouchpointSteps(touchpoint); + if (steps.length === 0) { + return; + } + setSteps(steps); + setStepIndex(0); + setMenuOpen(false); + setRun(true); + }; +} diff --git a/src/components/GuidedTour/guidedTourSteps.ts b/src/components/GuidedTour/guidedTourSteps.ts new file mode 100644 index 0000000000..86582af2d1 --- /dev/null +++ b/src/components/GuidedTour/guidedTourSteps.ts @@ -0,0 +1,211 @@ +// ABOUTME: Assembles the per-section tour steps and holds the pure logic that advances the tour. +// ABOUTME: Also builds a minimal single-touchpoint walk from a step's dependency chain. +import { ACTIONS, EVENTS, STATUS, type Step } from "react-joyride"; +import { dashboardSteps } from "./steps/dashboard"; +import { healthSteps } from "./steps/health"; +import { catalogSteps } from "./steps/catalog"; +import { playbookSteps } from "./steps/playbooks"; +import { viewSteps } from "./steps/views"; +import { type TourStepData } from "./steps/shared"; + +export * from "./steps/shared"; + +/** + * The sections a user can choose from the tour menu. "full" runs them all. + */ +export type TourSection = "full" | "health" | "catalog" | "playbooks" | "views"; + +export type TourCapabilities = { + /** When false, the playbooks walkthrough is omitted entirely. */ + canRunPlaybooks?: boolean; +}; + +const sectionSteps: Record, Step[]> = { + health: healthSteps, + catalog: catalogSteps, + playbooks: playbookSteps, + views: viewSteps +}; + +/** + * Builds the step list for a chosen section, or the complete tour. The + * playbooks section is omitted unless the user can run playbooks. + */ +export function buildTourSteps( + section: TourSection, + { canRunPlaybooks = true }: TourCapabilities = {} +): Step[] { + switch (section) { + case "health": + return healthSteps; + case "catalog": + return catalogSteps; + case "playbooks": + return canRunPlaybooks ? playbookSteps : []; + case "views": + return viewSteps; + case "full": + default: + return [ + ...dashboardSteps, + ...healthSteps, + ...catalogSteps, + ...(canRunPlaybooks ? playbookSteps : []), + ...viewSteps + ]; + } +} + +/** Every section's steps, in tour order, for resolving a touchpoint's chain. */ +const allSteps: Step[] = [ + ...dashboardSteps, + ...healthSteps, + ...catalogSteps, + ...playbookSteps, + ...viewSteps +]; + +function stepData(step: Step): TourStepData { + return (step.data ?? {}) as TourStepData; +} + +/** + * Builds the smallest guided walk that reaches a single touchpoint: the + * transitive `dependsOn` closure of the touchpoint's step, returned in the + * step order of the section it belongs to. Only the prerequisite steps needed + * to reach the target are included — sibling/optional steps are dropped. + */ +export function buildTouchpointSteps(touchpoint: string): Step[] { + const target = (Object.keys(sectionSteps) as Exclude[]) + .map((name) => sectionSteps[name]) + .find((steps) => steps.some((s) => stepData(s).touchpoint === touchpoint)); + if (!target) { + return []; + } + const byKey = new Map(); + for (const step of target) { + const key = stepData(step).key; + if (key) { + byKey.set(key, step); + } + } + const wanted = new Set(); + const visit = (key?: string) => { + if (!key || wanted.has(key)) { + return; + } + wanted.add(key); + stepData(byKey.get(key) ?? ({} as Step)).dependsOn?.forEach(visit); + }; + const targetStep = target.find((s) => stepData(s).touchpoint === touchpoint)!; + visit(stepData(targetStep).key); + return target.filter((s) => { + const key = stepData(s).key; + return key != null && wanted.has(key); + }); +} + +/** The complete tour, used as the default and for menu option "full". */ +export const tourSteps: Step[] = buildTourSteps("full"); + +/** + * The state the tour observes to decide when a gated step should advance. + */ +export type TourContext = { + pathname: string; + params: URLSearchParams; +}; + +function advanceFrom(steps: Step[], stepIndex: number): number | "finish" { + const next = stepIndex + 1; + return next >= steps.length ? "finish" : next; +} + +/** + * Decides whether a navigation- or param-gated step should advance given the + * current app state. Returns the next step index, "finish", or null for no-op. + */ +export function resolveAutoAdvance( + steps: Step[], + stepIndex: number, + ctx: TourContext +): number | "finish" | null { + const data = steps[stepIndex]?.data as TourStepData | undefined; + if (!data) { + return null; + } + const navigated = + !!data.advanceOnNavigateTo && data.advanceOnNavigateTo === ctx.pathname; + const paramPresent = + !!data.advanceOnParam && ctx.params.get(data.advanceOnParam) != null; + const pathMatched = + !!data.advanceOnPathMatch && data.advanceOnPathMatch.test(ctx.pathname); + if (!navigated && !paramPresent && !pathMatched) { + return null; + } + return advanceFrom(steps, stepIndex); +} + +/** + * Decides where to go when the current step's target can't be found, based on + * the step's `onMissing` setting. + */ +export function resolveTargetNotFound( + steps: Step[], + stepIndex: number +): { run: boolean; stepIndex: number } { + const data = steps[stepIndex]?.data as TourStepData | undefined; + if (data?.onMissing === "finish") { + return { run: false, stepIndex: 0 }; + } + if (data?.onMissing === "skipSection") { + const nextSection = steps.findIndex( + (step, index) => + index > stepIndex && (step.data as TourStepData)?.sectionStart + ); + return nextSection === -1 + ? { run: false, stepIndex: 0 } + : { run: true, stepIndex: nextSection }; + } + const next = advanceFrom(steps, stepIndex); + return next === "finish" + ? { run: false, stepIndex: 0 } + : { run: true, stepIndex: next }; +} + +type TourEvent = { + action: string; + type: string; + status: string; + index: number; +}; + +/** + * Translates a react-joyride button event into the controlled tour state to + * apply, or null when the event requires no change. + */ +export function reduceTourEvent( + event: TourEvent +): { run: boolean; stepIndex: number } | null { + const { action, type, status, index } = event; + + if ( + status === STATUS.FINISHED || + status === STATUS.SKIPPED || + action === ACTIONS.CLOSE || + action === ACTIONS.SKIP + ) { + return { run: false, stepIndex: 0 }; + } + + if (type === EVENTS.STEP_AFTER) { + if (action === ACTIONS.NEXT) { + return { run: true, stepIndex: index + 1 }; + } + if (action === ACTIONS.PREV) { + return { run: true, stepIndex: index - 1 }; + } + } + + return null; +} diff --git a/src/components/GuidedTour/steps/catalog.ts b/src/components/GuidedTour/steps/catalog.ts new file mode 100644 index 0000000000..add783c1fc --- /dev/null +++ b/src/components/GuidedTour/steps/catalog.ts @@ -0,0 +1,133 @@ +// ABOUTME: The catalog section of the guided tour and its checklist touchpoints. +// ABOUTME: Drills from the resource summary into a type, an item, its spec, relationships and playbooks. +import { type Step } from "react-joyride"; +import { + findConfigClassTarget, + findConfigItemTarget, + findConfigTypeTarget, + tourTarget, + type TourStepData +} from "./shared"; + +export const catalogSteps: Step[] = [ + { + target: tourTarget("catalog"), + title: "Catalog", + content: "Now open the Catalog.", + placement: "right", + skipBeacon: true, + buttons: [], + data: { + key: "catalog.view", + touchpoint: "catalog.view", + advanceOnNavigateTo: "/catalog", + sectionStart: true + } as TourStepData + }, + { + target: tourTarget("catalog-summary"), + title: "Catalog", + content: "All the tracked resources are shown here, grouped by type.", + placement: "center", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "catalog.summary", + dependsOn: ["catalog.view"] + } as TourStepData + }, + { + target: findConfigClassTarget, + title: "Pick a group", + content: "Click a group to expand the resource types inside it.", + placement: "right", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + data: { + key: "catalog.expand", + dependsOn: ["catalog.view"], + advanceOnTargetClick: true, + onMissing: "skip" + } as TourStepData + }, + { + target: findConfigTypeTarget, + title: "Pick a type", + content: "Click a type to see the resources of that kind.", + placement: "right", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + data: { + key: "catalog.open-type", + touchpoint: "catalog.open-type", + dependsOn: ["catalog.expand"], + advanceOnParam: "configType", + onMissing: "skipSection", + scrollIntoView: true + } as TourStepData + }, + { + target: findConfigItemTarget, + title: "Open a resource", + content: "Open any resource to see its full details.", + placement: "right", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + data: { + key: "catalog.view-item", + touchpoint: "catalog.view-item", + dependsOn: ["catalog.open-type"], + advanceOnPathMatch: /^\/catalog\/[0-9a-f]{8}-/i, + onMissing: "skipSection" + } as TourStepData + }, + { + target: tourTarget("config-spec"), + title: "Spec", + content: + "This is the resource's full spec — the configuration we scraped for it.", + placement: "center", + skipBeacon: true, + buttons: ["primary"], + targetWaitTimeout: 8000, + data: { + key: "catalog.view-spec", + touchpoint: "catalog.view-spec", + dependsOn: ["catalog.view-item"], + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("tab-Relationships"), + title: "Relationships", + content: "We can see how catalog items are linked.", + placement: "bottom", + skipBeacon: true, + buttons: [], + data: { + key: "catalog.view-relationships", + touchpoint: "catalog.view-relationships", + dependsOn: ["catalog.view-item"], + advanceOnTargetClick: true, + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("tab-Playbooks"), + title: "Playbooks", + content: "These are operations you can perform on any catalog item.", + placement: "bottom", + skipBeacon: true, + buttons: [], + data: { + key: "catalog.view-playbooks", + touchpoint: "catalog.view-playbooks", + dependsOn: ["catalog.view-item"], + advanceOnTargetClick: true, + onMissing: "skip" + } as TourStepData + } +]; diff --git a/src/components/GuidedTour/steps/dashboard.ts b/src/components/GuidedTour/steps/dashboard.ts new file mode 100644 index 0000000000..5949bdfd03 --- /dev/null +++ b/src/components/GuidedTour/steps/dashboard.ts @@ -0,0 +1,33 @@ +// ABOUTME: The dashboard intro steps shown at the start of the full guided tour. +// ABOUTME: Not part of the getting-started checklist; purely a scene-setting opener. +import { type Step } from "react-joyride"; +import { tourTarget, type TourStepData } from "./shared"; + +export const dashboardSteps: Step[] = [ + { + target: tourTarget("dashboard"), + title: "Dashboard", + content: "Let's start here. Click Dashboard to open it.", + placement: "right", + skipBeacon: true, + buttons: [], + data: { + key: "dashboard.view", + advanceOnNavigateTo: "/", + sectionStart: true + } as TourStepData + }, + { + target: tourTarget("dashboard"), + title: "Dashboard", + content: + "This is the central view of everything happening across your infrastructure — health, incidents, and changes, all at a glance.", + placement: "right", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "dashboard.intro", + dependsOn: ["dashboard.view"] + } as TourStepData + } +]; diff --git a/src/components/GuidedTour/steps/health.ts b/src/components/GuidedTour/steps/health.ts new file mode 100644 index 0000000000..16dbe08628 --- /dev/null +++ b/src/components/GuidedTour/steps/health.ts @@ -0,0 +1,136 @@ +// ABOUTME: The health-checks section of the guided tour and its checklist touchpoints. +// ABOUTME: Walks from the Health nav through a check's stats, timeline, graph and on-demand run. +import { type Step } from "react-joyride"; +import { findCheckRowTarget, tourTarget, type TourStepData } from "./shared"; + +export const healthSteps: Step[] = [ + { + target: tourTarget("health"), + title: "Health", + content: "Now click Health to see your health checks.", + placement: "right", + skipBeacon: true, + buttons: [], + data: { + key: "health.view", + touchpoint: "health.view", + advanceOnNavigateTo: "/health", + sectionStart: true + } as TourStepData + }, + { + target: tourTarget("checks-section"), + title: "Health checks", + content: "All of your health checks are listed here.", + placement: "left", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "health.checks-section", + dependsOn: ["health.view"] + } as TourStepData + }, + { + target: findCheckRowTarget, + title: "Open a check", + content: "Click a check to open its details.", + placement: "right", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + // The checks table has a sticky header; offset the scroll so the + // highlighted row lands below it instead of behind it. + scrollOffset: 120, + data: { + key: "health.open-check", + touchpoint: "health.open-check", + dependsOn: ["health.view"], + advanceOnParam: "checkId", + onMissing: "skipSection" + } as TourStepData + }, + { + target: tourTarget("check-stats"), + title: "Check stats", + content: + "These are the health check stats — uptime, latency, severity and more.", + placement: "bottom", + skipBeacon: true, + buttons: ["primary"], + targetWaitTimeout: 5000, + data: { + key: "health.check-stats", + dependsOn: ["health.open-check"] + } as TourStepData + }, + { + target: tourTarget("check-timeline"), + title: "Check timeline", + content: + "And this is the check timeline — the age, duration and message of each run.", + placement: "top", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "health.check-timeline", + dependsOn: ["health.open-check"] + } as TourStepData + }, + { + target: tourTarget("check-tab-graph"), + title: "Graph view", + content: "Click the Graph tab to view the check history as a chart.", + placement: "bottom", + skipBeacon: true, + buttons: [], + data: { + key: "health.view-graph", + touchpoint: "health.view-graph", + dependsOn: ["health.open-check"], + advanceOnTargetClick: true, + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("check-graph"), + title: "Graph view", + content: "Here we can see the check history in graph view as well.", + placement: "top", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "health.check-graph", + dependsOn: ["health.view-graph"], + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("check-run-now"), + title: "Run on demand", + content: + "Checks run on their configured schedule, but you can also trigger one on demand here.", + placement: "top", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "health.run-now", + touchpoint: "health.run-now", + dependsOn: ["health.open-check"], + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("dialog-button-close"), + title: "That's health checks", + content: "Close this check when you're ready to move on.", + placement: "left", + skipBeacon: true, + buttons: [], + data: { + key: "health.close", + dependsOn: ["health.open-check"], + advanceOnTargetClick: true, + onMissing: "skip" + } as TourStepData + } +]; diff --git a/src/components/GuidedTour/steps/playbooks.ts b/src/components/GuidedTour/steps/playbooks.ts new file mode 100644 index 0000000000..84de962f69 --- /dev/null +++ b/src/components/GuidedTour/steps/playbooks.ts @@ -0,0 +1,130 @@ +// ABOUTME: The playbooks section of the guided tour and its checklist touchpoints. +// ABOUTME: Covers running a playbook, its parameters, and inspecting a past run's details. +import { type Step } from "react-joyride"; +import { + findPlaybookCardTarget, + findPlaybookRunButtonTarget, + findPlaybookRunRowTarget, + tourTarget, + type TourStepData +} from "./shared"; + +export const playbookSteps: Step[] = [ + { + target: tourTarget("playbooks"), + title: "Playbooks", + content: "Open Playbooks to see the actions you can run.", + placement: "right", + skipBeacon: true, + buttons: [], + data: { + key: "playbooks.view", + touchpoint: "playbooks.view", + advanceOnNavigateTo: "/playbooks", + sectionStart: true + } as TourStepData + }, + { + target: findPlaybookCardTarget, + title: "Run an action", + content: + "Each playbook runs an action on demand. Click Run on this one to launch it.", + placement: "bottom", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + data: { + key: "playbooks.open-card", + dependsOn: ["playbooks.view"], + advanceOnTargetClick: true, + clickTarget: findPlaybookRunButtonTarget, + onMissing: "skipSection" + } as TourStepData + }, + { + target: tourTarget("playbook-params"), + title: "Parameters", + content: "Set any parameters the action needs here.", + placement: "right", + skipBeacon: true, + buttons: ["primary"], + targetWaitTimeout: 5000, + data: { + key: "playbooks.params", + dependsOn: ["playbooks.open-card"], + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("playbook-run-submit"), + title: "Execute", + content: "And this is how you execute it.", + placement: "top", + skipBeacon: true, + buttons: ["primary"], + data: { + key: "playbooks.run", + touchpoint: "playbooks.run", + dependsOn: ["playbooks.open-card"], + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("dialog-button-close"), + title: "Past runs", + content: "Close this and let's look at previous runs.", + placement: "left", + skipBeacon: true, + buttons: [], + data: { + key: "playbooks.close", + dependsOn: ["playbooks.open-card"], + advanceOnTargetClick: true, + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("tab-Runs"), + title: "Runs", + content: "Open the Runs tab to see past executions.", + placement: "bottom", + skipBeacon: true, + buttons: [], + data: { + key: "playbooks.runs-tab", + dependsOn: ["playbooks.view"], + advanceOnNavigateTo: "/playbooks/runs" + } as TourStepData + }, + { + target: findPlaybookRunRowTarget, + title: "Inspect a run", + content: "Click any past run to inspect it.", + placement: "bottom", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + data: { + key: "playbooks.open-run", + dependsOn: ["playbooks.runs-tab"], + advanceOnPathMatch: /^\/playbooks\/runs\/[0-9a-f]{8}-/i, + onMissing: "skip" + } as TourStepData + }, + { + target: tourTarget("playbook-run-details"), + title: "Run details", + content: + "This is where all the run details live — its status, parameters, logs and the result of every action.", + placement: "center", + skipBeacon: true, + buttons: ["primary"], + targetWaitTimeout: 8000, + data: { + key: "playbooks.view-run", + touchpoint: "playbooks.view-run", + dependsOn: ["playbooks.open-run"], + onMissing: "skip" + } as TourStepData + } +]; diff --git a/src/components/GuidedTour/steps/shared.ts b/src/components/GuidedTour/steps/shared.ts new file mode 100644 index 0000000000..c8c2bfa094 --- /dev/null +++ b/src/components/GuidedTour/steps/shared.ts @@ -0,0 +1,138 @@ +// ABOUTME: Shared types and DOM target pickers used by every tour section file. +// ABOUTME: Steps carry gating + checklist metadata (key, dependsOn, touchpoint) in step.data. + +export type TourStepData = { + /** Stable identifier for this step, used to express dependencies between steps. */ + key?: string; + /** Keys of the steps that must run before this one to reach it (its prerequisites). */ + dependsOn?: string[]; + /** The checklist touchpoint this step satisfies (recorded when the user does it). */ + touchpoint?: string; + /** Advance once the user navigates to this pathname. */ + advanceOnNavigateTo?: string; + /** Advance once this search param is present in the URL. */ + advanceOnParam?: string; + /** Advance once the pathname matches this pattern (e.g. a resource detail). */ + advanceOnPathMatch?: RegExp; + /** Advance once the step's target element is clicked. */ + advanceOnTargetClick?: boolean; + /** + * When advancing on a click, listen on this element instead of the + * highlighted target (e.g. highlight a card but advance on its Run button). + */ + clickTarget?: () => HTMLElement | null; + /** A "Learn more" documentation link shown in the tooltip. */ + docLink?: string; + /** Marks the first step of a section, so a section can be skipped to/over. */ + sectionStart?: boolean; + /** + * Scroll the target into view when the step activates. Needed for targets in + * nested scroll containers that react-joyride doesn't scroll (e.g. a catalog + * type below the fold). + */ + scrollIntoView?: boolean; + /** + * What to do when the target can't be found: + * - "skip" (default): move on to the next step. + * - "skipSection": jump to the next section (or end if this is the last). + * - "finish": end the tour. + */ + onMissing?: "skip" | "skipSection" | "finish"; +}; + +/** + * Selector helper so the steps and the components agree on the same markers. + */ +export const tourTarget = (id: string) => `[data-tour="${id}"]`; + +/** + * Picks the check row to highlight, preferring an unhealthy check, then a + * healthy one, then any check row. Returns null when no leaf check row exists. + */ +export function findCheckRowTarget(): HTMLElement | null { + return ( + document.querySelector( + '[data-tour="check-row"][data-tour-status="unhealthy"]' + ) ?? + document.querySelector( + '[data-tour="check-row"][data-tour-status="healthy"]' + ) ?? + document.querySelector('[data-tour="check-row"]') + ); +} + +/** + * Picks the catalog config-class group to highlight, preferring Kubernetes, + * then any group. Returns null when no group row is rendered. + */ +export function findConfigClassTarget(): HTMLElement | null { + return ( + document.querySelector( + '[data-tour="config-class"][data-tour-class="Kubernetes"]' + ) ?? document.querySelector('[data-tour="config-class"]') + ); +} + +/** + * Picks the catalog type row to highlight, preferring Kubernetes Pods, then + * any type. Returns null when no type row is rendered. + */ +export function findConfigTypeTarget(): HTMLElement | null { + return ( + document.querySelector( + '[data-tour="config-type"][data-tour-type="Kubernetes::Pod"]' + ) ?? document.querySelector('[data-tour="config-type"]') + ); +} + +/** + * Picks the first catalog item row to highlight, or null when none exist. + */ +export function findConfigItemTarget(): HTMLElement | null { + return document.querySelector('[data-tour="catalog-item"]'); +} + +/** + * Picks the playbook card to highlight, preferring a Kubernetes logs playbook, + * then any playbook. Returns null when no playbook card is rendered. + */ +export function findPlaybookCardTarget(): HTMLElement | null { + return ( + document.querySelector( + '[data-tour="playbook-card"][data-tour-name*="kubernetes log"]' + ) ?? document.querySelector('[data-tour="playbook-card"]') + ); +} + +/** + * Finds the Run button inside the preferred playbook card so the tour can + * advance once the user opens its run form. + */ +export function findPlaybookRunButtonTarget(): HTMLElement | null { + return ( + findPlaybookCardTarget()?.querySelector( + '[data-tour="playbook-card-run"]' + ) ?? null + ); +} + +/** + * Picks the first past playbook run row to highlight, or null when none exist. + */ +export function findPlaybookRunRowTarget(): HTMLElement | null { + return document.querySelector('[data-tour="playbook-run-row"]'); +} + +/** + * Picks the sidebar view to highlight, preferring one named "system", then any + * view, then the dashboard. Returns null when none are present. + */ +export function findViewTarget(): HTMLElement | null { + return ( + document.querySelector( + '[data-tour="views"][data-tour-name="system"]' + ) ?? + document.querySelector('[data-tour="views"]') ?? + document.querySelector('[data-tour="dashboard"]') + ); +} diff --git a/src/components/GuidedTour/steps/views.ts b/src/components/GuidedTour/steps/views.ts new file mode 100644 index 0000000000..45ecbb9d9c --- /dev/null +++ b/src/components/GuidedTour/steps/views.ts @@ -0,0 +1,39 @@ +// ABOUTME: The views section of the guided tour and its checklist touchpoint. +// ABOUTME: Opens a custom view and explains that views compose graphs, tables and charts. +import { type Step } from "react-joyride"; +import { findViewTarget, tourTarget, type TourStepData } from "./shared"; + +export const viewSteps: Step[] = [ + { + target: findViewTarget, + title: "Views", + content: "Open a view to see it.", + placement: "right", + skipBeacon: true, + buttons: [], + targetWaitTimeout: 5000, + data: { + key: "views.open", + touchpoint: "views.open", + advanceOnTargetClick: true, + sectionStart: true, + onMissing: "skipSection" + } as TourStepData + }, + { + target: tourTarget("view-content"), + title: "Views", + content: + "Views are customizable dashboards — compose graphs, tables, charts and more to visualize exactly the data you care about.", + placement: "center", + skipBeacon: true, + buttons: ["primary"], + targetWaitTimeout: 8000, + data: { + key: "views.content", + dependsOn: ["views.open"], + docLink: "https://flanksource.com/docs/guide/views", + onMissing: "skip" + } as TourStepData + } +]; diff --git a/src/components/GuidedTour/touchpoints.ts b/src/components/GuidedTour/touchpoints.ts new file mode 100644 index 0000000000..bf01d0f79e --- /dev/null +++ b/src/components/GuidedTour/touchpoints.ts @@ -0,0 +1,71 @@ +// ABOUTME: Canonical getting-started checklist: categories and the touchpoints they track. +// ABOUTME: Each touchpoint id is the `key` recorded in person_touchpoints when the user does it. +import { type TourSection } from "./guidedTourSteps"; + +export type TouchpointItem = { + /** Recorded key, e.g. "catalog.view-item". Matches a tour step's touchpoint. */ + id: string; + /** Checklist label shown to the user. */ + label: string; +}; + +export type TouchpointCategory = { + /** Category identifier, also the tour section started from its header. */ + id: Exclude; + /** Heading shown above the category's checklist items. */ + title: string; + items: TouchpointItem[]; +}; + +export const touchpointCategories: TouchpointCategory[] = [ + { + id: "health", + title: "Monitor resources", + items: [ + { id: "health.view", label: "See what's healthy and what's broken" }, + { id: "health.open-check", label: "Investigate a failing check" }, + { id: "health.view-graph", label: "Spot when a check started failing" }, + { id: "health.run-now", label: "Re-run a check to confirm a fix" } + ] + }, + { + id: "catalog", + title: "Explore the catalog", + items: [ + { + id: "catalog.view", + label: "Find a resource across your infrastructure" + }, + { id: "catalog.open-type", label: "Browse all resources of one kind" }, + { id: "catalog.view-item", label: "Inspect a single resource" }, + { id: "catalog.view-spec", label: "Review a resource's configuration" }, + { + id: "catalog.view-relationships", + label: "Trace a resource's dependencies" + }, + { + id: "catalog.view-playbooks", + label: "Find actions you can run on a resource" + } + ] + }, + { + id: "playbooks", + title: "Run playbooks", + items: [ + { id: "playbooks.view", label: "Discover ready-made automations" }, + { id: "playbooks.run", label: "Fix an issue with a playbook" }, + { id: "playbooks.view-run", label: "Review what a past playbook run did" } + ] + }, + { + id: "views", + title: "Build custom views", + items: [{ id: "views.open", label: "See your data in a custom dashboard" }] + } +]; + +/** Every touchpoint id known to the checklist, for validation/iteration. */ +export const allTouchpointIds: string[] = touchpointCategories.flatMap( + (category) => category.items.map((item) => item.id) +); diff --git a/src/components/GuidedTour/useTouchpoints.ts b/src/components/GuidedTour/useTouchpoints.ts new file mode 100644 index 0000000000..5f4e8d7a8c --- /dev/null +++ b/src/components/GuidedTour/useTouchpoints.ts @@ -0,0 +1,80 @@ +// ABOUTME: React Query hooks for the getting-started checklist's completed touchpoints. +// ABOUTME: useCompletedTouchpoints reads the user's keys; useRecordTouchpoint records one on use. +import { useUser } from "@flanksource-ui/context"; +import { + fetchPersonTouchpoints, + recordPersonTouchpoint +} from "@flanksource-ui/api/services/touchpoints"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect } from "react"; + +const touchpointsKey = (personId?: string) => ["person_touchpoints", personId]; + +/** Guards against concurrent duplicate writes of the same key across components. */ +const inFlight = new Set(); + +/** + * The set of touchpoint keys the current user has completed. + */ +export function useCompletedTouchpoints() { + const { user } = useUser(); + const personId = user?.id; + return useQuery({ + queryKey: touchpointsKey(personId), + enabled: !!personId, + queryFn: async () => { + const { data } = await fetchPersonTouchpoints(personId!); + return new Set((data ?? []).map((row) => row.key)); + } + }); +} + +/** + * Returns a stable `record(key)` callback that marks a touchpoint complete for + * the current user. Recording is best-effort and never surfaces an error; it + * skips keys already known complete or in flight to avoid redundant writes. + */ +export function useRecordTouchpoint() { + const { user } = useUser(); + const personId = user?.id; + const queryClient = useQueryClient(); + + return useCallback( + (key: string) => { + if (!personId) { + return; + } + const known = queryClient.getQueryData>( + touchpointsKey(personId) + ); + const guard = `${personId}:${key}`; + if (known?.has(key) || inFlight.has(guard)) { + return; + } + inFlight.add(guard); + recordPersonTouchpoint(personId, key) + .then(() => { + queryClient.setQueryData>( + touchpointsKey(personId), + (prev) => new Set(prev).add(key) + ); + }) + .catch(() => {}) + .finally(() => inFlight.delete(guard)); + }, + [personId, queryClient] + ); +} + +/** + * Records a touchpoint once when the component mounts (or when `enabled` + * becomes true). Use for "viewed X" touchpoints tied to a page/panel. + */ +export function useRecordTouchpointOnMount(key: string, enabled = true) { + const record = useRecordTouchpoint(); + useEffect(() => { + if (enabled) { + record(key); + } + }, [key, enabled, record]); +} diff --git a/src/components/Layout/AppSidebar.tsx b/src/components/Layout/AppSidebar.tsx index 9d4fdbc45b..08e64fa900 100644 --- a/src/components/Layout/AppSidebar.tsx +++ b/src/components/Layout/AppSidebar.tsx @@ -85,6 +85,14 @@ const SidebarLink = React.forwardRef< ); }); +/** + * Stable marker used by the interactive tour to target a nav link. + * "/" is the dashboard; everything else uses its first path segment. + */ +function tourSlug(href: string) { + return href === "/" ? "dashboard" : href.replace(/^\//, "").split("/")[0]; +} + const NavItem = React.memo(function NavItem({ item, collapsed, @@ -101,7 +109,11 @@ const NavItem = React.memo(function NavItem({ return ( - + {!collapsed && ( {item.name} diff --git a/src/components/Playbooks/Runs/PlaybookRunsList.tsx b/src/components/Playbooks/Runs/PlaybookRunsList.tsx index b40899688d..4bd2d9c8cc 100644 --- a/src/components/Playbooks/Runs/PlaybookRunsList.tsx +++ b/src/components/Playbooks/Runs/PlaybookRunsList.tsx @@ -20,7 +20,11 @@ const playbookRunsTableColumns: MRT_ColumnDef[] = [ header: "Name", accessorKey: "name", Cell: ({ row }) => { - return ; + return ( + + + + ); }, size: 400 }, diff --git a/src/components/Playbooks/Runs/Submit/SubmitPlaybookRunForm.tsx b/src/components/Playbooks/Runs/Submit/SubmitPlaybookRunForm.tsx index 9d4ffdbe01..a9ce49ecd5 100644 --- a/src/components/Playbooks/Runs/Submit/SubmitPlaybookRunForm.tsx +++ b/src/components/Playbooks/Runs/Submit/SubmitPlaybookRunForm.tsx @@ -1,4 +1,5 @@ import { useSubmitPlaybookRunMutation } from "@flanksource-ui/api/query-hooks/playbooks"; +import { useRecordTouchpoint } from "@flanksource-ui/components/GuidedTour/useTouchpoints"; import { PlaybookSpec } from "@flanksource-ui/api/types/playbooks"; import { Button } from "@flanksource-ui/ui/Buttons/Button"; import { Modal, ModalSize } from "@flanksource-ui/ui/Modal"; @@ -57,6 +58,7 @@ export default function SubmitPlaybookRunForm({ ); const navigate = useNavigate(); + const recordTouchpoint = useRecordTouchpoint(); const initialValues: Partial = useMemo(() => { return { @@ -109,6 +111,7 @@ export default function SubmitPlaybookRunForm({ initialValues={initialValues} validateOnMount onSubmit={(values) => { + recordTouchpoint("playbooks.run"); submitPlaybookRun(values as SubmitPlaybookRunFormValues); }} > @@ -136,15 +139,18 @@ export default function SubmitPlaybookRunForm({
)} - +
+ +
+ + {doneCount}/{category.items.length} + +
+
+
+
+
    + {category.items.map((item) => { + const isDone = completed.has(item.id); + return ( +
  • + {isDone ? ( + + ) : ( + + )} + + {item.label} + + +
  • + ); + })} +
+
+ ); +} + +export function GettingStartedPage() { + const { data: completed } = useCompletedTouchpoints(); + const canRunPlaybooks = useCanRunPlaybooks(); + const startSection = useStartTourSection(); + const startTouchpoint = useStartTouchpoint(); + const openTourMenu = useStartTour(); + + const completedSet = completed ?? new Set(); + const categories = touchpointCategories.filter( + (category) => category.id !== "playbooks" || canRunPlaybooks + ); + + return ( + <> + + + Getting started + + ]} + /> + } + extra={ + + } + contentClass="p-0 h-full" + > +
+
+
+

+ Get the most out of Mission Control +

+

+ Work through this checklist to learn how to monitor resources, + explore the catalog, run playbooks and build custom views. Each + item ticks off as you try it — click “Show me” for a guided walk + through. +

+
+ {categories.map((category) => ( + startSection(category.id)} + onStartItem={startTouchpoint} + /> + ))} +
+
+
+ + ); +} diff --git a/src/pages/config/ConfigList.tsx b/src/pages/config/ConfigList.tsx index ad012a84fe..294d3ca51a 100644 --- a/src/pages/config/ConfigList.tsx +++ b/src/pages/config/ConfigList.tsx @@ -102,7 +102,10 @@ export function ConfigListPage() { -
+
{showConfigSummaryList ? (
{!isLoading ? ( -
+
-
+
{playbookRunsWithActions ? ( = ({ id }) => { ) } > -
+