@servicetitan/web-components 22.15.0 → 22.16.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.
@@ -1,22 +1,25 @@
1
- import { FC } from 'react';
1
+ import { FC, useContext } from 'react';
2
+ import { TablePopupPropsContext } from '@servicetitan/design-system';
2
3
  import { render } from '@testing-library/react';
3
4
  import { screen } from 'shadow-dom-testing-library';
4
5
  import '@testing-library/jest-dom';
5
6
 
6
- import { register } from '../register';
7
7
  import { IWebComponent } from '../common';
8
8
  import { useMFEDataContext } from '../mfe-data-context';
9
9
 
10
- describe(`[web-component] ${register.name}`, () => {
11
- const Component: any = WEB_COMPONENT_NAME;
10
+ describe('[web-components] register', () => {
11
+ let onloadRefs: Function[];
12
+ const componentName = 'mock-app';
12
13
  const handleRef = (webComponent: IWebComponent | null) => {
13
14
  webComponent?.provide({
14
- onReady: () => {},
15
- onDispose: () => {},
15
+ onReady: jest.fn(),
16
+ onDispose: jest.fn(),
16
17
  });
17
18
  };
18
19
  const TestComponent: FC<Record<string, any>> = props => {
19
20
  const data = useMFEDataContext();
21
+ const popupProps = useContext(TablePopupPropsContext);
22
+
20
23
  return (
21
24
  <div>
22
25
  {Object.entries(props).map(([name, value]) => (
@@ -25,30 +28,125 @@ describe(`[web-component] ${register.name}`, () => {
25
28
  {Object.entries(data).map(([name, value]) => (
26
29
  <span key={name}>{`Data: ${name}, Value: ${JSON.stringify(value)}`}</span>
27
30
  ))}
31
+ <span>TablePopupPropsContext appendTo: {String(popupProps({}).appendTo)}</span>
28
32
  </div>
29
33
  );
30
34
  };
31
35
 
36
+ class Page {
37
+ static get componentShadowRoot() {
38
+ return document.querySelector(componentName)!.shadowRoot!;
39
+ }
40
+
41
+ static get portalShadowRoot() {
42
+ return document.querySelector('body')!.lastElementChild!.shadowRoot!;
43
+ }
44
+ }
45
+
32
46
  beforeAll(() => {
33
47
  /*
34
48
  * This register can only happen once, unfortunately. So the TestComponent needs to
35
49
  * work for all tests. If ran more than once, window.customElements.define will throw
36
50
  * the error: "NotSupportedError: This name has already been registered in the registry."
37
51
  *
38
- * The other issue is that the global WEB_COMPONENT_NAME can only be set in the jest config
39
- * via package.json, so we can only have one WEB_COMPONENT_NAME name for the test suite.
40
- *
41
52
  * Also note that there is not way to "unregister" a web component, so an afterAll
42
53
  * cannot be used.
43
54
  */
55
+ (global as any).WEB_COMPONENT_NAME = componentName;
56
+ const { register } = require('../register');
44
57
  register(TestComponent, true);
58
+
59
+ /*
60
+ * mock <link>'s onload so that we can call it directly from the test,
61
+ * because it won't call itself in the test environment
62
+ */
63
+ Object.defineProperty(HTMLLinkElement.prototype, 'onload', {
64
+ get() {
65
+ return this._onload;
66
+ },
67
+ set(onload: Function) {
68
+ onloadRefs.push(onload);
69
+ this._onload = onload;
70
+ },
71
+ });
45
72
  });
46
73
 
47
- const subject = (props: Record<string, any>) => {
74
+ beforeEach(() => {
75
+ onloadRefs = [];
76
+ });
77
+
78
+ const subject = (props: Record<string, any> = {}, dataAttrs: Record<string, any> = {}) => {
48
79
  const data = props && { 'data-mfe-data': JSON.stringify(props) };
49
- return render(<Component ref={handleRef} {...data} />);
80
+ const Component: any = componentName;
81
+ const renderResult = render(<Component ref={handleRef} {...data} {...dataAttrs} />);
82
+
83
+ for (const onload of onloadRefs) {
84
+ onload();
85
+ }
86
+
87
+ return renderResult;
50
88
  };
51
89
 
90
+ describe('when component has style urls', () => {
91
+ let styleUrls: string[];
92
+
93
+ beforeEach(() => {
94
+ styleUrls = ['foo.css', 'bar.css'];
95
+ });
96
+
97
+ function expectStylesInDom(styleUrls: string[], shadowRootFn: () => ShadowRoot) {
98
+ expect(shadowRootFn().querySelectorAll('link').length).toBe(styleUrls.length);
99
+ for (const styleUrl of styleUrls) {
100
+ const link = shadowRootFn().querySelector(
101
+ `link[href='${styleUrl}'][rel='stylesheet'][crossorigin='anonymous']`
102
+ );
103
+ expect(link).toBeInTheDocument();
104
+ }
105
+ }
106
+
107
+ test('loads the styles into the component shadow DOM', () => {
108
+ subject({}, { 'style-urls': JSON.stringify(styleUrls) });
109
+ expectStylesInDom(styleUrls, () => Page.componentShadowRoot);
110
+ });
111
+
112
+ test('loads the styles into the portal shadow DOM', () => {
113
+ subject({}, { 'style-urls': JSON.stringify(styleUrls) });
114
+ expectStylesInDom(styleUrls, () => Page.portalShadowRoot);
115
+ });
116
+
117
+ describe('when a design system link is present in the DOM', () => {
118
+ let designSystemLink: HTMLLinkElement;
119
+
120
+ beforeEach(() => {
121
+ designSystemLink = Object.assign(document.createElement('link'), {
122
+ href: 'http://localhost/design-system.foobar.bundle.css',
123
+ rel: 'stylesheet',
124
+ });
125
+ document.body.appendChild(designSystemLink);
126
+ });
127
+
128
+ afterEach(() => {
129
+ designSystemLink.remove();
130
+ });
131
+
132
+ test('loads the styles and design system styles into the component shadow DOM', () => {
133
+ subject({}, { 'style-urls': JSON.stringify(styleUrls) });
134
+ expectStylesInDom(
135
+ [...styleUrls, designSystemLink.href],
136
+ () => Page.componentShadowRoot
137
+ );
138
+ });
139
+
140
+ test('loads the styles and design system styles into the portal shadow DOM', () => {
141
+ subject({}, { 'style-urls': JSON.stringify(styleUrls) });
142
+ expectStylesInDom(
143
+ [...styleUrls, designSystemLink.href],
144
+ () => Page.portalShadowRoot
145
+ );
146
+ });
147
+ });
148
+ });
149
+
52
150
  describe('when component has custom data-attributes', () => {
53
151
  const props = {
54
152
  string: 'foo',
@@ -81,7 +179,7 @@ describe(`[web-component] ${register.name}`, () => {
81
179
  const setup = () => {
82
180
  subject(props);
83
181
 
84
- const webComponent = document.getElementsByTagName(Component);
182
+ const webComponent = document.getElementsByTagName(componentName);
85
183
  webComponent[0]?.setAttribute(
86
184
  'data-mfe-data',
87
185
  JSON.stringify({ ...props, string: 'baz' })
@@ -96,4 +194,29 @@ describe(`[web-component] ${register.name}`, () => {
96
194
  });
97
195
  });
98
196
  });
197
+
198
+ describe('when WEB_COMPONENT_NAME is not set', () => {
199
+ let register: typeof import('../register').register;
200
+
201
+ beforeEach(() => {
202
+ jest.isolateModules(() => {
203
+ (global as any).WEB_COMPONENT_NAME = undefined;
204
+ register = require('../register').register;
205
+ });
206
+ jest.spyOn(console, 'error').mockImplementation(jest.fn());
207
+ });
208
+
209
+ test('logs an error', () => {
210
+ register(TestComponent, true);
211
+ // eslint-disable-next-line no-console
212
+ expect(console.error).toHaveBeenCalledWith('"WEB_COMPONENT_NAME" is not defined!');
213
+ });
214
+ });
215
+
216
+ test('configures the shadow root in PopupPropsContext', () => {
217
+ subject();
218
+ expect(screen.getByShadowText(/TablePopupPropsContext appendTo:/)).toHaveTextContent(
219
+ /[object ShadowRoot]/
220
+ );
221
+ });
99
222
  });
@@ -1,101 +1,95 @@
1
+ import { ExposedDependencies } from '../common';
1
2
  import { getVersionMismatches, isVersionedUrl } from '../utils';
2
3
 
3
- describe('getVersionMismatches', () => {
4
- const getSharedDependencies = () => ({
5
- '@servicetitan/design-system': 'SharedDependencies.ServiceTitan.DesignSystem',
6
- 'classnames': 'SharedDependencies.ClassNames',
7
- 'formstate': 'SharedDependencies.FormState',
8
- 'mobx': 'SharedDependencies.MobX',
9
- 'mobx-react': 'SharedDependencies.MobXReact',
10
- 'mobx-utils': 'SharedDependencies.MobXUtils',
11
- 'react': 'SharedDependencies.React',
12
- 'react-dom': 'SharedDependencies.ReactDOM',
13
- });
4
+ describe('[web-components] utils', () => {
5
+ describe(`${getVersionMismatches.name}`, () => {
6
+ let sharedDependencies: Record<string, string> = {};
7
+ let dependencies: Record<string, string> = {};
8
+ let exposedDependencies: ExposedDependencies = {};
14
9
 
15
- const getDependencies = () => ({
16
- '@servicetitan/confirm': '^21.7.1',
17
- '@servicetitan/culture': '^21.7.1',
18
- '@servicetitan/datetime-utils': '^21.7.1',
19
- '@servicetitan/design-system': '~11.8.0',
20
- '@servicetitan/form': '^21.7.1',
21
- '@servicetitan/log-service': '^21.7.1',
22
- '@servicetitan/react-ioc': '^21.7.1',
23
- '@servicetitan/tokens': '~12.1.11',
24
- '@servicetitan/web-components': '^21.7.1',
25
- 'accounting': '~0.4.1',
26
- 'axios': '~0.25.0',
27
- 'classnames': '~2.3.1',
28
- 'formstate': '^2.0.0',
29
- 'mobx': '~6.6.0',
30
- 'mobx-react': '~7.5.0',
31
- 'react': '~17.0.2',
32
- 'react-dom': '~17.0.2',
33
- 'react-router-dom': '~6.3.0',
34
- 'resumablejs': '1.0.2',
35
- 'utils': '^0.15.9',
36
- });
10
+ beforeEach(() => {
11
+ sharedDependencies = {
12
+ '@servicetitan/design-system': 'SharedDependencies.ServiceTitan.DesignSystem',
13
+ };
37
14
 
38
- const getExposedDependencies = () => ({
39
- '@servicetitan/design-system': {
40
- version: '~11.8.0',
41
- variable: 'SharedDependencies.ServiceTitan.DesignSystem',
42
- },
43
- 'classnames': {
44
- version: '~2.3.1',
45
- variable: 'SharedDependencies.ClassNames',
46
- },
47
- 'formstate': {
48
- version: '~2.0.0',
49
- variable: 'SharedDependencies.FormState',
50
- },
51
- 'mobx': {
52
- version: '~6.6.0',
53
- variable: 'SharedDependencies.MobX',
54
- },
55
- 'mobx-react': {
56
- version: '~7.5.0',
57
- variable: 'SharedDependencies.MobXReact',
58
- },
59
- 'mobx-utils': {
60
- version: '~6.0.4',
61
- variable: 'SharedDependencies.MobXUtils',
62
- },
63
- 'react': {
64
- version: '~17.0.2',
65
- variable: 'SharedDependencies.React',
66
- },
67
- 'react-dom': {
68
- version: '~17.0.2',
69
- variable: 'SharedDependencies.ReactDOM',
70
- },
71
- });
15
+ dependencies = {
16
+ '@servicetitan/design-system': '~11.8.0',
17
+ };
18
+
19
+ exposedDependencies = {
20
+ '@servicetitan/design-system': {
21
+ version: '~11.8.0',
22
+ variable: 'SharedDependencies.ServiceTitan.DesignSystem',
23
+ },
24
+ };
25
+ });
72
26
 
73
- test('doesnt find mismatches when everything ok', () => {
74
- expect(
75
- getVersionMismatches(
76
- getExposedDependencies(),
77
- getDependencies(),
78
- getSharedDependencies()
79
- )
80
- ).toEqual({});
27
+ test('returns no mismatches', () => {
28
+ expect(
29
+ getVersionMismatches(exposedDependencies, dependencies, sharedDependencies)
30
+ ).toEqual({});
31
+ });
32
+
33
+ describe('when there is a versions mismatch', () => {
34
+ beforeEach(() => (dependencies['@servicetitan/design-system'] = '~12.0.0'));
35
+
36
+ test('returns one mismatch', () => {
37
+ expect(
38
+ getVersionMismatches(exposedDependencies, dependencies, sharedDependencies)
39
+ ).toEqual({
40
+ '@servicetitan/design-system': {
41
+ host: '~11.8.0',
42
+ package: '~12.0.0',
43
+ },
44
+ });
45
+ });
46
+ });
47
+
48
+ describe('when a dependency is missing from exposed dependencies', () => {
49
+ beforeEach(() => delete exposedDependencies['@servicetitan/design-system']);
50
+
51
+ test('returns an object citing the dependency is missing from the host', () => {
52
+ expect(
53
+ getVersionMismatches(exposedDependencies, dependencies, sharedDependencies)
54
+ ).toEqual({
55
+ '@servicetitan/design-system': {
56
+ host: 'missing',
57
+ package: '~11.8.0',
58
+ },
59
+ });
60
+ });
61
+ });
62
+
63
+ describe('when a variable does not match', () => {
64
+ beforeEach(() => (exposedDependencies['@servicetitan/design-system'].variable = 'foo'));
65
+
66
+ test('returns an object citing the variable is wrong', () => {
67
+ expect(
68
+ getVersionMismatches(exposedDependencies, dependencies, sharedDependencies)
69
+ ).toEqual({
70
+ '@servicetitan/design-system': {
71
+ host: 'wrong global variable',
72
+ package: '~11.8.0',
73
+ },
74
+ });
75
+ });
76
+ });
81
77
  });
82
- });
83
78
 
84
- describe('isVersionedUrl', () => {
85
- const results: [string, boolean][] = [
86
- ['localhost:9090', false],
87
- ['localhost:9090@1.1.1', false],
88
- ['localhost:9090@dev', false],
89
- ['//unpkg.servicetitan.com/@servicetitan/test', false],
90
- ['//unpkg.servicetitan.com/@servicetitan/test@dev', false],
91
- ['//unpkg.servicetitan.com/@servicetitan/test@0.0.0', true],
92
- ['//unpkg.servicetitan.com/@servicetitan/test@0.0.0-hash', true],
93
- ['//unpkg.servicetitan.com/@servicetitan/test@^16', false],
94
- ];
79
+ describe(`${isVersionedUrl.name}`, () => {
80
+ const results: [string, boolean][] = [
81
+ ['localhost:9090', false],
82
+ ['localhost:9090@1.1.1', false],
83
+ ['localhost:9090@dev', false],
84
+ ['//unpkg.servicetitan.com/@servicetitan/test', false],
85
+ ['//unpkg.servicetitan.com/@servicetitan/test@dev', false],
86
+ ['//unpkg.servicetitan.com/@servicetitan/test@0.0.0', true],
87
+ ['//unpkg.servicetitan.com/@servicetitan/test@0.0.0-hash', true],
88
+ ['//unpkg.servicetitan.com/@servicetitan/test@^16', false],
89
+ ];
95
90
 
96
- for (const [url, result] of results) {
97
- test(`versioned url check for '${url}' should be ${result}`, () => {
91
+ test.each(results)(`providing %s returns %s`, (url, result) => {
98
92
  expect(isVersionedUrl(url)).toEqual(result);
99
93
  });
100
- }
94
+ });
101
95
  });
package/src/common.ts CHANGED
@@ -93,19 +93,26 @@ export async function getJson<T = any>(
93
93
  try {
94
94
  response = await fetch(url, config);
95
95
  } catch {
96
- if (retries > 0) {
96
+ if (retries > 0 && !config.signal?.aborted) {
97
97
  return new Promise((resolve, reject) => {
98
98
  const { signal } = config;
99
99
 
100
100
  const timeoutId = window.setTimeout(() => {
101
101
  getJson(url, {
102
102
  ...config,
103
+ onRequestError,
103
104
  retries: retries - 1,
104
105
  retryDelay: retryDelay * 4,
105
106
  }).then(resolve, reject);
106
107
  }, retryDelay);
107
108
 
108
- signal?.addEventListener('abort', () => window.clearTimeout(timeoutId));
109
+ const abortHandler = () => {
110
+ window.clearTimeout(timeoutId);
111
+ signal?.removeEventListener('abort', abortHandler);
112
+ reject(signal?.reason);
113
+ };
114
+
115
+ signal?.addEventListener('abort', abortHandler);
109
116
  });
110
117
  }
111
118
 
package/src/loader.tsx CHANGED
@@ -264,6 +264,7 @@ export const Loader: <T>(props: LoaderProps<T> & { ref?: Ref<LoaderRef> }) => Re
264
264
  script.crossOrigin = 'anonymous';
265
265
  script.onload = () => {
266
266
  script.dataset.webComponent = WebComponent;
267
+ script.onload = null;
267
268
  resolve();
268
269
  };
269
270
  script.async = true;
@@ -276,6 +277,7 @@ export const Loader: <T>(props: LoaderProps<T> & { ref?: Ref<LoaderRef> }) => Re
276
277
  const originalOnload = script.onload;
277
278
  script.onload = (...args) => {
278
279
  originalOnload?.call(script, ...args);
280
+ script.onload = null;
279
281
  resolve();
280
282
  };
281
283
  });
@@ -25,9 +25,5 @@ export const MFEDataContext = createContext<JSON>({});
25
25
  export function useMFEDataContext<T extends MFEContextData<T>>() {
26
26
  const context = useContext(MFEDataContext) as T;
27
27
 
28
- if (!context) {
29
- throw new Error('useMFEDataContext must be used within a MFEDataContext.Provider');
30
- }
31
-
32
28
  return context;
33
29
  }