diff --git a/apps/api/src/tutorials.ts b/apps/api/src/tutorials.ts new file mode 100644 index 00000000..f86f3a5e --- /dev/null +++ b/apps/api/src/tutorials.ts @@ -0,0 +1,182 @@ +import prisma from '@briefer/database' +import { + OnboardingTutorialStep, + StepStates, + TutorialState, +} from '@briefer/types' +import { logger } from './logger.js' + +const ONBOARDING_STEP_ORDER: OnboardingTutorialStep[] = [ + 'connectDataSource', + 'runQuery', + 'runPython', + 'createVisualization', + 'publishDashboard', + 'inviteTeamMembers', +] + +export const stepStatesFromStep = ( + stepIds: OnboardingTutorialStep[], + currentStep: OnboardingTutorialStep, + isComplete: boolean +): StepStates => { + const currentStepIndex = stepIds.indexOf(currentStep) + + return stepIds.reduce((acc, stepId, index) => { + if (isComplete) { + return { ...acc, [stepId]: 'completed' } + } + + if (index < currentStepIndex) { + return { ...acc, [stepId]: 'completed' } + } else if (index === currentStepIndex) { + return { ...acc, [stepId]: 'current' } + } else { + return { ...acc, [stepId]: 'upcoming' } + } + }, {} as StepStates) +} + +export const getTutorialState = async ( + workspaceId: string, + _tutorialType: 'onboarding' +): Promise => { + const tutorial = await prisma().onboardingTutorial.findUnique({ + where: { + workspaceId, + }, + }) + + if (!tutorial) { + return null + } + + const stepStates = stepStatesFromStep( + ONBOARDING_STEP_ORDER, + tutorial.currentStep, + tutorial.isComplete + ) + + return { + id: tutorial.id, + isCompleted: tutorial.isComplete, + isDismissed: tutorial.isDismissed, + stepStates, + } +} + +export const advanceTutorial = async ( + workspaceId: string, + tutorialType: 'onboarding', + // TODO don't allow null here - this is just for testing + ifCurrentStep: OnboardingTutorialStep | null +): Promise => { + const tutorial = await prisma().onboardingTutorial.findUnique({ + where: { + workspaceId, + }, + }) + + if (!tutorial) { + logger().error( + { workspaceId, tutorialType }, + 'Trying to advance tutorial that does not exist' + ) + return null + } + + if ( + ifCurrentStep && + (tutorial.isComplete || tutorial.currentStep !== ifCurrentStep) + ) { + return { + id: tutorial.id, + isCompleted: tutorial.isComplete, + isDismissed: tutorial.isDismissed, + stepStates: stepStatesFromStep( + ONBOARDING_STEP_ORDER, + tutorial.currentStep, + tutorial.isComplete + ), + } + } + + const currentIndex = ONBOARDING_STEP_ORDER.indexOf(tutorial.currentStep) + const nextStepIndex = currentIndex + 1 + const nextStep = ONBOARDING_STEP_ORDER[nextStepIndex] + + if (nextStepIndex === ONBOARDING_STEP_ORDER.length) { + await prisma().onboardingTutorial.update({ + where: { + workspaceId, + }, + data: { + isComplete: true, + }, + }) + + return { + id: tutorial.id, + isCompleted: true, + isDismissed: tutorial.isDismissed, + stepStates: stepStatesFromStep( + ONBOARDING_STEP_ORDER, + tutorial.currentStep, + true + ), + } + } + + if (!nextStep) { + logger().error( + { workspaceId, tutorialType }, + 'Trying to advance tutorial to a step that does not exist' + ) + return null + } + + await prisma().onboardingTutorial.update({ + where: { + workspaceId, + }, + data: { + currentStep: nextStep, + }, + }) + + return { + id: tutorial.id, + isCompleted: tutorial.isComplete, + isDismissed: tutorial.isDismissed, + stepStates: stepStatesFromStep( + ONBOARDING_STEP_ORDER, + nextStep, + tutorial.isComplete + ), + } +} + +export const dismissTutorial = async ( + workspaceId: string, + _tutorialType: 'onboarding' +): Promise => { + const tutorial = await prisma().onboardingTutorial.update({ + where: { + workspaceId, + }, + data: { + isDismissed: true, + }, + }) + + return { + id: tutorial.id, + isCompleted: true, + isDismissed: tutorial.isDismissed, + stepStates: stepStatesFromStep( + ONBOARDING_STEP_ORDER, + tutorial.currentStep, + tutorial.isComplete + ), + } +} diff --git a/apps/api/src/v1/workspaces/workspace/data-sources/index.ts b/apps/api/src/v1/workspaces/workspace/data-sources/index.ts index 2bf4a94e..dbb056ea 100644 --- a/apps/api/src/v1/workspaces/workspace/data-sources/index.ts +++ b/apps/api/src/v1/workspaces/workspace/data-sources/index.ts @@ -24,6 +24,8 @@ import { fetchDataSourceStructure } from '../../../../datasources/structure.js' import { canUpdateWorkspace } from '../../../../auth/token.js' import { captureDatasourceCreated } from '../../../../events/posthog.js' import { IOServer } from '../../../../websocket/index.js' +import { advanceTutorial } from '../../../../tutorials.js' +import { broadcastTutorialStepStates } from '../../../../websocket/workspace/tutorial.js' const dataSourcePayload = z.union([ z.object({ @@ -393,6 +395,9 @@ const dataSourcesRouter = (socketServer: IOServer) => { }) res.status(201).json(datasource) + + await advanceTutorial(workspaceId, 'onboarding', 'connectDataSource') + broadcastTutorialStepStates(socketServer, workspaceId, 'onboarding') } catch (err) { req.log.error( { diff --git a/apps/api/src/v1/workspaces/workspace/documents/document/publish.ts b/apps/api/src/v1/workspaces/workspace/documents/document/publish.ts index 1feedbe6..d608fb70 100644 --- a/apps/api/src/v1/workspaces/workspace/documents/document/publish.ts +++ b/apps/api/src/v1/workspaces/workspace/documents/document/publish.ts @@ -9,6 +9,8 @@ import { DocumentPersistor } from '../../../../../yjs/v2/persistors.js' import { getYDocWithoutHistory } from '../../../../../yjs/v2/documents.js' import { getDocId, getYDocForUpdate } from '../../../../../yjs/v2/index.js' import { broadcastDocument } from '../../../../../websocket/workspace/documents.js' +import { advanceTutorial } from '../../../../../tutorials.js' +import { broadcastTutorialStepStates } from '../../../../../websocket/workspace/tutorial.js' export default function publishRouter(socketServer: IOServer) { const publishRouter = Router({ mergeParams: true }) @@ -22,6 +24,7 @@ export default function publishRouter(socketServer: IOServer) { return } + let hasDashboard = false try { await prisma().$transaction( async (tx) => { @@ -41,13 +44,14 @@ export default function publishRouter(socketServer: IOServer) { doc.id, doc.workspaceId, async (yDoc) => { + hasDashboard = yDoc.dashboard.size > 0 await tx.yjsAppDocument.create({ data: { documentId, state: Buffer.from( Y.encodeStateAsUpdate(getYDocWithoutHistory(yDoc)) ), - hasDashboard: yDoc.dashboard.size > 0, + hasDashboard, }, }) setPristine(yDoc.ydoc) @@ -65,6 +69,9 @@ export default function publishRouter(socketServer: IOServer) { await broadcastDocument(socketServer, workspaceId, documentId) res.sendStatus(204) + + await advanceTutorial(workspaceId, 'onboarding', 'publishDashboard') + broadcastTutorialStepStates(socketServer, workspaceId, 'onboarding') } catch (err) { req.log.error( { diff --git a/apps/api/src/v1/workspaces/workspace/index.ts b/apps/api/src/v1/workspaces/workspace/index.ts index d23b83e7..0d4f691a 100644 --- a/apps/api/src/v1/workspaces/workspace/index.ts +++ b/apps/api/src/v1/workspaces/workspace/index.ts @@ -9,7 +9,6 @@ import { IOServer } from '../../../websocket/index.js' import { getParam } from '../../../utils/express.js' import { broadcastDocuments } from '../../../websocket/workspace/documents.js' import { UserWorkspaceRole, updateWorkspace } from '@briefer/database' -import onboardingRouter from './onboarding.js' import filesRouter from './files.js' import componentsRouter from './components.js' import { canUpdateWorkspace, hasWorkspaceRoles } from '../../../auth/token.js' @@ -17,6 +16,7 @@ import { WorkspaceEditFormValues } from '@briefer/types' import { encrypt } from '@briefer/database' import { config } from '../../../config/index.js' import { isWorkspaceNameValid } from '../../../utils/validation.js' +import tutorialsRouter from './tutorials.js' const isAdmin = hasWorkspaceRoles([UserWorkspaceRole.admin]) @@ -84,10 +84,10 @@ export default function workspaceRouter(socketServer: IOServer) { res.status(200).json(updatedWorkspace) }) - router.use('/onboarding', onboardingRouter) + router.use('/tutorials', tutorialsRouter(socketServer)) router.use('/data-sources', dataSourcesRouter(socketServer)) router.use('/documents', documentsRouter(socketServer)) - router.use('/users', usersRouter) + router.use('/users', usersRouter(socketServer)) router.use('/favorites', favoritesRouter) router.use( '/environment-variables', diff --git a/apps/api/src/v1/workspaces/workspace/onboarding.ts b/apps/api/src/v1/workspaces/workspace/onboarding.ts deleted file mode 100644 index 61ba6bd1..00000000 --- a/apps/api/src/v1/workspaces/workspace/onboarding.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Router } from 'express' -import { getParam } from '../../../utils/express.js' -import z from 'zod' -import { OnboardingStep } from '@briefer/types' -import { updateOnboardingStep } from '@briefer/database' - -const onboardingRouter = Router({ mergeParams: true }) - -const onboardingPayload = z.object({ - onboardingStep: OnboardingStep, -}) - -onboardingRouter.put('/', async (req, res) => { - const workspaceId = getParam(req, 'workspaceId') - const payload = onboardingPayload.safeParse(req.body) - if (!payload.success) { - res.status(400).end() - return - } - - const workspace = updateOnboardingStep( - workspaceId, - payload.data.onboardingStep - ) - - res.json(workspace) -}) - -export default onboardingRouter diff --git a/apps/api/src/v1/workspaces/workspace/tutorials.ts b/apps/api/src/v1/workspaces/workspace/tutorials.ts new file mode 100644 index 00000000..05f19eaa --- /dev/null +++ b/apps/api/src/v1/workspaces/workspace/tutorials.ts @@ -0,0 +1,118 @@ +import { Router } from 'express' +import { IOServer } from '../../../websocket' +import { getParam } from '../../../utils/express.js' +import { + advanceTutorial, + dismissTutorial, + getTutorialState, +} from '../../../tutorials.js' +import { broadcastTutorialStepStates } from '../../../websocket/workspace/tutorial.js' +import { logger } from '../../../logger.js' +import prisma from '@briefer/database' + +export default function tutorialsRouter(socketServer: IOServer) { + const router = Router({ mergeParams: true }) + + router.get('/:tutorialType', async (req, res) => { + const workspaceId = getParam(req, 'workspaceId') + const tutorialType = getParam(req, 'tutorialType') + + if (tutorialType !== 'onboarding') { + res.sendStatus(400) + return + } + + try { + const tutorialState = await getTutorialState(workspaceId, tutorialType) + + if (!tutorialState) { + res.status(404).end() + return + } + + res.json(tutorialState) + } catch (err) { + logger().error( + { err, workspaceId, tutorialType }, + 'Failed to get tutorial state' + ) + res.status(500).end() + } + }) + + router.post('/:tutorialType', async (req, res) => { + const workspaceId = getParam(req, 'workspaceId') + const tutorialType = getParam(req, 'tutorialType') + + if (tutorialType !== 'onboarding') { + res.sendStatus(400) + return + } + + try { + const tutorialState = await advanceTutorial( + workspaceId, + tutorialType, + null + ) + + if (!tutorialState) { + res.status(404).end() + return + } + + res.json(tutorialState) + broadcastTutorialStepStates(socketServer, workspaceId, tutorialType) + } catch (err) { + logger().error( + { err, workspaceId, tutorialType }, + 'Failed to dismiss tutorial' + ) + res.status(500).end() + } + }) + + router.post('/:tutorialType/dismiss', async (req, res) => { + const workspaceId = getParam(req, 'workspaceId') + const tutorialType = getParam(req, 'tutorialType') + + if (tutorialType !== 'onboarding') { + res.sendStatus(400) + return + } + + const tutorial = await prisma().onboardingTutorial.findUnique({ + where: { + workspaceId, + }, + }) + + if (!tutorial) { + return res.sendStatus(404) + } + + if (!tutorial.isComplete) { + return res.sendStatus(400) + } + + try { + const tutorialState = await dismissTutorial(workspaceId, tutorialType) + + if (!tutorialState) { + res.status(404).end() + return + } + + res.json(tutorialState) + broadcastTutorialStepStates(socketServer, workspaceId, tutorialType) + } catch (err) { + logger().error( + { err, workspaceId, tutorialType }, + 'Failed to dismiss tutorial' + ) + res.status(500).end() + } + }) + + return router +} diff --git a/apps/api/src/v1/workspaces/workspace/users/index.ts b/apps/api/src/v1/workspaces/workspace/users/index.ts index 1c9d7d07..3bd309f1 100644 --- a/apps/api/src/v1/workspaces/workspace/users/index.ts +++ b/apps/api/src/v1/workspaces/workspace/users/index.ts @@ -15,8 +15,9 @@ import { generatePassword, hashPassword } from '../../../../password.js' import { hasWorkspaceRoles } from '../../../../auth/token.js' import { isUserNameValid } from '../../../../utils/validation.js' import * as posthog from '../../../../events/posthog.js' - -const usersRouter = Router({ mergeParams: true }) +import { advanceTutorial } from '../../../../tutorials.js' +import { broadcastTutorialStepStates } from '../../../../websocket/workspace/tutorial.js' +import { IOServer } from '../../../../websocket/index.js' const userSchema = z.object({ name: z.string(), @@ -28,94 +29,103 @@ const userSchema = z.object({ ]), }) -usersRouter.get('/', async (req, res) => { - const workspaceId = getParam(req, 'workspaceId') - res.json(await listWorkspaceUsers(workspaceId)) -}) +const usersRouter = (socketServer: IOServer) => { + const router = Router({ mergeParams: true }) + + router.get('/', async (req, res) => { + const workspaceId = getParam(req, 'workspaceId') + res.json(await listWorkspaceUsers(workspaceId)) + }) + + const isAdmin = hasWorkspaceRoles([UserWorkspaceRole.admin]) + + router.post('/', isAdmin, async (req, res) => { + const workspaceId = getParam(req, 'workspaceId') + try { + const result = userSchema.safeParse(req.body) + if (!result.success) { + res.status(400).end() + return + } + + const email = result.data.email.trim() + + if (!isUserNameValid(result.data.name)) { + res.status(400).end() + return + } + + const workspace = await getWorkspaceById(workspaceId) + if (!workspace) { + res.status(404).end() + return + } + + const password = generatePassword(24) + let invitee = await getUserByEmail(email) + if (!invitee) { + const passwordDigest = await hashPassword(password) + invitee = await createUser(email, result.data.name, passwordDigest) + } + + await addUserToWorkspace(invitee.id, workspaceId, result.data.role) + posthog.captureUserCreated(invitee, workspace.id) + + res.json({ + ...invitee, + password, + }) + + await advanceTutorial(workspaceId, 'onboarding', 'inviteTeamMembers') + broadcastTutorialStepStates(socketServer, workspaceId, 'onboarding') + } catch (err) { + req.log.error({ err, workspaceId }, 'Error creating user') + res.sendStatus(500) + } + }) -const isAdmin = hasWorkspaceRoles([UserWorkspaceRole.admin]) + async function belongsToWorkspace( + req: Request, + res: Response, + next: NextFunction + ) { + const workspaceId = getParam(req, 'workspaceId') + const userId = getParam(req, 'userId') -usersRouter.post('/', isAdmin, async (req, res) => { - const workspaceId = getParam(req, 'workspaceId') - try { - const result = userSchema.safeParse(req.body) - if (!result.success) { + if (!validate(userId) || !validate(workspaceId)) { res.status(400).end() return } - const email = result.data.email.trim() - - if (!isUserNameValid(result.data.name)) { - res.status(400).end() + const uw = req.session.userWorkspaces[workspaceId] + if (!uw) { + res.status(403).end() return } - const workspace = await getWorkspaceById(workspaceId) - if (!workspace) { - res.status(404).end() + next() + } + + function isAdminOrSelf(req: Request, res: Response, next: NextFunction) { + const workspaceId = getParam(req, 'workspaceId') + const role = req.session.userWorkspaces[workspaceId]?.role + if (role === 'admin') { + next() return } - const password = generatePassword(24) - let invitee = await getUserByEmail(email) - if (!invitee) { - const passwordDigest = await hashPassword(password) - invitee = await createUser(email, result.data.name, passwordDigest) + const userId = getParam(req, 'userId') + if (userId === req.session.user.id) { + next() + return } - await addUserToWorkspace(invitee.id, workspaceId, result.data.role) - posthog.captureUserCreated(invitee, workspace.id) - - res.json({ - ...invitee, - password, - }) - } catch (err) { - req.log.error({ err, workspaceId }, 'Error creating user') - res.sendStatus(500) - } -}) - -async function belongsToWorkspace( - req: Request, - res: Response, - next: NextFunction -) { - const workspaceId = getParam(req, 'workspaceId') - const userId = getParam(req, 'userId') - - if (!validate(userId) || !validate(workspaceId)) { - res.status(400).end() - return - } - - const uw = req.session.userWorkspaces[workspaceId] - if (!uw) { res.status(403).end() - return } - next() -} + router.use('/:userId', isAdminOrSelf, belongsToWorkspace, userRouter) -function isAdminOrSelf(req: Request, res: Response, next: NextFunction) { - const workspaceId = getParam(req, 'workspaceId') - const role = req.session.userWorkspaces[workspaceId]?.role - if (role === 'admin') { - next() - return - } - - const userId = getParam(req, 'userId') - if (userId === req.session.user.id) { - next() - return - } - - res.status(403).end() + return router } -usersRouter.use('/:userId', isAdminOrSelf, belongsToWorkspace, userRouter) - export default usersRouter diff --git a/apps/api/src/websocket/index.ts b/apps/api/src/websocket/index.ts index 12be33c3..d18e4696 100644 --- a/apps/api/src/websocket/index.ts +++ b/apps/api/src/websocket/index.ts @@ -15,6 +15,7 @@ import { Comment, DataSourceTable, PythonSuggestionsResult, + TutorialState, } from '@briefer/types' import { v4 as uuidv4 } from 'uuid' import { logger } from '../logger.js' @@ -96,6 +97,11 @@ interface EmitEvents { documentId: string commentId: string }) => void + 'workspace-tutorial-update': (msg: { + workspaceId: string + tutorialType: 'onboarding' + tutorialState: TutorialState + }) => void } export interface Socket extends BaseSocket { diff --git a/apps/api/src/websocket/workspace/tutorial.ts b/apps/api/src/websocket/workspace/tutorial.ts new file mode 100644 index 00000000..073fefbc --- /dev/null +++ b/apps/api/src/websocket/workspace/tutorial.ts @@ -0,0 +1,20 @@ +import { getTutorialState } from '../../tutorials.js' +import { IOServer } from '../index.js' + +export async function broadcastTutorialStepStates( + socket: IOServer, + workspaceId: string, + tutorialType: 'onboarding' +) { + const tutorialState = await getTutorialState(workspaceId, tutorialType) + + if (!tutorialState) { + return + } + + socket.to(workspaceId).emit('workspace-tutorial-update', { + workspaceId, + tutorialType, + tutorialState, + }) +} diff --git a/apps/api/src/workspace/index.ts b/apps/api/src/workspace/index.ts index 4e2f5bc5..92a449a4 100644 --- a/apps/api/src/workspace/index.ts +++ b/apps/api/src/workspace/index.ts @@ -5,6 +5,7 @@ import { UserWorkspaceRole, ApiWorkspace, createWorkspace as prismaCreateWorkspace, + createDocument, } from '@briefer/database' import { IOServer } from '../websocket/index.js' import { WorkspaceCreateInput } from '@briefer/types' @@ -71,6 +72,14 @@ export class WorkspaceCreator implements IWorkspaceCreator { skipDuplicates: true, }) + await tx.onboardingTutorial.create({ + data: { + workspaceId: workspace.id, + currentStep: 'connectDataSource', + isComplete: false, + }, + }) + return { workspace, invitedUsers: guestUsers } } diff --git a/apps/api/src/yjs/v2/executor/python.ts b/apps/api/src/yjs/v2/executor/python.ts index 18e93102..3bf95655 100644 --- a/apps/api/src/yjs/v2/executor/python.ts +++ b/apps/api/src/yjs/v2/executor/python.ts @@ -13,10 +13,17 @@ import { updateDataframes } from './index.js' import { DataFrame } from '@briefer/types' import { PythonEvents } from '../../../events/index.js' import { WSSharedDocV2 } from '../index.js' +import { advanceTutorial } from '../../../tutorials.js' +import { broadcastTutorialStepStates } from '../../../websocket/workspace/tutorial.js' export type PythonEffects = { executePython: typeof executePython listDataFrames: typeof listDataFrames + advanceTutorial: typeof advanceTutorial + broadcastTutorialStepStates: ( + workspaceId: string, + tutorialType: 'onboarding' + ) => Promise } export interface IPythonExecutor { @@ -112,6 +119,13 @@ export class PythonExecutor implements IPythonExecutor { 'python block executed' ) executionItem.setCompleted(errored ? 'error' : 'success') + + await this.effects.advanceTutorial( + this.workspaceId, + 'onboarding', + 'runPython' + ) + this.effects.broadcastTutorialStepStates(this.workspaceId, 'onboarding') } catch (err) { logger().error( { @@ -148,6 +162,17 @@ export class PythonExecutor implements IPythonExecutor { { executePython, listDataFrames, + advanceTutorial, + broadcastTutorialStepStates: ( + workspaceId: string, + tutorialType: 'onboarding' + ) => { + return broadcastTutorialStepStates( + doc.socketServer, + workspaceId, + tutorialType + ) + }, } ) } diff --git a/apps/api/src/yjs/v2/executor/sql.ts b/apps/api/src/yjs/v2/executor/sql.ts index a1ff9a58..46fc0b6a 100644 --- a/apps/api/src/yjs/v2/executor/sql.ts +++ b/apps/api/src/yjs/v2/executor/sql.ts @@ -14,10 +14,17 @@ import { renameDataFrame, } from '../../../python/query/index.js' import { logger } from '../../../logger.js' -import { DataFrame, RunQueryResult } from '@briefer/types' +import { + DataFrame, + OnboardingTutorialStep, + RunQueryResult, +} from '@briefer/types' import { SQLEvents } from '../../../events/index.js' import { WSSharedDocV2 } from '../index.js' import { updateDataframes } from './index.js' +import { advanceTutorial } from '../../../tutorials.js' +import { IOServer } from '../../../websocket/index.js' +import { broadcastTutorialStepStates } from '../../../websocket/workspace/tutorial.js' export type SQLEffects = { makeSQLQuery: typeof makeSQLQuery @@ -25,6 +32,11 @@ export type SQLEffects = { renameDataFrame: typeof renameDataFrame listDataFrames: typeof listDataFrames documentHasRunSQLSelectionEnabled: (id: string) => Promise + advanceTutorial: typeof advanceTutorial + broadcastTutorialStepStates: ( + workspaceId: string, + tutorialType: 'onboarding' + ) => Promise } export interface ISQLExecutor { @@ -239,6 +251,13 @@ export class SQLExecutor implements ISQLExecutor { }, 'sql block executed' ) + + await this.effects.advanceTutorial( + this.workspaceId, + 'onboarding', + 'runQuery' + ) + this.effects.broadcastTutorialStepStates(this.workspaceId, 'onboarding') } catch (err) { logger().error( { @@ -347,6 +366,17 @@ export class SQLExecutor implements ISQLExecutor { listDataSources, renameDataFrame, listDataFrames, + advanceTutorial, + broadcastTutorialStepStates: ( + workspaceId: string, + tutorialType: 'onboarding' + ) => { + return broadcastTutorialStepStates( + doc.socketServer, + workspaceId, + tutorialType + ) + }, documentHasRunSQLSelectionEnabled: (id: string) => prisma() .document.findFirst({ diff --git a/apps/api/src/yjs/v2/executor/visualization.ts b/apps/api/src/yjs/v2/executor/visualization.ts index f6b08f9d..b1f1487d 100644 --- a/apps/api/src/yjs/v2/executor/visualization.ts +++ b/apps/api/src/yjs/v2/executor/visualization.ts @@ -15,9 +15,16 @@ import { createVisualization } from '../../../python/visualizations.js' import { z } from 'zod' import { VisEvents } from '../../../events/index.js' import { WSSharedDocV2 } from '../index.js' +import { advanceTutorial } from '../../../tutorials.js' +import { broadcastTutorialStepStates } from '../../../websocket/workspace/tutorial.js' export type VisualizationEffects = { createVisualization: typeof createVisualization + advanceTutorial: typeof advanceTutorial + broadcastTutorialStepStates: ( + workspaceId: string, + tutorialType: 'onboarding' + ) => Promise } export interface IVisualizationExecutor { @@ -186,6 +193,13 @@ export class VisualizationExecutor implements IVisualizationExecutor { }, 'visualization block run completed' ) + + await this.effects.advanceTutorial( + this.workspaceId, + 'onboarding', + 'createVisualization' + ) + this.effects.broadcastTutorialStepStates(this.workspaceId, 'onboarding') } catch (err) { logger().error( { @@ -210,7 +224,20 @@ export class VisualizationExecutor implements IVisualizationExecutor { doc.workspaceId, doc.documentId, doc.dataframes, - { createVisualization } + { + createVisualization, + advanceTutorial, + broadcastTutorialStepStates: ( + workspaceId: string, + tutorialType: 'onboarding' + ) => { + return broadcastTutorialStepStates( + doc.socketServer, + workspaceId, + tutorialType + ) + }, + } ) } } diff --git a/apps/api/src/yjs/v2/index.ts b/apps/api/src/yjs/v2/index.ts index 33f8fb4c..8e955fdf 100644 --- a/apps/api/src/yjs/v2/index.ts +++ b/apps/api/src/yjs/v2/index.ts @@ -408,7 +408,8 @@ export class WSSharedDocV2 { public updating: number = 0 public clock: number = 0 - private socketServer: IOServer + public socketServer: IOServer + private title: string = '' private executor: Executor private aiExecutor: AIExecutor diff --git a/apps/web/src/components/DashboarNotebookGroupButton.tsx b/apps/web/src/components/DashboarNotebookGroupButton.tsx index bb55bf07..10b82238 100644 --- a/apps/web/src/components/DashboarNotebookGroupButton.tsx +++ b/apps/web/src/components/DashboarNotebookGroupButton.tsx @@ -44,6 +44,7 @@ function DashboardNotebookGroupButton(props: Props) { active={isDashboardButtonDisabled} > - - ) -} diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index cb382c5a..514f1f25 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -64,7 +64,7 @@ type ConfigItem = { const configs = (workspaceId: string): ConfigItem[] => [ { - id: 'environments', + id: 'environments-sidebar-item', name: 'Environments', href: `/workspaces/${workspaceId}/environments`, hidden: true, @@ -72,35 +72,35 @@ const configs = (workspaceId: string): ConfigItem[] => [ allowedRoles: new Set(['admin']), }, { - id: 'data-sources', + id: 'data-sources-sidebar-item', name: 'Data sources', href: `/workspaces/${workspaceId}/data-sources`, icon: CircleStackIcon, allowedRoles: new Set(['admin', 'editor']), }, { - id: 'users', + id: 'users-sidebar-item', name: 'Users', href: `/workspaces/${workspaceId}/users`, icon: UsersIcon, allowedRoles: new Set(['admin', 'editor', 'viewer']), }, { - id: 'integrations', + id: 'integrations-sidebar-item', name: 'Integrations', href: `/workspaces/${workspaceId}/integrations`, icon: PuzzlePieceIcon, allowedRoles: new Set(['admin', 'editor']), }, { - id: 'settings', + id: 'settings-sidebar-item', name: 'Settings', href: `/workspaces/${workspaceId}/settings`, icon: AdjustmentsHorizontalIcon, allowedRoles: new Set(['admin']), }, { - id: 'thrash', + id: 'thrash-sidebar-item', name: 'Trash', href: `/workspaces/${workspaceId}/trash`, icon: TrashIcon, @@ -389,6 +389,7 @@ export default function Layout({ {user.roles[workspaceId] !== 'viewer' && ( + ) + + const dismissButton = ( + + ) + + return ( + s === 'completed') + .length + } + actionButtons={ + <> + {dismissButton} + {completeButton} + + } + > + +

+ { + "You can connect a database in the 'data sources' page at the bottom left corner." + } +

+

+ { + 'When adding a data source, pick your database type, and enter the connection details.' + } +

+ + } + status={tutorialState.stepStates['connectDataSource']} + isExpanded={expandedStep.get('connectDataSource') ?? false} + onExpand={() => toggleExpanded('connectDataSource')} + > + { + router.push(`/workspaces/${workspaceId}/data-sources/new`) + }} + /> + {/* TODO: Deactivated on open-source + {}} + /> + */} +
+ + +

+ { + "Add a query block to a page, select the data source you've just connected, and write your query." + } +

+

Then, press the run button to see the results.

+ + } + status={tutorialState.stepStates['runQuery']} + isExpanded={expandedStep.get('runQuery') ?? false} + onExpand={() => toggleExpanded('runQuery')} + > + { + // TODO we need a setTimeout here because otherwise the listener + // within the tour highlight provider will trigger before the + // element is rendered and will dismiss the tour + setTimeout(() => { + setTourActive(true) + setSelector('#last-plus-button') + }, 0) + }} + hidden={!isWithinDocumentPage} + /> +
+ + +

+ Add a Python block, write some code, and press the run button. +

+ { + "Tip: you can manipulate query results with Python. Briefer puts every query's result into variable containing a Pandas Data Frame." + } + + } + status={tutorialState.stepStates['runPython']} + isExpanded={expandedStep.get('runPython') ?? false} + onExpand={() => toggleExpanded('runPython')} + > + { + // TODO we need a setTimeout here because otherwise the listener + // within the tour highlight provider will trigger before the + // element is rendered and will dismiss the tour + setTimeout(() => { + setTourActive(true) + setSelector('#last-plus-button') + }, 0) + }} + hidden={!isWithinDocumentPage} + /> +
+ + +

+ Add a visualization block to your page, select the data frame from + your query, and choose the visualization type. +

+

Then pick what goes on the x and y axis to see a graph.

+ + } + status={tutorialState.stepStates['createVisualization']} + isExpanded={expandedStep.get('createVisualization') ?? false} + onExpand={() => toggleExpanded('createVisualization')} + > + { + // TODO we need a setTimeout here because otherwise the listener + // within the tour highlight provider will trigger before the + // element is rendered and will dismiss the tour + setTimeout(() => { + setTourActive(true) + setSelector('#last-plus-button') + }, 0) + }} + /> +
+ + +

+ { + 'Switch to the dashboard view using the button at the top right corner.' + } +

+

+ { + "Then, drag and drop your notebook's blocks to create a dashboard." + } +

+

+ { + " When you're done, click the 'publish' button to save your dashboard." + } +

+ + } + status={tutorialState.stepStates['publishDashboard']} + isExpanded={expandedStep.get('publishDashboard') ?? false} + onExpand={() => toggleExpanded('publishDashboard')} + > + { + // TODO we need a setTimeout here because otherwise the listener + // within the tour highlight provider will trigger before the + // element is rendered and will dismiss the tour + setTimeout(() => { + setTourActive(true) + setSelector('#dashboard-view-button') + }, 0) + }} + /> +
+ + +

+ { + "Add users by accessing the users page and clicking the 'add user' button on the top right corner." + } +

+

+ { + "Every time you add a user, we'll generate a random password for them. Once they log in, they can change it." + } +

+ + } + status={tutorialState.stepStates['inviteTeamMembers']} + isExpanded={expandedStep.get('inviteTeamMembers') ?? false} + onExpand={() => toggleExpanded('inviteTeamMembers')} + > +
+
+ ) +} + +type TutorialProps = { + name: string + children: + | React.ReactElement + | React.ReactElement[] + onAdvanceTutorial: () => void + totalSteps: number + completedSteps: number + actionButtons?: React.ReactNode +} + +export const Tutorial = (props: TutorialProps) => { + const router = useRouter() + const [isCollapsed, setIsCollapsed] = useState(false) + + const ChevronIcon = isCollapsed ? ChevronUpIcon : ChevronDownIcon + + const isWithinDocumentPage = useMemo(() => { + return isDocumentPath(router.pathname) + }, [router.pathname]) + + return ( +
+
+
+ Welcome to Briefer + +
+ +
+
+
+
+ {React.Children.map(props.children, (child, index) => { + return React.cloneElement(child, { + isLast: index === React.Children.count(props.children) - 1, + }) + })} +
+
+
+ {props.actionButtons} +
+
+
+ ) +} + +const TutorialStepStatusIndicator = (props: { + status: TutorialStepStatus + isLast: boolean +}) => { + const statusBall = useMemo(() => { + if (props.status === 'current') { + return ( + <> +
+
+ + ) + } + + if (props.status === 'completed') { + return + } + + return ( +
+ ) + }, [props.status]) + + return ( + <> +
+
+
+ +
+ {statusBall} +
+ + ) +} + +type TutorialStepActionProps = { + label: string + hidden?: boolean + onClick: () => void +} + +const TutorialStepAction = (props: TutorialStepActionProps) => { + if (props.hidden) { + return null + } + + return ( + + ) +} + +type TutorialStepProps = { + name: string + description: string | React.ReactNode + status: TutorialStepStatus + isExpanded: boolean + onExpand: () => void + isLast?: boolean + children?: + | React.ReactElement + | React.ReactElement[] +} + +const TutorialStep = (props: TutorialStepProps) => { + const stepRef = useRef(null) + + // focus on the current step when it changes + useEffect(() => { + if (props.status === 'current') { + const stepElement = stepRef.current + const clock = setTimeout( + () => + stepElement?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }), + 1200 + ) + + return () => { + clearTimeout(clock) + } + } + }, [props.status]) + + return ( +
  • + + +
    + +
    +
    + {props.description} +
    + + {props.children && ( +
    + {props.children} +
    + )} +
    +
    +
  • + ) +} diff --git a/apps/web/src/components/onboarding/index.tsx b/apps/web/src/components/onboarding/index.tsx deleted file mode 100644 index cec32001..00000000 --- a/apps/web/src/components/onboarding/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Fragment, useCallback, useState } from 'react' -import { Dialog, Transition } from '@headlessui/react' -import OnboardingSteps, { - ONBOARDING_STEP_CHAIN, - useOnboardingSteps, -} from './onboardingSteps' -import { ArrowRightIcon, CheckIcon } from '@heroicons/react/24/solid' -import { - ActivateTrialStep, - DataSourceStep, - JoinSlackStep, - WelcomeStep, -} from './steps' -import { OnboardingStep } from '@briefer/types' - -const getCurrentStepContent = ( - currentStepId: OnboardingStep | undefined -): React.FC<{ - currentStepId: OnboardingStep - goToStep: (stepId: OnboardingStep) => void -}> | null => { - if (!currentStepId) { - return WelcomeStep - } - - switch (currentStepId) { - case 'intro': - return WelcomeStep - case 'connectDataSource': - return DataSourceStep - case 'activateTrial': - return ActivateTrialStep - case 'joinSlack': - case 'done': - return JoinSlackStep - default: - return null - } -} - -function Onboarding() { - const [open, setOpen] = useState(true) - - const { currentStepId, isLoading, goToStep, nextStep, prevStep } = - useOnboardingSteps() - - const StepContent = getCurrentStepContent(currentStepId) - - const isFirstStep = ONBOARDING_STEP_CHAIN[0].id === currentStepId - const isLastStep = - ONBOARDING_STEP_CHAIN[ONBOARDING_STEP_CHAIN.length - 1].id === currentStepId - - const onNextStep = useCallback(() => { - if (isLastStep) { - setOpen(false) - } - - nextStep() - }, [isLastStep, nextStep]) - - return ( - - null}> - -
    - - -
    -
    - - -
    -
    - -
    - -
    - {StepContent && ( - - )} - -
    - - {!isFirstStep && ( - - )} -
    -
    -
    -
    -
    -
    -
    -
    -
    - ) -} - -export default Onboarding diff --git a/apps/web/src/components/onboarding/onboardingSteps.tsx b/apps/web/src/components/onboarding/onboardingSteps.tsx deleted file mode 100644 index 7d19ccef..00000000 --- a/apps/web/src/components/onboarding/onboardingSteps.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useStringQuery } from '@/hooks/useQueryArgs' -import { useWorkspaces } from '@/hooks/useWorkspaces' -import { OnboardingStep } from '@briefer/types' -import { CheckCircleIcon } from '@heroicons/react/20/solid' -import { useCallback, useMemo, useState } from 'react' - -type StepChainItem = { - id: OnboardingStep - name: string -} - -export const ONBOARDING_STEP_CHAIN: StepChainItem[] = [ - { - id: 'intro', - name: 'Introduction', - }, - { - id: 'connectDataSource', - name: 'Connect a data source', - }, - { - id: 'joinSlack', - name: 'Community and support', - }, -] - -function OnboardingSteps({ - currentStepId, - goToStep, -}: { - currentStepId: OnboardingStep - goToStep: (stepId: OnboardingStep) => void -}) { - const currentStepIndex = useMemo( - () => ONBOARDING_STEP_CHAIN.findIndex((step) => step.id === currentStepId), - [currentStepId] - ) - - return ( - - ) -} - -export const useOnboardingSteps = () => { - const workspaceId = useStringQuery('workspaceId') - const [workspaces, { updateOnboarding }] = useWorkspaces() - const isLoading = workspaces.isLoading - - const currentWorkspace = workspaces.data.find( - (workspace) => workspace.id === workspaceId - ) - - const goToStep = useCallback( - (stepId: OnboardingStep) => { - if (!currentWorkspace) { - return - } - - updateOnboarding(currentWorkspace.id, stepId) - }, - [currentWorkspace, updateOnboarding] - ) - - const nextStep = useCallback(() => { - if (!currentWorkspace) { - return - } - - const currentStepIndex = ONBOARDING_STEP_CHAIN.findIndex( - (step) => step.id === currentWorkspace.onboardingStep - ) - - if (currentStepIndex === ONBOARDING_STEP_CHAIN.length - 1) { - updateOnboarding(workspaceId, 'done') - return - } - - const nextStep = ONBOARDING_STEP_CHAIN[currentStepIndex + 1] - updateOnboarding(currentWorkspace.id, nextStep.id) - }, [currentWorkspace, updateOnboarding]) - - const prevStep = useCallback(() => { - if (!currentWorkspace) { - return - } - - const currentStepIndex = ONBOARDING_STEP_CHAIN.findIndex( - (step) => step.id === currentWorkspace.onboardingStep - ) - - if (currentStepIndex === 0) { - return - } - - const prevStep = ONBOARDING_STEP_CHAIN[currentStepIndex - 1] - updateOnboarding(currentWorkspace.id, prevStep.id) - }, [currentWorkspace, updateOnboarding]) - - return { - isLoading, - currentStepId: currentWorkspace?.onboardingStep, - goToStep, - nextStep, - prevStep, - } -} - -export default OnboardingSteps diff --git a/apps/web/src/components/onboarding/steps.tsx b/apps/web/src/components/onboarding/steps.tsx deleted file mode 100644 index dadec9d1..00000000 --- a/apps/web/src/components/onboarding/steps.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import { Dialog } from '@headlessui/react' -import { DataSourceIcons } from '../DataSourceIcons' -import { useStringQuery } from '@/hooks/useQueryArgs' -import { useDataSources } from '@/hooks/useDatasources' -import { InlineWidget } from 'react-calendly' -import { useSession } from '@/hooks/useAuth' -import { useState } from 'react' - -export const WelcomeStep = () => { - return ( -
    - - Welcome to Briefer! - - -

    - Hey there! I'm Lucas, founder of Briefer, and I'm excited to - have you on board. -

    -

    - Here's a quick video to help you get started with Briefer. - I'd highly recommend watching it before you dive in. -

    - -
    - -
    -
    -
    - ) -} - -export const DataSourceStep = () => { - const workspaceId = useStringQuery('workspaceId') ?? '' - const [isUsingCsv, setIsUsingCsv] = useState(false) - - const [{ datasources: dataSources }] = useDataSources(workspaceId) - - if (dataSources.size > 0) { - return ( -
    - - Your data source is connected - - -

    You've connected a data source to Briefer. Great job!

    -

    - You will be able to query this data source using Briefer's - query blocks. -

    - -
    - -
    -
    -
    - ) - } - - if (isUsingCsv) { - return ( -
    - - Here is how to use CSV files - - -

    - You can upload files directly into your pages by using the file - upload block. -

    -

    Here's a quick video on how to use files in your pages.

    - -
    - -
    -
    -
    - ) - } - - return ( -
    - - Let's connect to a data source - - -

    - Briefer works better when it's connected to your data sources. -

    -

    Which of these data sources would you like to connect first?

    - -
    - setIsUsingCsv(true)} - /> -
    -
    -
    - ) -} - -export const ActivateTrialStep = () => { - const session = useSession({ redirectToLogin: true }) - const email = session.data?.email - - return ( -
    - - Now, let's get you some extra features - - -

    - Let's be honest, you're probably tired of these onboarding - screens and just want to get to the good stuff, but hear me out. -

    -

    - If you hop on a 15-minute call with me, I'll give you a free - trial of our professional plan for 30 days. -

    -
    - -
    -

    - I'm doing this is because I want to hear about your use cases and - help you get the most out of Briefer. -

    -
    -
    - ) -} - -export const JoinSlackStep = () => { - return ( -
    - - Join Slack - - -

    - Briefer's Slack community is the perfect place to get help, share - feedback, and connect with other people in the data community. -

    -

    - If you hop in and say hi, I'll be there to welcome you - personally, and you can ask me anything you want. -

    - -

    - I'm doing this is because I want to hear about your use cases and - help you get the most out of Briefer. -

    -
    -
    - ) -} diff --git a/apps/web/src/components/v2Editor/PlusButton.tsx b/apps/web/src/components/v2Editor/PlusButton.tsx index 2c5c5334..b483cbc9 100644 --- a/apps/web/src/components/v2Editor/PlusButton.tsx +++ b/apps/web/src/components/v2Editor/PlusButton.tsx @@ -38,11 +38,13 @@ const useClickOutside = ( } interface Props { - alwaysVisible: boolean + alwaysOpen: boolean onAddBlock: (type: BlockType) => void isEditable: boolean writebackEnabled: boolean + isLast: boolean } + function PlusButton(props: Props) { const wrapperRef = useRef(null) const [showOptions, setShowOptions] = useState(false) @@ -63,13 +65,21 @@ function PlusButton(props: Props) { [props.onAddBlock] ) + const btnDivProps = props.isLast ? { id: 'last-plus-button' } : {} + return ( -
    +
    - {props.isEditable && (showOptions || props.alwaysVisible) && ( + {props.isEditable && (showOptions || props.alwaysOpen) && (
    } onAdd={onAddText} text="Text" /> } onAdd={onAddSQL} text="Query" /> } onAdd={onAddPython} text="Python" /> } onAdd={onAddVisualization} text="Visualization" /> } onAdd={onAddPivotTable} text="Pivot" /> {props.writebackEnabled && ( } onAdd={onAddWriteback} text="Writeback" @@ -196,6 +212,7 @@ function BlockList(props: BlockListProps) { } type BlockSuggestionProps = { + id: string icon: JSX.Element text: string onAdd: () => void @@ -207,7 +224,7 @@ function BlockSuggestion(props: BlockSuggestionProps) { }, [props.onAdd]) return ( -
    +