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
10 changes: 10 additions & 0 deletions apps/web/src/components/admin/settings/inline-spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ArrowPathIcon } from '@heroicons/react/24/solid'

/**
* Tiny inline saving indicator used by debounced-save settings pages.
* Renders nothing when `visible` is false.
*/
export function InlineSpinner({ visible }: { visible: boolean }) {
if (!visible) return null
return <ArrowPathIcon className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
}
2 changes: 2 additions & 0 deletions apps/web/src/components/admin/settings/settings-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
BeakerIcon,
BookOpenIcon,
TagIcon,
MegaphoneIcon,
} from '@heroicons/react/24/solid'
import { cn } from '@/lib/shared/utils'
import type { FeatureFlags } from '@/lib/shared/types'
Expand Down Expand Up @@ -56,6 +57,7 @@ export function buildNavSections(flags?: { helpCenter?: boolean }): NavSection[]
label: 'Customization',
items: [
{ label: 'Branding', to: '/admin/settings/branding', icon: PaintBrushIcon },
{ label: 'Portal', to: '/admin/settings/portal', icon: MegaphoneIcon },
{ label: 'Widget', to: '/admin/settings/portal-widget', icon: ChatBubbleLeftRightIcon },
],
},
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,49 @@ vi.mock('@/lib/server/functions/auth', () => ({
'Single sign-on is configured for your domain but is not currently available. Contact your administrator.',
}))

// input-otp schedules three real setTimeouts (0/10/50 ms) on mount for
// password-manager detection. Under happy-dom those timers can fire
// after the test env has torn down `window`, surfacing as an unhandled
// "window is not defined" error that fails the run. The tests here
// don't exercise OTP-input behaviour — they just fire `change` on the
// rendered control — so stub the library with a plain text input.
vi.mock('@/components/ui/input-otp', () => ({
InputOTP: ({
children,
onChange,
onComplete,
value,
maxLength,
['aria-label']: ariaLabel,
...rest
}: {
children?: React.ReactNode
onChange?: (value: string) => void
onComplete?: (value: string) => void
value?: string
maxLength?: number
'aria-label'?: string
} & React.InputHTMLAttributes<HTMLInputElement>) => (
<>
<input
aria-label={ariaLabel}
value={value ?? ''}
maxLength={maxLength}
onChange={(e) => {
const next = e.target.value
onChange?.(next)
if (maxLength && next.length === maxLength) onComplete?.(next)
}}
{...rest}
/>
{children}
</>
),
InputOTPGroup: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
InputOTPSlot: () => null,
InputOTPSeparator: () => null,
}))

import { PortalAuthForm } from '../portal-auth-form'
import { authClient } from '@/lib/client/auth-client'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PortalWelcomeCard } from '../portal-welcome-card'
import type { PortalWelcomeCard as PortalWelcomeCardData } from '@/lib/shared/types/settings'

const emptyBody = { type: 'doc', content: [{ type: 'paragraph' }] }

const richBody = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Tell us what you would like to see next.' }],
},
],
}

describe('<PortalWelcomeCard>', () => {
it('renders nothing when welcomeCard is undefined', () => {
const { container } = render(<PortalWelcomeCard welcomeCard={undefined} />)
expect(container.firstChild).toBeNull()
})

it('renders nothing when disabled', () => {
const data: PortalWelcomeCardData = {
enabled: false,
title: 'Share your product feedback!',
body: richBody,
}
const { container } = render(<PortalWelcomeCard welcomeCard={data} />)
expect(container.firstChild).toBeNull()
})

it('renders nothing when enabled but title and body are both empty', () => {
const data: PortalWelcomeCardData = {
enabled: true,
title: ' ',
body: emptyBody,
}
const { container } = render(<PortalWelcomeCard welcomeCard={data} />)
expect(container.firstChild).toBeNull()
})

it('renders title only when body is empty', () => {
const data: PortalWelcomeCardData = {
enabled: true,
title: 'Share your product feedback!',
body: emptyBody,
}
render(<PortalWelcomeCard welcomeCard={data} />)
expect(screen.getByRole('heading', { name: 'Share your product feedback!' })).toBeDefined()
})

it('renders body only when title is empty', () => {
const data: PortalWelcomeCardData = {
enabled: true,
title: '',
body: richBody,
}
render(<PortalWelcomeCard welcomeCard={data} />)
expect(screen.queryByRole('heading')).toBeNull()
expect(screen.getByText(/Tell us what you would like to see next\./)).toBeDefined()
})

it('renders both title and body when both are populated', () => {
const data: PortalWelcomeCardData = {
enabled: true,
title: 'Share your product feedback!',
body: richBody,
}
render(<PortalWelcomeCard welcomeCard={data} />)
expect(screen.getByRole('heading', { name: 'Share your product feedback!' })).toBeDefined()
expect(screen.getByText(/Tell us what you would like to see next\./)).toBeDefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll'
import { Spinner } from '@/components/shared/spinner'
import { useRouter, useRouteContext } from '@tanstack/react-router'
import { FeedbackHeader } from '@/components/public/feedback/feedback-header'
import { PortalWelcomeCard } from '@/components/public/feedback/portal-welcome-card'
import { FeedbackSidebar } from '@/components/public/feedback/feedback-sidebar'
import { FeedbackToolbar } from '@/components/public/feedback/feedback-toolbar'
import {
Expand All @@ -13,6 +14,7 @@ import {
import { usePublicFilters } from '@/components/public/feedback/use-public-filters'
import { PostCard } from '@/components/public/post-card'
import type { BoardWithStats } from '@/lib/shared/types'
import type { PortalWelcomeCard as PortalWelcomeCardData } from '@/lib/shared/types/settings'
import type { PostStatusEntity, Tag } from '@/lib/shared/db-types'
import { useAuthBroadcast } from '@/lib/client/hooks/use-auth-broadcast'
import {
Expand Down Expand Up @@ -40,6 +42,8 @@ interface FeedbackContainerProps {
user?: { name: string | null; email: string } | null
/** Whether anonymous voting is enabled (visitors can vote without signing in) */
anonymousVotingEnabled?: boolean
/** Welcome card to render above the post list. Undefined / disabled = hidden. */
welcomeCard?: PortalWelcomeCardData
}

export function FeedbackContainer({
Expand All @@ -57,6 +61,7 @@ export function FeedbackContainer({
defaultBoardId,
user,
anonymousVotingEnabled = false,
welcomeCard,
}: FeedbackContainerProps): React.ReactElement {
const intl = useIntl()
const router = useRouter()
Expand Down Expand Up @@ -208,6 +213,8 @@ export function FeedbackContainer({
<div className="py-6">
<div className="flex gap-8">
<div className="flex-1 min-w-0">
<PortalWelcomeCard welcomeCard={welcomeCard} />

<FeedbackHeader
workspaceName={workspaceName}
boards={boards}
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/public/feedback/portal-welcome-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { memo } from 'react'
import { RichTextContent } from '@/components/ui/rich-text-editor'
import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc'
import { cn } from '@/lib/shared/utils'
import type { PortalWelcomeCard as PortalWelcomeCardData } from '@/lib/shared/types/settings'

interface PortalWelcomeCardProps {
welcomeCard: PortalWelcomeCardData | undefined
}

function PortalWelcomeCardImpl({ welcomeCard }: PortalWelcomeCardProps) {
if (!welcomeCard?.enabled) return null
const trimmedTitle = welcomeCard.title.trim()
const hasTitle = trimmedTitle.length > 0
const hasBody = !isEmptyTiptapDoc(welcomeCard.body)
if (!hasTitle && !hasBody) return null

return (
<section
aria-labelledby={hasTitle ? 'portal-welcome-title' : undefined}
className="mb-6 rounded-xl border border-border/60 bg-card/60 p-5 sm:p-6"
>
{hasTitle && (
<h2 id="portal-welcome-title" className="text-xl sm:text-2xl font-semibold tracking-tight">
{trimmedTitle}
</h2>
)}
{hasBody && (
<RichTextContent
content={welcomeCard.body}
className={cn(hasTitle && 'mt-2 text-muted-foreground')}
/>
)}
</section>
)
}

// Body rendering goes through DOMPurify.sanitize and TipTap's
// generateContentHTML, so memoize on the welcomeCard reference to keep
// the admin live-preview cheap on title-only keystrokes.
export const PortalWelcomeCard = memo(PortalWelcomeCardImpl)
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest'
import { ValidationError } from '@/lib/shared/errors'
import { mergeWelcomeCard, normalizeWelcomeCardInput, publicWelcomeCard } from '../settings.helpers'
import { DEFAULT_PORTAL_CONFIG } from '../settings.types'

describe('normalizeWelcomeCardInput', () => {
it('returns the input unchanged when undefined', () => {
expect(normalizeWelcomeCardInput(undefined)).toBeUndefined()
})

it('passes through enabled with no validation', () => {
const out = normalizeWelcomeCardInput({ enabled: true })
expect(out).toEqual({ enabled: true })
})

it('trims the title', () => {
const out = normalizeWelcomeCardInput({ title: ' Hello ' })
expect(out?.title).toBe('Hello')
})

it('rejects a title longer than 120 chars', () => {
expect(() => normalizeWelcomeCardInput({ title: 'a'.repeat(121) })).toThrow(ValidationError)
})

it('accepts a title of exactly 120 chars', () => {
const out = normalizeWelcomeCardInput({ title: 'a'.repeat(120) })
expect(out?.title?.length).toBe(120)
})

it('strips disallowed nodes from the body', () => {
const out = normalizeWelcomeCardInput({
body: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'safe' }] },
// Disallowed node type — must be stripped.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ type: 'rogueNode', attrs: { evil: 'true' } } as any,
],
},
})
const body = out?.body
expect(body?.type).toBe('doc')
const types = body?.content?.map((c) => c.type) ?? []
expect(types).not.toContain('rogueNode')
expect(types).toContain('paragraph')
})

it('returns an empty doc when body sanitizes to nothing usable', () => {
const out = normalizeWelcomeCardInput({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
body: { type: 'notDoc' } as any,
})
expect(out?.body).toEqual({ type: 'doc' })
})
})

describe('mergeWelcomeCard', () => {
const seed = DEFAULT_PORTAL_CONFIG.welcomeCard!

it('returns existing when partial is undefined', () => {
expect(mergeWelcomeCard(seed, undefined)).toBe(seed)
})

it('overrides scalar fields shallowly', () => {
const out = mergeWelcomeCard(seed, { enabled: true, title: 'Hi' })
expect(out.enabled).toBe(true)
expect(out.title).toBe('Hi')
expect(out.body).toEqual(seed.body)
})

it('replaces the body wholesale rather than deep-merging it', () => {
const existing = {
enabled: true,
title: 'Old',
body: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'old text' }],
},
],
},
}
const out = mergeWelcomeCard(existing, { body: { type: 'doc' } })
expect(out.body).toEqual({ type: 'doc' })
expect(out.body.content).toBeUndefined()
expect(out.title).toBe('Old')
})

it('falls back to defaults when there is no existing card', () => {
const out = mergeWelcomeCard(undefined, { enabled: true })
expect(out.enabled).toBe(true)
expect(out.title).toBe(seed.title)
expect(out.body).toEqual(seed.body)
})
})

describe('publicWelcomeCard', () => {
it('returns undefined when the card is undefined', () => {
expect(publicWelcomeCard(undefined)).toBeUndefined()
})

it('returns undefined when the card is disabled — never expose drafts', () => {
expect(
publicWelcomeCard({ enabled: false, title: 'draft', body: { type: 'doc' } })
).toBeUndefined()
})

it('returns the card verbatim when enabled', () => {
const card = { enabled: true, title: 'Hi', body: { type: 'doc' } }
expect(publicWelcomeCard(card)).toEqual(card)
})
})
Loading