@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.
- package/LICENSE.txt +30 -0
- package/README.md +7 -0
- package/dist/cjs/AbstractExtension.js +22 -0
- package/dist/cjs/AbstractExtension.js.map +1 -0
- package/dist/cjs/AsyncPanelExtension.js +19 -0
- package/dist/cjs/AsyncPanelExtension.js.map +1 -0
- package/dist/cjs/AsyncPanelExtension.test.js +121 -0
- package/dist/cjs/AsyncPanelExtension.test.js.map +1 -0
- package/dist/cjs/ButtonExtension.js +13 -0
- package/dist/cjs/ButtonExtension.js.map +1 -0
- package/dist/cjs/ButtonExtension.test.js +49 -0
- package/dist/cjs/ButtonExtension.test.js.map +1 -0
- package/dist/cjs/LinkExtension.js +14 -0
- package/dist/cjs/LinkExtension.js.map +1 -0
- package/dist/cjs/LinkExtension.test.js +49 -0
- package/dist/cjs/LinkExtension.test.js.map +1 -0
- package/dist/cjs/ModalExtension.js +121 -0
- package/dist/cjs/ModalExtension.js.map +1 -0
- package/dist/cjs/ModalExtension.test.js +121 -0
- package/dist/cjs/ModalExtension.test.js.map +1 -0
- package/dist/cjs/PageExtension.js +13 -0
- package/dist/cjs/PageExtension.js.map +1 -0
- package/dist/cjs/PageExtension.test.js +34 -0
- package/dist/cjs/PageExtension.test.js.map +1 -0
- package/dist/cjs/PanelExtension.js +36 -0
- package/dist/cjs/PanelExtension.js.map +1 -0
- package/dist/cjs/PanelExtension.test.js +112 -0
- package/dist/cjs/PanelExtension.test.js.map +1 -0
- package/dist/cjs/SectionExtension.js +13 -0
- package/dist/cjs/SectionExtension.js.map +1 -0
- package/dist/cjs/SectionExtension.test.js +46 -0
- package/dist/cjs/SectionExtension.test.js.map +1 -0
- package/dist/cjs/extension-type.js +15 -0
- package/dist/cjs/extension-type.js.map +1 -0
- package/dist/cjs/index.js +21 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/AbstractExtension.js +18 -0
- package/dist/esm/AbstractExtension.js.map +1 -0
- package/dist/esm/AsyncPanelExtension.js +16 -0
- package/dist/esm/AsyncPanelExtension.js.map +1 -0
- package/dist/esm/AsyncPanelExtension.test.js +118 -0
- package/dist/esm/AsyncPanelExtension.test.js.map +1 -0
- package/dist/esm/ButtonExtension.js +10 -0
- package/dist/esm/ButtonExtension.js.map +1 -0
- package/dist/esm/ButtonExtension.test.js +46 -0
- package/dist/esm/ButtonExtension.test.js.map +1 -0
- package/dist/esm/LinkExtension.js +11 -0
- package/dist/esm/LinkExtension.js.map +1 -0
- package/dist/esm/LinkExtension.test.js +46 -0
- package/dist/esm/LinkExtension.test.js.map +1 -0
- package/dist/esm/ModalExtension.js +117 -0
- package/dist/esm/ModalExtension.js.map +1 -0
- package/dist/esm/ModalExtension.test.js +118 -0
- package/dist/esm/ModalExtension.test.js.map +1 -0
- package/dist/esm/PageExtension.js +10 -0
- package/dist/esm/PageExtension.js.map +1 -0
- package/dist/esm/PageExtension.test.js +31 -0
- package/dist/esm/PageExtension.test.js.map +1 -0
- package/dist/esm/PanelExtension.js +32 -0
- package/dist/esm/PanelExtension.js.map +1 -0
- package/dist/esm/PanelExtension.test.js +109 -0
- package/dist/esm/PanelExtension.test.js.map +1 -0
- package/dist/esm/SectionExtension.js +10 -0
- package/dist/esm/SectionExtension.js.map +1 -0
- package/dist/esm/SectionExtension.test.js +43 -0
- package/dist/esm/SectionExtension.test.js.map +1 -0
- package/dist/esm/extension-type.js +12 -0
- package/dist/esm/extension-type.js.map +1 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types/AbstractExtension.d.ts +11 -0
- package/dist/types/AbstractExtension.d.ts.map +1 -0
- package/dist/types/AsyncPanelExtension.d.ts +18 -0
- package/dist/types/AsyncPanelExtension.d.ts.map +1 -0
- package/dist/types/AsyncPanelExtension.test.d.ts +2 -0
- package/dist/types/AsyncPanelExtension.test.d.ts.map +1 -0
- package/dist/types/ButtonExtension.d.ts +10 -0
- package/dist/types/ButtonExtension.d.ts.map +1 -0
- package/dist/types/ButtonExtension.test.d.ts +2 -0
- package/dist/types/ButtonExtension.test.d.ts.map +1 -0
- package/dist/types/LinkExtension.d.ts +11 -0
- package/dist/types/LinkExtension.d.ts.map +1 -0
- package/dist/types/LinkExtension.test.d.ts +2 -0
- package/dist/types/LinkExtension.test.d.ts.map +1 -0
- package/dist/types/ModalExtension.d.ts +60 -0
- package/dist/types/ModalExtension.d.ts.map +1 -0
- package/dist/types/ModalExtension.test.d.ts +2 -0
- package/dist/types/ModalExtension.test.d.ts.map +1 -0
- package/dist/types/PageExtension.d.ts +11 -0
- package/dist/types/PageExtension.d.ts.map +1 -0
- package/dist/types/PageExtension.test.d.ts +5 -0
- package/dist/types/PageExtension.test.d.ts.map +1 -0
- package/dist/types/PanelExtension.d.ts +19 -0
- package/dist/types/PanelExtension.d.ts.map +1 -0
- package/dist/types/PanelExtension.test.d.ts +2 -0
- package/dist/types/PanelExtension.test.d.ts.map +1 -0
- package/dist/types/SectionExtension.d.ts +8 -0
- package/dist/types/SectionExtension.d.ts.map +1 -0
- package/dist/types/SectionExtension.test.d.ts +2 -0
- package/dist/types/SectionExtension.test.d.ts.map +1 -0
- package/dist/types/extension-type.d.ts +10 -0
- package/dist/types/extension-type.d.ts.map +1 -0
- package/dist/types/index.d.ts +11 -0
- package/dist/types/index.d.ts.map +1 -0
- package/lib/AbstractExtension.ts +31 -0
- package/lib/AsyncPanelExtension.test.ts +145 -0
- package/lib/AsyncPanelExtension.ts +34 -0
- package/lib/ButtonExtension.test.ts +61 -0
- package/lib/ButtonExtension.ts +20 -0
- package/lib/LinkExtension.test.ts +61 -0
- package/lib/LinkExtension.ts +22 -0
- package/lib/ModalExtension.test.ts +147 -0
- package/lib/ModalExtension.ts +188 -0
- package/lib/PageExtension.test.ts +38 -0
- package/lib/PageExtension.ts +21 -0
- package/lib/PanelExtension.test.ts +134 -0
- package/lib/PanelExtension.ts +51 -0
- package/lib/SectionExtension.test.ts +57 -0
- package/lib/SectionExtension.ts +18 -0
- package/lib/extension-type.ts +10 -0
- package/lib/index.ts +21 -0
- 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
|
+
};
|
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
|
+
};
|