@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.
- package/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/scripts/tailwind/prefix-plugin.js +131 -182
- package/scripts/tailwind/prefix-plugin.test.mjs +110 -0
- package/scripts/tailwind/tailwind-content.js +29 -28
- package/scripts/vite/create-dev-server-config.mjs +14 -3
- package/scripts/vite/portal-build-shared.js +5 -3
- package/src/dev-module-config/__tests__/build-api-routes-custom-menu.test.ts +32 -0
- package/src/dev-module-config/__tests__/custom-menu.test.ts +77 -0
- package/src/dev-module-config/__tests__/drill-down-panels.test.ts +65 -0
- package/src/dev-module-config/__tests__/fixtures/golden-module.fixture.ts +2 -0
- package/src/dev-module-config/__tests__/flatten-visible-fields.test.ts +34 -1
- package/src/dev-module-config/__tests__/module-config-transform.test.ts +27 -0
- package/src/dev-module-config/runtime/module-config-runtime.ts +3 -1
- package/src/dev-module-config/schema/custom-menu.ts +124 -0
- package/src/dev-module-config/schema/validate-module-config.ts +2 -0
- package/src/dev-module-config/transform/build-hubspot-data.ts +14 -0
- package/src/dev-module-config/ui/FieldRenderer.tsx +2 -0
- package/src/dev-module-config/ui/ModuleConfigAnimatedPresence.tsx +4 -2
- package/src/dev-module-config/ui/ModuleConfigShell.tsx +16 -4
- package/src/dev-module-config/ui/editors/MenuOptionsEditor.tsx +34 -0
- package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +16 -11
- package/src/dev-module-config/ui/navigation/ModuleConfigNavContext.tsx +27 -23
- package/src/dev-module-config/ui/navigation/drill-down-panels.ts +48 -0
- package/src/routing/build-api-routes.ts +45 -15
- package/src/routing/custom-menu-path.ts +14 -0
- 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
|
|
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
|
|
|
@@ -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
|
-
{
|
|
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
|
-
{
|
|
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={
|
|
299
|
+
show={drillDownLayers.length > 0}
|
|
299
300
|
variant="slide"
|
|
300
301
|
enterFrom="right"
|
|
301
|
-
className="absolute inset-0 z-10 overflow-
|
|
302
|
+
className="absolute inset-0 z-10 overflow-hidden bg-[var(--mc-bg-shell)]"
|
|
302
303
|
>
|
|
303
|
-
{
|
|
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
|
}
|