@woodsportal/hubspot-kit 5.0.2 → 5.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +2 -2
  3. package/scripts/tailwind/prefix-plugin.js +131 -182
  4. package/scripts/tailwind/prefix-plugin.test.mjs +110 -0
  5. package/scripts/tailwind/tailwind-content.js +29 -28
  6. package/scripts/vite/create-dev-server-config.mjs +14 -3
  7. package/scripts/vite/portal-build-shared.js +5 -3
  8. package/src/dev-module-config/__tests__/build-api-routes-custom-menu.test.ts +32 -0
  9. package/src/dev-module-config/__tests__/custom-menu.test.ts +77 -0
  10. package/src/dev-module-config/__tests__/drill-down-panels.test.ts +65 -0
  11. package/src/dev-module-config/__tests__/fixtures/golden-module.fixture.ts +2 -0
  12. package/src/dev-module-config/__tests__/flatten-visible-fields.test.ts +34 -1
  13. package/src/dev-module-config/__tests__/module-config-transform.test.ts +27 -0
  14. package/src/dev-module-config/runtime/module-config-runtime.ts +3 -1
  15. package/src/dev-module-config/schema/custom-menu.ts +124 -0
  16. package/src/dev-module-config/schema/validate-module-config.ts +2 -0
  17. package/src/dev-module-config/transform/build-hubspot-data.ts +14 -0
  18. package/src/dev-module-config/ui/FieldRenderer.tsx +2 -0
  19. package/src/dev-module-config/ui/ModuleConfigAnimatedPresence.tsx +4 -2
  20. package/src/dev-module-config/ui/ModuleConfigShell.tsx +16 -4
  21. package/src/dev-module-config/ui/editors/MenuOptionsEditor.tsx +34 -0
  22. package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +16 -11
  23. package/src/dev-module-config/ui/navigation/ModuleConfigNavContext.tsx +27 -23
  24. package/src/dev-module-config/ui/navigation/drill-down-panels.ts +48 -0
  25. package/src/routing/build-api-routes.ts +45 -15
  26. package/src/routing/custom-menu-path.ts +14 -0
  27. package/templates/module-hybrid-cdn/src/assets/css/main.prod.css +2 -2
@@ -135,11 +135,16 @@ function prodDefaultDataStub(mode) {
135
135
 
136
136
  export function portalPlugins(mode, { codeSplitting = false, routeFileIgnorePattern } = {}) {
137
137
  const routeIgnore = routeFileIgnorePattern ?? (isPrefix(mode) ? 'dev\\.' : undefined)
138
+ const prefixPlugins = isPrefix(mode)
139
+ ? [tailwindPrefixPlugin(), TailwindContentPlugin()]
140
+ : []
138
141
  return [
139
142
  prodDevModuleConfigRouteStub(mode),
140
143
  prodDevHubSpotLiveStub(mode),
141
144
  prodSdkTerminalLogSinksStub(mode),
142
145
  prodDefaultDataStub(mode),
146
+ // Prefix must run before React / compiler so JSX className is still in the source.
147
+ ...prefixPlugins,
143
148
  TanStackRouterVite({
144
149
  autoCodeSplitting: codeSplitting,
145
150
  ...(routeIgnore ? { routeFileIgnorePattern: routeIgnore } : {}),
@@ -152,9 +157,6 @@ export function portalPlugins(mode, { codeSplitting = false, routeFileIgnorePatt
152
157
  },
153
158
  }),
154
159
  tailwindcss(),
155
- ...(isPrefix(mode)
156
- ? [tailwindPrefixPlugin(), TailwindContentPlugin()]
157
- : []),
158
160
  ]
159
161
  }
160
162
 
@@ -0,0 +1,32 @@
1
+ import { buildApiRoutes } from '../../routing/build-api-routes'
2
+
3
+ describe('buildApiRoutes custom menu', () => {
4
+ it('maps custom menu items to opaque paths without ids', () => {
5
+ const routes = buildApiRoutes([
6
+ {
7
+ label: 'Help Center',
8
+ menuUrl: 'https://help.example.com',
9
+ icon: '<svg></svg>',
10
+ selectOption: 'iframe',
11
+ objectType: 'CUSTOM_MENU',
12
+ },
13
+ {
14
+ label: 'Contacts',
15
+ hubspotObjectTypeId: '0-1',
16
+ objectType: 'HUBSPOT_DEFINED',
17
+ },
18
+ ])
19
+
20
+ expect(routes[0]).toMatchObject({
21
+ path: 'custom-menu:help-center',
22
+ title: 'Help Center',
23
+ isCustomMenu: true,
24
+ selectOption: 'iframe',
25
+ menuUrl: 'https://help.example.com',
26
+ isRequiredAuth: false,
27
+ })
28
+ expect(routes[0]).not.toHaveProperty('customMenuId')
29
+ expect(routes[1]?.path).toBe('/0-1')
30
+ expect(routes[1]?.isCustomMenu).toBeFalsy()
31
+ })
32
+ })
@@ -0,0 +1,77 @@
1
+ import {
2
+ applyCustomMenuItemsToModule,
3
+ isValidMenuUrl,
4
+ normalizeMenuUrl,
5
+ resolveSelectOption,
6
+ sanitizeCustomMenuItem,
7
+ validateCustomMenuItems,
8
+ } from '../schema/custom-menu'
9
+
10
+ describe('custom-menu schema', () => {
11
+ it('prepends https:// when protocol is missing', () => {
12
+ expect(normalizeMenuUrl('docs.example.com')).toBe('https://docs.example.com')
13
+ expect(normalizeMenuUrl('https://docs.example.com')).toBe('https://docs.example.com')
14
+ })
15
+
16
+ it('validates normalized URLs', () => {
17
+ expect(isValidMenuUrl('https://docs.example.com')).toBe(true)
18
+ expect(isValidMenuUrl('not a url')).toBe(false)
19
+ })
20
+
21
+ it('resolves legacy open_in_new_tab checkbox values', () => {
22
+ expect(resolveSelectOption({ open_in_new_tab: true })).toBe('open_in_new_tab')
23
+ expect(resolveSelectOption({ open_in_new_tab: false })).toBe('iframe')
24
+ expect(resolveSelectOption({ select_option: 'iframe' })).toBe('iframe')
25
+ })
26
+
27
+ it('sanitizes custom menu items without an id field', () => {
28
+ const item = sanitizeCustomMenuItem({
29
+ id: 'legacy-id',
30
+ menu_name: ' Help ',
31
+ menu_url: 'help.example.com',
32
+ icon_svg_code: ' ',
33
+ select_option: 'iframe',
34
+ })
35
+
36
+ expect(item).toEqual({
37
+ menu_name: 'Help',
38
+ menu_url: 'https://help.example.com',
39
+ select_option: 'iframe',
40
+ })
41
+ expect('id' in item).toBe(false)
42
+ })
43
+
44
+ it('validates required menu fields', () => {
45
+ const errors = validateCustomMenuItems({
46
+ menu_options: {
47
+ custom_menu: true,
48
+ custom_menus: [{ menu_name: '', menu_url: '', select_option: 'iframe' }],
49
+ },
50
+ })
51
+
52
+ expect(errors.some((error) => error.path.endsWith('menu_name'))).toBe(true)
53
+ expect(errors.some((error) => error.path.endsWith('menu_url'))).toBe(true)
54
+ })
55
+
56
+ it('applies sanitized custom menus to module values and strips id', () => {
57
+ const next = applyCustomMenuItemsToModule({
58
+ menu_options: {
59
+ custom_menu: true,
60
+ custom_menus: [
61
+ {
62
+ id: 'legacy-id',
63
+ menu_name: 'Docs',
64
+ menu_url: 'docs.example.com',
65
+ select_option: 'iframe',
66
+ },
67
+ ],
68
+ },
69
+ })
70
+
71
+ const items = (next.menu_options as { custom_menus: Array<Record<string, unknown>> })
72
+ .custom_menus
73
+ expect(items).toHaveLength(1)
74
+ expect(items[0]?.menu_url).toBe('https://docs.example.com')
75
+ expect(items[0]?.id).toBeUndefined()
76
+ })
77
+ })
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ applyDrillDownPanelRegistration,
5
+ listDrillDownLayers,
6
+ } from '../ui/navigation/drill-down-panels'
7
+
8
+ describe('applyDrillDownPanelRegistration', () => {
9
+ it('appends a new panel to the stack', () => {
10
+ const card = { kind: 'card' }
11
+ const next = applyDrillDownPanelRegistration({}, [], 'card', card)
12
+
13
+ expect(next.order).toEqual(['card'])
14
+ expect(next.panels.card).toBe(card)
15
+ })
16
+
17
+ it('keeps ancestor panels when a nested panel is registered', () => {
18
+ const card = { kind: 'card' }
19
+ const property = { kind: 'property' }
20
+ const withCard = applyDrillDownPanelRegistration({}, [], 'card', card)
21
+ const withProperty = applyDrillDownPanelRegistration(
22
+ withCard.panels,
23
+ withCard.order,
24
+ 'property',
25
+ property,
26
+ )
27
+
28
+ expect(withProperty.order).toEqual(['card', 'property'])
29
+ expect(listDrillDownLayers(withProperty.panels, withProperty.order).map((layer) => layer.id)).toEqual([
30
+ 'card',
31
+ 'property',
32
+ ])
33
+ })
34
+
35
+ it('returns the same state when the panel reference is unchanged', () => {
36
+ const card = { kind: 'card' }
37
+ const withCard = applyDrillDownPanelRegistration({}, [], 'card', card)
38
+ const again = applyDrillDownPanelRegistration(withCard.panels, withCard.order, 'card', card)
39
+
40
+ expect(again.panels).toBe(withCard.panels)
41
+ expect(again.order).toBe(withCard.order)
42
+ })
43
+
44
+ it('removes only the nested panel so the ancestor stays on top', () => {
45
+ const card = { kind: 'card' }
46
+ const property = { kind: 'property' }
47
+ const withCard = applyDrillDownPanelRegistration({}, [], 'card', card)
48
+ const stacked = applyDrillDownPanelRegistration(
49
+ withCard.panels,
50
+ withCard.order,
51
+ 'property',
52
+ property,
53
+ )
54
+ const afterClose = applyDrillDownPanelRegistration(
55
+ stacked.panels,
56
+ stacked.order,
57
+ 'property',
58
+ null,
59
+ )
60
+
61
+ expect(afterClose.order).toEqual(['card'])
62
+ expect(afterClose.panels.card).toBe(card)
63
+ expect(afterClose.panels.property).toBeUndefined()
64
+ })
65
+ })
@@ -16,6 +16,7 @@ export const goldenModuleFixture: ModuleFieldValues = {
16
16
  menu_options: {
17
17
  hubspot_defined_object: true,
18
18
  hubspot_custom_object: false,
19
+ custom_menu: true,
19
20
  objects: [
20
21
  {
21
22
  tab_name: 'Contacts',
@@ -26,6 +27,7 @@ export const goldenModuleFixture: ModuleFieldValues = {
26
27
  },
27
28
  ],
28
29
  hubspot_custom_objects: [],
30
+ custom_menus: [],
29
31
  },
30
32
  other_options: {
31
33
  show_company_name: true,
@@ -4,7 +4,7 @@ import fieldsSchema from './fixtures/fields.json'
4
4
  import { goldenModuleFixture } from './fixtures/golden-module.fixture'
5
5
  import type { HubSpotFieldDefinition } from '../schema/types'
6
6
  import { HUBSPOT_ONLY_FIELD_NAMES, MODULE_CONFIG_UI_HIDDEN_FIELD_NAMES } from '../schema/types'
7
- import { flattenVisibleFields } from '../schema/visibility'
7
+ import { flattenVisibleFields, isFieldVisible } from '../schema/visibility'
8
8
 
9
9
  const goldenFields = fieldsSchema as Array<HubSpotFieldDefinition>
10
10
 
@@ -105,3 +105,36 @@ describe('flattenVisibleFields', () => {
105
105
  })
106
106
  })
107
107
  })
108
+
109
+ describe('isFieldVisible nested repeater items', () => {
110
+ const buttonNameField: HubSpotFieldDefinition = {
111
+ name: 'button_name',
112
+ label: 'Button Name',
113
+ type: 'text',
114
+ visibility: {
115
+ controlling_field_path: 'home.dashboard_cards.properties.property_value_show_as',
116
+ controlling_value_regex: 'button',
117
+ operator: 'EQUAL',
118
+ },
119
+ }
120
+
121
+ it('shows fields from nested Display Properties item values', () => {
122
+ expect(
123
+ isFieldVisible(buttonNameField, {
124
+ moduleValues: {},
125
+ repeaterPath: 'home.dashboard_cards.properties',
126
+ itemValues: { property_value_show_as: 'button' },
127
+ }),
128
+ ).toBe(true)
129
+ })
130
+
131
+ it('hides nested fields when the controlling item value does not match', () => {
132
+ expect(
133
+ isFieldVisible(buttonNameField, {
134
+ moduleValues: {},
135
+ repeaterPath: 'home.dashboard_cards.properties',
136
+ itemValues: { property_value_show_as: 'simpleText' },
137
+ }),
138
+ ).toBe(false)
139
+ })
140
+ })
@@ -41,6 +41,33 @@ describe('buildHubSpotDataFromFields', () => {
41
41
  expect(built.enableDashboardCards).toBe(true)
42
42
  expect(built.sidebarMenuOptions.length).toBeGreaterThan(0)
43
43
  })
44
+
45
+ it('maps enabled custom menu items into sidebarMenuOptions', () => {
46
+ const built = buildHubSpotDataFromFields({
47
+ ...goldenModuleFixture,
48
+ menu_options: {
49
+ ...(goldenModuleFixture.menu_options as Record<string, unknown>),
50
+ custom_menu: true,
51
+ custom_menus: [
52
+ {
53
+ menu_name: 'Help Center',
54
+ menu_url: 'help.example.com',
55
+ icon_svg_code: '<svg></svg>',
56
+ select_option: 'iframe',
57
+ },
58
+ ],
59
+ },
60
+ })
61
+
62
+ expect(built.sidebarMenuOptions).toContainEqual(
63
+ expect.objectContaining({
64
+ label: 'Help Center',
65
+ menuUrl: 'https://help.example.com',
66
+ selectOption: 'iframe',
67
+ objectType: 'CUSTOM_MENU',
68
+ }),
69
+ )
70
+ })
44
71
  })
45
72
 
46
73
  describe('buildCssVarsFromFields', () => {
@@ -19,6 +19,7 @@ import type { BuiltHubSpotData } from '../transform/build-hubspot-data'
19
19
  import type { HubSpotFieldDefinition, ModuleFieldValues } from '../schema/types'
20
20
  import type { HubSpotPortalData } from '@/hubspot/types'
21
21
  import { defaultData } from '@/defaultData'
22
+ import { applyCustomMenuItemsToModule } from '../schema/custom-menu'
22
23
 
23
24
  export type ModuleConfigState = {
24
25
  schema: Array<HubSpotFieldDefinition>
@@ -88,7 +89,8 @@ export async function updateModuleFieldValues(
88
89
  nextModuleValues: ModuleFieldValues,
89
90
  ): Promise<ModuleConfigState> {
90
91
  if (!state) throw new Error('Module config runtime not initialized')
91
- const overrides = deepMerge({}, nextModuleValues)
92
+ const sanitizedValues = applyCustomMenuItemsToModule(nextModuleValues)
93
+ const overrides = deepMerge({}, sanitizedValues)
92
94
  state = buildState(state.schema, state.defaults, overrides)
93
95
  await saveOverrides(overrides)
94
96
  applyRuntimeSideEffects(state)
@@ -0,0 +1,124 @@
1
+ import type { ModuleFieldValues } from './types'
2
+
3
+ export type OpenType = 'open_in_new_tab' | 'iframe'
4
+
5
+ export type CustomMenuItem = {
6
+ menu_name: string
7
+ menu_url: string
8
+ icon_svg_code?: string
9
+ select_option: OpenType
10
+ }
11
+
12
+ export function normalizeMenuUrl(raw: string): string {
13
+ const trimmed = raw.trim()
14
+ if (!trimmed) return ''
15
+ if (/^https?:\/\//i.test(trimmed)) return trimmed
16
+ return `https://${trimmed}`
17
+ }
18
+
19
+ export function isValidMenuUrl(url: string): boolean {
20
+ try {
21
+ const parsed = new URL(url)
22
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ export function resolveSelectOption(item: ModuleFieldValues): OpenType {
29
+ const value = item.select_option
30
+ if (value === 'iframe' || value === 'open_in_new_tab') {
31
+ return value
32
+ }
33
+ if (item.open_in_new_tab === true) {
34
+ return 'open_in_new_tab'
35
+ }
36
+ if (item.open_in_new_tab === false) {
37
+ return 'iframe'
38
+ }
39
+ return 'open_in_new_tab'
40
+ }
41
+
42
+ export function sanitizeCustomMenuItem(item: ModuleFieldValues): CustomMenuItem {
43
+ const menuName = String(item.menu_name ?? '').trim()
44
+ const menuUrl = normalizeMenuUrl(String(item.menu_url ?? ''))
45
+ const iconSvg = String(item.icon_svg_code ?? '').trim()
46
+
47
+ return {
48
+ menu_name: menuName,
49
+ menu_url: menuUrl,
50
+ icon_svg_code: iconSvg || undefined,
51
+ select_option: resolveSelectOption(item),
52
+ }
53
+ }
54
+
55
+ export function sanitizeCustomMenuItems(items: unknown): Array<CustomMenuItem> {
56
+ if (!Array.isArray(items)) return []
57
+
58
+ return items
59
+ .filter((item): item is ModuleFieldValues => item !== null && typeof item === 'object')
60
+ .map((item) => sanitizeCustomMenuItem(item))
61
+ }
62
+
63
+ export function applyCustomMenuItemsToModule(
64
+ moduleValues: ModuleFieldValues,
65
+ ): ModuleFieldValues {
66
+ const next = structuredClone(moduleValues)
67
+ const menuOptions = (next.menu_options ?? {}) as ModuleFieldValues
68
+ const items = sanitizeCustomMenuItems(menuOptions.custom_menus)
69
+ menuOptions.custom_menus = items
70
+ next.menu_options = menuOptions
71
+ return next
72
+ }
73
+
74
+ export function validateCustomMenuItems(
75
+ moduleValues: ModuleFieldValues,
76
+ ): Array<{ path: string; label: string; message: string }> {
77
+ const menuOptions = (moduleValues.menu_options ?? {}) as ModuleFieldValues
78
+ if (!menuOptions.custom_menu) return []
79
+
80
+ const items = Array.isArray(menuOptions.custom_menus)
81
+ ? (menuOptions.custom_menus as Array<ModuleFieldValues>)
82
+ : []
83
+ const errors: Array<{ path: string; label: string; message: string }> = []
84
+
85
+ items.forEach((item, index) => {
86
+ const basePath = `menu_options.custom_menus.${index}`
87
+ const menuName = String(item.menu_name ?? '').trim()
88
+ const menuUrlRaw = String(item.menu_url ?? '').trim()
89
+ const menuUrl = normalizeMenuUrl(menuUrlRaw)
90
+
91
+ if (!menuName) {
92
+ errors.push({
93
+ path: `${basePath}.menu_name`,
94
+ label: 'Menu Name',
95
+ message: 'Menu Name is required',
96
+ })
97
+ }
98
+
99
+ if (!menuUrlRaw) {
100
+ errors.push({
101
+ path: `${basePath}.menu_url`,
102
+ label: 'Menu URL',
103
+ message: 'Menu URL is required',
104
+ })
105
+ } else if (!isValidMenuUrl(menuUrl)) {
106
+ errors.push({
107
+ path: `${basePath}.menu_url`,
108
+ label: 'Menu URL',
109
+ message: 'Enter a valid URL',
110
+ })
111
+ }
112
+
113
+ const selectOption = resolveSelectOption(item)
114
+ if (selectOption !== 'open_in_new_tab' && selectOption !== 'iframe') {
115
+ errors.push({
116
+ path: `${basePath}.select_option`,
117
+ label: 'Select Option',
118
+ message: 'Select Option is required',
119
+ })
120
+ }
121
+ })
122
+
123
+ return errors
124
+ }
@@ -1,6 +1,7 @@
1
1
  import { isFieldVisible, type VisibilityContext } from '../schema/visibility'
2
2
  import { getNestedValue } from '../runtime/module-config-runtime'
3
3
  import type { HubSpotFieldDefinition, ModuleFieldValues } from '../schema/types'
4
+ import { validateCustomMenuItems } from '../schema/custom-menu'
4
5
 
5
6
  export type ValidationError = {
6
7
  path: string
@@ -71,6 +72,7 @@ export function validateModuleConfig(
71
72
  ): Array<ValidationError> {
72
73
  const errors: Array<ValidationError> = []
73
74
  validateFieldsAtPath(schema, moduleValues, '', { moduleValues }, errors)
75
+ errors.push(...validateCustomMenuItems(moduleValues))
74
76
 
75
77
  const seen = new Set<string>()
76
78
  return errors.filter((error) => {
@@ -1,5 +1,6 @@
1
1
  import type { ModuleFieldValues } from '../schema/types'
2
2
  import { normalizeRepeaterItems } from '../schema/normalize-repeater-items'
3
+ import { sanitizeCustomMenuItems } from '../schema/custom-menu'
3
4
 
4
5
  type ColorValue = { color?: string; opacity?: number | string }
5
6
 
@@ -154,6 +155,19 @@ function buildRouteLists(module: ModuleFieldValues) {
154
155
  }
155
156
  }
156
157
 
158
+ const customMenus = sanitizeCustomMenuItems(menuOptions?.custom_menus)
159
+ if (menuOptions?.custom_menu && customMenus.length > 0) {
160
+ for (const item of customMenus) {
161
+ dynamicRouteList.push({
162
+ label: item.menu_name,
163
+ menuUrl: item.menu_url,
164
+ icon: item.icon_svg_code || undefined,
165
+ selectOption: item.select_option,
166
+ objectType: 'CUSTOM_MENU',
167
+ })
168
+ }
169
+ }
170
+
157
171
  return { iframeList, dynamicSidebarList, dynamicRouteList }
158
172
  }
159
173
 
@@ -221,6 +221,8 @@ export function FieldRenderer({
221
221
  ? 'properties_value'
222
222
  : field.name === 'dashboard_cards'
223
223
  ? 'card_title'
224
+ : field.name === 'custom_menus'
225
+ ? 'menu_name'
224
226
  : 'list_object_name'
225
227
 
226
228
  return (
@@ -46,6 +46,8 @@ export function ModuleConfigAnimatedPresence({
46
46
 
47
47
  if (!mounted) return null
48
48
 
49
+ const rendered = show ? children : contentRef.current
50
+
49
51
  if (variant === 'slide') {
50
52
  const slideFrom = enterFrom === 'left' ? 'left' : 'right'
51
53
 
@@ -55,7 +57,7 @@ export function ModuleConfigAnimatedPresence({
55
57
  data-visible={visible ? 'true' : 'false'}
56
58
  data-enter-from={slideFrom}
57
59
  >
58
- {contentRef.current}
60
+ {rendered}
59
61
  </div>
60
62
  )
61
63
  }
@@ -66,7 +68,7 @@ export function ModuleConfigAnimatedPresence({
66
68
  visible ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-2 opacity-0'
67
69
  }`.trim()}
68
70
  >
69
- {contentRef.current}
71
+ {rendered}
70
72
  </div>
71
73
  )
72
74
  }
@@ -59,7 +59,7 @@ function ModuleConfigShellInner({
59
59
  discardDraft,
60
60
  confirmDiscardIfDirty,
61
61
  } = useModuleConfig()
62
- const { segments, drillDownPanel } = useModuleConfigNav()
62
+ const { segments, drillDownPanel, drillDownLayers } = useModuleConfigNav()
63
63
  const { theme, toggleTheme } = useModuleConfigUi()
64
64
  const [tab, setTab] = useState<Tab>('CONTENT')
65
65
  const [tabEnterFrom, setTabEnterFrom] = useState<ModuleConfigEnterFrom>('right')
@@ -294,13 +294,25 @@ function ModuleConfigShellInner({
294
294
  )
295
295
  })}
296
296
  </div>
297
+ {/* Keep ancestor layers mounted (hidden) so nested repeaters like Display Properties can drill in. */}
297
298
  <ModuleConfigAnimatedPresence
298
- show={Boolean(drillDownPanel)}
299
+ show={drillDownLayers.length > 0}
299
300
  variant="slide"
300
301
  enterFrom="right"
301
- className="absolute inset-0 z-10 overflow-y-auto bg-[var(--mc-bg-shell)] px-4 py-3"
302
+ className="absolute inset-0 z-10 overflow-hidden bg-[var(--mc-bg-shell)]"
302
303
  >
303
- {drillDownPanel}
304
+ {drillDownLayers.map((layer, index) => {
305
+ const isTop = index === drillDownLayers.length - 1
306
+ return (
307
+ <div
308
+ key={layer.id}
309
+ className={`absolute inset-0 overflow-y-auto px-4 py-3 ${isTop ? '' : 'hidden'}`}
310
+ aria-hidden={!isTop}
311
+ >
312
+ {layer.panel}
313
+ </div>
314
+ )
315
+ })}
304
316
  </ModuleConfigAnimatedPresence>
305
317
  </div>
306
318
 
@@ -24,13 +24,19 @@ export function MenuOptionsEditor({ field, moduleValues, onChange }: MenuOptions
24
24
  const objectsField = children.find((c) => c.name === 'objects')
25
25
  const customToggle = children.find((c) => c.name === 'hubspot_custom_object')
26
26
  const customObjectsField = children.find((c) => c.name === 'hubspot_custom_objects')
27
+ const customMenuToggle = children.find((c) => c.name === 'custom_menu')
28
+ const customMenusField = children.find((c) => c.name === 'custom_menus')
27
29
 
28
30
  const standardEnabled = Boolean(menuValues.hubspot_defined_object)
29
31
  const customEnabled = Boolean(menuValues.hubspot_custom_object)
32
+ const customMenuEnabled = Boolean(menuValues.custom_menu)
30
33
  const objects = Array.isArray(menuValues.objects) ? (menuValues.objects as Array<ModuleFieldValues>) : []
31
34
  const customObjects = Array.isArray(menuValues.hubspot_custom_objects)
32
35
  ? (menuValues.hubspot_custom_objects as Array<ModuleFieldValues>)
33
36
  : []
37
+ const customMenus = Array.isArray(menuValues.custom_menus)
38
+ ? (menuValues.custom_menus as Array<ModuleFieldValues>)
39
+ : []
34
40
 
35
41
  const setMenuValue = (key: string, value: unknown) => {
36
42
  onChange(`${menuPath}.${key}`, value)
@@ -98,6 +104,34 @@ export function MenuOptionsEditor({ field, moduleValues, onChange }: MenuOptions
98
104
  />
99
105
  ) : null}
100
106
  </ModuleConfigAnimatedPresence>
107
+
108
+ {customMenuToggle ? (
109
+ <HsToggleRow
110
+ label={customMenuToggle.label}
111
+ helpText={customMenuToggle.help_text}
112
+ required={customMenuToggle.required}
113
+ checked={customMenuEnabled}
114
+ onChange={(checked) => setMenuValue('custom_menu', checked)}
115
+ />
116
+ ) : null}
117
+
118
+ <ModuleConfigAnimatedPresence
119
+ show={Boolean(customMenuEnabled && customMenusField && isFieldVisible(customMenusField, moduleValues))}
120
+ enterFrom="bottom"
121
+ >
122
+ {customMenusField ? (
123
+ <HsOccurrenceGroupEditor
124
+ field={customMenusField}
125
+ items={customMenus}
126
+ moduleValues={moduleValues}
127
+ path={`${menuPath}.custom_menus`}
128
+ onChange={onChange}
129
+ labelField="menu_name"
130
+ sectionLabel="Custom Menu"
131
+ visibilityContext={{ moduleValues }}
132
+ />
133
+ ) : null}
134
+ </ModuleConfigAnimatedPresence>
101
135
  </ModuleConfigCollapsible>
102
136
  )
103
137
  }