diff --git a/.changeset/onboarding-checklist-tasks.md b/.changeset/onboarding-checklist-tasks.md new file mode 100644 index 0000000000..c3af9627a5 --- /dev/null +++ b/.changeset/onboarding-checklist-tasks.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/common-utils': minor +'@hyperdx/api': minor +'@hyperdx/app': minor +--- + +Add a context-aware getting-started checklist to the sidebar. After the existing setup steps (connect ClickHouse, add data) complete, a second phase tracks product-usage milestones persisted per user on `user.onboardingData`: exploring data (running a non-trivial search), creating a dashboard, setting up an alert, and using the MCP server. Each task links to where to do it, the card can be dismissed, and the task registry is a single source of truth in `common-utils` (`ONBOARDING_TASK_IDS`) so adding a new task is a compile error until both the API validation enum and the frontend UI are updated. + +Completion is recorded server-side so it is surface-agnostic: the `alert` and `dashboard` tasks complete whether the action came from the UI, the external REST API v2, or an MCP tool (all authenticate with the user's personal access key, so the action is attributable to that user). The `dashboard` task specifically means "built a chart" — it completes only once a dashboard has at least one tile, not for an empty dashboard shell. This includes adding a tile to a temporary (unsaved, URL-state) dashboard, which never touches the backend: the app records that case directly against `POST /me/onboarding/task`. Alert recording covers the standalone `/alerts` create path and dashboard-tile alerts. MCP usage is recorded in the tool-tracing chokepoint. Exploring data is recorded from the search page on any non-trivial search (a non-empty where clause in either Lucene or SQL, or an applied filter). All recording is fire-and-forget and never blocks the triggering operation. After a UI action the frontend patches only `onboardingData` in the cached `me` object (no `me` refetch, so the many `useMe` consumers — metadata, ClickHouse settings, nav — are untouched). + +The checklist's "done" state is DERIVED from whether every current task is complete rather than a persisted flag, so adding or changing a task in `ONBOARDING_TASK_IDS` later automatically reopens the checklist for users who had finished the old set. Completing the final task in-session shows a brief celebration before the card hides; a user who was already finished on load sees nothing. The persisted `isDismissed` (`PATCH /me/onboarding/dismiss`) is only for the manual dismiss (X) when a user opts out early. The `me` response includes `onboardingData`, defaulted for users created before the field existed. diff --git a/packages/api/src/controllers/alerts.ts b/packages/api/src/controllers/alerts.ts index feba0444f9..59fe1d1976 100644 --- a/packages/api/src/controllers/alerts.ts +++ b/packages/api/src/controllers/alerts.ts @@ -8,6 +8,7 @@ import { groupBy } from 'lodash'; import { Types } from 'mongoose'; import { z } from 'zod'; +import { recordOnboardingTaskCompletion } from '@/controllers/user'; import type { ObjectId } from '@/models'; import Alert, { AlertChannel, @@ -288,10 +289,12 @@ export const createAlert = async ( alertInput: z.infer, userId: ObjectId, ) => { - return new Alert({ + const alert = await new Alert({ ...makeAlert(alertInput, userId), team: teamId, }).save(); + recordOnboardingTaskCompletion(userId, 'alert'); + return alert; }; // create an update alert function based off of the above create alert function @@ -299,9 +302,10 @@ export const updateAlert = async ( id: string, teamId: ObjectId, alertInput: AlertInput, + userId?: ObjectId, ) => { // should consider clearing AlertHistory when updating an alert? - return Alert.findOneAndUpdate( + const alert = await Alert.findOneAndUpdate( { _id: id, team: teamId, @@ -311,6 +315,13 @@ export const updateAlert = async ( returnDocument: 'after', }, ); + // Editing an existing alert is still "set up an alert" for onboarding — + // record it the same as create so the checklist completes from any edit, + // not only the initial create. No-op once already recorded. + if (alert != null) { + recordOnboardingTaskCompletion(userId, 'alert'); + } + return alert; }; export const getAlerts = async ( @@ -367,7 +378,7 @@ export const createOrUpdateDashboardAlerts = async ( alertsByTile: Record, userId?: ObjectId, ) => { - return Promise.all( + const result = await Promise.all( Object.entries(alertsByTile).map(async ([tileId, alert]) => { const filter = { dashboard: dashboardId, @@ -393,6 +404,14 @@ export const createOrUpdateDashboardAlerts = async ( }); }), ); + + // A tile alert never goes through the /alerts router, so this is the only + // place a dashboard-tile alert can complete the onboarding task. + if (result.length > 0) { + recordOnboardingTaskCompletion(userId, 'alert'); + } + + return result; }; export const deleteDashboardAlerts = async ( diff --git a/packages/api/src/controllers/dashboard.ts b/packages/api/src/controllers/dashboard.ts index e0ee27ff40..72afe5a430 100644 --- a/packages/api/src/controllers/dashboard.ts +++ b/packages/api/src/controllers/dashboard.ts @@ -14,6 +14,7 @@ import { getDashboardAlertsByTile, getTeamDashboardAlertsByDashboardAndTile, } from '@/controllers/alerts'; +import { recordOnboardingTaskCompletion } from '@/controllers/user'; import type { ObjectId } from '@/models'; import type { AlertDocument, IAlert } from '@/models/alert'; import Dashboard from '@/models/dashboard'; @@ -27,6 +28,18 @@ function pickAlertsByTile(tiles: Tile[]) { }, {}); } +// The 'dashboard' onboarding task means the user built something worth charting, +// so it completes only once a dashboard actually has a tile — an empty +// dashboard shell (created, then never filled in) does not count. +function recordDashboardOnboardingIfHasTiles( + userId: ObjectId | undefined, + tiles: { length: number } | null | undefined, +) { + if ((tiles?.length ?? 0) > 0) { + recordOnboardingTaskCompletion(userId, 'dashboard'); + } +} + /** * Rewrite any legacy `chart-1`..`chart-10` tile colors from #2265 in * an already-serialized dashboard JSON to their hue-named equivalents @@ -181,6 +194,8 @@ export async function createDashboard( userId, ); + recordDashboardOnboardingIfHasTiles(userId, newDashboard.tiles); + return newDashboard; } @@ -232,5 +247,7 @@ export async function updateDashboard( ); } + recordDashboardOnboardingIfHasTiles(userId, updatedDashboard.tiles); + return updatedDashboard; } diff --git a/packages/api/src/controllers/user.ts b/packages/api/src/controllers/user.ts index cde81ea9d8..6968ef6043 100644 --- a/packages/api/src/controllers/user.ts +++ b/packages/api/src/controllers/user.ts @@ -1,9 +1,11 @@ +import type { OnboardingTaskId } from '@hyperdx/common-utils/dist/types'; import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; import type { ObjectId } from '@/models'; import Alert from '@/models/alert'; import User from '@/models/user'; +import logger from '@/utils/logger'; export function findUserByAccessKey(accessKey: string) { return User.findOne({ accessKey }); } @@ -32,6 +34,60 @@ export function findUsersByTeam(team: string | ObjectId) { return User.find({ team }).sort({ createdAt: 1 }); } +// Idempotent: $addToSet means completing an already-completed task is a no-op, +// so the frontend can fire optimistically without guarding against duplicates. +// taskId is typed OnboardingTaskId so call sites can't pass an unknown key. +export function completeOnboardingTask( + userId: string | ObjectId, + taskId: OnboardingTaskId, +) { + return User.findByIdAndUpdate( + userId, + { $addToSet: { 'onboardingData.completedTasks': taskId } }, + { new: true }, + ); +} + +export function setOnboardingDismissed( + userId: string | ObjectId, + isDismissed: boolean, +) { + return User.findByIdAndUpdate( + userId, + { $set: { 'onboardingData.isDismissed': isDismissed } }, + { new: true }, + ); +} + +// Fire-and-forget wrapper for recording a product-usage task from an unrelated +// write path (creating an alert, saving a dashboard, an MCP tool call). +// Onboarding bookkeeping must never fail or delay the operation that triggered +// it, so errors are swallowed after logging. No-op when userId is absent (e.g. +// a tile alert upserted without an owning user). +// +// Unlike completeOnboardingTask (which the /me/onboarding/task route calls and +// whose returned doc seeds the client cache), this path ignores the result, so +// it guards on $ne to skip the DB write entirely once the task is recorded. +// These call sites fire on every save / every MCP tool call, so skipping the +// redundant $addToSet avoids write amplification on hot paths. +export function recordOnboardingTaskCompletion( + userId: string | ObjectId | undefined | null, + taskId: OnboardingTaskId, +) { + if (userId == null) { + return; + } + void User.updateOne( + { _id: userId, 'onboardingData.completedTasks': { $ne: taskId } }, + { $addToSet: { 'onboardingData.completedTasks': taskId } }, + ).catch(err => { + logger.warn( + { error: err, userId: userId.toString(), taskId }, + 'Failed to record onboarding task completion', + ); + }); +} + export async function deleteTeamMember( teamId: string | ObjectId, userIdToDelete: string, diff --git a/packages/api/src/mcp/tools/alerts/saveAlert.ts b/packages/api/src/mcp/tools/alerts/saveAlert.ts index 3434b57d61..452fe146b7 100644 --- a/packages/api/src/mcp/tools/alerts/saveAlert.ts +++ b/packages/api/src/mcp/tools/alerts/saveAlert.ts @@ -97,7 +97,12 @@ export function registerSaveAlert({ // ── Update existing alert ── if (alertId) { - const updated = await updateAlert(alertId, mongoTeamId, alertInput); + const updated = await updateAlert( + alertId, + mongoTeamId, + alertInput, + mongoUserId, + ); if (!updated) { return mcpUserError('Alert not found'); } diff --git a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts index fecd1922fa..69e75eac05 100644 --- a/packages/api/src/mcp/tools/dashboards/patchDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/patchDashboard.ts @@ -1,6 +1,7 @@ import { uniq } from 'lodash'; import * as config from '@/config'; +import { recordOnboardingTaskCompletion } from '@/controllers/user'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { mcpUserError } from '@/mcp/utils/errors'; import Dashboard from '@/models/dashboard'; @@ -25,7 +26,7 @@ export function registerPatchDashboard({ context, registerTool, }: ToolRegistrar): void { - const { teamId } = context; + const { teamId, userId } = context; const frontendUrl = config.FRONTEND_URL; registerTool( @@ -232,6 +233,10 @@ export function registerPatchDashboard({ }); } + if (updatedDashboard.tiles.length > 0) { + recordOnboardingTaskCompletion(userId, 'dashboard'); + } + // Return a lightweight response: the patched tile (if any) plus // updated dashboard metadata, without the full tile array. const output: Record = { diff --git a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts index 3425b27ecb..fed0e0c665 100644 --- a/packages/api/src/mcp/tools/dashboards/saveDashboard.ts +++ b/packages/api/src/mcp/tools/dashboards/saveDashboard.ts @@ -4,6 +4,7 @@ import mongoose from 'mongoose'; import { z } from 'zod'; import * as config from '@/config'; +import { recordOnboardingTaskCompletion } from '@/controllers/user'; import type { ToolRegistrar } from '@/mcp/tools/types'; import { formatZodIssues, mcpUserError } from '@/mcp/utils/errors'; import Dashboard, { IDashboard } from '@/models/dashboard'; @@ -42,7 +43,7 @@ export function registerSaveDashboard({ context, registerTool, }: ToolRegistrar): void { - const { teamId } = context; + const { teamId, userId } = context; const frontendUrl = config.FRONTEND_URL; registerTool( @@ -84,6 +85,7 @@ export function registerSaveDashboard({ if (!dashboardId) { return createDashboard({ teamId, + userId, frontendUrl, name, inputTiles, @@ -94,6 +96,7 @@ export function registerSaveDashboard({ } return updateDashboard({ teamId, + userId, frontendUrl, dashboardId, name, @@ -146,6 +149,7 @@ function assignFilterIds( async function createDashboard({ teamId, + userId, frontendUrl, name, inputTiles, @@ -154,6 +158,7 @@ async function createDashboard({ inputFilters, }: { teamId: string; + userId: string | undefined; frontendUrl: string | undefined; name: string; inputTiles: unknown[]; @@ -217,6 +222,10 @@ async function createDashboard({ ...(parsedContainers !== undefined ? { containers: parsedContainers } : {}), }).save(); + if (newDashboard.tiles.length > 0) { + recordOnboardingTaskCompletion(userId, 'dashboard'); + } + const externalDashboard = convertToExternalDashboard(newDashboard); return { content: [ @@ -246,6 +255,7 @@ async function createDashboard({ async function updateDashboard({ teamId, + userId, frontendUrl, dashboardId, name, @@ -255,6 +265,7 @@ async function updateDashboard({ inputFilters, }: { teamId: string; + userId: string | undefined; frontendUrl: string | undefined; dashboardId: string; name: string; @@ -380,6 +391,10 @@ async function updateDashboard({ existingTileIds, }); + if (updatedDashboard.tiles.length > 0) { + recordOnboardingTaskCompletion(userId, 'dashboard'); + } + const externalDashboard = convertToExternalDashboard(updatedDashboard); return { content: [ diff --git a/packages/api/src/mcp/utils/tracing.ts b/packages/api/src/mcp/utils/tracing.ts index 94ae190a4c..6954f71925 100644 --- a/packages/api/src/mcp/utils/tracing.ts +++ b/packages/api/src/mcp/utils/tracing.ts @@ -1,5 +1,6 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { recordOnboardingTaskCompletion } from '@/controllers/user'; import type { McpContext, ToolResult } from '@/mcp/tools/types'; import type { McpErrorCategory, McpErrorResult } from '@/mcp/utils/errors'; import { getErrorCategory } from '@/mcp/utils/errors'; @@ -118,6 +119,11 @@ export function withToolTracing( { ...logContext, durationMs }, `MCP tool completed: ${toolName}`, ); + // A successful tool call is the only reliable signal that the user + // exercised the MCP server; there is no read-time artifact to detect + // later. Fire-and-forget: onboarding bookkeeping must never affect + // the tool response, and $addToSet makes repeated calls a no-op. + recordOnboardingTaskCompletion(context.userId, 'mcp'); } span.setAttribute('mcp.tool.duration_ms', durationMs); diff --git a/packages/api/src/models/user.ts b/packages/api/src/models/user.ts index 9725b8d7ad..aa8b60d0a6 100644 --- a/packages/api/src/models/user.ts +++ b/packages/api/src/models/user.ts @@ -1,3 +1,7 @@ +import { + ONBOARDING_TASK_IDS, + type OnboardingData, +} from '@hyperdx/common-utils/dist/types'; // @ts-expect-error don't install the @types for this package, as it conflicts with mongoose import passportLocalMongoose from '@hyperdx/passport-local-mongoose'; import mongoose, { Schema } from 'mongoose'; @@ -11,6 +15,9 @@ export interface IUser { createdAt: Date; email: string; name: string; + // Optional so documents written before this field existed read back cleanly; + // the `me` route fills in defaults for those legacy users. + onboardingData?: OnboardingData; team: ObjectId; } @@ -30,6 +37,20 @@ const UserSchema = new Schema( return uuidv4(); }, }, + onboardingData: { + type: new Schema( + { + completedTasks: { + type: [String], + enum: ONBOARDING_TASK_IDS, + default: [], + }, + isDismissed: { type: Boolean, default: false }, + }, + { _id: false }, + ), + default: () => ({ completedTasks: [], isDismissed: false }), + }, }, { timestamps: true, diff --git a/packages/api/src/routers/api/__tests__/me.int.test.ts b/packages/api/src/routers/api/__tests__/me.int.test.ts index b2ad3fa8a1..92c85b83a3 100644 --- a/packages/api/src/routers/api/__tests__/me.int.test.ts +++ b/packages/api/src/routers/api/__tests__/me.int.test.ts @@ -1,5 +1,15 @@ -import { getAgent, getLoggedInAgent, getServer } from '@/fixtures'; +import { AlertThresholdType } from '@hyperdx/common-utils/dist/types'; + +import { + getAgent, + getLoggedInAgent, + getServer, + makeAlertInput, + makeTile, + randomMongoId, +} from '@/fixtures'; import User from '@/models/user'; +import Webhook, { WebhookService } from '@/models/webhook'; describe('me router', () => { const server = getServer(); @@ -28,11 +38,247 @@ describe('me router', () => { expect(resp.body.team.id).toEqual(team._id.toString()); }); + it('defaults onboardingData for users created before the field existed', async () => { + const { agent, user } = await getLoggedInAgent(server); + // Simulate a legacy document with no onboardingData subdocument. + await User.updateOne( + { _id: user._id }, + { $unset: { onboardingData: '' } }, + ); + + const resp = await agent.get('/me').expect(200); + + expect(resp.body.onboardingData).toEqual({ + completedTasks: [], + isDismissed: false, + }); + }); + it('rejects an unauthenticated request', async () => { await getAgent(server).get('/me').expect(401); }); }); + describe('POST /me/onboarding/task', () => { + it('rejects an unauthenticated request', async () => { + await getAgent(server) + .post('/me/onboarding/task') + .send({ taskId: 'mcp' }) + .expect(401); + }); + + it('rejects an unknown task id', async () => { + const { agent } = await getLoggedInAgent(server); + await agent + .post('/me/onboarding/task') + .send({ taskId: 'not-a-real-task' }) + .expect(400); + }); + + it('records a task and is idempotent', async () => { + const { agent, user } = await getLoggedInAgent(server); + + const first = await agent + .post('/me/onboarding/task') + .send({ taskId: 'dashboard' }) + .expect(200); + expect(first.body.onboardingData.completedTasks).toEqual(['dashboard']); + + // Completing the same task again does not duplicate it. + const second = await agent + .post('/me/onboarding/task') + .send({ taskId: 'dashboard' }) + .expect(200); + expect(second.body.onboardingData.completedTasks).toEqual(['dashboard']); + + expect( + (await User.findById(user._id))?.onboardingData?.completedTasks, + ).toEqual(['dashboard']); + }); + }); + + // The 'alert' and 'dashboard' tasks are recorded server-side in the + // controllers so every write surface counts (UI, external API, MCP, and + // dashboard-tile alerts, which never hit the /alerts router). These verify + // the recording end-to-end through GET /me. + describe('onboarding task recording via product actions', () => { + it('records the dashboard task when a dashboard with a tile is created', async () => { + const { agent } = await getLoggedInAgent(server); + + await agent + .post('/dashboards') + .send({ name: 'Dash', tiles: [makeTile()], tags: [] }) + .expect(200); + + const resp = await agent.get('/me').expect(200); + expect(resp.body.onboardingData.completedTasks).toContain('dashboard'); + expect(resp.body.onboardingData.completedTasks).not.toContain('alert'); + }); + + it('does NOT record the dashboard task for an empty (tileless) dashboard', async () => { + const { agent } = await getLoggedInAgent(server); + + const created = await agent + .post('/dashboards') + .send({ name: 'Empty', tiles: [], tags: [] }) + .expect(200); + + let resp = await agent.get('/me').expect(200); + expect(resp.body.onboardingData.completedTasks).not.toContain( + 'dashboard', + ); + + // Adding a tile via update then completes it — the task means "built a + // chart", not "created a shell". + await agent + .patch(`/dashboards/${created.body.id}`) + .send({ ...created.body, tiles: [makeTile()] }) + .expect(200); + + resp = await agent.get('/me').expect(200); + expect(resp.body.onboardingData.completedTasks).toContain('dashboard'); + }); + + it('records the alert task when a saved-search alert is created', async () => { + const { agent, team } = await getLoggedInAgent(server); + const webhook = await Webhook.create({ + name: 'Test Webhook', + service: WebhookService.Slack, + url: 'https://hooks.slack.com/test', + team: team._id, + }); + const dashboard = await agent + .post('/dashboards') + .send({ name: 'Dash', tiles: [makeTile()], tags: [] }) + .expect(200); + + await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + const resp = await agent.get('/me').expect(200); + expect(resp.body.onboardingData.completedTasks).toContain('alert'); + }); + + it('records the alert task when an existing alert is edited (PUT /alerts/:id)', async () => { + const { agent, team, user } = await getLoggedInAgent(server); + const webhook = await Webhook.create({ + name: 'Test Webhook', + service: WebhookService.Slack, + url: 'https://hooks.slack.com/test', + team: team._id, + }); + const dashboard = await agent + .post('/dashboards') + .send({ name: 'Dash', tiles: [makeTile()], tags: [] }) + .expect(200); + + const created = await agent + .post('/alerts') + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + }), + ) + .expect(200); + + // Clear the task recorded by create so the update is the only thing that + // could re-record it. + await User.updateOne( + { _id: user._id }, + { $set: { 'onboardingData.completedTasks': [] } }, + ); + + await agent + .put(`/alerts/${created.body.data._id}`) + .send( + makeAlertInput({ + dashboardId: dashboard.body.id, + tileId: dashboard.body.tiles[0].id, + webhookId: webhook._id.toString(), + threshold: 42, + }), + ) + .expect(200); + + const resp = await agent.get('/me').expect(200); + expect(resp.body.onboardingData.completedTasks).toContain('alert'); + }); + + it('records the alert task for a dashboard-tile alert (never hits /alerts)', async () => { + const { agent, team } = await getLoggedInAgent(server); + const webhook = await Webhook.create({ + name: 'Test Webhook', + service: WebhookService.Slack, + url: 'https://hooks.slack.com/test', + team: team._id, + }); + const tileId = randomMongoId(); + const tile = makeTile({ + id: tileId, + alert: { + interval: '15m', + threshold: 8, + thresholdType: AlertThresholdType.ABOVE, + channel: { type: 'webhook', webhookId: webhook._id.toString() }, + }, + }); + + await agent + .post('/dashboards') + .send({ name: 'Dash', tiles: [tile], tags: [] }) + .expect(200); + + const resp = await agent.get('/me').expect(200); + // Saving the dashboard alone completes 'dashboard'; the inline tile alert + // completes 'alert' via createOrUpdateDashboardAlerts. + expect(resp.body.onboardingData.completedTasks).toEqual( + expect.arrayContaining(['dashboard', 'alert']), + ); + }); + }); + + describe('PATCH /me/onboarding/dismiss', () => { + it('rejects an unauthenticated request', async () => { + await getAgent(server) + .patch('/me/onboarding/dismiss') + .send({ isDismissed: true }) + .expect(401); + }); + + it('persists the dismissed flag', async () => { + const { agent, user } = await getLoggedInAgent(server); + + const resp = await agent + .patch('/me/onboarding/dismiss') + .send({ isDismissed: true }) + .expect(200); + expect(resp.body.onboardingData.isDismissed).toBe(true); + + expect((await User.findById(user._id))?.onboardingData?.isDismissed).toBe( + true, + ); + + // And it can be un-dismissed. + await agent + .patch('/me/onboarding/dismiss') + .send({ isDismissed: false }) + .expect(200); + expect((await User.findById(user._id))?.onboardingData?.isDismissed).toBe( + false, + ); + }); + }); + describe('PATCH /me/accessKey', () => { it('rejects an unauthenticated request', async () => { // The new verb is covered by the mount-time isUserAuthenticated in diff --git a/packages/api/src/routers/api/alerts.ts b/packages/api/src/routers/api/alerts.ts index 386e3d3417..b47e5e2393 100644 --- a/packages/api/src/routers/api/alerts.ts +++ b/packages/api/src/routers/api/alerts.ts @@ -350,7 +350,7 @@ router.put( const { id } = req.params; const alertInput = req.body; await validateAlertInput(teamId, alertInput); - const alert = await updateAlert(id, teamId, alertInput); + const alert = await updateAlert(id, teamId, alertInput, req.user?._id); if (alert == null) { return res.sendStatus(404); } diff --git a/packages/api/src/routers/api/me.ts b/packages/api/src/routers/api/me.ts index 3586a6f6ac..343f86af1a 100644 --- a/packages/api/src/routers/api/me.ts +++ b/packages/api/src/routers/api/me.ts @@ -1,12 +1,23 @@ import type { MeApiResponse, + OnboardingDataApiResponse, RotateAccessKeyApiResponse, } from '@hyperdx/common-utils/dist/types'; +import { + CompleteOnboardingTaskApiBodySchema, + DismissOnboardingApiBodySchema, + OnboardingDataSchema, +} from '@hyperdx/common-utils/dist/types'; import express from 'express'; +import { validateRequest } from 'zod-express-middleware'; import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config'; import { getTeam } from '@/controllers/team'; -import { rotateUserAccessKey } from '@/controllers/user'; +import { + completeOnboardingTask, + rotateUserAccessKey, + setOnboardingDismissed, +} from '@/controllers/user'; import { Api404Error } from '@/utils/errors'; import { sendJson } from '@/utils/serialization'; @@ -24,6 +35,7 @@ router.get('/', async (req, res: express.Response, next) => { createdAt, email, name, + onboardingData, team: teamId, } = req.user; @@ -38,6 +50,9 @@ router.get('/', async (req, res: express.Response, next) => { email, id, name, + // Parse through the schema so users created before onboardingData existed + // (and any partially-written subdocument) read back with defaults. + onboardingData: OnboardingDataSchema.parse(onboardingData ?? {}), team, usageStatsEnabled: USAGE_STATS_ENABLED, aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY), @@ -74,4 +89,49 @@ router.patch('/accessKey', async (req, res: RotateAccessKeyExpRes, next) => { } }); +type OnboardingExpRes = express.Response; + +// Mark a product-usage onboarding task complete for the caller. The user id +// comes from the session, never the request, so a caller can only ever mutate +// their own onboarding state. Idempotent (see completeOnboardingTask). +router.post( + '/onboarding/task', + validateRequest({ body: CompleteOnboardingTaskApiBodySchema }), + async (req, res: OnboardingExpRes, next) => { + try { + const userId = req.user?._id; + if (userId == null) { + throw new Api404Error('Request without user found'); + } + + const user = await completeOnboardingTask(userId, req.body.taskId); + return sendJson(res, { + onboardingData: OnboardingDataSchema.parse(user?.onboardingData ?? {}), + }); + } catch (e) { + next(e); + } + }, +); + +router.patch( + '/onboarding/dismiss', + validateRequest({ body: DismissOnboardingApiBodySchema }), + async (req, res: OnboardingExpRes, next) => { + try { + const userId = req.user?._id; + if (userId == null) { + throw new Api404Error('Request without user found'); + } + + const user = await setOnboardingDismissed(userId, req.body.isDismissed); + return sendJson(res, { + onboardingData: OnboardingDataSchema.parse(user?.onboardingData ?? {}), + }); + } catch (e) { + next(e); + } + }, +); + export default router; diff --git a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts index bdc6b089f9..b7955d9295 100644 --- a/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts +++ b/packages/api/src/routers/external-api/__tests__/dashboards.int.test.ts @@ -1,5 +1,9 @@ import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; -import { MetricsDataType, SourceKind } from '@hyperdx/common-utils/dist/types'; +import { + MetricsDataType, + OnboardingTaskId, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; import { omit } from 'lodash'; import { ObjectId } from 'mongodb'; import request from 'supertest'; @@ -17,6 +21,7 @@ import Alert, { AlertSource, AlertThresholdType } from '@/models/alert'; import Connection from '@/models/connection'; import Dashboard from '@/models/dashboard'; import { Source } from '@/models/source'; +import User from '@/models/user'; import Webhook, { WebhookService } from '@/models/webhook'; import { ExternalDashboardTile, @@ -217,6 +222,43 @@ describe('External API v2 Dashboards - old format', () => { return agent[method](url).set('Authorization', `Bearer ${user?.accessKey}`); }; + describe('onboarding task recording', () => { + const completedTasks = async () => + (await User.findById(user._id))?.onboardingData?.completedTasks ?? []; + + // Recording is fire-and-forget (not awaited by the handler), so poll + // briefly rather than reading once immediately after the response. + const waitForTask = async (task: OnboardingTaskId) => { + for (let i = 0; i < 20; i++) { + if ((await completedTasks()).includes(task)) return true; + await new Promise(r => setTimeout(r, 25)); + } + return false; + }; + + it('records the dashboard task when a v2 dashboard with a tile is created', async () => { + await authRequest('post', BASE_URL) + .send({ + name: 'With tile', + tiles: [createTimeSeriesChart(traceSource._id.toString())], + tags: [], + }) + .expect(200); + + expect(await waitForTask('dashboard')).toBe(true); + }); + + it('does not record the dashboard task for a tileless v2 dashboard', async () => { + await authRequest('post', BASE_URL) + .send({ name: 'Empty', tiles: [], tags: [] }) + .expect(200); + + // Give any stray write a chance to land, then assert it did not. + await new Promise(r => setTimeout(r, 200)); + expect(await completedTasks()).not.toContain('dashboard'); + }); + }); + describe('Response Format', () => { it('should return responses in the expected (new) format when creating the dashboard in the old format', async () => { // Create a dashboard with known values for testing diff --git a/packages/api/src/routers/external-api/v2/alerts.ts b/packages/api/src/routers/external-api/v2/alerts.ts index 693d21d64b..f497ecf3f1 100644 --- a/packages/api/src/routers/external-api/v2/alerts.ts +++ b/packages/api/src/routers/external-api/v2/alerts.ts @@ -692,7 +692,7 @@ router.put( const alertInput = req.body; await validateAlertInput(teamId, alertInput); - const alert = await updateAlert(id, teamId, alertInput); + const alert = await updateAlert(id, teamId, alertInput, req.user?._id); if (alert == null) { return res.status(404).json({ message: 'Alert not found' }); diff --git a/packages/api/src/routers/external-api/v2/dashboards.ts b/packages/api/src/routers/external-api/v2/dashboards.ts index 898cb7b933..a038d73653 100644 --- a/packages/api/src/routers/external-api/v2/dashboards.ts +++ b/packages/api/src/routers/external-api/v2/dashboards.ts @@ -3,6 +3,7 @@ import { uniq } from 'lodash'; import { z } from 'zod'; import { deleteDashboard } from '@/controllers/dashboard'; +import { recordOnboardingTaskCompletion } from '@/controllers/user'; import Dashboard, { IDashboard } from '@/models/dashboard'; import { processRequestWithEnhancedErrors as validateRequest } from '@/utils/enhancedErrors'; import { ExternalDashboardTileWithId, objectIdSchema } from '@/utils/zod'; @@ -2644,6 +2645,13 @@ router.post( ...(containers !== undefined ? { containers } : {}), }).save(); + // Complete the 'dashboard' onboarding task only when the dashboard has a + // tile (matches the internal controllers; an empty dashboard shell does + // not count). Fire-and-forget, so it never affects the response. + if (newDashboard.tiles.length > 0) { + recordOnboardingTaskCompletion(req.user?._id, 'dashboard'); + } + res.json({ data: convertToExternalDashboard(newDashboard), }); @@ -2910,6 +2918,10 @@ router.put( existingTileIds, }); + if (updatedDashboard.tiles.length > 0) { + recordOnboardingTaskCompletion(req.user?._id, 'dashboard'); + } + res.json({ data: convertToExternalDashboard(updatedDashboard), }); diff --git a/packages/app/package.json b/packages/app/package.json index 8900ff4c55..5baa7417c2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -14,7 +14,7 @@ "build:clickhouse": "NEXT_PUBLIC_THEME=clickstack NEXT_PUBLIC_IS_LOCAL_MODE=true NEXT_PUBLIC_CLICKHOUSE_BUILD=true next build --webpack && node scripts/prepare-clickhouse-build-export.js", "run:clickhouse": "test -d out && npx rimraf tmp && mkdir tmp && cp -r out tmp/clickstack && echo 'visit http://localhost:3000/clickstack to start' && npx serve tmp -l 3000 || echo 'run build:clickhouse first'", "start": "next start", - "lint": "npx eslint . --ext .ts,.tsx --max-warnings 564", + "lint": "npx eslint . --ext .ts,.tsx --max-warnings 568", "lint:fix": "npx eslint . --ext .ts,.tsx --fix", "lint:styles": "stylelint **/*/*.{css,scss}", "ci:lint": "yarn lint && yarn tsc --noEmit && yarn lint:styles --quiet", diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 7c818cbaba..d235ef6e29 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -84,6 +84,7 @@ import { keepPreviousData, useIsFetching } from '@tanstack/react-query'; import { SortingState } from '@tanstack/react-table'; import CodeMirror from '@uiw/react-codemirror'; +import api, { useCompleteOnboardingTask } from '@/api'; import { ActiveFilterPills } from '@/components/ActiveFilterPills'; import { AlertStatusIcon } from '@/components/AlertStatusIcon'; import { ContactSupportText } from '@/components/ContactSupportText'; @@ -1251,6 +1252,11 @@ export function DBSearchPage() { [key: string]: Error | ClickHouseQueryError; }>({}); + const completeOnboardingTask = useCompleteOnboardingTask(); + const { data: me } = api.useMe(); + const hasExploredData = + me?.onboardingData?.completedTasks.includes('advancedQuery') ?? false; + useEffect(() => { if (!isBrowser || !IS_LOCAL_MODE) return; const nullQueryErrors = (event: StorageEvent) => { @@ -1277,6 +1283,18 @@ export function DBSearchPage() { filters, orderBy, }); + // "Explored data" completes on any non-trivial user-run search: + // a non-empty where clause in either language (the search page + // defaults to Lucene, so requiring SQL made this practically + // unreachable), or any applied filter. A blank default search does + // not count. The task is a one-time milestone but this runs on every + // search, so skip the request once it's already recorded — otherwise + // every subsequent qualifying search fires a redundant (idempotent) POST. + const hasWhere = where.trim() !== ''; + const hasFilters = (filters ?? []).length > 0; + if (!IS_LOCAL_MODE && !hasExploredData && (hasWhere || hasFilters)) { + completeOnboardingTask.mutate('advancedQuery'); + } }, )(); setPatternColumn(draftPatternColumn || null); @@ -1290,6 +1308,8 @@ export function DBSearchPage() { setQueryErrors, draftPatternColumn, setPatternColumn, + completeOnboardingTask, + hasExploredData, ]); const debouncedSubmit = useDebouncedCallback(onSubmit, 1000); diff --git a/packages/app/src/OnboardingChecklist.tsx b/packages/app/src/OnboardingChecklist.tsx deleted file mode 100644 index 9250987dd1..0000000000 --- a/packages/app/src/OnboardingChecklist.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import React, { useMemo } from 'react'; -import Link from 'next/link'; -import { - ActionIcon, - Badge, - Card, - Collapse, - Group, - Loader, - Stack, - Text, - UnstyledButton, -} from '@mantine/core'; -import { - IconArrowRight, - IconCheck, - IconChevronDown, - IconChevronUp, -} from '@tabler/icons-react'; - -import { useQueriedChartConfig } from './hooks/useChartConfig'; -import api from './api'; -import { NOW } from './config'; -import { useConnections } from './connection'; -import { useSources } from './source'; -import { useLocalStorage } from './utils'; - -interface OnboardingStep { - id: string; - title: string; - description: string; - isComplete: boolean; - isLoading?: boolean; - href?: string; - onClick?: () => void; -} -const OnboardingChecklist = ({ - onAddDataClick, -}: { - onAddDataClick?: () => void; -}) => { - const [isCollapsed, setIsCollapsed] = useLocalStorage( - 'onboardingChecklistCollapsed', - false, - ); - - const { data: team, isLoading: isTeamLoading } = api.useTeam(); - const { data: connections, isLoading: isConnectionsLoading } = - useConnections(); - const { data: sources, isLoading: isSourcesLoading } = useSources(); - - // Check if team is new (less than 3 days old) - const isNewTeam = useMemo(() => { - if (!team?.createdAt) return false; - const threeDaysAgo = new Date(NOW - 1000 * 60 * 60 * 24 * 3); - return new Date(team.createdAt) > threeDaysAgo; - }, [team]); - - const shouldShow = useMemo( - () => isTeamLoading === false && isNewTeam, - [isTeamLoading, isNewTeam], - ); - - const firstConnection = useMemo(() => connections?.[0], [connections]); - const firstConnectionSources = useMemo( - () => sources?.filter(source => source.connection === firstConnection?.id), - [sources, firstConnection], - ); - - const sourceRowsConfig = useMemo( - () => ({ - select: 'sum(total_rows) as total_rows', - from: { - databaseName: 'system', - tableName: 'tables', - }, - where: '', - filtersLogicalOperator: 'OR' as const, - filters: (firstConnectionSources ?? []).map(source => ({ - type: 'sql' as const, - condition: `table = '${source.from.tableName}' AND database = '${source.from.databaseName}'`, - })), - connection: firstConnection?.id ?? '', - }), - [firstConnectionSources, firstConnection], - ); - const { data: sourceRowsData, isLoading: isSourceRowsLoading } = - useQueriedChartConfig(sourceRowsConfig, { - // Skip the chart query when there's no connection to query against. - // Without this guard, the query fires with `connection: ''` (see - // sourceRowsConfig above), which sends a clickhouse-proxy request - // with no `x-hyperdx-connection-id` header and fails Zod validation - // on the API. This blocks brand-new teams (< 3 days old) from using - // the team settings page until they manually add a connection. - enabled: shouldShow && !!firstConnection?.id, - }); - const hasData = sourceRowsData?.data?.[0]?.total_rows > 0; - // const hasData = false; - - // Check if connections exist - const hasConnections = useMemo(() => { - return connections && connections.length > 0; - }, [connections]); - // const hasConnections = false; - // const hasSources = false; - - // Check if sources exist - const hasSources = useMemo(() => { - return sources && sources.length > 0; - }, [sources]); - - const steps: OnboardingStep[] = useMemo( - () => [ - { - id: 'connection', - title: 'Connect to ClickHouse', - description: 'Set up your database connection', - isComplete: hasConnections ?? false, - isLoading: isConnectionsLoading, - href: hasConnections ? undefined : '/team', - }, - { - id: 'sources', - title: 'Create Data Sources', - description: 'Configure where your data comes from', - isComplete: hasSources ?? false, - isLoading: isSourcesLoading, - href: hasSources ? undefined : '/team', - }, - { - id: 'data', - title: 'Add Data', - description: 'Start sending logs, metrics, or traces', - isComplete: hasData, - isLoading: isSourceRowsLoading, // We'll implement data checking later - onClick: hasData ? undefined : onAddDataClick, - }, - ], - [ - hasConnections, - hasSources, - hasData, - isConnectionsLoading, - isSourcesLoading, - onAddDataClick, - isSourceRowsLoading, - ], - ); - - const completedSteps = steps.filter(step => step.isComplete).length; - const isAllComplete = completedSteps === steps.length; - - // Don't show if team is not new or still loading - if (!shouldShow) { - return null; - } - - return ( - - - - - Get Started - - - {completedSteps}/{steps.length} - - - setIsCollapsed(!isCollapsed)} - > - {isCollapsed ? ( - - ) : ( - - )} - - - - - - {steps.map((step, index) => { - const StepContent = ( - -
- {step.isLoading ? ( - - ) : step.isComplete ? ( - - ) : ( - - {index + 1} - - )} -
- -
- - {step.title} - - - {step.description} - -
- - {!step.isComplete && (step.href || step.onClick) && ( - - )} -
- ); - - if (step.href && !step.isComplete) { - return ( - - - {StepContent} - - - ); - } - - if (step.onClick && !step.isComplete) { - return ( - - {StepContent} - - ); - } - - return ( -
- {StepContent} -
- ); - })} - - {isAllComplete && ( - - - 🎉 Great job! You're all set up. - - - )} -
-
-
- ); -}; - -export default OnboardingChecklist; diff --git a/packages/app/src/OnboardingChecklist/OnboardingChecklist.tsx b/packages/app/src/OnboardingChecklist/OnboardingChecklist.tsx new file mode 100644 index 0000000000..0f5ae349ce --- /dev/null +++ b/packages/app/src/OnboardingChecklist/OnboardingChecklist.tsx @@ -0,0 +1,109 @@ +import { + ActionIcon, + Anchor, + Card, + Collapse, + Divider, + Group, + Stack, + Text, +} from '@mantine/core'; +import { IconChevronDown, IconChevronUp } from '@tabler/icons-react'; + +import { useLocalStorage } from '@/utils'; + +import { StepRow } from './StepRow'; +import { useOnboardingCompletion } from './useOnboardingCompletion'; + +const OnboardingChecklist = ({ + onAddDataClick, +}: { + onAddDataClick?: () => void; +}) => { + const [isCollapsed, setIsCollapsed] = useLocalStorage( + 'onboardingChecklistCollapsed', + false, + ); + + const { + steps, + phaseLabel, + completedCount, + activeStepId, + isCelebrating, + shouldShow, + dismiss, + isDismissing, + } = useOnboardingCompletion(onAddDataClick); + + if (!shouldShow) { + return null; + } + + return ( + + + + {phaseLabel} + + + + {completedCount}/{steps.length} + + setIsCollapsed(!isCollapsed)} + > + {isCollapsed ? ( + + ) : ( + + )} + + + + + + + {steps.map(step => ( + + ))} + + {isCelebrating && ( + + 🎉 You're all set! + + )} + + + + + dismiss()} + disabled={isDismissing} + > + Dismiss and don't show again + + + + ); +}; + +export default OnboardingChecklist; diff --git a/packages/app/src/OnboardingChecklist/StepRow.tsx b/packages/app/src/OnboardingChecklist/StepRow.tsx new file mode 100644 index 0000000000..4cf794835a --- /dev/null +++ b/packages/app/src/OnboardingChecklist/StepRow.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import Link from 'next/link'; +import { + Group, + Loader, + Paper, + Text, + ThemeIcon, + Tooltip, + UnstyledButton, +} from '@mantine/core'; +import { IconCheck, IconChevronRight } from '@tabler/icons-react'; + +import { OnboardingStep } from './onboardingTasks'; + +export function StepRow({ + step, + isActive, +}: { + step: OnboardingStep; + isActive: boolean; +}) { + const isActionable = + !step.isComplete && (step.href != null || step.onClick != null); + + const circle = step.isComplete ? ( + + + + ) : step.isLoading ? ( + + ) : ( + + ); + + const stepContent = ( + + {circle} + + {step.title} + + {isActionable && ( + + )} + + ); + + // The active (next) step is elevated onto a surface card; every other row is + // flush against the muted card background. + const rowBody = isActive ? ( + + {stepContent} + + ) : ( + + {stepContent} + + ); + + // The row shows only the title; the description (what to do) surfaces on hover + // for a task that isn't done yet — a completed, struck-through task needs no + // instructions. + const row = + !step.isComplete && step.description ? ( + + {rowBody} + + ) : ( + rowBody + ); + + if (step.href && !step.isComplete) { + return ( + + {row} + + ); + } + + if (step.onClick && !step.isComplete) { + return ( + + {row} + + ); + } + + return {row}; +} diff --git a/packages/app/src/OnboardingChecklist/index.ts b/packages/app/src/OnboardingChecklist/index.ts new file mode 100644 index 0000000000..f1b0c39238 --- /dev/null +++ b/packages/app/src/OnboardingChecklist/index.ts @@ -0,0 +1 @@ +export { default } from './OnboardingChecklist'; diff --git a/packages/app/src/OnboardingChecklist/onboardingTasks.ts b/packages/app/src/OnboardingChecklist/onboardingTasks.ts new file mode 100644 index 0000000000..4f29a233d6 --- /dev/null +++ b/packages/app/src/OnboardingChecklist/onboardingTasks.ts @@ -0,0 +1,59 @@ +import type { OnboardingTaskId } from '@hyperdx/common-utils/dist/types'; +import { ONBOARDING_TASK_IDS } from '@hyperdx/common-utils/dist/types'; + +export interface OnboardingStep { + id: string; + title: string; + description: string; + isComplete: boolean; + isLoading?: boolean; + href?: string; + onClick?: () => void; +} + +// Presentation for each product-usage task. Typed as an exhaustive +// Record: adding a new id to ONBOARDING_TASK_IDS in +// common-utils makes this object a compile error until copy + a link are +// provided, which is the type-safety guarantee the feature is built around. +export const PRODUCT_TASKS: Record< + OnboardingTaskId, + { title: string; description: string; href: string } +> = { + advancedQuery: { + title: 'Explore your data', + description: 'Run a search with a filter or query condition', + href: '/search', + }, + dashboard: { + title: 'Build a dashboard', + description: 'Add a chart tile to a dashboard', + href: '/dashboards', + }, + alert: { + title: 'Set up an alert', + description: 'Get notified when something looks off', + href: '/alerts', + }, + mcp: { + title: 'Connect the MCP server', + description: 'Query your data from an AI agent', + // The MCP setup lives on the "API & Agents" tab of team settings. + href: '/team', + }, +}; + +// UI render order for the product-usage phase, decoupled from the enum order in +// common-utils. Typed as an exhaustive Record so a new +// id added to ONBOARDING_TASK_IDS is a compile error here until it's given a +// weight — and because the order is derived by sorting ONBOARDING_TASK_IDS +// (the SSOT), that new id always renders and can't be silently untracked. +const PRODUCT_TASK_ORDER_WEIGHT: Record = { + advancedQuery: 0, + dashboard: 1, + alert: 2, + mcp: 3, +}; + +export const PRODUCT_TASK_ORDER: OnboardingTaskId[] = [ + ...ONBOARDING_TASK_IDS, +].sort((a, b) => PRODUCT_TASK_ORDER_WEIGHT[a] - PRODUCT_TASK_ORDER_WEIGHT[b]); diff --git a/packages/app/src/OnboardingChecklist/useOnboardingCompletion.ts b/packages/app/src/OnboardingChecklist/useOnboardingCompletion.ts new file mode 100644 index 0000000000..c0f32650e7 --- /dev/null +++ b/packages/app/src/OnboardingChecklist/useOnboardingCompletion.ts @@ -0,0 +1,224 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; + +import api from '@/api'; +import { useConnections } from '@/connection'; +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { useSources } from '@/source'; + +import { + OnboardingStep, + PRODUCT_TASK_ORDER, + PRODUCT_TASKS, +} from './onboardingTasks'; + +interface OnboardingCompletion { + steps: OnboardingStep[]; + phaseLabel: string; + completedCount: number; + isPhaseComplete: boolean; + activeStepId?: string; + isCelebrating: boolean; + shouldShow: boolean; + dismiss: () => void; + isDismissing: boolean; +} + +export function useOnboardingCompletion( + onAddDataClick?: () => void, +): OnboardingCompletion { + const { data: me, isLoading: isMeLoading } = api.useMe(); + const { data: connections, isLoading: isConnectionsLoading } = + useConnections(); + const { data: sources, isLoading: isSourcesLoading } = useSources(); + const dismissOnboarding = api.useDismissOnboarding(); + + const onboardingData = me?.onboardingData; + const completedTasks = useMemo( + () => new Set(onboardingData?.completedTasks ?? []), + [onboardingData], + ); + + const hasConnections = (connections?.length ?? 0) > 0; + const hasSources = (sources?.length ?? 0) > 0; + + const firstConnection = connections?.[0]; + const firstConnectionSources = useMemo( + () => sources?.filter(source => source.connection === firstConnection?.id), + [sources, firstConnection], + ); + + const sourceRowsConfig = useMemo( + () => ({ + select: 'sum(total_rows) as total_rows', + from: { + databaseName: 'system', + tableName: 'tables', + }, + where: '', + filtersLogicalOperator: 'OR' as const, + filters: (firstConnectionSources ?? []).map(source => ({ + type: 'sql' as const, + condition: `table = '${source.from.tableName}' AND database = '${source.from.databaseName}'`, + })), + connection: firstConnection?.id ?? '', + }), + [firstConnectionSources, firstConnection], + ); + const { data: sourceRowsData, isLoading: isSourceRowsLoading } = + useQueriedChartConfig(sourceRowsConfig, { + // Skip the chart query when there's no connection to query against. + // Without this guard it fires with `connection: ''` and fails Zod + // validation on the API's clickhouse-proxy. + enabled: !!firstConnection?.id, + }); + const hasData = sourceRowsData?.data?.[0]?.total_rows > 0; + + // Phase 1: setup steps, detected by reading team state. + const setupSteps: OnboardingStep[] = useMemo( + () => [ + { + id: 'connection', + title: 'Connect to ClickHouse', + description: 'Set up your database connection', + isComplete: hasConnections, + isLoading: isConnectionsLoading, + href: hasConnections ? undefined : '/team', + }, + { + id: 'sources', + title: 'Create data sources', + description: 'Configure where your data comes from', + isComplete: hasSources, + isLoading: isSourcesLoading, + href: hasSources ? undefined : '/team', + }, + { + id: 'data', + title: 'Add data', + description: 'Start sending logs, metrics, or traces', + isComplete: hasData, + isLoading: isSourceRowsLoading, + onClick: hasData ? undefined : onAddDataClick, + }, + ], + [ + hasConnections, + hasSources, + hasData, + isConnectionsLoading, + isSourcesLoading, + isSourceRowsLoading, + onAddDataClick, + ], + ); + + const isSetupComplete = setupSteps.every(step => step.isComplete); + + // Phase 2: product-usage tasks, persisted per user. Only surfaced once the + // setup phase is done — setup first, then getting started using the product. + const productSteps: OnboardingStep[] = useMemo( + () => + PRODUCT_TASK_ORDER.map(id => ({ + id, + title: PRODUCT_TASKS[id].title, + description: PRODUCT_TASKS[id].description, + href: PRODUCT_TASKS[id].href, + isComplete: completedTasks.has(id), + })), + [completedTasks], + ); + + const steps = isSetupComplete ? productSteps : setupSteps; + const phaseLabel = isSetupComplete + ? 'Get started with HyperDX' + : 'Set up ClickHouse'; + const completedCount = steps.filter(step => step.isComplete).length; + const isPhaseComplete = completedCount === steps.length; + // The first not-yet-complete step is the "active" one — highlighted like a + // call-to-action in the mockup. + const activeStepId = steps.find(step => !step.isComplete)?.id; + + // "Done" is DERIVED from whether every current task is complete — we never + // persist a "completed" flag. This is deliberate: adding or changing a task + // in ONBOARDING_TASK_IDS later leaves a previously-finished user with an + // unmet task, so the checklist reappears on its own. (isDismissed is only for + // the manual X, when a user opts out early.) + const allTasksComplete = isSetupComplete && isPhaseComplete; + + // Every input that feeds `allTasksComplete` must be settled before we trust + // it. If we latched on `me` alone, the setup queries (connections/sources/ + // row-count) could still be loading — making tasks look incomplete for a + // beat, then flipping to complete once they resolve, which reads as an + // "in-session completion" and wrongly shows + celebrates on load. + // The row-count query is disabled until there's a connection to query; a + // disabled query reports isLoading:true forever, so only wait on it when it's + // actually enabled (i.e. a connection exists). + const sourceRowsSettled = !firstConnection?.id || !isSourceRowsLoading; + const inputsReady = + !isMeLoading && + me != null && + onboardingData != null && + !isConnectionsLoading && + !isSourcesLoading && + sourceRowsSettled; + + // Only celebrate for a completion that happens IN THIS SESSION. Latch the + // completion state the first time all inputs are ready: if the user was + // already done on arrival, that's past work — hide, no celebration. If they + // finish while the card is open, hold it up briefly to celebrate. + const [celebrationDone, setCelebrationDone] = useState(false); + const [wasCompleteOnLoad, setWasCompleteOnLoad] = useState( + null, + ); + + // Read the latest completion state inside the latch effect without making it + // a dependency: the effect must run only when `inputsReady` flips, and + // `allTasksComplete` merely seeds the initial value. A ref keeps deps + // exhaustive without re-latching on every completion change. + const allTasksCompleteRef = useRef(allTasksComplete); + useEffect(() => { + allTasksCompleteRef.current = allTasksComplete; + }, [allTasksComplete]); + + useEffect(() => { + if (inputsReady) { + setWasCompleteOnLoad(prev => + prev === null ? allTasksCompleteRef.current : prev, + ); + } + }, [inputsReady]); + + const completedInSession = wasCompleteOnLoad === false && allTasksComplete; + + useEffect(() => { + if (!completedInSession) { + return; + } + const timer = setTimeout(() => setCelebrationDone(true), 4000); + return () => clearTimeout(timer); + }, [completedInSession]); + + const isCelebrating = completedInSession && !celebrationDone; + + // Don't render until inputs are ready and we've latched the load-time state — + // otherwise we'd flash the card during the setup-query load. Then hide when + // dismissed, or once all current tasks are complete (after any in-session + // celebration). + const shouldShow = + inputsReady && + wasCompleteOnLoad !== null && + !onboardingData.isDismissed && + (!allTasksComplete || isCelebrating); + + return { + steps, + phaseLabel, + completedCount, + isPhaseComplete, + activeStepId, + isCelebrating, + shouldShow, + dismiss: () => dismissOnboarding.mutate(true), + isDismissing: dismissOnboarding.isPending, + }; +} diff --git a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx index d3f10d02a5..fddffc60bc 100644 --- a/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx +++ b/packages/app/src/__tests__/DBSearchPage.directTrace.test.tsx @@ -243,6 +243,7 @@ jest.mock('../api', () => ({ isSuccess: true, }), }, + useCompleteOnboardingTask: () => ({ mutate: jest.fn() }), })); jest.mock('@/utils', () => ({ diff --git a/packages/app/src/__tests__/OnboardingChecklist.test.tsx b/packages/app/src/__tests__/OnboardingChecklist.test.tsx new file mode 100644 index 0000000000..e6f6f67da8 --- /dev/null +++ b/packages/app/src/__tests__/OnboardingChecklist.test.tsx @@ -0,0 +1,300 @@ +import { ONBOARDING_TASK_IDS } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import api from '@/api'; +import { useConnections } from '@/connection'; +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import OnboardingChecklist from '@/OnboardingChecklist'; +import { useSources } from '@/source'; + +jest.mock('@/api', () => ({ + __esModule: true, + default: { + useMe: jest.fn(), + useDismissOnboarding: jest.fn(), + }, +})); +jest.mock('@/connection', () => ({ useConnections: jest.fn() })); +jest.mock('@/source', () => ({ useSources: jest.fn() })); +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: jest.fn(), +})); + +const mockUseMe = jest.mocked(api.useMe); +const mockUseDismiss = jest.mocked(api.useDismissOnboarding); +const mockUseConnections = jest.mocked(useConnections); +const mockUseSources = jest.mocked(useSources); +const mockUseQueriedChartConfig = jest.mocked(useQueriedChartConfig); + +const dismissMutate = jest.fn(); + +function setMe( + onboardingData: { completedTasks: string[]; isDismissed: boolean } | null, +) { + mockUseMe.mockReturnValue({ + data: + onboardingData === null + ? null + : { + id: 'u1', + email: 'a@b.com', + accessKey: 'k', + name: 'User', + createdAt: '', + onboardingData, + }, + isLoading: false, + } as unknown as ReturnType); +} + +// setupComplete=false -> no connections/sources/data (setup phase visible). +// loading=true -> the setup queries report isLoading (data still undefined), +// mirroring the real async load where completion isn't yet knowable. +function setSetup(complete: boolean, loading = false) { + const conn = complete ? [{ id: 'c1' }] : []; + const src = complete + ? [ + { + id: 's1', + connection: 'c1', + from: { databaseName: 'd', tableName: 't' }, + }, + ] + : []; + mockUseConnections.mockReturnValue({ + data: loading ? undefined : conn, + isLoading: loading, + } as unknown as ReturnType); + mockUseSources.mockReturnValue({ + data: loading ? undefined : src, + isLoading: loading, + } as unknown as ReturnType); + mockUseQueriedChartConfig.mockReturnValue({ + data: loading ? undefined : { data: [{ total_rows: complete ? 10 : 0 }] }, + isLoading: loading, + } as unknown as ReturnType); +} + +function renderChecklist() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + , + ); +} + +describe('OnboardingChecklist', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseDismiss.mockReturnValue({ + mutate: dismissMutate, + isPending: false, + } as unknown as ReturnType); + }); + + it('renders the setup phase before setup is complete', () => { + setMe({ completedTasks: [], isDismissed: false }); + setSetup(false); + + renderChecklist(); + + expect(screen.getByText('Set up ClickHouse')).toBeInTheDocument(); + expect(screen.getByText('Connect to ClickHouse')).toBeInTheDocument(); + // Product-phase tasks are not shown yet. + expect(screen.queryByText('Build a dashboard')).not.toBeInTheDocument(); + }); + + it('renders the product phase once setup is complete and reflects completed tasks', () => { + setMe({ completedTasks: ['dashboard'], isDismissed: false }); + setSetup(true); + + renderChecklist(); + + expect(screen.getByText('Get started with HyperDX')).toBeInTheDocument(); + expect(screen.getByText('Build a dashboard')).toBeInTheDocument(); + expect(screen.getByText('Explore your data')).toBeInTheDocument(); + // 1 of 4 product tasks complete. + expect(screen.getByText('1/4')).toBeInTheDocument(); + }); + + it('surfaces a task description on hover for a non-completed task', async () => { + setMe({ completedTasks: [], isDismissed: false }); + setSetup(true); + + renderChecklist(); + + // The row shows only the title; hovering reveals the "what to do" + // description via tooltip (it's no longer rendered inline). + expect(screen.queryByText('Add a chart tile to a dashboard')).toBeNull(); + await userEvent.hover(screen.getByText('Build a dashboard')); + expect( + await screen.findByText('Add a chart tile to a dashboard'), + ).toBeInTheDocument(); + }); + + it('is hidden when dismissed', () => { + setMe({ completedTasks: [], isDismissed: true }); + setSetup(true); + + renderChecklist(); + + expect( + screen.queryByText('Get started with HyperDX'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('Set up ClickHouse')).not.toBeInTheDocument(); + }); + + it('hides immediately (no celebration) when already complete on load', () => { + // Completion from a past session is derived, not a persisted flag: the card + // just doesn't render. No dismiss is written. + setMe({ + completedTasks: ['advancedQuery', 'dashboard', 'alert', 'mcp'], + isDismissed: false, + }); + setSetup(true); + + renderChecklist(); + + expect( + screen.queryByText('Get started with HyperDX'), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/You're all set/)).not.toBeInTheDocument(); + expect(dismissMutate).not.toHaveBeenCalled(); + }); + + it('does not celebrate on load when completion resolves after mount (async load)', () => { + jest.useFakeTimers(); + try { + // Mount while the setup queries are still loading, even though the user is + // already fully complete. Nothing should render yet. + setMe({ + completedTasks: ['advancedQuery', 'dashboard', 'alert', 'mcp'], + isDismissed: false, + }); + setSetup(true, /* loading */ true); + const { rerender } = renderChecklist(); + expect( + screen.queryByText('Get started with HyperDX'), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/You're all set/)).not.toBeInTheDocument(); + + // Queries resolve -> already complete on load, so still nothing, and no + // celebration timer is armed. + setSetup(true, /* loading */ false); + rerender( + + + + + , + ); + act(() => { + jest.advanceTimersByTime(4000); + }); + expect(screen.queryByText(/You're all set/)).not.toBeInTheDocument(); + expect( + screen.queryByText('Get started with HyperDX'), + ).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); + + it('celebrates when the last task completes in-session, then hides — without persisting dismissal', () => { + jest.useFakeTimers(); + try { + // Mount with one task still open. + setMe({ + completedTasks: ['advancedQuery', 'dashboard', 'alert'], + isDismissed: false, + }); + setSetup(true); + const { rerender } = renderChecklist(); + expect(screen.queryByText(/You're all set/)).not.toBeInTheDocument(); + + // Finish the last task -> celebration appears. + setMe({ + completedTasks: ['advancedQuery', 'dashboard', 'alert', 'mcp'], + isDismissed: false, + }); + rerender( + + + + + , + ); + expect(screen.getByText(/You're all set/)).toBeInTheDocument(); + + // After the delay the card hides. We do NOT persist an isDismissed flag — + // completion is derived, so adding a task later reopens the checklist. + act(() => { + jest.advanceTimersByTime(4000); + }); + expect(screen.queryByText(/You're all set/)).not.toBeInTheDocument(); + expect(dismissMutate).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it('reopens when a new task appears after previously being complete', () => { + // Simulates ONBOARDING_TASK_IDS gaining a task: a user who had finished the + // old set now has an unmet task, so the checklist shows again — because we + // derive "done" instead of persisting it. + setMe({ + // Every id EXCEPT one is complete (stands in for a newly-added task). + completedTasks: ['advancedQuery', 'dashboard', 'alert'], + isDismissed: false, + }); + setSetup(true); + + renderChecklist(); + + expect(screen.getByText('Get started with HyperDX')).toBeInTheDocument(); + expect(screen.getByText('3/4')).toBeInTheDocument(); + }); + + it('renders one row per ONBOARDING_TASK_IDS entry (render order covers the SSOT)', () => { + // Guards the exhaustiveness contract: PRODUCT_TASK_ORDER is derived by + // sorting ONBOARDING_TASK_IDS, so the count of rendered product tasks must + // equal the SSOT size — a new id can't be silently dropped from the UI. + setMe({ completedTasks: [], isDismissed: false }); + setSetup(true); + + renderChecklist(); + + expect( + screen.getByText(`0/${ONBOARDING_TASK_IDS.length}`), + ).toBeInTheDocument(); + }); + + it('stays hidden when the user manually dismissed it', () => { + setMe({ completedTasks: [], isDismissed: true }); + setSetup(true); + + renderChecklist(); + + expect( + screen.queryByText('Get started with HyperDX'), + ).not.toBeInTheDocument(); + }); + + it('dismisses when the dismiss button is clicked', async () => { + setMe({ completedTasks: [], isDismissed: false }); + setSetup(true); + + renderChecklist(); + + await userEvent.click(screen.getByLabelText('Dismiss checklist')); + expect(dismissMutate).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/app/src/__tests__/dashboard.remote.test.ts b/packages/app/src/__tests__/dashboard.remote.test.ts index e1adf39d1d..ebe4404e16 100644 --- a/packages/app/src/__tests__/dashboard.remote.test.ts +++ b/packages/app/src/__tests__/dashboard.remote.test.ts @@ -6,7 +6,13 @@ // `jest.isolateModules` did not override the hoisted `jest.mock` factory // reliably enough to share a file. jest.mock('../config', () => ({ IS_LOCAL_MODE: false })); -jest.mock('../api', () => ({ hdxServer: jest.fn() })); +jest.mock('../api', () => ({ + __esModule: true, + default: { useMe: () => ({ data: null }) }, + hdxServer: jest.fn(), + useMarkOnboardingTaskComplete: () => jest.fn(), + useCompleteOnboardingTask: () => ({ mutate: jest.fn() }), +})); jest.mock('@mantine/notifications', () => ({ notifications: { show: jest.fn() }, })); diff --git a/packages/app/src/__tests__/dashboard.test.ts b/packages/app/src/__tests__/dashboard.test.ts index a2bcd58fbf..c9d9cfb7e8 100644 --- a/packages/app/src/__tests__/dashboard.test.ts +++ b/packages/app/src/__tests__/dashboard.test.ts @@ -1,4 +1,10 @@ -jest.mock('../api', () => ({ hdxServer: jest.fn() })); +jest.mock('../api', () => ({ + __esModule: true, + default: { useMe: () => ({ data: null }) }, + hdxServer: jest.fn(), + useMarkOnboardingTaskComplete: () => jest.fn(), + useCompleteOnboardingTask: () => ({ mutate: jest.fn() }), +})); jest.mock('../config', () => ({ IS_LOCAL_MODE: true })); jest.mock('@mantine/notifications', () => ({ notifications: { show: jest.fn() }, diff --git a/packages/app/src/__tests__/useDashboardOnboarding.test.tsx b/packages/app/src/__tests__/useDashboardOnboarding.test.tsx new file mode 100644 index 0000000000..22a939b416 --- /dev/null +++ b/packages/app/src/__tests__/useDashboardOnboarding.test.tsx @@ -0,0 +1,85 @@ +import { act, renderHook } from '@testing-library/react'; + +// Local (temporary URL-state) dashboards never hit the backend, so setDashboard +// must record the 'dashboard' onboarding task itself once a tile exists. These +// tests exercise that branch in isolation. + +const mutate = jest.fn(); +let localDashboardValue: unknown = null; +const setLocalDashboard = jest.fn((v: unknown) => { + localDashboardValue = v; +}); +let meData: { + onboardingData: { completedTasks: string[]; isDismissed: boolean }; +} | null = null; + +jest.mock('../config', () => ({ IS_LOCAL_MODE: false })); +jest.mock('@mantine/notifications', () => ({ + notifications: { show: jest.fn() }, +})); +jest.mock('nuqs', () => ({ + parseAsJson: () => ({}), + useQueryState: () => [localDashboardValue, setLocalDashboard], +})); +jest.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ mutate: jest.fn() }), + useQuery: () => ({ data: undefined, isFetching: false }), + useQueryClient: () => ({ invalidateQueries: jest.fn() }), +})); +jest.mock('../api', () => ({ + __esModule: true, + default: { useMe: () => ({ data: meData }) }, + hdxServer: jest.fn(), + useMarkOnboardingTaskComplete: () => jest.fn(), + useCompleteOnboardingTask: () => ({ mutate }), +})); + +import { useDashboard } from '@/dashboard'; + +const tile = { + id: 't1', + x: 0, + y: 0, + w: 1, + h: 1, + config: { name: 'c', source: 's', displayType: 'line', select: [] }, +}; + +function makeDashboard(tiles: unknown[]) { + return { id: '', name: 'Temp', tiles, tags: [] } as never; +} + +describe('useDashboard local-dashboard onboarding', () => { + beforeEach(() => { + jest.clearAllMocks(); + localDashboardValue = null; + meData = { onboardingData: { completedTasks: [], isDismissed: false } }; + }); + + it('records the dashboard task when a tile is added to a temporary dashboard', () => { + const { result } = renderHook(() => useDashboard({})); + act(() => { + result.current.setDashboard(makeDashboard([tile])); + }); + expect(mutate).toHaveBeenCalledWith('dashboard'); + }); + + it('does not record for an empty temporary dashboard', () => { + const { result } = renderHook(() => useDashboard({})); + act(() => { + result.current.setDashboard(makeDashboard([])); + }); + expect(mutate).not.toHaveBeenCalled(); + }); + + it('does not re-record once the task is already completed', () => { + meData = { + onboardingData: { completedTasks: ['dashboard'], isDismissed: false }, + }; + const { result } = renderHook(() => useDashboard({})); + act(() => { + result.current.setDashboard(makeDashboard([tile])); + }); + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/__tests__/useMarkOnboardingTaskComplete.test.tsx b/packages/app/src/__tests__/useMarkOnboardingTaskComplete.test.tsx new file mode 100644 index 0000000000..8bd6bc850e --- /dev/null +++ b/packages/app/src/__tests__/useMarkOnboardingTaskComplete.test.tsx @@ -0,0 +1,80 @@ +import type { MeApiResponse } from '@hyperdx/common-utils/dist/types'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; + +import { useMarkOnboardingTaskComplete } from '@/api'; + +function makeMe( + completedTasks: MeApiResponse['onboardingData']['completedTasks'], +) { + return { + id: 'u1', + email: 'a@b.com', + accessKey: 'k', + name: 'User', + createdAt: '', + onboardingData: { completedTasks, isDismissed: false }, + team: { id: 't1', name: 'Team' }, + usageStatsEnabled: false, + aiAssistantEnabled: false, + } as unknown as MeApiResponse; +} + +function setup(initialMe: MeApiResponse | null) { + const queryClient = new QueryClient(); + if (initialMe !== null) { + queryClient.setQueryData(['me'], initialMe); + } + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useMarkOnboardingTaskComplete(), { + wrapper, + }); + return { queryClient, mark: result.current }; +} + +describe('useMarkOnboardingTaskComplete', () => { + it('appends the task to completedTasks in the me cache', () => { + const { queryClient, mark } = setup(makeMe([])); + + act(() => mark('alert')); + + expect( + queryClient.getQueryData(['me'])?.onboardingData + .completedTasks, + ).toEqual(['alert']); + }); + + it('does not duplicate an already-completed task', () => { + const { queryClient, mark } = setup(makeMe(['alert'])); + + act(() => mark('alert')); + + expect( + queryClient.getQueryData(['me'])?.onboardingData + .completedTasks, + ).toEqual(['alert']); + }); + + it('preserves referential identity of sibling fields (no consumer fan-out)', () => { + const { queryClient, mark } = setup(makeMe([])); + const before = queryClient.getQueryData(['me'])!; + + act(() => mark('dashboard')); + + const after = queryClient.getQueryData(['me'])!; + // Only onboardingData is a new object; team/name/etc keep their identity so + // useMe consumers that read those don't see a change. + expect(after.team).toBe(before.team); + expect(after.onboardingData).not.toBe(before.onboardingData); + }); + + it('is a no-op when the me cache is empty', () => { + const { queryClient, mark } = setup(null); + + act(() => mark('alert')); + + expect(queryClient.getQueryData(['me'])).toBeUndefined(); + }); +}); diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index a9195c91b4..5f9edc149f 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -1,3 +1,4 @@ +import { useCallback } from 'react'; import Router from 'next/router'; import type { HTTPError, Options, ResponsePromise } from 'ky'; import ky from 'ky-universal'; @@ -9,6 +10,8 @@ import type { AlertsApiResponse, InstallationApiResponse, MeApiResponse, + OnboardingDataApiResponse, + OnboardingTaskId, PresetDashboard, PresetDashboardFilter, RotateAccessKeyApiResponse, @@ -83,23 +86,96 @@ export const hdxServer = ( }); }; +// Standalone (not just an `api.` method) so other mutation hooks in this file +// can compose it — e.g. the alert/dashboard save hooks call it on success. +// Idempotent on the server, seeds the `me` cache from the response. +export function useCompleteOnboardingTask() { + const queryClient = useQueryClient(); + return useMutation< + OnboardingDataApiResponse, + Error | HTTPError, + OnboardingTaskId + >({ + mutationFn: async (taskId: OnboardingTaskId) => + hdxServer('me/onboarding/task', { + method: 'POST', + json: { taskId }, + }).json(), + onSuccess: data => { + queryClient.setQueryData(['me'], prev => + prev == null ? prev : { ...prev, onboardingData: data.onboardingData }, + ); + }, + }); +} + +// Patch ONLY `onboardingData.completedTasks` in the cached `me` object, with no +// network request and no query invalidation. Use this when the backend has +// already recorded a task (alerts/dashboards record server-side) and the client +// just needs its cache kept in sync. Invalidating `['me']` instead would refetch +// for every `useMe` consumer — useMetadata, clickhouse settings, AppNav, etc. — +// which is a wide blast radius for a change only the sidebar checklist cares +// about. Returns a stable callback; a no-op if the task is already recorded. +export function useMarkOnboardingTaskComplete() { + const queryClient = useQueryClient(); + return useCallback( + (taskId: OnboardingTaskId) => { + queryClient.setQueryData(['me'], prev => { + if (prev == null) { + return prev; + } + if (prev.onboardingData.completedTasks.includes(taskId)) { + return prev; + } + return { + ...prev, + onboardingData: { + ...prev.onboardingData, + completedTasks: [...prev.onboardingData.completedTasks, taskId], + }, + }; + }); + }, + [queryClient], + ); +} + const api = { useCreateAlert() { + const markOnboardingTaskComplete = useMarkOnboardingTaskComplete(); return useMutation<{ data: Alert }, Error, Alert>({ mutationFn: async alert => server('alerts', { method: 'POST', json: alert, }).json(), + // The backend records the 'alert' onboarding task on create (see + // createAlert). Patch just onboardingData in the `me` cache so the sidebar + // checklist updates without refetching `me` for every consumer. + onSuccess: () => { + if (!IS_LOCAL_MODE) { + markOnboardingTaskComplete('alert'); + } + }, }); }, useUpdateAlert() { + const markOnboardingTaskComplete = useMarkOnboardingTaskComplete(); return useMutation<{ data: Alert }, Error, { id: string } & Alert>({ mutationFn: async alert => server(`alerts/${alert.id}`, { method: 'PUT', json: alert, }).json(), + // The backend records the 'alert' onboarding task on update too (see + // updateAlert), so editing an existing alert completes the checklist. + // Patch just onboardingData in the `me` cache to avoid a full `me` + // refetch, matching useCreateAlert. + onSuccess: () => { + if (!IS_LOCAL_MODE) { + markOnboardingTaskComplete('alert'); + } + }, }); }, useDeleteAlert() { @@ -293,6 +369,28 @@ const api = { }, }); }, + // Marks a product-usage onboarding task complete. Idempotent on the server + // ($addToSet), so callers fire it optimistically without checking whether the + // task is already done. Seeds the `me` cache from the response so the sidebar + // checklist ticks instantly without a refetch. + useCompleteOnboardingTask, + useDismissOnboarding() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (isDismissed: boolean) => + hdxServer('me/onboarding/dismiss', { + method: 'PATCH', + json: { isDismissed }, + }).json(), + onSuccess: data => { + queryClient.setQueryData(['me'], prev => + prev == null + ? prev + : { ...prev, onboardingData: data.onboardingData }, + ); + }, + }); + }, useDeleteTeamMember() { return useMutation< { message: string }, diff --git a/packages/app/src/components/AppNav/AppNav.components.tsx b/packages/app/src/components/AppNav/AppNav.components.tsx index 253626b959..03783cb3dd 100644 --- a/packages/app/src/components/AppNav/AppNav.components.tsx +++ b/packages/app/src/components/AppNav/AppNav.components.tsx @@ -46,7 +46,7 @@ export const AppNavContext = React.createContext<{ export const AppNavCloudBanner = () => { return ( -
+
Ready to deploy on ClickHouse Cloud?