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
Original file line number Diff line number Diff line change
Expand Up @@ -6,53 +6,7 @@
border-bottom: 1px solid var(--global-border);
}

.tabsList {
.list {
display: flex;
gap: var(--spacing-2);
}

.tabsTrigger {
position: relative;
display: flex;
gap: var(--spacing-1half);
align-items: center;
padding: var(--spacing-1half) var(--spacing-3) var(--spacing-1half)
var(--spacing-2);
font-size: var(--font-size-4);
font-weight: 500;
color: var(--global-mute-text);
cursor: pointer;
background: transparent;
border: none;
border-radius: var(--border-radius-md);
transition: all 0.2s ease;
}

.tabsTrigger:disabled,
.tabsTrigger[aria-disabled='true'] {
color: var(--button-disabled-foreground);
background-color: transparent;
}

.tabsTrigger:hover {
color: var(--global-foreground);
background-color: var(--global-muted-background);
}

.tabsTrigger[data-state='active'] {
color: var(--global-foreground);
}

.tabsTrigger[data-state='active']::after {
position: absolute;
bottom: -6px;
left: 0;
width: 100%;
height: 1px;
content: '';
background-color: var(--primary-accent);
}

.tabsList a[aria-disabled='true'] {
pointer-events: none;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { TabsList, TabsTrigger } from '@liam-hq/ui'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import type { FC } from 'react'
import { createClient } from '../../../libs/db/server'
import { urlgen } from '../../../libs/routes/urlgen'
import styles from './ProjectHeader.module.css'
import { PROJECT_TABS } from './projectConstants'
import { TabItem } from './TabItem'

type ProjectHeaderProps = {
projectId: string
Expand Down Expand Up @@ -57,71 +55,23 @@ async function getProject(projectId: string) {

export const ProjectHeader: FC<ProjectHeaderProps> = async ({
projectId,
branchOrCommit = 'main', // TODO: get default branch from API(using currentOrganization)
branchOrCommit = 'main',
}) => {
const project = await getProject(projectId)

return (
<div className={styles.wrapper}>
<TabsList className={styles.tabsList}>
{PROJECT_TABS.map((tab) => {
const Icon = tab.icon
const isSchemaTab = tab.value === 'schema'
const isDisabled = isSchemaTab && !project.schemaPath
let href: string

switch (tab.value) {
case 'project':
href = urlgen('projects/[projectId]/ref/[branchOrCommit]', {
projectId,
branchOrCommit,
})
break
case 'schema':
href = urlgen(
'projects/[projectId]/ref/[branchOrCommit]/schema/[...schemaFilePath]',
{
projectId,
branchOrCommit,
schemaFilePath: project.schemaPath?.path || '',
},
)
break
case 'sessions':
href = urlgen(
'projects/[projectId]/ref/[branchOrCommit]/sessions',
{
projectId,
branchOrCommit,
},
)
break
}

const tabTrigger = (
<TabsTrigger
value={tab.value}
className={styles.tabsTrigger}
disabled={isDisabled}
aria-disabled={isDisabled}
>
<Icon size={16} />
{tab.label}
</TabsTrigger>
)

return (
<Link
href={href}
key={tab.value}
aria-disabled={isDisabled}
tabIndex={isDisabled ? -1 : undefined}
>
{tabTrigger}
</Link>
)
})}
</TabsList>
<div className={styles.list}>
{PROJECT_TABS.map((tab) => (
<TabItem
key={tab.value}
item={tab}
projectId={projectId}
branchOrCommit={branchOrCommit}
schemaFilePath={project.schemaPath?.path}
/>
))}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
.link {
position: relative;
display: flex;
gap: var(--spacing-1half);
align-items: center;
padding: var(--spacing-1half) var(--spacing-3) var(--spacing-1half)
var(--spacing-2);
font-size: var(--font-size-4);
font-weight: 500;
color: var(--global-mute-text);
cursor: pointer;
background: transparent;
border: none;
border-radius: var(--border-radius-md);
transition: all 0.2s ease;
}

.link[aria-disabled='true'] {
color: var(--button-disabled-foreground);
background-color: transparent;
}

.link:hover:not([aria-disabled='true']) {
color: var(--global-foreground);
background-color: var(--global-muted-background);
}

.link[data-active='true'] {
color: var(--global-foreground);
}

.link[data-active='true']::after {
position: absolute;
bottom: -6px;
left: 0;
width: 100%;
height: 1px;
content: '';
background-color: var(--primary-accent);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { PROJECT_TAB, type ProjectTab } from '../projectConstants'
import { TabItem } from './TabItem'

const TABS: readonly [ProjectTab, ProjectTab, ProjectTab] = [
{ value: PROJECT_TAB.PROJECT, label: 'Project' },
{ value: PROJECT_TAB.SCHEMA, label: 'Schema' },
{ value: PROJECT_TAB.SESSIONS, label: 'Sessions' },
]

const meta = {
component: TabItem,
parameters: {
layout: 'centered',
nextjs: {
appDirectory: true,
navigation: {
pathname: '/projects/123/ref/main',
},
},
},
decorators: [
(Story) => (
<div style={{ display: 'flex', gap: '8px' }}>
<Story />
</div>
),
],
argTypes: {
item: {
description: 'Tab item configuration',
},
projectId: {
control: 'text',
description: 'Project ID',
},
branchOrCommit: {
control: 'text',
description: 'Branch name or commit hash',
},
schemaFilePath: {
control: 'text',
description: 'Schema file path (required for schema tab)',
},
},
args: {
projectId: 'my-project',
branchOrCommit: 'main',
item: TABS[0],
},
} satisfies Meta<typeof TabItem>

export default meta
type Story = StoryObj<typeof TabItem>

export const Default: Story = {
render: (args) => {
const [projectTab, schemaTab, sessionsTab] = TABS
return (
<>
<TabItem {...args} item={projectTab} />
<TabItem {...args} item={schemaTab} schemaFilePath="schema.sql" />
<TabItem {...args} item={sessionsTab} />
</>
)
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client'

import { BookMarked, ErdIcon, MessagesSquare } from '@liam-hq/ui'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { type FC, useMemo } from 'react'
import { match } from 'ts-pattern'
import { urlgen } from '../../../../libs/routes'
import type { ProjectTab, ProjectTabValue } from '../projectConstants'
import styles from './TabItem.module.css'

function getActiveTabFromPath(pathname: string): ProjectTabValue {
if (pathname.endsWith('/sessions')) return 'sessions'
if (pathname.includes('/schema/')) return 'schema'
return 'project'
}

function getIconComponent(tabValue: ProjectTabValue) {
return match(tabValue)
.with('project', () => BookMarked)
.with('schema', () => ErdIcon)
.with('sessions', () => MessagesSquare)
.exhaustive()
}

type Props = {
item: ProjectTab
projectId: string
branchOrCommit: string
schemaFilePath?: string
}

export const TabItem: FC<Props> = ({
item,
projectId,
branchOrCommit,
schemaFilePath,
}) => {
const pathname = usePathname()
const activeTab = getActiveTabFromPath(pathname)

const Icon = getIconComponent(item.value)
const isSchemaTab = item.value === 'schema'
const isDisabled = isSchemaTab && !schemaFilePath
const isActive = item.value === activeTab

const href = useMemo(() => {
return match(item.value)
.with('project', () =>
urlgen('projects/[projectId]/ref/[branchOrCommit]', {
projectId,
branchOrCommit,
}),
)
.with('schema', () =>
urlgen(
'projects/[projectId]/ref/[branchOrCommit]/schema/[...schemaFilePath]',
{
projectId,
branchOrCommit,
schemaFilePath: schemaFilePath || '',
},
),
)
.with('sessions', () =>
urlgen('projects/[projectId]/ref/[branchOrCommit]/sessions', {
projectId,
branchOrCommit,
}),
)
.exhaustive()
}, [item, projectId, branchOrCommit, schemaFilePath])

return (
<Link
href={href}
data-active={isActive}
aria-disabled={isDisabled}
tabIndex={isDisabled ? -1 : undefined}
className={styles.link}
>
<Icon size={16} />
Comment on lines +74 to +82

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block navigation when schema tab is disabled

The new tab component keeps rendering a Link with an href even when the schema tab is marked disabled (aria-disabled + tabIndex=-1). Without the pointer-event guard that existed previously, clicking the disabled schema tab still follows the link to /schema/, which 404s when no schema file is available. Consider omitting the href or preventing the click when schemaFilePath is undefined so the disabled state actually prevents navigation.

Useful? React with 👍 / 👎.

{item.label}
</Link>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TabItem'
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { BookMarked, ErdIcon, MessagesSquare } from '@liam-hq/ui'
import { type InferOutput, literal, union } from 'valibot'

export const PROJECT_TAB = {
Expand All @@ -7,22 +6,21 @@ export const PROJECT_TAB = {
SESSIONS: 'sessions',
} as const

export const ProjectTabSchema = union([
const ProjectTabSchema = union([
literal(PROJECT_TAB.PROJECT),
literal(PROJECT_TAB.SCHEMA),
literal(PROJECT_TAB.SESSIONS),
])

export type ProjectTabValue = InferOutput<typeof ProjectTabSchema>

type ProjectTab = {
export type ProjectTab = {
value: ProjectTabValue
label: string
icon: typeof BookMarked | typeof ErdIcon | typeof MessagesSquare
}

export const PROJECT_TABS: ProjectTab[] = [
{ value: PROJECT_TAB.PROJECT, label: 'Project', icon: BookMarked },
{ value: PROJECT_TAB.SCHEMA, label: 'Schema', icon: ErdIcon },
{ value: PROJECT_TAB.SESSIONS, label: 'Sessions', icon: MessagesSquare },
{ value: PROJECT_TAB.PROJECT, label: 'Project' },
{ value: PROJECT_TAB.SCHEMA, label: 'Schema' },
{ value: PROJECT_TAB.SESSIONS, label: 'Sessions' },
]
Loading