Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
const navItems = [
{ label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' },
// Future: { label: 'Notifications', href: '/dashboard/notifications', icon: 'fa-solid fa-bell' },
// Future: { label: 'Privacy', href: '/dashboard/privacy', icon: 'fa-solid fa-shield-halved' },
]
function NavLink({ item, active, onClick }) {
return (
<Link
href={item.href}
onClick={onClick}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span>
</Link>
)
}
function SidebarContent({ isActive, onNavigate }) {
return (
<>
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
</div>
<nav className="space-y-1 flex-1">
{navItems.map((item) => (
<NavLink key={item.href} item={item} active={isActive(item.href)} onClick={onNavigate} />
))}
</nav>
<div className="mt-auto pt-6 space-y-2">
<Link
href="/studio"
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
onClick={onNavigate}
>
<i className="fa-solid fa-palette w-5 text-center" />
Creator Studio
</Link>
</div>
</>
)
}
function SectionSidebar({ sections = [], activeSection, onSectionChange, dirtyMap = {} }) {
return (
<>
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
</div>
<nav className="space-y-1 flex-1">
{sections.map((section) => {
const active = section.key === activeSection
const isDirty = !!dirtyMap[section.key]
return (
<button
key={section.key}
type="button"
onClick={() => onSectionChange?.(section.key)}
className={`group relative w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{section.icon ? <i className={`${section.icon} w-5 text-center text-base`} /> : null}
<span className="flex flex-col items-start gap-0.5">
<span className="flex items-center gap-2">
{section.label}
{isDirty && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" title="Unsaved changes" />
)}
</span>
{section.description && !active ? (
<span className="text-[11px] font-normal text-slate-500 leading-tight">{section.description}</span>
) : null}
</span>
</button>
)
})}
</nav>
</>
)
}
export default function SettingsLayout({ children, title, sections = null, activeSection = null, onSectionChange = null, dirtyMap = {} }) {
const { url } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const hasSectionMode = Array.isArray(sections) && sections.length > 0 && typeof onSectionChange === 'function'
const isActive = (href) => url.startsWith(href)
const currentSection = hasSectionMode
? sections.find((section) => section.key === activeSection)
: null
return (
<div className="min-h-screen bg-nova-900">
{/* Mobile top bar */}
<div className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
{hasSectionMode ? (
<div className="flex items-center gap-2">
<label className="block flex-1">
<span className="sr-only">Settings section</span>
<select
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-accent/50 appearance-none"
value={activeSection || ''}
onChange={(e) => onSectionChange(e.target.value)}
>
{sections.map((section) => (
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
{section.label}{dirtyMap[section.key] ? ' •' : ''}
</option>
))}
</select>
</label>
{dirtyMap[activeSection] ? (
<span className="inline-flex items-center rounded-full bg-amber-400/15 px-2 py-1 text-[10px] font-semibold text-amber-300 border border-amber-400/20">
Unsaved
</span>
) : null}
</div>
) : (
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold text-white">Settings</h1>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="text-slate-400 hover:text-white p-2"
aria-label="Toggle navigation"
>
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
</button>
</div>
)}
</div>
{/* Mobile nav overlay (legacy mode only) */}
{!hasSectionMode && mobileOpen && (
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
<nav
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
onClick={(e) => e.stopPropagation()}
>
<SidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} />
</nav>
</div>
)}
<div className="flex">
{/* Desktop sidebar */}
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
{hasSectionMode ? (
<SectionSidebar sections={sections} activeSection={activeSection} onSectionChange={onSectionChange} dirtyMap={dirtyMap} />
) : (
<SidebarContent isActive={isActive} />
)}
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-5xl">
{title && (
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">{title}</h1>
{currentSection?.description ? (
<p className="text-sm text-slate-400 mt-1">{currentSection.description}</p>
) : null}
</div>
)}
{children}
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,448 @@
import React, { useEffect, useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
import { studioModule, studioSurface, trackStudioEvent } from '../utils/studioEvents'
const baseNavGroups = [
{
label: 'Creator Studio',
items: [
{ label: 'Overview', href: '/studio', icon: 'fa-solid fa-chart-line' },
{ label: 'Search', href: '/studio/search', icon: 'fa-solid fa-magnifying-glass' },
{ label: 'Groups', href: '/studio/groups', icon: 'fa-solid fa-people-group' },
],
},
{
label: 'Create',
items: [
{ label: 'New Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'New Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
{ label: 'New Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
{ label: 'New Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
],
},
{
label: 'Content',
items: [
{ label: 'All Content', href: '/studio/content', icon: 'fa-solid fa-table-cells-large' },
{ label: 'Artworks', href: '/studio/artworks', icon: 'fa-solid fa-images' },
{ label: 'Cards', href: '/studio/cards', icon: 'fa-solid fa-id-card' },
{ label: 'Collections', href: '/studio/collections', icon: 'fa-solid fa-layer-group' },
{ label: 'Stories', href: '/studio/stories', icon: 'fa-solid fa-feather-pointed' },
],
},
{
label: 'Library',
items: [
{ label: 'Drafts', href: '/studio/drafts', icon: 'fa-solid fa-file-pen' },
{ label: 'Scheduled', href: '/studio/scheduled', icon: 'fa-solid fa-calendar-days' },
{ label: 'Calendar', href: '/studio/calendar', icon: 'fa-solid fa-calendar-range' },
{ label: 'Archived', href: '/studio/archived', icon: 'fa-solid fa-box-archive' },
{ label: 'Assets', href: '/studio/assets', icon: 'fa-solid fa-photo-film' },
],
},
{
label: 'Engagement',
items: [
{ label: 'Inbox', href: '/studio/inbox', icon: 'fa-solid fa-inbox' },
{ label: 'Activity', href: '/studio/activity', icon: 'fa-solid fa-bell' },
{ label: 'Comments', href: '/studio/comments', icon: 'fa-solid fa-comments' },
{ label: 'Followers', href: '/studio/followers', icon: 'fa-solid fa-user-group' },
{ label: 'Challenges', href: '/studio/challenges', icon: 'fa-solid fa-trophy' },
],
},
{
label: 'Insights',
items: [
{ label: 'Analytics', href: '/studio/analytics', icon: 'fa-solid fa-chart-pie' },
{ label: 'Growth', href: '/studio/growth', icon: 'fa-solid fa-chart-line' },
],
},
{
label: 'Creator',
items: [
{ label: 'Profile', href: '/studio/profile', icon: 'fa-solid fa-id-card' },
{ label: 'Featured Content', href: '/studio/featured', icon: 'fa-solid fa-wand-magic-sparkles' },
{ label: 'Preferences', href: '/studio/preferences', icon: 'fa-solid fa-sliders' },
{ label: 'Studio Settings', href: '/studio/settings', icon: 'fa-solid fa-gear' },
],
},
]
const baseQuickCreateItems = [
{ label: 'Artwork', href: '/upload', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Card', href: '/studio/cards/create', icon: 'fa-solid fa-id-card' },
{ label: 'Story', href: '/creator/stories/create', icon: 'fa-solid fa-feather-pointed' },
{ label: 'Collection', href: '/settings/collections/create', icon: 'fa-solid fa-layer-group' },
]
const STUDIO_CONTEXT_STORAGE_KEY = 'sb.studio.last-context'
const RESTORABLE_STUDIO_PATHS = ['/studio', '/studio/artworks', '/studio/collections', '/studio/settings']
function supportsStudioContextRestore(pathname) {
return RESTORABLE_STUDIO_PATHS.includes(pathname)
}
function studioRouteKeyForPath(pathname) {
if (pathname === '/studio/artworks' || pathname.endsWith('/artworks')) return 'studio_artworks_url'
if (pathname === '/studio/collections' || pathname.endsWith('/collections')) return 'studio_collections_url'
if (pathname === '/studio/settings' || pathname.endsWith('/settings')) return 'studio_settings_url'
if (pathname.endsWith('/members')) return 'studio_members_url'
if (pathname.endsWith('/invitations')) return 'studio_invitations_url'
return 'studio_url'
}
function nestedRouteKeyFor(topLevelRouteKey) {
return topLevelRouteKey.replace(/_url$/, '')
}
function groupStudioUrlForPath(group, pathname) {
if (!group) return '/studio'
const routeKey = studioRouteKeyForPath(pathname)
const nestedRouteKey = nestedRouteKeyFor(routeKey)
return group[routeKey] || group.urls?.[nestedRouteKey] || group.studio_url || group.urls?.studio || '/studio'
}
function personalStudioUrlForPath(pathname) {
if (pathname === '/studio/artworks' || pathname.endsWith('/artworks')) return '/studio/artworks'
if (pathname === '/studio/collections' || pathname.endsWith('/collections')) return '/studio/collections'
if (pathname === '/studio/settings' || pathname.endsWith('/settings')) return '/studio/settings'
return '/studio'
}
function persistStudioContext(slug) {
if (typeof window === 'undefined') return
try {
window.sessionStorage.setItem(STUDIO_CONTEXT_STORAGE_KEY, slug || '')
} catch {
// Ignore storage failures so Studio navigation keeps working.
}
}
function readPersistedStudioContext() {
if (typeof window === 'undefined') return null
try {
return window.sessionStorage.getItem(STUDIO_CONTEXT_STORAGE_KEY)
} catch {
return null
}
}
function navigateToStudioUrl(targetUrl) {
if (typeof window === 'undefined' || !targetUrl) return
if (typeof window.location?.assign === 'function') {
window.location.assign(targetUrl)
return
}
window.location.href = targetUrl
}
function NavLink({ item, active }) {
return (
<Link
href={item.href}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span>
</Link>
)
}
export default function StudioLayout({ children, title, subtitle, actions }) {
const { url, props } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const pathname = url.split('?')[0]
const studioGroups = Array.isArray(props.studio_groups) ? props.studio_groups : []
const currentGroup = props.studioGroup || null
const canManageNews = Boolean(props.auth?.user?.is_admin || props.auth?.user?.is_moderator)
const navGroups = baseNavGroups.map((group) => {
if (!canManageNews || group.label !== 'Content') {
return group
}
return {
...group,
items: [
...group.items,
{ label: 'News', href: '/studio/news', icon: 'fa-solid fa-newspaper' },
],
}
})
const quickCreateItems = (canManageNews
? [...baseQuickCreateItems, { label: 'News Article', href: '/studio/news/create', icon: 'fa-solid fa-newspaper' }]
: baseQuickCreateItems
).map((item) => {
if (currentGroup?.urls && item.label === 'Artwork') {
return { ...item, href: currentGroup.urls?.studio_artworks ? `/upload?group=${currentGroup.slug}` : item.href }
}
if (currentGroup?.urls && item.label === 'Collection') {
return { ...item, href: `/settings/collections/create?group=${currentGroup.slug}` }
}
return item
})
useEffect(() => {
const moduleKey = studioModule(pathname)
const surface = studioSurface(pathname)
trackStudioEvent('studio_opened', {
surface,
module: moduleKey,
})
trackStudioEvent('studio_module_opened', {
surface,
module: moduleKey,
})
}, [pathname])
useEffect(() => {
if (!currentGroup?.slug) return
persistStudioContext(currentGroup.slug)
}, [currentGroup?.slug])
useEffect(() => {
if (currentGroup || !supportsStudioContextRestore(pathname)) return
const storedSlug = readPersistedStudioContext()
if (!storedSlug) return
const nextGroup = studioGroups.find((group) => group.slug === storedSlug)
if (!nextGroup) {
persistStudioContext('')
return
}
const targetUrl = groupStudioUrlForPath(nextGroup, pathname)
if (targetUrl && targetUrl !== pathname) {
navigateToStudioUrl(targetUrl)
}
}, [currentGroup, pathname, studioGroups])
const isActive = (href) => {
if (href === '/studio') return pathname === '/studio'
return pathname.startsWith(href)
}
const handleQuickCreateClick = (item) => {
trackStudioEvent('studio_quick_create_used', {
surface: studioSurface(pathname),
module: item.label.toLowerCase(),
meta: {
href: item.href,
label: item.label,
},
})
}
const handleContextChange = (nextSlug) => {
persistStudioContext(nextSlug)
const nextGroup = studioGroups.find((group) => group.slug === nextSlug)
const targetUrl = nextGroup
? groupStudioUrlForPath(nextGroup, pathname)
: personalStudioUrlForPath(pathname)
if (targetUrl !== pathname) {
navigateToStudioUrl(targetUrl)
}
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(34,197,94,0.12),_transparent_35%),linear-gradient(180deg,_#06101d_0%,_#020617_45%,_#02040a_100%)]">
<div className="sticky top-16 z-30 border-b border-white/10 bg-slate-950/80 backdrop-blur-xl lg:hidden">
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Creator Studio</p>
<h1 className="text-lg font-bold text-white">{title || 'Creator Studio'}</h1>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="rounded-full border border-white/10 p-2 text-slate-400 hover:text-white"
aria-label="Toggle navigation"
>
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
</button>
</div>
</div>
{mobileOpen && (
<div className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden" onClick={() => setMobileOpen(false)}>
<nav
className="absolute left-0 top-0 bottom-0 w-80 overflow-y-auto border-r border-white/10 bg-slate-950 p-4 pt-20"
onClick={(event) => event.stopPropagation()}
>
<StudioSidebarContent
currentGroup={currentGroup}
studioGroups={studioGroups}
navGroups={navGroups}
quickCreateItems={quickCreateItems}
isActive={isActive}
onNavigate={() => setMobileOpen(false)}
onQuickCreate={handleQuickCreateClick}
onContextChange={handleContextChange}
/>
</nav>
</div>
)}
<div className="flex">
<aside className="sticky top-16 hidden min-h-[calc(100vh-4rem)] w-72 self-start border-r border-white/10 bg-slate-950/55 p-4 pt-6 backdrop-blur-xl lg:flex lg:flex-col">
<StudioSidebarContent
currentGroup={currentGroup}
studioGroups={studioGroups}
navGroups={navGroups}
quickCreateItems={quickCreateItems}
isActive={isActive}
onQuickCreate={handleQuickCreateClick}
onContextChange={handleContextChange}
/>
</aside>
<main className="min-w-0 flex-1 px-4 pb-10 pt-4 lg:px-8 lg:pt-6">
<section className="mb-6 rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_35%),linear-gradient(135deg,_rgba(15,23,42,0.84),_rgba(2,6,23,0.95))] p-5 shadow-[0_22px_70px_rgba(2,6,23,0.32)] lg:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/70">Creator Studio</p>
{title && <h1 className="mt-2 text-3xl font-semibold text-white lg:text-4xl">{title}</h1>}
{subtitle && <p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">{subtitle}</p>}
{currentGroup ? <p className="mt-4 inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Group context: {currentGroup.name}</p> : null}
</div>
<div className="flex flex-wrap items-center gap-3 lg:justify-end">
{studioGroups.length > 0 ? <ContextSwitcher currentGroup={currentGroup} studioGroups={studioGroups} onContextChange={handleContextChange} /> : null}
{actions}
<div className="relative">
<button
type="button"
onClick={() => setCreateOpen((current) => !current)}
className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15"
>
<i className="fa-solid fa-plus" />
Create New
</button>
{createOpen && (
<div className="absolute right-0 top-[calc(100%+0.75rem)] z-20 min-w-[220px] rounded-[24px] border border-white/10 bg-slate-950/95 p-2 shadow-[0_18px_40px_rgba(2,6,23,0.5)] backdrop-blur-xl">
{quickCreateItems.map((item) => (
<a
key={item.href}
href={item.href}
onClick={() => handleQuickCreateClick(item)}
className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm text-slate-200 transition hover:bg-white/[0.06] hover:text-white"
>
<i className={`${item.icon} w-5 text-center text-sky-200`} />
<span>New {item.label}</span>
</a>
))}
</div>
)}
</div>
</div>
</div>
</section>
{children}
</main>
</div>
</div>
)
}
function ContextSwitcher({ currentGroup, studioGroups, onContextChange }) {
return (
<label className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/20 px-3 py-2 text-sm text-slate-200">
<i className="fa-solid fa-people-group text-sky-200" />
<select
value={currentGroup?.slug || ''}
onChange={(event) => onContextChange?.(event.target.value)}
className="bg-transparent text-sm text-white outline-none"
>
<option value="" className="bg-slate-950 text-white">Personal studio</option>
{studioGroups.map((group) => (
<option key={group.slug} value={group.slug} className="bg-slate-950 text-white">
{group.name}
</option>
))}
</select>
</label>
)
}
function StudioSidebarContent({ currentGroup, studioGroups, navGroups, quickCreateItems, isActive, onNavigate, onQuickCreate, onContextChange }) {
return (
<>
<div className="mb-6 rounded-[26px] border border-white/10 bg-white/[0.04] p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Skinbase Nova</p>
<h2 className="mt-2 text-xl font-semibold text-white">Creator Studio</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">Create, manage, and grow from one modular workspace built for every creator surface.</p>
{studioGroups.length > 0 ? (
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Context</p>
<select
value={currentGroup?.slug || ''}
onChange={(event) => onContextChange?.(event.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-slate-950/80 px-3 py-2 text-sm text-white outline-none"
>
<option value="">Personal studio</option>
{studioGroups.map((group) => (
<option key={group.slug} value={group.slug}>{group.name}</option>
))}
</select>
</div>
) : null}
</div>
<nav className="flex-1 space-y-5" onClick={onNavigate}>
{navGroups.map((group) => (
<div key={group.label}>
<h3 className="mb-2 px-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{group.label}</h3>
<div className="space-y-1">
{group.items.map((item) => (
<NavLink key={item.href} item={item} active={isActive(item.href)} />
))}
</div>
</div>
))}
</nav>
<div className="mt-6 rounded-[24px] border border-white/10 bg-[linear-gradient(135deg,_rgba(15,23,42,0.95),_rgba(12,74,110,0.4))] p-4">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-100/70">Quick create</p>
<div className="mt-3 grid gap-2">
{quickCreateItems.map((item) => (
<a
key={item.href}
href={item.href}
className="inline-flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.06]"
onClick={() => {
onQuickCreate?.(item)
onNavigate?.()
}}
>
<i className={`${item.icon} w-5 text-center text-sky-200`} />
<span>New {item.label}</span>
</a>
))}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,101 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import StudioLayout from '../StudioLayout'
let pageMock = { url: '/studio', props: {} }
const originalLocation = window.location
vi.mock('@inertiajs/react', () => ({
Link: ({ href, children, ...props }) => <a href={href} {...props}>{children}</a>,
usePage: () => pageMock,
}))
vi.mock('../../utils/studioEvents', () => ({
studioModule: () => 'overview',
studioSurface: () => '/studio',
trackStudioEvent: vi.fn(),
}))
describe('StudioLayout group context persistence', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
assign: vi.fn(),
},
})
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null)
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
})
})
it('restores the last selected group context on supported personal studio routes', async () => {
pageMock = {
url: '/studio',
props: {
studio_groups: [
{
slug: 'warp-collective',
name: 'Warp Collective',
studio_url: '/studio/groups/warp-collective',
},
],
studioGroup: null,
},
}
Storage.prototype.getItem.mockImplementation((key) => (key === 'sb.studio.last-context' ? 'warp-collective' : null))
render(
<StudioLayout title="Studio" subtitle="Overview">
<div>Body</div>
</StudioLayout>,
)
await waitFor(() => {
expect(window.location.assign).toHaveBeenCalledWith('/studio/groups/warp-collective')
})
})
it('stores the selected group slug and navigates into that group context', async () => {
pageMock = {
url: '/studio',
props: {
studio_groups: [
{
slug: 'warp-collective',
name: 'Warp Collective',
studio_url: '/studio/groups/warp-collective',
},
],
studioGroup: null,
},
}
render(
<StudioLayout title="Studio" subtitle="Overview">
<div>Body</div>
</StudioLayout>,
)
const [contextSwitcher] = screen.getAllByRole('combobox')
await userEvent.selectOptions(contextSwitcher, 'warp-collective')
expect(Storage.prototype.setItem).toHaveBeenCalledWith('sb.studio.last-context', 'warp-collective')
await waitFor(() => {
expect(window.location.assign).toHaveBeenCalledWith('/studio/groups/warp-collective')
})
})
})