Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
85d5589
Update donate enable option to be the same as other topLevelNav
rchlfryn Mar 23, 2026
4a650e7
Add banner description components to replace overlooked descriptions
rchlfryn Mar 23, 2026
9a74a54
Update donate button
rchlfryn Mar 23, 2026
a451a32
Replace top level automated nav items with built-in pages (minus fore…
rchlfryn Mar 23, 2026
616e1d7
Add ability for nav item to not have subnav items (purely UI change)
rchlfryn Mar 23, 2026
1c400f1
Update forecast tab to use built in pages
rchlfryn Mar 23, 2026
4503f60
Sort pages, built-in pages, and posts by title in reference dropdown
rchlfryn Mar 23, 2026
49d192c
Add migration
rchlfryn Mar 23, 2026
c134389
Add script to add built in pages on prod
rchlfryn Mar 23, 2026
b6eba56
Change read only nav items to be editable by superAdmin
rchlfryn Mar 23, 2026
1572b6b
Update nav to actually use built-in pages with hardcoded as fallback …
rchlfryn Mar 23, 2026
bab3fe3
Update one time script to use sql instead of update
rchlfryn Mar 23, 2026
4e7fda4
Add isInNav boolean to prevent users from accidentally deleting built…
rchlfryn Mar 23, 2026
a829fd1
Update migration to include isInNav
rchlfryn Mar 23, 2026
018e820
TODO - figure out why nav is incorrect after one time seed
rchlfryn Mar 23, 2026
e4e34c5
Merge branch 'main' into nav-updates
rchlfryn Mar 25, 2026
35efe60
Redo migration to fix json
rchlfryn Mar 25, 2026
60a9781
Remove unnecessary fallback for migration
rchlfryn Mar 25, 2026
6d186fc
Remove type assertions
rchlfryn Mar 25, 2026
1590fc2
Add obs fallback
rchlfryn Mar 25, 2026
4b3811b
Make tenant slug immutable after creation
rchlfryn Mar 26, 2026
b1f932b
Revert "Make tenant slug immutable after creation"
rchlfryn Apr 6, 2026
6a8dae9
Rename/restructure readonly functionality to be hasLandingPage
rchlfryn Apr 15, 2026
7d5d3f2
Remove deletion warning
rchlfryn Apr 15, 2026
83ab063
Merge branch 'main' into nav-updates
rchlfryn Apr 15, 2026
3cbaa09
Redo migration and use backfill migration to avoid using fallbacks
rchlfryn Apr 15, 2026
95c5af0
Fix bad migration
rchlfryn Apr 15, 2026
9cafb76
Clean up use of hasLandingPage & add singleLinkNavTab
rchlfryn Apr 16, 2026
17413de
Update migrations
rchlfryn Apr 16, 2026
fd24c2c
Unify navigation helper files into navTab adding displayMode
rchlfryn Apr 17, 2026
86efaf0
Merge branch 'main' into nav-updates
rchlfryn Apr 18, 2026
158e76a
Update migrations
rchlfryn Apr 18, 2026
f02d029
Treat donate button the same as other tabs and allow button to be put…
rchlfryn Apr 18, 2026
714762c
Add hook to clean unused tab data
rchlfryn Apr 19, 2026
130a514
Merge branch 'main' into nav-updates
rchlfryn Apr 20, 2026
d71d920
Merge branch 'main' into nav-updates
rchlfryn Apr 20, 2026
3f166f1
Refactor and move topLevelNavItem to be in utils-pure
rchlfryn Apr 21, 2026
28b4127
Fix migration for #1011
rchlfryn Apr 22, 2026
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
203 changes: 203 additions & 0 deletions __tests__/server/headerUtils.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import {
findNavigationItemBySlug,
getCanonicalUrlsFromNavigation,
getNavigationPathForSlug,
topLevelNavItem,
} from '../../src/components/Header/utils-pure'

import type { TopLevelNavItem } from '../../src/components/Header/utils'
import type { Navigation } from '../../src/payload-types'

type NavTab = Navigation['weather']

describe('Header Utilities', () => {
const mockNavItems: TopLevelNavItem[] = [
Expand Down Expand Up @@ -530,4 +534,203 @@ describe('Header Utilities', () => {
expect(result.excludedSlugs).toEqual(['about', 'unique-page'])
})
})

describe('topLevelNavItem — displayMode branching', () => {
const internalLink: NonNullable<NonNullable<NavTab>['link']> = {
type: 'internal',
url: '/donate-membership',
label: 'Donate',
}

describe("displayMode: 'dropdown'", () => {
it('returns an entry with items (no top-level link)', () => {
const tab: NavTab = {
options: { displayMode: 'dropdown' },
items: [
{
id: 'learn',
link: { type: 'internal', url: '/learn', label: 'Learn' },
},
],
}

const result = topLevelNavItem({ tab, label: 'Education' })

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
displayMode: 'dropdown',
label: 'Education',
items: [
{
id: 'learn',
link: { type: 'internal', url: '/education/learn', label: 'Learn' },
},
],
})
})

it('returns empty array when dropdown has no items', () => {
const tab: NavTab = {
options: { displayMode: 'dropdown' },
items: [],
}

expect(topLevelNavItem({ tab, label: 'Education' })).toEqual([])
})

it('defaults to dropdown when displayMode is not set', () => {
const tab: NavTab = {
items: [
{
id: 'learn',
link: { type: 'internal', url: '/learn', label: 'Learn' },
},
],
}

const result = topLevelNavItem({ tab, label: 'Education' })

expect(result).toHaveLength(1)
expect(result[0].displayMode).toBe('dropdown')
expect(result[0].items).toBeDefined()
})
})

describe("displayMode: 'link'", () => {
it('returns a single entry with the top-level link and no items', () => {
const tab: NavTab = {
options: { displayMode: 'link' },
link: { type: 'internal', url: '/blog', label: 'Blog' },
}

const result = topLevelNavItem({ tab, label: 'Blog' })

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
displayMode: 'link',
link: { type: 'internal', url: '/blog', label: 'Blog' },
label: 'Blog',
})
expect(result[0].items).toBeUndefined()
})

it('returns empty array when link mode has no link', () => {
const tab: NavTab = {
options: { displayMode: 'link' },
}

expect(topLevelNavItem({ tab, label: 'Blog' })).toEqual([])
})

it('ignores items when mode is link', () => {
const tab: NavTab = {
options: { displayMode: 'link' },
link: { type: 'internal', url: '/blog', label: 'Blog' },
items: [
{
id: 'ignored',
link: { type: 'internal', url: '/ignored', label: 'Ignored' },
},
],
}

const result = topLevelNavItem({ tab, label: 'Blog' })

expect(result).toHaveLength(1)
expect(result[0].items).toBeUndefined()
})
})

describe("displayMode: 'button'", () => {
it('returns a single entry flagged as a button with the link', () => {
const tab: NavTab = {
options: { displayMode: 'button' },
link: internalLink,
}

const result = topLevelNavItem({ tab, label: 'Donate' })

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
displayMode: 'button',
link: { type: 'internal', url: '/donate-membership', label: 'Donate' },
label: 'Donate',
})
})

it('returns empty array when button mode has no link', () => {
const tab: NavTab = {
options: { displayMode: 'button' },
}

expect(topLevelNavItem({ tab, label: 'Donate' })).toEqual([])
})

it('preserves external link details (newTab) for button mode', () => {
const tab: NavTab = {
options: { displayMode: 'button' },
link: {
type: 'external',
url: 'https://example.com/donate',
label: 'Donate',
newTab: true,
},
}

const result = topLevelNavItem({ tab, label: 'Donate' })

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
displayMode: 'button',
link: {
type: 'external',
url: 'https://example.com/donate',
label: 'Donate',
newTab: true,
},
label: 'Donate',
})
})
})

describe('enabled toggle', () => {
it('returns empty array when the tab is disabled regardless of mode', () => {
const linkTab: NavTab = {
options: { displayMode: 'link', enabled: false },
link: { type: 'internal', url: '/blog', label: 'Blog' },
}
const buttonTab: NavTab = {
options: { displayMode: 'button', enabled: false },
link: internalLink,
}
const dropdownTab: NavTab = {
options: { displayMode: 'dropdown', enabled: false },
items: [
{
id: 'learn',
link: { type: 'internal', url: '/learn', label: 'Learn' },
},
],
}

expect(topLevelNavItem({ tab: linkTab, label: 'Blog' })).toEqual([])
expect(topLevelNavItem({ tab: buttonTab, label: 'Donate' })).toEqual([])
expect(topLevelNavItem({ tab: dropdownTab, label: 'Education' })).toEqual([])
})

it('renders the tab when enabled is true', () => {
const tab: NavTab = {
options: { displayMode: 'link', enabled: true },
link: { type: 'internal', url: '/blog', label: 'Blog' },
}

expect(topLevelNavItem({ tab, label: 'Blog' })).toHaveLength(1)
})
})

it('returns empty array when the tab is missing', () => {
// NavTab is an optional field — confirm the function handles undefined gracefully
expect(topLevelNavItem({ tab: undefined, label: 'Weather' })).toEqual([])
})
})
})
1 change: 0 additions & 1 deletion consistent-type-assertions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ src/collections/Users/components/InviteUser.tsx
src/collections/Users/components/InviteUserDrawer.tsx
src/collections/Users/components/inviteUserAction.ts
src/collections/Users/components/resendInviteActions.ts
src/components/Header/utils.ts
src/endpoints/seed/upsert.ts
src/globals/Diagnostics/actions/revalidateCache.ts
src/utilities/removeNonDeterministicKeys.ts
2 changes: 2 additions & 0 deletions src/app/(payload)/admin/importMap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/collections/BuiltInPages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const BuiltInPages: CollectionConfig<'pages'> = {
group: 'Content',
useAsTitle: 'title',
baseListFilter: filterByTenant,
defaultColumns: ['title', 'url', 'tenant'],
},
fields: [
titleField(),
Expand Down
7 changes: 3 additions & 4 deletions src/collections/Navigations/fields/itemsField.ts
Comment thread
rchlfryn marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { navLink } from '@/fields/navLink'
import { merge } from 'lodash-es'
import { ArrayField, FieldHook } from 'payload'
import { ArrayField, Condition, FieldHook } from 'payload'

// Condition: show field only when item has sub-items (accordion/section mode)
const hasSubItems = (_data: unknown, siblingData: Record<string, unknown>) =>
const hasSubItems: Condition = (_, siblingData) =>
Array.isArray(siblingData?.items) && siblingData.items.length > 0

// Copy link.label to standalone label when sub-items are added,
Expand Down Expand Up @@ -66,8 +66,7 @@ export const itemsField = ({
...navLink,
admin: {
...navLink.admin,
condition: (data: unknown, siblingData: Record<string, unknown>) =>
!hasSubItems(data, siblingData),
condition: (...args: Parameters<Condition>) => !hasSubItems(...args),
},
hooks: {
// navLink.hooks contains clearIrrelevantLinkValues; we add our cleanup hook
Expand Down
107 changes: 107 additions & 0 deletions src/collections/Navigations/fields/navTab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { navLink } from '@/fields/navLink'
import { Field, Tab, toWords } from 'payload'
import { itemsField } from './itemsField'

export type DisplayMode = 'dropdown' | 'link' | 'button'

const isDisplayMode = (value: unknown): value is DisplayMode =>
value === 'dropdown' || value === 'link' || value === 'button'

const MODE_OPTIONS: { label: string; value: DisplayMode }[] = [
{ label: 'Dropdown — a menu of sub-items', value: 'dropdown' },
{ label: 'Link — a single clickable link to one page', value: 'link' },
{ label: 'Button — a styled call-to-action button', value: 'button' },
]

const getMode = (siblingData: unknown): DisplayMode | null => {
if (typeof siblingData !== 'object' || siblingData === null) return null
if (!('options' in siblingData)) return null
const options = siblingData.options
if (typeof options !== 'object' || options === null) return null
if (!('displayMode' in options)) return null
return isDisplayMode(options.displayMode) ? options.displayMode : null
}

/**
* Unified helper for navigation tabs. Every tab has the same shape; its rendering
* (dropdown, single link, or button) is controlled by the `options.displayMode` field.
*/
export const navTab = ({
name,
description,
defaultMode = 'dropdown',
hasEnabledToggle = true,
enabledToggleDescription = 'If hidden, pages with links in this nav item will not be accessible at their navigation-nested URLs.',
}: {
name: string
description?: string
defaultMode?: DisplayMode
hasEnabledToggle?: boolean
enabledToggleDescription?: string
}): Tab => {
const displayModeField: Field = {
name: 'displayMode',
type: 'radio',
defaultValue: defaultMode,
options: MODE_OPTIONS,
admin: {
layout: 'vertical',
},
}

const enabledField: Field = {
type: 'checkbox',
defaultValue: true,
name: 'enabled',
label: 'Show in navigation',
admin: { description: enabledToggleDescription },
}

const optionsGroup: Field = {
type: 'group',
name: 'options',
fields: hasEnabledToggle ? [displayModeField, enabledField] : [displayModeField],
}

const linkField: Field = {
...navLink,
label: '',
admin: {
...navLink.admin,
condition: (_, siblingData) => {
const mode = getMode(siblingData)
return mode === 'link' || mode === 'button'
},
},
}

const items: Field = itemsField({
label: `${toWords(name)} Nav Items`,
description: `Dropdown items under ${toWords(name)}`,
overrides: {
admin: {
condition: (_, siblingData) => getMode(siblingData) === 'dropdown',
},
},
})

let fields: Field[] = [optionsGroup, linkField, items]

if (description) {
const descriptionField: Field = {
type: 'ui',
name: `${name}Description`,
admin: {
components: {
Field: {
path: '@/components/BannerDescription#BannerDescription',
clientProps: { message: description, type: 'info' },
},
},
},
}
fields = [descriptionField, ...fields]
}

return { name, fields }
}
Loading
Loading