Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/light-clocks-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'rushdb-dashboard': minor
'rushdb-core': minor
---

Minor UX improvements
2 changes: 1 addition & 1 deletion platform/core/src/dashboard/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class ProjectService {
}

async createProject(
properties: CreateProjectDto,
properties: Partial<CreateProjectDto>,
workspaceId: string,
userId: string,
_transaction?: Transaction
Expand Down
47 changes: 44 additions & 3 deletions platform/core/src/dashboard/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { IDecodedResetToken } from '@/dashboard/auth/auth.types'
import { ResetPasswordAuthDto } from '@/dashboard/auth/dto/reset-password-auth.dto'
import { EncryptionService } from '@/dashboard/auth/encryption/encryption.service'
import { ProjectService } from '@/dashboard/project/project.service'
import { TokenService } from '@/dashboard/token/token.service'
import { ICreatedUserData } from '@/dashboard/user/interfaces/authenticated-user.interface'
import { AcceptWorkspaceInvitationParams } from '@/dashboard/user/interfaces/user-properties.interface'
import {
Expand All @@ -37,6 +38,10 @@ import { User } from './user.entity'

import type { UserRow } from '@/database/sql/schema/types'

const INITIAL_PROJECT_NAME = 'My first project'
const INITIAL_TOKEN_NAME = 'Initial'
const INITIAL_TOKEN_DESCRIPTION = 'Initial API Key to get you started quickly.'

@Injectable()
export class UserService {
constructor(
Expand All @@ -46,7 +51,9 @@ export class UserService {
@Inject(forwardRef(() => WorkspaceService))
private readonly workspaceService: WorkspaceService,
@Inject(forwardRef(() => ProjectService))
private readonly projectService: ProjectService
private readonly projectService: ProjectService,
@Inject(forwardRef(() => TokenService))
private readonly tokenService: TokenService
) {}

normalize(row?: UserRow): User | undefined {
Expand Down Expand Up @@ -90,14 +97,48 @@ export class UserService {

if (allowedLogins.length === 0 || allowedLogins.includes(properties.login)) {
const userRow = await this.createUserNode(properties, transaction)
await this.workspaceService.createWorkspace({ name: 'Default Workspace' }, userRow.id, transaction)
const workspace = await this.workspaceService.createWorkspace(
{ name: 'Default Workspace' },
userRow.id,
transaction
)
await this.createInitialProjectResources(userRow.id, workspace.toJson().id, transaction)

return { userData: this.normalize(userRow) }
return { userData: this.normalize(userRow), workspaceId: workspace.toJson().id }
} else {
throw new BadRequestException('Provided login is not allowed')
}
}

/**
* First-time setup only: every freshly registered user gets a starter project
* and an API key so they can reach the SDK in one click. This runs once at
* signup — deleting all projects later does NOT recreate them, because a user
* who already went through onboarding is no longer "fresh".
*/
private async createInitialProjectResources(
userId: string,
workspaceId: string,
transaction?: Transaction
): Promise<void> {
const project = await this.projectService.createProject(
{ name: INITIAL_PROJECT_NAME },
workspaceId,
userId,
transaction
)

await this.tokenService.createToken(
{
name: INITIAL_TOKEN_NAME,
description: INITIAL_TOKEN_DESCRIPTION,
expiration: '*'
},
project.toJson().id,
transaction
)
}

/**
* Just-in-time user creation for Enterprise SSO. Unlike {@link create}, this
* does NOT spin up a personal "Default Workspace" — the caller (SSO service)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,21 @@ const maskToken = (value: string) => `${value.slice(0, 8)}${'•'.repeat(14)}`
function ConnectionRow({
actions,
children,
dataTour,
icon,
label
}: {
actions?: ReactNode
children: ReactNode
dataTour?: string
icon: ReactNode
label: string
}) {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap">
<div
className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap"
data-tour={dataTour}
>
<span className="flex w-28 shrink-0 items-center gap-2 text-sm text-content2 [&>svg]:h-4 [&>svg]:w-4">
{icon}
{label}
Expand Down Expand Up @@ -69,6 +74,7 @@ export function ProjectConnectionPanel({ loading, project }: { loading?: boolean

<div className="flex flex-col divide-y">
<ConnectionRow
dataTour="project-help-sdk-input"
actions={
tokenValue ?
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,6 @@ import { trackProjectCreated, trackApiKeyGenerated } from '~/lib/analytics'
import { $currentWorkspaceId } from '~/features/workspaces/stores/current'
import { $currentProjectId } from '~/features/projects/stores/id'
import { $router, isProjectPage, redirectRoute } from '~/lib/router'
import { $tourAllowed, $tourStep, setTourStep } from '~/features/tour/stores/tour'

const onboardingProjectCreationSteps = new Set([
'homeNewProject',
'newProjectName',
'newProjectCustomDb',
'newProjectCreate'
])

export const useCreateProjectMutation = () => {
const queryClient = useQueryClient()
Expand Down Expand Up @@ -50,12 +42,7 @@ export const useCreateProjectMutation = () => {
}
const projectIsInactive = project.status === 'pending' || project.status === 'provisioning'
if (!projectIsInactive) {
if ($tourAllowed.get() && onboardingProjectCreationSteps.has($tourStep.get())) {
setTourStep('projectImportRadio', true)
redirectRoute('projectImportData', { id: project.id })
} else {
redirectRoute('projectHelp', { id: project.id })
}
redirectRoute('projectHelp', { id: project.id })
} else {
redirectRoute('projectSettings', { id: project.id })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,11 @@ function isNDJSONorJSON(input: string): 'NDJSON' | 'JSON' | 'Unknown' {
}
}

function labelFromFileName(fileName: string): string {
const base = fileName.replace(/\.[^.]+$/, '').replace(/s$/i, '')
return base.toUpperCase()
}

type SupportedImportFileType = 'json' | 'jsonl' | 'ndjson' | 'csv' | 'unknown'

function parseJsonLines(input: string): string {
Expand Down Expand Up @@ -642,7 +647,7 @@ export function ImportRecords() {
setUploadError(null)
setLastDetectedType(detectedType.toUpperCase())

$label.set('')
$label.set(labelFromFileName(file.name))

if (detectedType === 'csv') {
$csvData.set(content)
Expand Down
19 changes: 17 additions & 2 deletions platform/dashboard/src/features/tour/components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
import type { CallBackProps } from 'react-joyride'
import Joyride, { EVENTS, STATUS } from 'react-joyride'
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useUpdateUserMutation } from '~/features/auth/hooks/useAuthMutations'
import type { routes } from '~/lib/router'
import { $router, openRoute, projectRoutes } from '~/lib/router'
Expand All @@ -18,6 +19,8 @@ import { $currentProjectId } from '~/features/projects/stores/id'
import { useWaitForSelectorStable } from '~/features/tour/hooks/useWaitForSelector'
import type { TourStepKey } from '~/features/tour/types'
import type * as ConfettiModule from '@tsparticles/confetti'
import { workspaceProjectsQueryOptions } from '~/features/workspaces/queries/workspaceQueries'
import { $currentWorkspaceId } from '~/features/workspaces/stores/current'

type TourStepData = {
key: TourStepKey
Expand Down Expand Up @@ -81,6 +84,12 @@ export function OnboardingTour() {
const run = useStore($tourEffective)
const { mutateAsync: updateSettings } = useUpdateUserMutation()
const isAllowed = useStore($tourAllowed)
const workspaceId = useStore($currentWorkspaceId)
const { data: projects } = useQuery({
...workspaceProjectsQueryOptions(workspaceId),
enabled: isAllowed && Boolean(workspaceId)
})
const fallbackProjectId = projects?.[0]?.id

const currentStep = steps.find((step) => getStepData(step)?.key === currentKey)
const targetSelector = typeof currentStep?.target === 'string' ? currentStep.target : ''
Expand Down Expand Up @@ -129,8 +138,14 @@ export function OnboardingTour() {
const nextKey = keys[index + 1]
if (data.redirectTo) {
const route = data.redirectTo
if (isProjectRouteName(route) && projectId) {
openRoute(route, { id: projectId })
if (isProjectRouteName(route)) {
const targetProjectId = projectId ?? fallbackProjectId
if (targetProjectId) {
openRoute(route, { id: targetProjectId })
}
// No project to land on (e.g. a returning user with all projects
// deleted): skip the redirect; the tour stays paused until the
// user opens a project page.
} else {
openRoute(route)
}
Expand Down
80 changes: 3 additions & 77 deletions platform/dashboard/src/features/tour/config/steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,91 +10,17 @@ export const stepDefinitions: Record<TourStepKey, Step> = {
<div className="space-y-4">
<h2 className="text-2xl font-bold text-content">Welcome to RushDB!</h2>
<p className="text-content2">
Congratulations on creating your account and first workspace. Let’s walk through the key features.
Congratulations on creating your account. We've already created your first project and API key —
let's walk through the key features.
</p>
</div>
),
data: {
route: 'home',
redirectTo: 'projectHelp',
key: 'welcome'
}
},
homeNewProject: {
target: '[data-tour="new-project-btn"]',
placement: 'bottom',
content: (
<div className="space-y-4">
<h3 className="text-lg font-bold text-content">Create a Project</h3>
<p className="font-bold text-content2">Projects help you separate and manage your data.</p>
<p className="text-content2">
Each project acts as its own data space — perfect for staging vs. production, or isolated tenants.
Click to create your first one — we’ll guide you from there. See{' '}
<a
className="ml-1 text-accent underline"
href="https://docs.rushdb.com/get-started/quick-tutorial"
target="_blank"
rel="noreferrer"
>
our Quick Tutorial{' '}
</a>
to get started faster.
</p>
</div>
),
data: {
route: 'home',
redirectTo: 'newProject',
key: 'homeNewProject'
}
},
newProjectName: {
target: '[data-tour="project-name-input"]',
placement: 'right',
content: (
<div className="space-y-4">
<h3 className="text-lg font-bold text-content">Enter Project Name</h3>
<p className="text-content2">Give your project a descriptive name so you can find it later.</p>
</div>
),
data: {
route: 'newProject',
key: 'newProjectName'
}
},
newProjectCustomDb: {
target: '[data-tour="custom-neo4j-container"]',
placement: 'right',
content: (
<div className="space-y-4">
<h3 className="text-lg font-bold text-content">Connect Custom Neo4j</h3>
<p className="text-content2">
You can attach your own Neo4j instance (Aura or self-hosted) for full data isolation and control
over your infrastructure.
</p>
</div>
),
data: {
route: 'newProject',
key: 'newProjectCustomDb'
}
},
newProjectCreate: {
target: '[data-tour="create-project-btn"]',
placement: 'right',
content: (
<div className="space-y-4">
<h3 className="text-lg font-bold text-content">Create Your Project</h3>
<p className="text-content2">Everything’s set—click “Create project” to finish the setup.</p>
</div>
),
data: {
route: 'newProject',
key: 'newProjectCreate',
noNext: true,
nextShouldBeManuallySet: true,
waitForManualAction: true
}
},
projectSdkTokenOverview: {
target: '[data-tour="project-help-sdk-input"]',
placement: 'top',
Expand Down
4 changes: 0 additions & 4 deletions platform/dashboard/src/features/tour/stores/tour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import type { TourStepKey } from '../types'

export const keys: TourStepKey[] = [
'welcome',
'homeNewProject',
'newProjectName',
'newProjectCustomDb',
'newProjectCreate',
'projectSdkTokenOverview',
'projectSdkTokenTabInfo',
'projectImportDataTab',
Expand Down
4 changes: 0 additions & 4 deletions platform/dashboard/src/features/tour/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
export type TourStepKey =
| 'welcome'
| 'homeNewProject'
| 'newProjectName'
| 'newProjectCustomDb'
| 'newProjectCreate'
| 'projectSdkTokenOverview'
| 'projectSdkTokenTabInfo'
| 'projectImportDataTab'
Expand Down
22 changes: 2 additions & 20 deletions platform/dashboard/src/pages/project/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,7 @@ import { useCurrentWorkspaceQuery } from '~/features/workspaces/hooks/useWorkspa
import { useWorkspaceProjectsQuery } from '~/features/workspaces/hooks/useWorkspaceQueries'

import { usePlatformSettings } from '~/features/auth/hooks/useAuthQueries'
import { setTourStep } from '~/features/tour/stores/tour.ts'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useStore } from '@nanostores/react'
import { $tourAllowed, $tourStep } from '~/features/tour/stores/tour.ts'

const DEFAULT_ONBOARDING_PROJECT_NAME = 'My First RushDB Project'
const onboardingProjectSteps = new Set([
'homeNewProject',
'newProjectName',
'newProjectCustomDb',
'newProjectCreate'
])

// Type for form values
type ProjectFormValues = {
Expand Down Expand Up @@ -57,9 +46,6 @@ function CreateProjectForm({ className, ...props }: TPolymorphicComponentProps<'
const { data: workspace } = useCurrentWorkspaceQuery()
const { data: platformSettings } = usePlatformSettings()
const { data: projects, isFetching: isProjectsFetching } = useWorkspaceProjectsQuery()
const tourAllowed = useStore($tourAllowed)
const tourStep = useStore($tourStep)
const isOnboardingProjectCreation = tourAllowed && onboardingProjectSteps.has(tourStep)
const maxProjects = workspace?.projectLimit ?? null
const showUpgradeButton = useMemo(() => {
if (isProjectsFetching) {
Expand All @@ -73,10 +59,6 @@ function CreateProjectForm({ className, ...props }: TPolymorphicComponentProps<'

const [selectedTab, setSelectedTab] = useState<'shared' | 'custom'>('shared')

useEffect(() => {
setTourStep('newProjectName', false)
}, [])

// Use the appropriate schema based on selected tab
const getSchema = () => {
switch (selectedTab) {
Expand All @@ -92,7 +74,7 @@ function CreateProjectForm({ className, ...props }: TPolymorphicComponentProps<'
const getDefaultValues = useCallback((): Partial<ProjectFormValues> => {
const base = {
description: '',
name: isOnboardingProjectCreation ? DEFAULT_ONBOARDING_PROJECT_NAME : '',
name: '',
dataSource: selectedTab as ProjectFormValues['dataSource']
}

Expand All @@ -102,7 +84,7 @@ function CreateProjectForm({ className, ...props }: TPolymorphicComponentProps<'
default:
return base
}
}, [isOnboardingProjectCreation, selectedTab])
}, [selectedTab])

const {
formState: { errors, isSubmitting },
Expand Down
Loading