From 97bce9df5af13140c5ae6ceaf706ed2e47cfc0b9 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Tue, 14 Jul 2026 13:24:27 +0100 Subject: [PATCH] feat(onboarding): data-driven auto-detect for tenant Initial Setup steps Auto-complete the tenant "Initial Setup" onboarding steps from live data instead of relying solely on the backend `completedSteps`. A step reads as done the moment its underlying data exists (MSP profile filled, a customer/device/teammate added), and the hook writes that completion back. - New `useTenantOnboardingAutoDetect` hook: reads three schema-backed signals (MSP profile, customer count, connected-device count) in one Relay query plus the REST user count, and fires `completeTenantStepInBackground` when a step's condition holds but it isn't yet persisted. Returns `completedByData` so the card shows a step as done without waiting for the mutation round-trip. - New `tenantOnboardingAutoDetectRelayQuery` (single round-trip, no waterfall, no raw-POST GraphQL); device count filtered to ONLINE/OFFLINE only. - Wire the hook into the dashboard + Initial Setup card. - Add repo-scoped .gitignore (mirrors the monorepo frontend rules). NOTE: the client-side detect-and-write-back is a documented temporary stopgap until `tenantOnboardingProgress` computes completion authoritatively on the backend. --- .gitignore | 39 +++ .../components/dashboard-content.tsx | 8 +- .../components/initial-setup-card.tsx | 228 +++++++++++++----- .../use-tenant-onboarding-auto-detect.ts | 148 ++++++++++++ .../tenant-onboarding-auto-detect-relay.ts | 35 +++ 5 files changed, 392 insertions(+), 66 deletions(-) create mode 100644 .gitignore create mode 100644 src/app/(app)/onboarding/hooks/use-tenant-onboarding-auto-detect.ts create mode 100644 src/graphql/onboarding/tenant-onboarding-auto-detect-relay.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6179ac4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Frontend .gitignore — mirrors the frontend-relevant rules from the +# openframe-oss-tenant monorepo root .gitignore, scoped to this standalone repo's root. + +# Dependencies +node_modules/ + +# Yalc (local core-lib linking) +.yalc +yalc.lock + +# Next.js / build output +.next/ +dist/ +tsconfig.tsbuildinfo +next-env.d.ts +/artifacts/ + +# Generated — regenerated by the build, never committed +# Relay compiler artifacts (npm run relay) +**/__generated__/*.ts +# Schema-derived enums (scripts/generate-schema-enums.mjs) +src/generated/ + +# Env (note: matches the monorepo — .env.local stays tracked) +.env + +# Logs +*.log +**/logs + +# Editors / OS +.idea/ +*.iml +.vscode/chrome-debug-profile +**/.DS_Store +**.DS_Store + +# Claude +**/.claude/ diff --git a/src/app/(app)/dashboard/components/dashboard-content.tsx b/src/app/(app)/dashboard/components/dashboard-content.tsx index c25d54c..f63b4d8 100644 --- a/src/app/(app)/dashboard/components/dashboard-content.tsx +++ b/src/app/(app)/dashboard/components/dashboard-content.tsx @@ -2,7 +2,7 @@ import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; import { Suspense } from 'react'; -import { InitialSetupCard } from '@/app/(app)/onboarding/components/initial-setup-card'; +import { InitialSetupCard, InitialSetupSkeleton } from '@/app/(app)/onboarding/components/initial-setup-card'; import { isSaasTenantMode } from '@/lib/app-mode'; import { featureFlags } from '@/lib/feature-flags'; import { useOnboardingStore } from '@/stores/onboarding-store'; @@ -38,9 +38,11 @@ export default function DashboardContent() { {/* Local Suspense so the setup card's suspending queries (e.g. DeviceSetupStep's `useDeviceOrganizations`, a `useSuspenseQuery`) are caught here instead of bubbling to the route-level `loading.tsx` and re-flashing the whole dashboard - skeleton. `fallback={null}` — the card just appears when ready, no skeleton. */} + skeleton. Fallback is the card skeleton (not `null`) so the suspend doesn't + flash an empty gap between the card's own count-loading skeleton and its + content — the same skeleton carries through. */} {newOnboardingEnabled && ( - + }> )} diff --git a/src/app/(app)/onboarding/components/initial-setup-card.tsx b/src/app/(app)/onboarding/components/initial-setup-card.tsx index ab81ff8..d52110d 100644 --- a/src/app/(app)/onboarding/components/initial-setup-card.tsx +++ b/src/app/(app)/onboarding/components/initial-setup-card.tsx @@ -7,11 +7,12 @@ import { MonitorIcon, UsersGroupIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; -import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; -import { useState } from 'react'; +import { Button, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { type ReactNode, useState } from 'react'; import { TenantOnboardingStep } from '@/generated/schema-enums'; import { useOnboardingMutations } from '@/graphql/onboarding/use-onboarding-mutations'; import { useOnboardingStore } from '@/stores/onboarding-store'; +import { useTenantOnboardingAutoDetect } from '../hooks/use-tenant-onboarding-auto-detect'; import { countCompleted, isStepDone, TENANT_ONBOARDING_STEPS } from '../onboarding-steps'; import { CompanyTeamStep } from './company-team-step'; import { CustomerSetupStep } from './customer-setup-step'; @@ -19,18 +20,88 @@ import { DeviceSetupStep } from './device-setup-step'; import { MspSetupStep } from './msp-setup-step'; import { OnboardingAccordionItem, type OnboardingStepStatus } from './onboarding-accordion'; +interface StepMeta { + step: TenantOnboardingStep; + icon: ReactNode; + title: string; + description: string; +} + +/** + * Single source of truth for the four steps' static presentation (icon, title, + * description), shared by the real card and {@link InitialSetupSkeleton} so the + * skeleton matches the card 1:1 (same icons, titles, descriptions, order). The + * step-specific expanded body is wired up separately in the card. + */ +const STEP_META: readonly StepMeta[] = [ + { + step: TenantOnboardingStep.MSP_SETUP, + icon: , + title: 'Complete MSP Setup', + description: + 'Set your company name, upload a logo, and add your website so clients recognize your brand across all touchpoints.', + }, + { + step: TenantOnboardingStep.CUSTOMERS_SETUP, + icon: , + title: 'Customers Setup', + description: 'Add your first client - Customer name, service tier, and SLA. Devices need an org to belong to.', + }, + { + step: TenantOnboardingStep.DEVICE_MANAGEMENT, + icon: , + title: 'Device Management', + description: 'Run one command on a client machine to connect it to OpenFrame and start monitoring.', + }, + { + step: TenantOnboardingStep.COMPANY_TEAM, + icon: , + title: 'Company & Team', + description: 'Invite your technicians and assign roles so everyone has the right access from day one.', + }, +]; + /** - * Tenant "Initial Setup" block on the Dashboard. Step statuses, the "X/Y done" - * counter and the "Complete Setup" affordance are all driven by - * `tenantOnboardingProgress` (see the onboarding store). The block sits on the - * darker page background (`bg-ods-bg`, not the lighter `bg-ods-card`) so it doesn't - * read as a card. + * Tenant "Initial Setup" block on the Dashboard. Mount gate only: nothing until + * onboarding progress has loaded, and permanently hidden once Initial Setup is + * complete (a one-time surface). When active, it renders {@link InitialSetupCardContent}, + * which suspends on its step counts — the loading skeleton is the dashboard + * `}>` that wraps this card, so the whole + * load (counts + the content's own suspending queries) shows one skeleton, not two. */ export function InitialSetupCard() { - const tenant = useOnboardingStore(state => state.tenant); const isLoaded = useOnboardingStore(state => state.isLoaded); + const tenant = useOnboardingStore(state => state.tenant); + + // Render only when progress is loaded AND we actually have a tenant record that + // isn't complete. Guarding on `!tenant` matters: `refreshOnboardingProgress` marks + // the store loaded even on a failed/empty fetch (tenant stays null), and the content + // fires its data queries the instant it mounts — we must not mount it on null. + if (!isLoaded || !tenant || tenant.completed) { + return null; + } + + return ; +} + +/** + * The card body. Suspends (via {@link useTenantOnboardingAutoDetect}) until every step + * count has loaded, then renders once in its fully-settled state — step statuses, the + * "X/Y done" counter and the "Complete Setup" affordance driven by + * `tenantOnboardingProgress` unioned with the live data. Sits on the darker page + * background (`bg-ods-bg`, not `bg-ods-card`) so it doesn't read as a card. + */ +function InitialSetupCardContent() { + const tenant = useOnboardingStore(state => state.tenant); const { completeTenantStep, completeTenantStepInBackground, completeTenant, isMutating } = useOnboardingMutations(); + // Auto-close steps whose underlying data already exists (MSP profile filled, + // customer/device/teammate added) — see the hook for criteria. Suspends until the + // counts load; `completedByData` feeds the display union below. + // ⚠️ TEMPORARY client-side stopgap — drop this union and read `completedSteps` from + // the store once the backend computes step completion in `tenantOnboardingProgress`. + const completedByData = useTenantOnboardingAutoDetect(); + // Which step's "Mark as Complete" is currently committing — drives that button's // loading spinner. Cleared when the mutation settles (success or error). const [completingStep, setCompletingStep] = useState(null); @@ -39,7 +110,12 @@ export function InitialSetupCard() { completeTenantStep(step, () => setCompletingStep(null)); }; - const completedSteps = tenant?.completedSteps ?? []; + // Display state = backend-persisted steps ∪ steps already satisfied by live data, + // so a step reads as done immediately without waiting for its background mutation + // to round-trip (the hook writes those to the backend for persistence). No dedup + // needed: `countCompleted` builds its own Set and `isStepDone` uses `.includes`, so + // an overlap between the two sources is harmless. + const completedSteps = [...(tenant?.completedSteps ?? []), ...completedByData]; const total = TENANT_ONBOARDING_STEPS.length; const done = countCompleted(TENANT_ONBOARDING_STEPS, completedSteps); const allDone = done >= total; @@ -47,12 +123,30 @@ export function InitialSetupCard() { const statusOf = (step: TenantOnboardingStep): OnboardingStepStatus => isStepDone(step, completedSteps) ? 'completed' : 'active'; - // Render nothing until progress is loaded — no skeleton flash on the dashboard — - // and hide the whole block once the tenant Initial Setup is complete: it's a - // one-time setup surface, so there's nothing left to show after `completed`. - if (!isLoaded || tenant?.completed) { - return null; - } + const renderStepBody = (step: TenantOnboardingStep): ReactNode => { + const completed = isStepDone(step, completedSteps); + const completing = completingStep === step; + const onComplete = () => completeStep(step); + switch (step) { + case TenantOnboardingStep.MSP_SETUP: + return ; + case TenantOnboardingStep.CUSTOMERS_SETUP: + return ; + case TenantOnboardingStep.DEVICE_MANAGEMENT: + return ( + completeTenantStepInBackground(TenantOnboardingStep.DEVICE_MANAGEMENT)} + /> + ); + case TenantOnboardingStep.COMPANY_TEAM: + return ; + default: + return null; + } + }; return (
@@ -78,55 +172,63 @@ export function InitialSetupCard() {
- } - status={statusOf(TenantOnboardingStep.MSP_SETUP)} - title="Complete MSP Setup" - description="Set your company name, upload a logo, and add your website so clients recognize your brand across all touchpoints." - > - completeStep(TenantOnboardingStep.MSP_SETUP)} - /> - - } - status={statusOf(TenantOnboardingStep.CUSTOMERS_SETUP)} - title="Customers Setup" - description="Add your first client - Customer name, service tier, and SLA. Devices need an org to belong to." - > - completeStep(TenantOnboardingStep.CUSTOMERS_SETUP)} - /> - - } - status={statusOf(TenantOnboardingStep.DEVICE_MANAGEMENT)} - title="Device Management" - description="Run one command on a client machine to connect it to OpenFrame and start monitoring." - > - completeStep(TenantOnboardingStep.DEVICE_MANAGEMENT)} - onCompleteBackground={() => completeTenantStepInBackground(TenantOnboardingStep.DEVICE_MANAGEMENT)} - /> - - } - status={statusOf(TenantOnboardingStep.COMPANY_TEAM)} - title="Company & Team" - description="Invite your technicians and assign roles so everyone has the right access from day one." - > - completeStep(TenantOnboardingStep.COMPANY_TEAM)} + {STEP_META.map(meta => ( + + {renderStepBody(meta.step)} + + ))} +
+
+ ); +} + +/** + * Loading placeholder for the card, rendered 1:1 from the same frame and `STEP_META` + * as {@link InitialSetupCardContent}: identical section, header, and four accordion + * rows via `OnboardingAccordionItem`'s `loading` mode (real icon/title/description, + * only the trailing status control skeletoned). Header shows the static title/label + * with just the unknown done-count skeletoned. + * + * Used as the `` fallback around the card (see dashboard-content): the card + * body renders `DeviceSetupStep`, whose `useDeviceOrganizations` suspends, so reusing + * this same skeleton keeps the loading → content transition seamless (no empty gap). + */ +export function InitialSetupSkeleton() { + const total = TENANT_ONBOARDING_STEPS.length; + return ( +
+
+
+

Initial Setup

+ {/* Same text + classes as the real subtitle; only the unknown done-count is a + skeleton, inline (no flex/gap, no extra space) so it doesn't shift on swap. */} +
+ {total} steps to complete · /{total}{' '} + done +
+
+ {/* "Complete Setup" button placeholder — matches the real button's box + (`h-10 md:h-12`, `w-full md:w-auto`; fixed desktop width since a skeleton + has no content to size to). */} + +
+ +
+ {STEP_META.map(meta => ( + - + ))}
); diff --git a/src/app/(app)/onboarding/hooks/use-tenant-onboarding-auto-detect.ts b/src/app/(app)/onboarding/hooks/use-tenant-onboarding-auto-detect.ts new file mode 100644 index 0000000..6f6f107 --- /dev/null +++ b/src/app/(app)/onboarding/hooks/use-tenant-onboarding-auto-detect.ts @@ -0,0 +1,148 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef } from 'react'; +import { useLazyLoadQuery } from 'react-relay'; +import type { FetchPolicy } from 'relay-runtime'; +import type { tenantOnboardingAutoDetectRelayQuery as AutoDetectQuery } from '@/__generated__/tenantOnboardingAutoDetectRelayQuery.graphql'; +import { DEVICE_STATUS } from '@/app/(app)/devices/constants/device-statuses'; +import { TENANT_ONBOARDING_STEPS } from '@/app/(app)/onboarding/onboarding-steps'; +import { TenantOnboardingStep } from '@/generated/schema-enums'; +import { tenantOnboardingAutoDetectRelayQuery } from '@/graphql/onboarding/tenant-onboarding-auto-detect-relay'; +import { useOnboardingMutations } from '@/graphql/onboarding/use-onboarding-mutations'; +import { apiClient } from '@/lib/api-client'; +import { useOnboardingStore } from '@/stores/onboarding-store'; + +// Device-status filter: only ONLINE/OFFLINE count as "a device connected" — ARCHIVED +// (removed) and PENDING (still enrolling) must NOT auto-complete DEVICE_MANAGEMENT. +// Module-level for a stable reference (see AUTO_DETECT_OPTIONS). +const AUTO_DETECT_VARIABLES = { + deviceFilter: { statuses: [DEVICE_STATUS.ONLINE, DEVICE_STATUS.OFFLINE] }, +}; + +// `store-and-network`: fetch fresh on every mount (each dashboard visit), then serve the +// Relay store on re-renders WITHOUT re-suspending. The no-re-suspend part is what matters: +// this component also suspends on a sibling TanStack `useSuspenseQuery` (users), and +// `network-only` can thrash (re-suspend/refetch) when a component keeps suspending on +// another source before it commits — store-and-network commits from the store instead. +// (Stable module-level VARS/OPTIONS are belt-and-suspenders — Relay memoizes variables by +// value, so equal-valued inline objects wouldn't refetch on their own — but keep intent +// clear at no cost.) +const AUTO_DETECT_OPTIONS = { fetchPolicy: 'store-and-network' as FetchPolicy }; + +/** + * Data-driven auto-completion for the tenant "Initial Setup" steps. + * + * ⚠️ TEMPORARY — this whole client-side detect-and-write-back is a stopgap. Completion + * SHOULD be computed authoritatively by the backend inside `tenantOnboardingProgress` + * (it already owns customers/devices/users/tenant-profile). Until it does, the frontend + * polls those counts here and writes the steps back. Known limitations that go away once + * the backend owns this: steps only auto-close when the user visits the dashboard; + * thresholds hardcode seeding assumptions (default org, owner); a failed write-back isn't + * retried until the next visit; the top-bar CTA can briefly lag the card. When the + * backend lands, delete this hook and read `completedSteps` straight from the store. + * + * A step is really done the moment its underlying data exists — the MSP profile is + * filled, a customer/device/teammate has been added. This hook reads those live + * counts and, when a step's condition holds but the step isn't yet in the backend + * `completedSteps`, fires `completeTenantStepInBackground` to persist it. + * + * It returns `completedByData` (the steps whose live data already satisfies their + * criteria) so the card can union it with the backend `completedSteps` for display — + * a step reads as done immediately, without waiting for the background mutation to + * round-trip. The backend stays the source of truth: we only WRITE completion. + * + * Data fetching: + * - The three schema-backed signals (MSP profile, org count, connected-device + * count) come from ONE Relay query (`store-and-network`: fetched fresh on every + * mount, store-served on re-render), not four separate suspense reads — no request + * waterfall, no raw-POST GraphQL. + * - The user count is REST (`api/users` `totalElements`, which matches Settings → + * Employees; the GraphQL `users` count did not). + * + * MUST be called only from a component mounted while onboarding is active (both reads + * suspend and have no `enabled`/mount gate of their own) and wrapped in a Suspense + * boundary — see InitialSetupCard, which gates on `!isLoaded || !tenant || completed`. + * + * Completion criteria (there is always a default org, hence `> 1` for customers): + * - MSP_SETUP: name + website + logo all filled + * - CUSTOMERS_SETUP: more than one organization (at least one real customer) + * - DEVICE_MANAGEMENT: at least one ONLINE/OFFLINE device + * - COMPANY_TEAM: 2 or more users (the owner plus at least one teammate) + */ +export function useTenantOnboardingAutoDetect(): Set { + const tenant = useOnboardingStore(state => state.tenant); + const { completeTenantStepInBackground } = useOnboardingMutations(); + + const data = useLazyLoadQuery( + tenantOnboardingAutoDetectRelayQuery, + AUTO_DETECT_VARIABLES, + AUTO_DETECT_OPTIONS, + ); + const mspComplete = Boolean( + data.tenantInfo?.name?.trim() && data.tenantInfo?.website?.trim() && data.tenantInfo?.image?.imageUrl?.trim(), + ); + const orgCount = data.organizations?.filteredCount ?? 0; + const deviceCount = data.deviceFilters?.filteredCount ?? 0; + + // User count stays REST. `useSuspenseQuery` under the same Suspense boundary; note + // TanStack clamps suspense staleTime/gcTime to a 1s minimum, so this is effectively + // "fresh on mount" (refetchOnMount:'always') rather than truly uncached. + const { data: usersCount = 0 } = useSuspenseQuery({ + queryKey: ['onboarding-auto-detect', 'users-count'], + queryFn: async () => { + try { + const res = await apiClient.get<{ totalElements?: number }>('api/users?page=0&size=1'); + return res.ok ? (res.data?.totalElements ?? 0) : 0; + } catch { + return 0; + } + }, + staleTime: 0, + gcTime: 0, + refetchOnMount: 'always', + }); + + const completedByData = useMemo(() => { + const steps = new Set(); + if (mspComplete) { + steps.add(TenantOnboardingStep.MSP_SETUP); + } + if (orgCount > 1) { + steps.add(TenantOnboardingStep.CUSTOMERS_SETUP); + } + if (deviceCount > 0) { + steps.add(TenantOnboardingStep.DEVICE_MANAGEMENT); + } + if (usersCount >= 2) { + steps.add(TenantOnboardingStep.COMPANY_TEAM); + } + return steps; + }, [mspComplete, orgCount, deviceCount, usersCount]); + + // Steps whose completion mutation we've already sent this mount. Per-mount only — + // resets on remount, and the next visit re-derives from the backend `completedSteps`. + const fired = useRef>(new Set()); + + // Persist ONE step at a time: fire the first not-yet-persisted, not-yet-fired step; + // its mutation updates the store (tenant reference changes) which re-runs this effect + // for the next one. Serializing avoids firing all satisfied steps at once, where the + // concurrent completeTenantOnboardingStep responses (each returns the full + // `completedSteps` and overwrites the store, last-write-wins) could clobber a + // sibling's just-written step. + useEffect(() => { + if (!tenant) { + return; + } + const next = TENANT_ONBOARDING_STEPS.find( + step => completedByData.has(step) && !tenant.completedSteps.includes(step) && !fired.current.has(step), + ); + if (!next) { + return; + } + fired.current.add(next); + completeTenantStepInBackground(next); + }, [tenant, completedByData, completeTenantStepInBackground]); + + return completedByData; +} diff --git a/src/graphql/onboarding/tenant-onboarding-auto-detect-relay.ts b/src/graphql/onboarding/tenant-onboarding-auto-detect-relay.ts new file mode 100644 index 0000000..6757439 --- /dev/null +++ b/src/graphql/onboarding/tenant-onboarding-auto-detect-relay.ts @@ -0,0 +1,35 @@ +import { graphql } from 'react-relay'; + +/** + * ⚠️ TEMPORARY — part of the client-side onboarding auto-detect stopgap; remove once the + * backend computes step completion in `tenantOnboardingProgress`. See + * `useTenantOnboardingAutoDetect` for the full rationale. + * + * The three schema-backed signals the tenant "Initial Setup" auto-detect needs, in a + * single round-trip (see `useTenantOnboardingAutoDetect`): + * - `tenantInfo` — MSP profile completeness (name + website + logo) + * - `organizations` — customer count (`filteredCount`) + * - `deviceFilters` — connected-device count; the caller passes `statuses:[ONLINE,OFFLINE]` + * so archived/pending devices do NOT count as "a device connected" + * + * Fetched with `network-only` so every dashboard visit reflects current data. The user + * count is NOT here — it comes from the REST `api/users` list, whose `totalElements` + * matches Settings → Employees (the GraphQL `users` count did not). + */ +export const tenantOnboardingAutoDetectRelayQuery = graphql` + query tenantOnboardingAutoDetectRelayQuery($deviceFilter: DeviceFilterInput) { + tenantInfo { + name + website + image { + imageUrl + } + } + organizations(first: 1) { + filteredCount + } + deviceFilters(filter: $deviceFilter) { + filteredCount + } + } +`;