@hubspot/ui-extensions 0.11.6 → 0.12.0

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.
@@ -0,0 +1,139 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createRenderer } from "../../testing/index.js";
4
+ import { createRemoteComponentInternal } from "../index.js";
5
+ import { __hubSpotComponentRegistry } from "../../shared/remoteComponents.js";
6
+ import { Button, Text } from "../../index.js";
7
+ import { InvalidComponentsError } from "../../testing/internal/errors.js";
8
+ import { useState } from 'react';
9
+ // Create custom components once at the top level
10
+ const CustomButton = createRemoteComponentInternal('TestCustomButton');
11
+ const CustomCard = createRemoteComponentInternal('TestCustomCard', {
12
+ fragmentProps: ['content'],
13
+ });
14
+ describe('createRemoteComponentInternal', () => {
15
+ it('should create a component that can be rendered', () => {
16
+ const { render, find } = createRenderer('crm.record.tab');
17
+ render(_jsx(CustomButton, { label: "Click me", onClick: () => { }, testId: "my-button" }));
18
+ const button = find(CustomButton);
19
+ expect(button.props.label).toBe('Click me');
20
+ expect(button.props.testId).toBe('my-button');
21
+ });
22
+ it('should register component in the registry', () => {
23
+ expect(__hubSpotComponentRegistry.isAllowedComponentName('TestCustomButton')).toBe(true);
24
+ });
25
+ it('should be findable using renderer.find()', () => {
26
+ const { render, find } = createRenderer('crm.record.tab');
27
+ render(_jsx(CustomButton, { label: "Find me", onClick: () => { } }));
28
+ const button = find(CustomButton);
29
+ expect(button).toBeDefined();
30
+ expect(button.props.label).toBe('Find me');
31
+ });
32
+ it('should work with find() using prop matchers', () => {
33
+ const { render, find } = createRenderer('crm.record.tab');
34
+ render(_jsxs(_Fragment, { children: [_jsx(CustomButton, { label: "Button 1", onClick: () => { }, variant: "primary" }), _jsx(CustomButton, { label: "Button 2", onClick: () => { }, variant: "secondary" })] }));
35
+ const primaryButton = find(CustomButton, { variant: 'primary' });
36
+ expect(primaryButton.props.label).toBe('Button 1');
37
+ const secondaryButton = find(CustomButton, { variant: 'secondary' });
38
+ expect(secondaryButton.props.label).toBe('Button 2');
39
+ });
40
+ it('should work with findAll()', () => {
41
+ const { render, findAll } = createRenderer('crm.record.tab');
42
+ render(_jsxs(_Fragment, { children: [_jsx(CustomButton, { label: "Button 1", onClick: () => { } }), _jsx(CustomButton, { label: "Button 2", onClick: () => { } }), _jsx(CustomButton, { label: "Button 3", onClick: () => { } })] }));
43
+ const buttons = findAll(CustomButton);
44
+ expect(buttons).toHaveLength(3);
45
+ expect(buttons[0].props.label).toBe('Button 1');
46
+ expect(buttons[1].props.label).toBe('Button 2');
47
+ expect(buttons[2].props.label).toBe('Button 3');
48
+ });
49
+ it('should work with findByTestId()', () => {
50
+ const { render, findByTestId } = createRenderer('crm.record.tab');
51
+ render(_jsx(CustomButton, { label: "Find by ID", onClick: () => { }, testId: "my-custom-button" }));
52
+ const button = findByTestId(CustomButton, 'my-custom-button');
53
+ expect(button.props.label).toBe('Find by ID');
54
+ });
55
+ it('should work with maybeFind()', () => {
56
+ const { render, maybeFind } = createRenderer('crm.record.tab');
57
+ render(_jsx(CustomButton, { label: "Maybe", onClick: () => { } }));
58
+ const button = maybeFind(CustomButton);
59
+ expect(button).not.toBeNull();
60
+ expect(button?.props.label).toBe('Maybe');
61
+ const nonExistent = maybeFind(Button);
62
+ expect(nonExistent).toBeNull();
63
+ });
64
+ it('should support fragment props', () => {
65
+ const { render, find } = createRenderer('crm.record.tab');
66
+ render(_jsx(CustomCard, { title: "My Card", content: _jsx(Text, { children: "Card content" }), testId: "my-card" }));
67
+ const card = find(CustomCard);
68
+ expect(card.props.title).toBe('My Card');
69
+ expect(card.props.testId).toBe('my-card');
70
+ expect(card.props.content?.find(Text).text).toBe('Card content');
71
+ });
72
+ it('should support event triggering via trigger()', () => {
73
+ const { render, find } = createRenderer('crm.record.tab');
74
+ let clicked = false;
75
+ const handleClick = () => {
76
+ clicked = true;
77
+ };
78
+ render(_jsx(CustomButton, { label: "Click me", onClick: handleClick }));
79
+ const button = find(CustomButton);
80
+ button.trigger('onClick');
81
+ expect(clicked).toBe(true);
82
+ });
83
+ it('should not throw InvalidComponentsError during rendering', () => {
84
+ const { render } = createRenderer('crm.record.tab');
85
+ expect(() => render(_jsx(CustomButton, { label: "Valid", onClick: () => { } }))).not.toThrow(InvalidComponentsError);
86
+ });
87
+ it('should work with state updates', () => {
88
+ const { render, find } = createRenderer('crm.record.tab');
89
+ function Counter() {
90
+ const [count, setCount] = useState(0);
91
+ const handleClick = () => {
92
+ setCount(count + 1);
93
+ };
94
+ return (_jsx(CustomButton, { label: `Count: ${count}`, onClick: handleClick, testId: "counter" }));
95
+ }
96
+ render(_jsx(Counter, {}));
97
+ const button = find(CustomButton);
98
+ expect(button.props.label).toBe('Count: 0');
99
+ button.trigger('onClick');
100
+ const updatedButton = find(CustomButton);
101
+ expect(updatedButton.props.label).toBe('Count: 1');
102
+ });
103
+ it('should work with nested components', () => {
104
+ const { render, find } = createRenderer('crm.record.tab');
105
+ render(_jsx(CustomCard, { title: "Card with button", content: _jsx(CustomButton, { label: "Nested button", onClick: () => { } }) }));
106
+ const card = find(CustomCard);
107
+ expect(card.props.title).toBe('Card with button');
108
+ // Fragment prop children are found globally, not as direct children
109
+ const nestedButton = find(CustomButton);
110
+ expect(nestedButton.props.label).toBe('Nested button');
111
+ });
112
+ it('should work with findChild() and findAllChildren()', () => {
113
+ const { render, findAll, find } = createRenderer('crm.record.tab');
114
+ render(_jsx(CustomCard, { title: "Card with buttons", content: _jsxs(_Fragment, { children: [_jsx(CustomButton, { label: "Button 1", onClick: () => { } }), _jsx(CustomButton, { label: "Button 2", onClick: () => { } })] }) }));
115
+ const card = find(CustomCard);
116
+ expect(card.props.title).toBe('Card with buttons');
117
+ // Fragment prop children are found globally, not as direct children
118
+ const buttons = findAll(CustomButton);
119
+ expect(buttons).toHaveLength(2);
120
+ expect(buttons[0].props.label).toBe('Button 1');
121
+ expect(buttons[1].props.label).toBe('Button 2');
122
+ });
123
+ it('should allow multiple custom components to coexist', () => {
124
+ const { render, find } = createRenderer('crm.record.tab');
125
+ render(_jsxs(_Fragment, { children: [_jsx(CustomButton, { label: "Button", onClick: () => { } }), _jsx(CustomCard, { title: "Card" })] }));
126
+ const button = find(CustomButton);
127
+ const card = find(CustomCard);
128
+ expect(button.props.label).toBe('Button');
129
+ expect(card.props.title).toBe('Card');
130
+ });
131
+ it('should work alongside built-in HubSpot components', () => {
132
+ const { render, find } = createRenderer('crm.record.tab');
133
+ render(_jsxs(_Fragment, { children: [_jsx(CustomButton, { label: "Custom", onClick: () => { } }), _jsx(Button, { variant: "primary", children: "Built-in" })] }));
134
+ const customButton = find(CustomButton);
135
+ const builtInButton = find(Button);
136
+ expect(customButton.props.label).toBe('Custom');
137
+ expect(builtInButton.props.variant).toBe('primary');
138
+ });
139
+ });
@@ -0,0 +1,35 @@
1
+ import type { HubSpotReactComponent, HubSpotReactFragmentProp, UnknownComponentProps } from '../shared/types/shared.ts';
2
+ /**
3
+ * Utility type for filtering out all of the prop names where the value is a ReactNode.
4
+ * We use this to know which props are allowed to be used as fragment props.
5
+ */
6
+ type ComponentFragmentPropName<TProps extends UnknownComponentProps> = {
7
+ [K in keyof TProps]: HubSpotReactFragmentProp extends Required<TProps>[K] ? K : never;
8
+ }[keyof TProps & string];
9
+ /**
10
+ * Options for creating a custom remote component.
11
+ */
12
+ export interface CreateRemoteComponentInternalOptions<TProps extends UnknownComponentProps = UnknownComponentProps> {
13
+ /**
14
+ * An array of prop names that are allowed to be used as fragment props (props that accept ReactNode children).
15
+ */
16
+ fragmentProps?: ComponentFragmentPropName<TProps>[];
17
+ }
18
+ /**
19
+ * Creates and registers a custom remote component for internal HubSpot use.
20
+ *
21
+ * This function is intended for HubSpot internal teams who need to create
22
+ * custom remote components that work with the UI Extensions testing utilities.
23
+ *
24
+ * Components created with this function will:
25
+ * - Be registered in the global component registry
26
+ * - Pass validation in testing utilities (isAllowedComponentName)
27
+ * - Work with renderer.find(), renderer.findAll(), and other testing utilities
28
+ *
29
+ * @param componentName - Unique name for the component (e.g., "FlywheelCustomButton").
30
+ * Recommended to prefix with team name to avoid collisions.
31
+ * @param options - Optional configuration including fragment props
32
+ * @returns A typed remote React component that can be rendered in a remote environment
33
+ */
34
+ export declare function createRemoteComponentInternal<TProps extends UnknownComponentProps>(componentName: string, options?: CreateRemoteComponentInternalOptions<TProps>): HubSpotReactComponent<TProps>;
35
+ export {};
@@ -0,0 +1,20 @@
1
+ import { __hubSpotComponentRegistry } from "../shared/remoteComponents.js";
2
+ /**
3
+ * Creates and registers a custom remote component for internal HubSpot use.
4
+ *
5
+ * This function is intended for HubSpot internal teams who need to create
6
+ * custom remote components that work with the UI Extensions testing utilities.
7
+ *
8
+ * Components created with this function will:
9
+ * - Be registered in the global component registry
10
+ * - Pass validation in testing utilities (isAllowedComponentName)
11
+ * - Work with renderer.find(), renderer.findAll(), and other testing utilities
12
+ *
13
+ * @param componentName - Unique name for the component (e.g., "FlywheelCustomButton").
14
+ * Recommended to prefix with team name to avoid collisions.
15
+ * @param options - Optional configuration including fragment props
16
+ * @returns A typed remote React component that can be rendered in a remote environment
17
+ */
18
+ export function createRemoteComponentInternal(componentName, options = {}) {
19
+ return __hubSpotComponentRegistry.createAndRegisterRemoteReactComponent(componentName, options);
20
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ import './clientTypes.ts';
2
2
  export { hubspot } from './hubspot.ts';
3
3
  export { logger } from './logger.ts';
4
4
  export * from './shared/types/index.ts';
5
- export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, SearchInput, Select, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from './shared/remoteComponents.tsx';
5
+ export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, ScoreCircle, SearchInput, Select, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from './shared/remoteComponents.tsx';
6
6
  export { useExtensionContext } from './hooks/useExtensionContext.tsx';
7
7
  export { useExtensionActions } from './hooks/useExtensionActions.tsx';
package/dist/index.js CHANGED
@@ -3,6 +3,6 @@ import "./clientTypes.js";
3
3
  export { hubspot } from "./hubspot.js";
4
4
  export { logger } from "./logger.js";
5
5
  export * from "./shared/types/index.js";
6
- export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, SearchInput, Select, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from "./shared/remoteComponents.js";
6
+ export { Accordion, Alert, AutoGrid, BarChart, Box, Button, ButtonRow, Card, Checkbox, CurrencyInput, DateInput, DescriptionList, DescriptionListItem, Divider, Dropdown, EmptyState, ErrorState, Flex, Form, Heading, Icon, Illustration, Image, Inline, Input, LineChart, Link, List, LoadingButton, LoadingSpinner, Modal, ModalBody, ModalFooter, MultiSelect, NumberInput, Panel, PanelBody, PanelFooter, PanelSection, ProgressBar, RadioButton, ScoreCircle, SearchInput, Select, Stack, Statistics, StatisticsItem, StatisticsTrend, StatusTag, StepIndicator, StepperInput, Tab, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, Tag, Text, TextArea, Textarea, Tile, TimeInput, Toggle, ToggleGroup, Tooltip, } from "./shared/remoteComponents.js";
7
7
  export { useExtensionContext } from "./hooks/useExtensionContext.js";
8
8
  export { useExtensionActions } from "./hooks/useExtensionActions.js";
@@ -494,6 +494,15 @@ export declare const BarChart: import("./types/shared.ts").HubSpotReactComponent
494
494
  * - {@link https://github.com/HubSpot/ui-extensions-examples/tree/main/charts-example Charts Example}
495
495
  */
496
496
  export declare const LineChart: import("./types/shared.ts").HubSpotReactComponent<componentTypes.ChartProps>;
497
+ /**
498
+ * The `ScoreCircle` component displays a score value (0-100) as a circular progress indicator with color-coded bands.
499
+ * Scores are color-coded: 0-32 (alert/red), 33-65 (warning/yellow), 66-100 (success/green).
500
+ * @example
501
+ * ```tsx
502
+ * <ScoreCircle score={75} />
503
+ * ```
504
+ */
505
+ export declare const ScoreCircle: import("./types/shared.ts").HubSpotReactComponent<componentTypes.ScoreProps>;
497
506
  /**
498
507
  * `Tabs` allow you to group related content in a compact space, allowing users to switch between views without leaving the page.
499
508
  * @example
@@ -504,6 +504,15 @@ export const BarChart = createAndRegisterRemoteReactComponent('BarChart');
504
504
  * - {@link https://github.com/HubSpot/ui-extensions-examples/tree/main/charts-example Charts Example}
505
505
  */
506
506
  export const LineChart = createAndRegisterRemoteReactComponent('LineChart');
507
+ /**
508
+ * The `ScoreCircle` component displays a score value (0-100) as a circular progress indicator with color-coded bands.
509
+ * Scores are color-coded: 0-32 (alert/red), 33-65 (warning/yellow), 66-100 (success/green).
510
+ * @example
511
+ * ```tsx
512
+ * <ScoreCircle score={75} />
513
+ * ```
514
+ */
515
+ export const ScoreCircle = createAndRegisterRemoteReactComponent('ScoreCircle');
507
516
  /**
508
517
  * `Tabs` allow you to group related content in a compact space, allowing users to switch between views without leaving the page.
509
518
  * @example
@@ -24,6 +24,7 @@ export type * from './loading-spinner.ts';
24
24
  export type * from './modal.ts';
25
25
  export type * from './panel.ts';
26
26
  export type * from './progress-bar.ts';
27
+ export type * from './score.ts';
27
28
  export type * from './selects.ts';
28
29
  export type * from './statistics.ts';
29
30
  export type * from './status-tag.ts';
@@ -272,6 +272,17 @@ export interface DateInputProps extends BaseDateInputForDate {
272
272
  * @defaultValue `'userTz'`
273
273
  */
274
274
  timezone?: 'userTz' | 'portalTz';
275
+ /**
276
+ * The value of the input. Must include the year, month, and day.
277
+ * - `year`: the four-digit year (e.g., `2023`).
278
+ * - `month`: starting at `0`, the number of the month (e.g., `0` = January, `11` = December).
279
+ * - `date`: the number of the day (e.g., `1` = the first day of the month).
280
+ */
281
+ value?: BaseDate;
282
+ /**
283
+ * The default date value. Uses the same format as the value field.
284
+ */
285
+ defaultValue?: BaseDate;
275
286
  }
276
287
  /**
277
288
  * Object that represents times.
@@ -0,0 +1,13 @@
1
+ import { BaseComponentProps } from '../shared.ts';
2
+ /**
3
+ * The props type for {@link !components.ScoreCircle}.
4
+ *
5
+ * @category Component Props
6
+ */
7
+ export type ScoreProps = BaseComponentProps & {
8
+ /**
9
+ * The numerical score to display. Must be a value between 0 and 100.
10
+ * Decimal values will be clamped to the nearest integer.
11
+ */
12
+ score: number;
13
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -3,6 +3,6 @@ export type * from './actions.ts';
3
3
  export type * from './context.ts';
4
4
  export type * from './crm.ts';
5
5
  export type * from './extension-points.ts';
6
- export type * from './http-requests.ts';
6
+ export * from './http-requests.ts';
7
7
  export type * from './reactions.ts';
8
8
  export * from './shared.ts';
@@ -1 +1,2 @@
1
+ export * from "./http-requests.js"; // NOTE: `http-requests.ts` has some exported enums so we can't use `export type`
1
2
  export * from "./shared.js"; // NOTE: `shared.ts` has some exported classes so we can't use `export type` for now
@@ -240,5 +240,10 @@ export declare class RemoteEvent<V> {
240
240
  constructor(value: V, event: Event);
241
241
  }
242
242
  export interface BaseComponentProps {
243
+ /**
244
+ * Used by `findByTestId()` to locate this component in tests.
245
+ *
246
+ * @see {@link https://developers.hubspot.com/docs/apps/developer-platform/add-features/ui-extensibility/testing/reference#findbytestid | Testing utilities reference}
247
+ */
243
248
  testId?: string;
244
249
  }
@@ -1,6 +1,16 @@
1
1
  import { Logger } from './logger.ts';
2
2
  import { HubspotExtendFunction } from './extend.ts';
3
3
  import type { ExtensionPoints, ExtensionPointApiContext, ExtensionPointApiActions } from './extension-points.ts';
4
+ export interface WorkersApi {
5
+ /**
6
+ * Hook added to worker globals so customer code can access extension context at runtime.
7
+ */
8
+ useExtensionContext: <ExtensionPoint extends keyof ExtensionPoints>() => ExtensionPointApiContext<ExtensionPoint>;
9
+ /**
10
+ * Hook added to worker globals so customer code can access extension actions at runtime.
11
+ */
12
+ useExtensionActions: <ExtensionPoint extends keyof ExtensionPoints>() => ExtensionPointApiActions<ExtensionPoint>;
13
+ }
4
14
  export interface WorkerGlobalsInternal {
5
15
  /**
6
16
  * A marker that the current global is a HubSpot extension worker.
@@ -19,14 +29,5 @@ export interface WorkerGlobalsInternal {
19
29
  * Namespace where all HubSpot APIs that go on the worker should live.
20
30
  * This avoids polluting the global and reduces the possibility of customer code having collisions with our code.
21
31
  */
22
- hsWorkerAPI: {
23
- /**
24
- * Hook added to worker globals so customer code can access extension context at runtime.
25
- */
26
- useExtensionContext: <ExtensionPoint extends keyof ExtensionPoints>() => ExtensionPointApiContext<ExtensionPoint>;
27
- /**
28
- * Hook added to worker globals so customer code can access extension actions at runtime.
29
- */
30
- useExtensionActions: <ExtensionPoint extends keyof ExtensionPoints>() => ExtensionPointApiActions<ExtensionPoint>;
31
- };
32
+ hsWorkerAPI: WorkersApi;
32
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubspot/ui-extensions",
3
- "version": "0.11.6",
3
+ "version": "0.12.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,8 @@
28
28
  "./crm": "./dist/crm/index.js",
29
29
  "./pages/home": "./dist/pages/home/index.js",
30
30
  "./experimental": "./dist/experimental/index.js",
31
- "./testing": "./dist/testing/index.js"
31
+ "./testing": "./dist/testing/index.js",
32
+ "./hs-internal": "./dist/hs-internal/index.js"
32
33
  },
33
34
  "license": "MIT",
34
35
  "dependencies": {
@@ -74,5 +75,5 @@
74
75
  "tsd": {
75
76
  "directory": "src/__tests__/test-d"
76
77
  },
77
- "gitHead": "12a14abf6ad776b12204dc37467a7c665a806adb"
78
+ "gitHead": "c5b66da065a341df3afd98ce6641bb3e6317aeb4"
78
79
  }