@atlassian/clientside-extensions 2.4.0-e0cce94-kuewz5ow

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 (122) hide show
  1. package/LICENSE.txt +30 -0
  2. package/README.md +7 -0
  3. package/dist/cjs/AbstractExtension.js +22 -0
  4. package/dist/cjs/AbstractExtension.js.map +1 -0
  5. package/dist/cjs/AsyncPanelExtension.js +19 -0
  6. package/dist/cjs/AsyncPanelExtension.js.map +1 -0
  7. package/dist/cjs/AsyncPanelExtension.test.js +121 -0
  8. package/dist/cjs/AsyncPanelExtension.test.js.map +1 -0
  9. package/dist/cjs/ButtonExtension.js +13 -0
  10. package/dist/cjs/ButtonExtension.js.map +1 -0
  11. package/dist/cjs/ButtonExtension.test.js +49 -0
  12. package/dist/cjs/ButtonExtension.test.js.map +1 -0
  13. package/dist/cjs/LinkExtension.js +14 -0
  14. package/dist/cjs/LinkExtension.js.map +1 -0
  15. package/dist/cjs/LinkExtension.test.js +49 -0
  16. package/dist/cjs/LinkExtension.test.js.map +1 -0
  17. package/dist/cjs/ModalExtension.js +121 -0
  18. package/dist/cjs/ModalExtension.js.map +1 -0
  19. package/dist/cjs/ModalExtension.test.js +121 -0
  20. package/dist/cjs/ModalExtension.test.js.map +1 -0
  21. package/dist/cjs/PageExtension.js +13 -0
  22. package/dist/cjs/PageExtension.js.map +1 -0
  23. package/dist/cjs/PageExtension.test.js +34 -0
  24. package/dist/cjs/PageExtension.test.js.map +1 -0
  25. package/dist/cjs/PanelExtension.js +36 -0
  26. package/dist/cjs/PanelExtension.js.map +1 -0
  27. package/dist/cjs/PanelExtension.test.js +112 -0
  28. package/dist/cjs/PanelExtension.test.js.map +1 -0
  29. package/dist/cjs/SectionExtension.js +13 -0
  30. package/dist/cjs/SectionExtension.js.map +1 -0
  31. package/dist/cjs/SectionExtension.test.js +46 -0
  32. package/dist/cjs/SectionExtension.test.js.map +1 -0
  33. package/dist/cjs/extension-type.js +15 -0
  34. package/dist/cjs/extension-type.js.map +1 -0
  35. package/dist/cjs/index.js +21 -0
  36. package/dist/cjs/index.js.map +1 -0
  37. package/dist/esm/AbstractExtension.js +18 -0
  38. package/dist/esm/AbstractExtension.js.map +1 -0
  39. package/dist/esm/AsyncPanelExtension.js +16 -0
  40. package/dist/esm/AsyncPanelExtension.js.map +1 -0
  41. package/dist/esm/AsyncPanelExtension.test.js +118 -0
  42. package/dist/esm/AsyncPanelExtension.test.js.map +1 -0
  43. package/dist/esm/ButtonExtension.js +10 -0
  44. package/dist/esm/ButtonExtension.js.map +1 -0
  45. package/dist/esm/ButtonExtension.test.js +46 -0
  46. package/dist/esm/ButtonExtension.test.js.map +1 -0
  47. package/dist/esm/LinkExtension.js +11 -0
  48. package/dist/esm/LinkExtension.js.map +1 -0
  49. package/dist/esm/LinkExtension.test.js +46 -0
  50. package/dist/esm/LinkExtension.test.js.map +1 -0
  51. package/dist/esm/ModalExtension.js +117 -0
  52. package/dist/esm/ModalExtension.js.map +1 -0
  53. package/dist/esm/ModalExtension.test.js +118 -0
  54. package/dist/esm/ModalExtension.test.js.map +1 -0
  55. package/dist/esm/PageExtension.js +10 -0
  56. package/dist/esm/PageExtension.js.map +1 -0
  57. package/dist/esm/PageExtension.test.js +31 -0
  58. package/dist/esm/PageExtension.test.js.map +1 -0
  59. package/dist/esm/PanelExtension.js +32 -0
  60. package/dist/esm/PanelExtension.js.map +1 -0
  61. package/dist/esm/PanelExtension.test.js +109 -0
  62. package/dist/esm/PanelExtension.test.js.map +1 -0
  63. package/dist/esm/SectionExtension.js +10 -0
  64. package/dist/esm/SectionExtension.js.map +1 -0
  65. package/dist/esm/SectionExtension.test.js +43 -0
  66. package/dist/esm/SectionExtension.test.js.map +1 -0
  67. package/dist/esm/extension-type.js +12 -0
  68. package/dist/esm/extension-type.js.map +1 -0
  69. package/dist/esm/index.js +10 -0
  70. package/dist/esm/index.js.map +1 -0
  71. package/dist/types/AbstractExtension.d.ts +11 -0
  72. package/dist/types/AbstractExtension.d.ts.map +1 -0
  73. package/dist/types/AsyncPanelExtension.d.ts +18 -0
  74. package/dist/types/AsyncPanelExtension.d.ts.map +1 -0
  75. package/dist/types/AsyncPanelExtension.test.d.ts +2 -0
  76. package/dist/types/AsyncPanelExtension.test.d.ts.map +1 -0
  77. package/dist/types/ButtonExtension.d.ts +10 -0
  78. package/dist/types/ButtonExtension.d.ts.map +1 -0
  79. package/dist/types/ButtonExtension.test.d.ts +2 -0
  80. package/dist/types/ButtonExtension.test.d.ts.map +1 -0
  81. package/dist/types/LinkExtension.d.ts +11 -0
  82. package/dist/types/LinkExtension.d.ts.map +1 -0
  83. package/dist/types/LinkExtension.test.d.ts +2 -0
  84. package/dist/types/LinkExtension.test.d.ts.map +1 -0
  85. package/dist/types/ModalExtension.d.ts +60 -0
  86. package/dist/types/ModalExtension.d.ts.map +1 -0
  87. package/dist/types/ModalExtension.test.d.ts +2 -0
  88. package/dist/types/ModalExtension.test.d.ts.map +1 -0
  89. package/dist/types/PageExtension.d.ts +11 -0
  90. package/dist/types/PageExtension.d.ts.map +1 -0
  91. package/dist/types/PageExtension.test.d.ts +5 -0
  92. package/dist/types/PageExtension.test.d.ts.map +1 -0
  93. package/dist/types/PanelExtension.d.ts +19 -0
  94. package/dist/types/PanelExtension.d.ts.map +1 -0
  95. package/dist/types/PanelExtension.test.d.ts +2 -0
  96. package/dist/types/PanelExtension.test.d.ts.map +1 -0
  97. package/dist/types/SectionExtension.d.ts +8 -0
  98. package/dist/types/SectionExtension.d.ts.map +1 -0
  99. package/dist/types/SectionExtension.test.d.ts +2 -0
  100. package/dist/types/SectionExtension.test.d.ts.map +1 -0
  101. package/dist/types/extension-type.d.ts +10 -0
  102. package/dist/types/extension-type.d.ts.map +1 -0
  103. package/dist/types/index.d.ts +11 -0
  104. package/dist/types/index.d.ts.map +1 -0
  105. package/lib/AbstractExtension.ts +31 -0
  106. package/lib/AsyncPanelExtension.test.ts +145 -0
  107. package/lib/AsyncPanelExtension.ts +34 -0
  108. package/lib/ButtonExtension.test.ts +61 -0
  109. package/lib/ButtonExtension.ts +20 -0
  110. package/lib/LinkExtension.test.ts +61 -0
  111. package/lib/LinkExtension.ts +22 -0
  112. package/lib/ModalExtension.test.ts +147 -0
  113. package/lib/ModalExtension.ts +188 -0
  114. package/lib/PageExtension.test.ts +38 -0
  115. package/lib/PageExtension.ts +21 -0
  116. package/lib/PanelExtension.test.ts +134 -0
  117. package/lib/PanelExtension.ts +51 -0
  118. package/lib/SectionExtension.test.ts +57 -0
  119. package/lib/SectionExtension.ts +18 -0
  120. package/lib/extension-type.ts +10 -0
  121. package/lib/index.ts +21 -0
  122. package/package.json +79 -0
@@ -0,0 +1,147 @@
1
+ // eslint-disable-next-line node/no-extraneous-import
2
+ import type { Dispatch, SetStateAction } from 'react';
3
+ import type { ExtensionAPI } from '@atlassian/clientside-extensions-registry';
4
+ import { onDebug } from '@atlassian/clientside-extensions-debug';
5
+ import * as ModalExtension from './ModalExtension';
6
+
7
+ jest.mock('@atlassian/clientside-extensions-debug');
8
+
9
+ interface TestContext {
10
+ value?: number;
11
+ }
12
+
13
+ describe('ModalExtension', () => {
14
+ // @ts-expect-error - invalid values for type. Factory should overwrite it.
15
+ const testAttributesProvider: ModalExtension.ModalAttributesProvider = (api, context: TestContext) => {
16
+ api.onCleanup(jest.fn());
17
+ api.updateAttributes({});
18
+
19
+ return {
20
+ type: 'not-valid',
21
+ label: context.value === 42 ? 'The meaning of life' : 'A label',
22
+ onAction: jest.fn(),
23
+ };
24
+ };
25
+
26
+ const testAPI: ExtensionAPI<typeof testAttributesProvider> = {
27
+ onCleanup: jest.fn(),
28
+ updateAttributes: jest.fn(),
29
+ };
30
+
31
+ it('should generate the extension descriptor with the correct type', () => {
32
+ const extensionProvider = ModalExtension.factory(testAttributesProvider);
33
+ const extension = extensionProvider(testAPI, {});
34
+
35
+ // expect all attributes to be merged, and type to be overwritten with the correct value.
36
+ expect(extension).toEqual({
37
+ type: 'modal',
38
+ label: 'A label',
39
+ onAction: expect.any(Function),
40
+ });
41
+ });
42
+
43
+ it('should provide a extensionAPI object to the extension provider', () => {
44
+ expect(testAPI.onCleanup).toHaveBeenCalledTimes(0);
45
+ expect(testAPI.updateAttributes).toHaveBeenCalledTimes(0);
46
+
47
+ const extensionProvider = ModalExtension.factory(testAttributesProvider);
48
+ extensionProvider(testAPI, {});
49
+
50
+ expect(testAPI.onCleanup).toHaveBeenCalledTimes(1);
51
+ expect(testAPI.updateAttributes).toHaveBeenCalledTimes(1);
52
+ });
53
+
54
+ it('should share any provided context with the attributes provider', () => {
55
+ const extensionProvider = ModalExtension.factory(testAttributesProvider);
56
+ const extension = extensionProvider(testAPI, { value: 42 });
57
+
58
+ // expect label to change because of context
59
+ expect(extension).toEqual({
60
+ type: 'modal',
61
+ label: 'The meaning of life',
62
+ onAction: expect.any(Function),
63
+ });
64
+ });
65
+
66
+ it('should export all the supported appreanace for modals', () => {
67
+ expect(ModalExtension.Appearance).toBeTruthy();
68
+ });
69
+
70
+ it('should export all the supported sizes for modals', () => {
71
+ expect(ModalExtension.Width).toBeTruthy();
72
+ });
73
+
74
+ describe('API', () => {
75
+ let closeFn;
76
+ let api: ModalExtension.Api;
77
+ const el: HTMLElement = document.createElement('div');
78
+ const setStateFn: Dispatch<SetStateAction<ModalExtension.ModalAction[]>> = () => {};
79
+
80
+ beforeEach(() => {
81
+ closeFn = jest.fn();
82
+ api = new ModalExtension.Api(closeFn, setStateFn);
83
+ });
84
+
85
+ it('#onMount callback used in getRenderCallback', () => {
86
+ const cb = jest.fn();
87
+ api.onMount(cb);
88
+ api.getRenderCallback()(el);
89
+ expect(cb).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ it('#onMount does nothing if not set', () => {
93
+ expect(() => {
94
+ api.getRenderCallback()(el);
95
+ }).not.toThrow();
96
+ });
97
+
98
+ it('#onMount does nothing if callback is not function', () => {
99
+ // @ts-expect-error checking that non-TS consumers don't shoot themselves (or others) in the foot
100
+ api.onMount(null);
101
+ expect(() => {
102
+ api.getRenderCallback()(el);
103
+ }).not.toThrow();
104
+ });
105
+
106
+ it('#onMount fails gracefully if callback throws error', () => {
107
+ api.onMount(() => {
108
+ throw Error('whoops');
109
+ });
110
+ expect(() => {
111
+ api.getRenderCallback()(el);
112
+ }).not.toThrow();
113
+ });
114
+
115
+ it('#onUnmount callback used in getCleanupCallback', () => {
116
+ const cb = jest.fn();
117
+ api.onUnmount(cb);
118
+ api.getCleanupCallback()(el);
119
+ expect(cb).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it('#onUnmount does nothing if not set', () => {
123
+ expect(() => {
124
+ api.getCleanupCallback()(el);
125
+ }).not.toThrow();
126
+ });
127
+
128
+ it('#onUnmount does nothing if callback is not function', () => {
129
+ // @ts-expect-error checking that non-TS consumers don't shoot themselves (or others) in the foot
130
+ api.onUnmount(null);
131
+ expect(() => {
132
+ api.getCleanupCallback()(el);
133
+ }).not.toThrow();
134
+ expect(onDebug).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it('#onUnmount fails gracefully if callback throws error', () => {
138
+ api.onUnmount(() => {
139
+ throw Error('whoops');
140
+ });
141
+ expect(() => {
142
+ api.getCleanupCallback()(el);
143
+ }).not.toThrow();
144
+ expect(onDebug).toHaveBeenCalledTimes(1);
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,188 @@
1
+ import type { ExtensionAttributes, ExtensionAttributesProvider } from '@atlassian/clientside-extensions-registry';
2
+ import { onDebug } from '@atlassian/clientside-extensions-debug';
3
+ import type {
4
+ Dispatch as ReactDispatch,
5
+ KeyboardEvent as ReactKeyboardEvent,
6
+ MouseEvent as ReactMouseEvent,
7
+ SetStateAction as ReactSetStateAction,
8
+ // eslint-disable-next-line node/no-extraneous-import
9
+ } from 'react';
10
+
11
+ import { ExtensionType } from './extension-type';
12
+ import type { LifecycleCallback, MountableExtension } from './AbstractExtension';
13
+ import { safeguard } from './AbstractExtension';
14
+
15
+ // A type defined by the @atlaskit/modal-dialog
16
+ type KeyboardOrMouseEvent = ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement> | KeyboardEvent;
17
+
18
+ interface ModalCloseCallback {
19
+ (e: KeyboardOrMouseEvent): boolean | void;
20
+ }
21
+
22
+ export type ModalAction = {
23
+ text: string;
24
+ onClick: () => void;
25
+ isDisabled?: boolean;
26
+ isLoading?: boolean;
27
+ testId?: string;
28
+ };
29
+
30
+ export type ModalRenderExtension = (api: Api) => void;
31
+
32
+ export interface ModalAttributes extends ExtensionAttributes {
33
+ onAction: ModalRenderExtension;
34
+ label: string;
35
+ type?: ExtensionType.modal;
36
+ }
37
+
38
+ export type ModalAttributesProvider<ContextT> = ExtensionAttributesProvider<ModalAttributes, ContextT>;
39
+
40
+ export const factory = <ContextT>(provider: ModalAttributesProvider<ContextT>): ModalAttributesProvider<ContextT> => {
41
+ return (...args) => {
42
+ const attributes = provider(...args);
43
+ return {
44
+ ...attributes,
45
+ type: ExtensionType.modal,
46
+ };
47
+ };
48
+ };
49
+
50
+ const noop = () => {};
51
+
52
+ export enum Appearance {
53
+ 'danger' = 'danger',
54
+ 'warning' = 'warning',
55
+ }
56
+
57
+ export enum Width {
58
+ 'small' = 'small',
59
+ 'medium' = 'medium',
60
+ 'large' = 'large',
61
+ 'x-large' = 'x-large',
62
+ }
63
+
64
+ export class Api implements MountableExtension {
65
+ private title = '';
66
+
67
+ private width!: Width;
68
+
69
+ private appearance!: Appearance;
70
+
71
+ private renderCallback: LifecycleCallback = noop;
72
+
73
+ private cleanupCallback: LifecycleCallback = noop;
74
+
75
+ private readonly closeModalFn: () => void = noop;
76
+
77
+ private onCloseCallback: ModalCloseCallback = () => true;
78
+
79
+ constructor(closeModalFn: () => void, setActions: ReactDispatch<ReactSetStateAction<ModalAction[]>>) {
80
+ this.closeModalFn = safeguard('onClose', closeModalFn);
81
+
82
+ this.setActions = (actions) => {
83
+ setActions(Api.filterActionProps(actions));
84
+
85
+ return this;
86
+ };
87
+ }
88
+
89
+ public setTitle = (title: string) => {
90
+ this.title = title;
91
+
92
+ return this;
93
+ };
94
+
95
+ public setWidth = (width: Width) => {
96
+ // Runtime checking for 'non-ts' usage
97
+ if (!(width in Width)) {
98
+ onDebug(({ error }) => ({
99
+ level: error,
100
+ message: `Invalid value specified as "width" of modal. Please make sure to use one of the supported values.`,
101
+ meta: {
102
+ value: width,
103
+ supported: Object.keys(Width).concat(),
104
+ },
105
+ }));
106
+ return this;
107
+ }
108
+
109
+ this.width = width;
110
+ return this;
111
+ };
112
+
113
+ public setAppearance = (appearance: Appearance) => {
114
+ // Runtime checking for 'non-ts' usage
115
+ if (!(appearance in Appearance)) {
116
+ onDebug(({ error }) => ({
117
+ level: error,
118
+ message: `Invalid value specified as "appearance" of modal. Please make sure to use one of the supported values.`,
119
+ meta: {
120
+ value: appearance,
121
+ supported: Object.keys(Appearance).concat(),
122
+ },
123
+ }));
124
+ return this;
125
+ }
126
+
127
+ this.appearance = appearance;
128
+ return this;
129
+ };
130
+
131
+ public onMount = (callback: LifecycleCallback) => {
132
+ this.renderCallback = safeguard('onMount', callback);
133
+
134
+ return this;
135
+ };
136
+
137
+ public onUnmount = (callback: LifecycleCallback) => {
138
+ this.cleanupCallback = safeguard('onUnmount', callback);
139
+
140
+ return this;
141
+ };
142
+
143
+ public closeModal = () => {
144
+ this.closeModalFn();
145
+ };
146
+
147
+ public getTitle() {
148
+ return this.title;
149
+ }
150
+
151
+ public getWidth() {
152
+ return this.width;
153
+ }
154
+
155
+ public getAppearance() {
156
+ return this.appearance;
157
+ }
158
+
159
+ public getRenderCallback() {
160
+ return this.renderCallback;
161
+ }
162
+
163
+ public getCleanupCallback() {
164
+ return this.cleanupCallback;
165
+ }
166
+
167
+ public setActions: (actions: ModalAction[]) => void;
168
+
169
+ public onClose(callback: ModalCloseCallback) {
170
+ this.onCloseCallback = safeguard('onClose', callback);
171
+
172
+ return this;
173
+ }
174
+
175
+ public getOnCloseCallback() {
176
+ return this.onCloseCallback;
177
+ }
178
+
179
+ static filterActionProps(actions: unknown[]): ModalAction[] {
180
+ if (!Array.isArray(actions)) {
181
+ return [];
182
+ }
183
+
184
+ return actions.map(({ text, onClick, isDisabled, isLoading, testId }) => {
185
+ return { text, onClick, isDisabled, isLoading, testId };
186
+ });
187
+ }
188
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as PageExtension from './PageExtension';
6
+
7
+ describe('PageExtension', () => {
8
+ it('should generate the extension shape', () => {
9
+ // given
10
+ const fakeRenderCallback = jest.fn();
11
+
12
+ // when
13
+ const extensionProvider = PageExtension.factory(fakeRenderCallback);
14
+ const extension = extensionProvider();
15
+
16
+ // then
17
+ expect(extension).toEqual({
18
+ type: 'page',
19
+ onAction: expect.any(Function),
20
+ });
21
+ });
22
+
23
+ it('should call the render callback', () => {
24
+ // given
25
+ const fakeRenderCallback = jest.fn();
26
+ const extensionProvider = PageExtension.factory(fakeRenderCallback);
27
+ const extension = extensionProvider();
28
+
29
+ const fakeNode = document.createElement('div');
30
+ const fakeDataProvider = { foo: 'bar' };
31
+
32
+ // when
33
+ extension.onAction(fakeNode, fakeDataProvider);
34
+
35
+ // then
36
+ expect(fakeRenderCallback).toHaveBeenCalledWith(fakeNode, fakeDataProvider);
37
+ });
38
+ });
@@ -0,0 +1,21 @@
1
+ import type { DataProviderPayload, ExtensionAttributes } from '@atlassian/clientside-extensions-registry';
2
+
3
+ import { ExtensionType } from './extension-type';
4
+
5
+ type RenderCallback<DataProviderT> = (node: HTMLElement, data: DataProviderPayload<DataProviderT>) => void;
6
+
7
+ export interface PageAttributes<DataProviderT> extends ExtensionAttributes {
8
+ onAction: RenderCallback<DataProviderT>;
9
+ type: ExtensionType.page;
10
+ }
11
+
12
+ export type PageAttributesProvider<DataProviderT = unknown> = () => PageAttributes<DataProviderT>;
13
+
14
+ export const factory = <DataProviderT>(renderCallback: RenderCallback<DataProviderT>): PageAttributesProvider<DataProviderT> => {
15
+ return () => ({
16
+ type: ExtensionType.page,
17
+ onAction: (node, data) => {
18
+ renderCallback(node, data);
19
+ },
20
+ });
21
+ };
@@ -0,0 +1,134 @@
1
+ import type { ExtensionAPI } from '@atlassian/clientside-extensions-registry';
2
+ import { onDebug } from '@atlassian/clientside-extensions-debug';
3
+ import * as PanelExtension from './PanelExtension';
4
+
5
+ jest.mock('@atlassian/clientside-extensions-debug');
6
+
7
+ interface TestContext {
8
+ value?: number;
9
+ }
10
+
11
+ describe('PanelExtension', () => {
12
+ // @ts-expect-error - invalid values for type. Factory should overwrite it.
13
+ const testAttributesProvider: PanelExtension.PanelAttributesProvider = (api, context: TestContext) => {
14
+ api.onCleanup(jest.fn());
15
+ api.updateAttributes({});
16
+
17
+ return {
18
+ type: 'not-valid',
19
+ label: context.value === 42 ? 'The meaning of life' : 'A label',
20
+ onAction: jest.fn(),
21
+ };
22
+ };
23
+
24
+ const testAPI: ExtensionAPI<typeof testAttributesProvider> = {
25
+ onCleanup: jest.fn(),
26
+ updateAttributes: jest.fn(),
27
+ };
28
+
29
+ it('should generate the extension descriptor with the correct type', () => {
30
+ const extensionProvider = PanelExtension.factory(testAttributesProvider);
31
+ const extension = extensionProvider(testAPI, {});
32
+
33
+ // expect all attributes to be merged, and type to be overwritten with the correct value.
34
+ expect(extension).toEqual({
35
+ type: 'panel',
36
+ label: 'A label',
37
+ onAction: expect.any(Function),
38
+ });
39
+ });
40
+
41
+ it('should provide a extensionAPI object to the extension provider', () => {
42
+ expect(testAPI.onCleanup).toHaveBeenCalledTimes(0);
43
+ expect(testAPI.updateAttributes).toHaveBeenCalledTimes(0);
44
+
45
+ const extensionProvider = PanelExtension.factory(testAttributesProvider);
46
+ extensionProvider(testAPI, {});
47
+
48
+ expect(testAPI.onCleanup).toHaveBeenCalledTimes(1);
49
+ expect(testAPI.updateAttributes).toHaveBeenCalledTimes(1);
50
+ });
51
+
52
+ it('should share any provided context with the attributes provider', () => {
53
+ const extensionProvider = PanelExtension.factory(testAttributesProvider);
54
+ const extension = extensionProvider(testAPI, { value: 42 });
55
+
56
+ // expect label to change because of context
57
+ expect(extension).toEqual({
58
+ type: 'panel',
59
+ label: 'The meaning of life',
60
+ onAction: expect.any(Function),
61
+ });
62
+ });
63
+
64
+ describe('API', () => {
65
+ let api: PanelExtension.Api;
66
+ const el: HTMLElement = document.createElement('div');
67
+
68
+ beforeEach(() => {
69
+ api = new PanelExtension.Api();
70
+ });
71
+
72
+ it('#onMount callback used in getRenderCallback', () => {
73
+ const cb = jest.fn();
74
+ api.onMount(cb);
75
+ api.getRenderCallback()(el);
76
+ expect(cb).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('#onMount does nothing if not set', () => {
80
+ expect(() => {
81
+ api.getRenderCallback()(el);
82
+ }).not.toThrow();
83
+ });
84
+
85
+ it('#onMount does nothing if callback is not function', () => {
86
+ // @ts-expect-error checking that non-TS consumers don't shoot themselves (or others) in the foot
87
+ api.onMount(null);
88
+ expect(() => {
89
+ api.getRenderCallback()(el);
90
+ }).not.toThrow();
91
+ });
92
+
93
+ it('#onMount fails gracefully if callback throws error', () => {
94
+ api.onMount(() => {
95
+ throw Error('whoops');
96
+ });
97
+ expect(() => {
98
+ api.getRenderCallback()(el);
99
+ }).not.toThrow();
100
+ });
101
+
102
+ it('#onUnmount callback used in getCleanupCallback', () => {
103
+ const cb = jest.fn();
104
+ api.onUnmount(cb);
105
+ api.getCleanupCallback()(el);
106
+ expect(cb).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it('#onUnmount does nothing if not set', () => {
110
+ expect(() => {
111
+ api.getCleanupCallback()(el);
112
+ }).not.toThrow();
113
+ });
114
+
115
+ it('#onUnmount does nothing if callback is not function', () => {
116
+ // @ts-expect-error checking that non-TS consumers don't shoot themselves (or others) in the foot
117
+ api.onUnmount(null);
118
+ expect(() => {
119
+ api.getCleanupCallback()(el);
120
+ }).not.toThrow();
121
+ expect(onDebug).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it('#onUnmount fails gracefully if callback throws error', () => {
125
+ api.onUnmount(() => {
126
+ throw Error('whoops');
127
+ });
128
+ expect(() => {
129
+ api.getCleanupCallback()(el);
130
+ }).not.toThrow();
131
+ expect(onDebug).toHaveBeenCalledTimes(1);
132
+ });
133
+ });
134
+ });
@@ -0,0 +1,51 @@
1
+ import type { Context, ExtensionAttributes, ExtensionAttributesProvider } from '@atlassian/clientside-extensions-registry';
2
+
3
+ import { ExtensionType } from './extension-type';
4
+ import type { LifecycleCallback, MountableExtension } from './AbstractExtension';
5
+ import { safeguard } from './AbstractExtension';
6
+
7
+ export type PanelRenderExtension = (api: Api, context?: Context<{}>, data?: unknown) => void;
8
+
9
+ export interface PanelAttributes extends ExtensionAttributes {
10
+ onAction: PanelRenderExtension;
11
+ type?: ExtensionType.panel;
12
+ }
13
+
14
+ export type PanelAttributesProvider<ContextT> = ExtensionAttributesProvider<PanelAttributes, ContextT>;
15
+
16
+ export const factory = <ContextT>(provider: PanelAttributesProvider<ContextT>): PanelAttributesProvider<ContextT> => {
17
+ return (...args) => {
18
+ return {
19
+ ...provider(...args),
20
+ type: ExtensionType.panel,
21
+ };
22
+ };
23
+ };
24
+
25
+ const noop = () => {};
26
+
27
+ export class Api implements MountableExtension {
28
+ private renderCallback: LifecycleCallback = noop;
29
+
30
+ private cleanupCallback: LifecycleCallback = noop;
31
+
32
+ public onMount(callback: LifecycleCallback) {
33
+ this.renderCallback = safeguard('onMount', callback);
34
+
35
+ return this;
36
+ }
37
+
38
+ public onUnmount(callback: LifecycleCallback) {
39
+ this.cleanupCallback = safeguard('onUnmount', callback);
40
+
41
+ return this;
42
+ }
43
+
44
+ public getRenderCallback() {
45
+ return this.renderCallback;
46
+ }
47
+
48
+ public getCleanupCallback() {
49
+ return this.cleanupCallback;
50
+ }
51
+ }
@@ -0,0 +1,57 @@
1
+ import type { ExtensionAPI } from '@atlassian/clientside-extensions-registry';
2
+ import * as SectionExtension from './SectionExtension';
3
+ import type { SectionAttributesProvider } from './SectionExtension';
4
+
5
+ interface TestContext {
6
+ value?: number;
7
+ }
8
+
9
+ describe('SectionExtension', () => {
10
+ // @ts-expect-error we set the invalid type
11
+ const testAttributesProvider: SectionAttributesProvider = (api, context: TestContext) => {
12
+ api.onCleanup(jest.fn());
13
+ api.updateAttributes({});
14
+
15
+ return {
16
+ type: 'not-valid',
17
+ label: context.value === 42 ? 'The meaning of life' : 'A label',
18
+ };
19
+ };
20
+ const testAPI: ExtensionAPI<typeof testAttributesProvider> = {
21
+ onCleanup: jest.fn(),
22
+ updateAttributes: jest.fn(),
23
+ };
24
+
25
+ it('should generate the extension descriptor with the correct type', () => {
26
+ const extensionProvider = SectionExtension.factory(testAttributesProvider);
27
+ const extension = extensionProvider(testAPI, {});
28
+
29
+ // expect all attributes to be merged, and type to be overwritten with the correct value.
30
+ expect(extension).toEqual({
31
+ type: 'section',
32
+ label: 'A label',
33
+ });
34
+ });
35
+
36
+ it('should provide a extensionAPI object to the extension provider', () => {
37
+ expect(testAPI.onCleanup).toHaveBeenCalledTimes(0);
38
+ expect(testAPI.updateAttributes).toHaveBeenCalledTimes(0);
39
+
40
+ const extensionProvider = SectionExtension.factory(testAttributesProvider);
41
+ extensionProvider(testAPI, {});
42
+
43
+ expect(testAPI.onCleanup).toHaveBeenCalledTimes(1);
44
+ expect(testAPI.updateAttributes).toHaveBeenCalledTimes(1);
45
+ });
46
+
47
+ it('should share any provided context with the attributes provider', () => {
48
+ const extensionProvider = SectionExtension.factory(testAttributesProvider);
49
+ const extension = extensionProvider(testAPI, { value: 42 });
50
+
51
+ // expect label to change because of context
52
+ expect(extension).toEqual({
53
+ type: 'section',
54
+ label: 'The meaning of life',
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,18 @@
1
+ import type { ExtensionAttributes, ExtensionAttributesProvider } from '@atlassian/clientside-extensions-registry';
2
+
3
+ import { ExtensionType } from './extension-type';
4
+
5
+ export interface SectionAttributes extends ExtensionAttributes {
6
+ type?: ExtensionType.section;
7
+ }
8
+
9
+ export type SectionAttributesProvider<ContextT> = ExtensionAttributesProvider<SectionAttributes, ContextT>;
10
+
11
+ export const factory = <ContextT>(provider: SectionAttributesProvider<ContextT>): SectionAttributesProvider<ContextT> => {
12
+ return (...args) => {
13
+ return {
14
+ ...provider(...args),
15
+ type: ExtensionType.section,
16
+ };
17
+ };
18
+ };
@@ -0,0 +1,10 @@
1
+ // eslint-disable-next-line import/prefer-default-export
2
+ export enum ExtensionType {
3
+ 'asyncPanel' = 'async-panel',
4
+ button = 'button',
5
+ link = 'link',
6
+ modal = 'modal',
7
+ panel = 'panel',
8
+ page = 'page',
9
+ section = 'section',
10
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ import * as AsyncPanelExtension from './AsyncPanelExtension';
2
+ import * as ButtonExtension from './ButtonExtension';
3
+ import * as LinkExtension from './LinkExtension';
4
+ import * as ModalExtension from './ModalExtension';
5
+ import * as PanelExtension from './PanelExtension';
6
+ import * as SectionExtension from './SectionExtension';
7
+ import * as PageExtension from './PageExtension';
8
+ import { ExtensionType } from './extension-type';
9
+
10
+ export type { MountableExtension, LifecycleCallback } from './AbstractExtension';
11
+
12
+ export {
13
+ AsyncPanelExtension,
14
+ ButtonExtension,
15
+ LinkExtension,
16
+ ModalExtension,
17
+ PanelExtension,
18
+ SectionExtension,
19
+ PageExtension,
20
+ ExtensionType,
21
+ };