cozy-ui-plus 12.1.4 → 12.2.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [12.2.0](https://github.com/cozy/cozy-libs/compare/cozy-ui-plus@12.1.5...cozy-ui-plus@12.2.0) (2026-07-27)
7
+
8
+ ### Features
9
+
10
+ - **intent:** Wait for intent readiness signal ([b8d64dd](https://github.com/cozy/cozy-libs/commit/b8d64dd023e57ef976df08fda295ab6fa643b7ad))
11
+
12
+ ## [12.1.5](https://github.com/cozy/cozy-libs/compare/cozy-ui-plus@12.1.4...cozy-ui-plus@12.1.5) (2026-07-23)
13
+
14
+ **Note:** Version bump only for package cozy-ui-plus
15
+
6
16
  ## [12.1.4](https://github.com/cozy/cozy-libs/compare/cozy-ui-plus@12.1.3...cozy-ui-plus@12.1.4) (2026-07-22)
7
17
 
8
18
  **Note:** Version bump only for package cozy-ui-plus
@@ -23,7 +23,7 @@ var _Dialog = _interopRequireDefault(require("cozy-ui/transpiled/react/Dialog"))
23
23
 
24
24
  var _IntentIframe = _interopRequireWildcard(require("../IntentIframe"));
25
25
 
26
- var _excluded = ["options", "action", "doctype", "children", "closable", "showCloseButton", "create", "tag", "onComplete", "onDismiss", "onReadyToUse", "iframeProps", "Component"];
26
+ var _excluded = ["options", "action", "doctype", "children", "closable", "showCloseButton", "create", "tag", "onComplete", "onDismiss", "onReadyToUse", "waitForReadyToUse", "iframeProps", "Component"];
27
27
 
28
28
  function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
29
29
 
@@ -45,6 +45,7 @@ var IntentDialogOpener = function IntentDialogOpener(props) {
45
45
  onComplete = props.onComplete,
46
46
  onDismiss = props.onDismiss,
47
47
  onReadyToUse = props.onReadyToUse,
48
+ waitForReadyToUse = props.waitForReadyToUse,
48
49
  iframeProps = props.iframeProps,
49
50
  Component = props.Component,
50
51
  componentProps = (0, _objectWithoutProperties2.default)(props, _excluded);
@@ -96,6 +97,7 @@ var IntentDialogOpener = function IntentDialogOpener(props) {
96
97
  onCancel: handleDismiss,
97
98
  onTerminate: handleComplete,
98
99
  onReadyToUse: onReadyToUse,
100
+ waitForReadyToUse: waitForReadyToUse,
99
101
  iframeProps: iframeProps
100
102
  })));
101
103
  }
@@ -116,6 +118,9 @@ IntentDialogOpener.propTypes = {
116
118
  /** Called when the intent service signals it is ready to use */
117
119
  onReadyToUse: _propTypes.default.func,
118
120
 
121
+ /** Keep the loading state until the intent service signals it is ready to use */
122
+ waitForReadyToUse: _propTypes.default.bool,
123
+
119
124
  /** Action you want to execute */
120
125
  action: _propTypes.default.string.isRequired,
121
126
 
@@ -141,7 +146,8 @@ IntentDialogOpener.defaultProps = {
141
146
  tag: 'span',
142
147
  closable: true,
143
148
  Component: _Dialog.default,
144
- showCloseButton: true
149
+ showCloseButton: true,
150
+ waitForReadyToUse: false
145
151
  };
146
152
  var _default = IntentDialogOpener;
147
153
  exports.default = _default;
@@ -46,6 +46,7 @@ function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Re
46
46
  var styles = {
47
47
  "intentContainer": "styles__intentContainer___5Zqqq",
48
48
  "intentPlaceHolder": "styles__intentPlaceHolder___1f_u6",
49
+ "intentContainer__loader": "styles__intentContainer__loader___1krAq",
49
50
  "intentContainer__error": "styles__intentContainer__error___YO3Lz"
50
51
  };
51
52
  var DEFAULT_DATA = {
@@ -71,16 +72,36 @@ var IntentIframe = /*#__PURE__*/function (_React$Component) {
71
72
 
72
73
  _this = _super.call.apply(_super, [this].concat(args));
73
74
  (0, _defineProperty2.default)((0, _assertThisInitialized2.default)(_this), "state", {
74
- loading: false
75
+ error: null,
76
+ frameLoaded: false,
77
+ readyToUse: false
75
78
  });
76
79
  (0, _defineProperty2.default)((0, _assertThisInitialized2.default)(_this), "onFrameLoaded", function () {
77
- var _this$props$iframePro, _this$props$iframePro2;
80
+ _this.setState({
81
+ frameLoaded: true
82
+ }, function () {
83
+ if (!_this.props.waitForReadyToUse || _this.state.readyToUse) {
84
+ _this.setIsLoading(false);
85
+ }
86
+ });
87
+ });
88
+ (0, _defineProperty2.default)((0, _assertThisInitialized2.default)(_this), "onReadyToUse", function () {
89
+ var _this$props$onReadyTo, _this$props;
78
90
 
79
91
  _this.setState({
80
- loading: false
92
+ readyToUse: true
93
+ }, function () {
94
+ if (_this.props.waitForReadyToUse && _this.state.frameLoaded) {
95
+ _this.setIsLoading(false);
96
+ }
81
97
  });
82
98
 
83
- (_this$props$iframePro = _this.props.iframeProps) === null || _this$props$iframePro === void 0 ? void 0 : (_this$props$iframePro2 = _this$props$iframePro.setIsLoading) === null || _this$props$iframePro2 === void 0 ? void 0 : _this$props$iframePro2.call(_this$props$iframePro, false);
99
+ (_this$props$onReadyTo = (_this$props = _this.props).onReadyToUse) === null || _this$props$onReadyTo === void 0 ? void 0 : _this$props$onReadyTo.call(_this$props);
100
+ });
101
+ (0, _defineProperty2.default)((0, _assertThisInitialized2.default)(_this), "setIsLoading", function (isLoading) {
102
+ var _this$props$iframePro, _this$props$iframePro2;
103
+
104
+ (_this$props$iframePro = _this.props.iframeProps) === null || _this$props$iframePro === void 0 ? void 0 : (_this$props$iframePro2 = _this$props$iframePro.setIsLoading) === null || _this$props$iframePro2 === void 0 ? void 0 : _this$props$iframePro2.call(_this$props$iframePro, isLoading);
84
105
  });
85
106
  return _this;
86
107
  }
@@ -90,14 +111,14 @@ var IntentIframe = /*#__PURE__*/function (_React$Component) {
90
111
  value: function componentDidMount() {
91
112
  var _this2 = this;
92
113
 
93
- var _this$props = this.props,
94
- action = _this$props.action,
95
- data = _this$props.data,
96
- type = _this$props.type,
97
- onCancel = _this$props.onCancel,
98
- onError = _this$props.onError,
99
- onTerminate = _this$props.onTerminate,
100
- client = _this$props.client;
114
+ var _this$props2 = this.props,
115
+ action = _this$props2.action,
116
+ data = _this$props2.data,
117
+ type = _this$props2.type,
118
+ onCancel = _this$props2.onCancel,
119
+ onError = _this$props2.onError,
120
+ onTerminate = _this$props2.onTerminate,
121
+ client = _this$props2.client;
101
122
  console.warn('Be carful to use `withBreakpoints()` and not `useBreakpoints()` in intents. See https://github.com/cozy/cozy-ui/issues/1807');
102
123
  var create;
103
124
 
@@ -110,28 +131,22 @@ var IntentIframe = /*#__PURE__*/function (_React$Component) {
110
131
  create = intents.create;
111
132
  }
112
133
 
113
- this.setState({
114
- loading: true
115
- });
116
134
  create(action, type, _objectSpread(_objectSpread({}, DEFAULT_DATA), data)).start(this.intentViewer, {
117
135
  onReady: this.onFrameLoaded,
118
136
  onHideCross: this.props.onHideCross,
119
137
  onShowCross: this.props.onShowCross,
120
- onReadyToUse: this.props.onReadyToUse
138
+ onReadyToUse: this.onReadyToUse
121
139
  }).then(function (result) {
122
140
  // eslint-disable-next-line promise/always-return
123
141
  result ? onTerminate && onTerminate(result) : onCancel();
124
142
  }).catch(function (error) {
125
- var _this2$props$iframePr, _this2$props$iframePr2;
126
-
127
143
  onError === null || onError === void 0 ? void 0 : onError(error);
128
144
 
129
145
  _this2.setState({
130
- error: error,
131
- loading: false
146
+ error: error
132
147
  });
133
148
 
134
- (_this2$props$iframePr = _this2.props.iframeProps) === null || _this2$props$iframePr === void 0 ? void 0 : (_this2$props$iframePr2 = _this2$props$iframePr.setIsLoading) === null || _this2$props$iframePr2 === void 0 ? void 0 : _this2$props$iframePr2.call(_this2$props$iframePr, false);
149
+ _this2.setIsLoading(false);
135
150
  });
136
151
  }
137
152
  }, {
@@ -139,19 +154,27 @@ var IntentIframe = /*#__PURE__*/function (_React$Component) {
139
154
  value: function render() {
140
155
  var _this3 = this;
141
156
 
142
- var iframeProps = this.props.iframeProps;
157
+ var _this$props3 = this.props,
158
+ iframeProps = _this$props3.iframeProps,
159
+ waitForReadyToUse = _this$props3.waitForReadyToUse;
143
160
  var _this$state = this.state,
144
161
  error = _this$state.error,
145
- loading = _this$state.loading;
162
+ frameLoaded = _this$state.frameLoaded,
163
+ readyToUse = _this$state.readyToUse;
164
+ var loading = error === null && (!frameLoaded || waitForReadyToUse && !readyToUse);
146
165
  return /*#__PURE__*/_react.default.createElement("div", (0, _extends2.default)({
147
166
  ref: function ref(intentViewer) {
148
167
  return _this3.intentViewer = intentViewer;
149
168
  },
150
169
  className: styles.intentContainer,
151
- "aria-busy": loading
152
- }, (0, _get.default)(iframeProps, 'wrapperProps')), loading && /*#__PURE__*/_react.default.createElement(_Spinner.default, (0, _extends2.default)({
170
+ "aria-busy": loading,
171
+ "data-iframe-loaded": frameLoaded,
172
+ "data-waiting-for-ready-to-use": waitForReadyToUse && !readyToUse
173
+ }, (0, _get.default)(iframeProps, 'wrapperProps')), loading && /*#__PURE__*/_react.default.createElement("div", {
174
+ className: styles.intentContainer__loader
175
+ }, /*#__PURE__*/_react.default.createElement(_Spinner.default, (0, _extends2.default)({
153
176
  size: "xxlarge"
154
- }, (0, _get.default)(iframeProps, 'spinnerProps'))), error && /*#__PURE__*/_react.default.createElement("div", {
177
+ }, (0, _get.default)(iframeProps, 'spinnerProps')))), error && /*#__PURE__*/_react.default.createElement("div", {
155
178
  className: styles.intentContainer__error
156
179
  }, error.message));
157
180
  }
@@ -177,10 +200,12 @@ IntentIframe.propTypes = {
177
200
  iframeProps: iframeProps,
178
201
  onHideCross: _propTypes.default.func,
179
202
  onShowCross: _propTypes.default.func,
180
- onReadyToUse: _propTypes.default.func
203
+ onReadyToUse: _propTypes.default.func,
204
+ waitForReadyToUse: _propTypes.default.bool
181
205
  };
182
206
  IntentIframe.defaultProps = {
183
- data: {}
207
+ data: {},
208
+ waitForReadyToUse: false
184
209
  };
185
210
 
186
211
  var _default = (0, _cozyClient.withClient)(IntentIframe);
@@ -10,6 +10,7 @@ declare namespace IntentDialogOpener {
10
10
  export let onComplete: PropTypes.Requireable<(...args: any[]) => any>;
11
11
  export let onDismiss: PropTypes.Requireable<(...args: any[]) => any>;
12
12
  export let onReadyToUse: PropTypes.Requireable<(...args: any[]) => any>;
13
+ export let waitForReadyToUse: PropTypes.Requireable<boolean>;
13
14
  export let action: PropTypes.Validator<string>;
14
15
  export let doctype: PropTypes.Validator<string>;
15
16
  export let closable: PropTypes.Validator<boolean>;
@@ -26,6 +27,8 @@ declare namespace IntentDialogOpener {
26
27
  export { Dialog as Component };
27
28
  let showCloseButton_1: boolean;
28
29
  export { showCloseButton_1 as showCloseButton };
30
+ let waitForReadyToUse_1: boolean;
31
+ export { waitForReadyToUse_1 as waitForReadyToUse };
29
32
  }
30
33
  }
31
34
  import PropTypes from 'prop-types';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -409,11 +409,31 @@ iframe {
409
409
  justify-content: center;
410
410
  align-items: center;
411
411
  }
412
+ .styles__intentContainer___5Zqqq {
413
+ position: relative;
414
+ }
415
+ .styles__intentContainer__loader___1krAq {
416
+ display: flex;
417
+ justify-content: center;
418
+ align-items: center;
419
+ }
420
+ .styles__intentContainer___5Zqqq[data-iframe-loaded=true] .styles__intentContainer__loader___1krAq {
421
+ position: absolute;
422
+ z-index: 1;
423
+ top: 0;
424
+ right: 0;
425
+ bottom: 0;
426
+ left: 0;
427
+ background-color: var(--paperBackgroundColor);
428
+ }
412
429
  /* Don't mess with the spinner centering until the iframe is fully loaded. */
413
- .styles__intentContainer___5Zqqq[aria-busy=true] iframe {
430
+ .styles__intentContainer___5Zqqq[data-iframe-loaded=false] iframe {
414
431
  height: 0;
415
432
  width: 0;
416
433
  }
434
+ .styles__intentContainer___5Zqqq[data-waiting-for-ready-to-use=true] iframe {
435
+ visibility: hidden;
436
+ }
417
437
  .styles__intentContainer__error___YO3Lz {
418
438
  color: var(--errorColor);
419
439
  font-size: 1.5em;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-ui-plus",
3
- "version": "12.1.4",
3
+ "version": "12.2.0",
4
4
  "description": "Cozy-ui-plus is an extension of cozy-ui and provides components based on cozy-client",
5
5
  "main": "dist/index.js",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "cozy-intent": "^2.31.1",
39
39
  "cozy-interapp": "^0.18.1",
40
40
  "cozy-logger": "^1.18.1",
41
- "cozy-sharing": "^37.2.2",
41
+ "cozy-sharing": "^37.2.4",
42
42
  "cozy-stack-client": "^60.21.1",
43
43
  "cozy-ui": "^138.1.0",
44
44
  "jest": "30.3.0",
@@ -78,5 +78,5 @@
78
78
  "react-dom": "^16 || ^17 || ^18",
79
79
  "twake-i18n": ">=0.3.0"
80
80
  },
81
- "gitHead": "ba501af781312be5d4f530b8dac3c6b9604da60e"
81
+ "gitHead": "539544014d6fd28e56744e6438899866d63a34fa"
82
82
  }
@@ -16,6 +16,7 @@ import DemoProvider from 'cozy-ui/docs/components/DemoProvider'
16
16
  fullWidth // extra props will be passed to the dialog
17
17
  maxWidth="md" // extra props will be passed to the dialog
18
18
  iframeProps={{ spinnerProps: { middle: true } }}
19
+ waitForReadyToUse
19
20
  onComplete={res => alert('intent has completed ! ' + JSON.stringify(res))}
20
21
  onDismiss={() => alert('intent has been dismissed !')}
21
22
  >
@@ -23,3 +24,7 @@ import DemoProvider from 'cozy-ui/docs/components/DemoProvider'
23
24
  </IntentDialogOpener>
24
25
  </DemoProvider>
25
26
  ```
27
+
28
+ Set `waitForReadyToUse` to keep the intent hidden behind the loading spinner
29
+ until its service sends the `readyToUse` signal. This option is disabled by
30
+ default because older intent services do not send this signal.
@@ -23,6 +23,7 @@ const IntentDialogOpener = props => {
23
23
  onComplete,
24
24
  onDismiss,
25
25
  onReadyToUse,
26
+ waitForReadyToUse,
26
27
  iframeProps,
27
28
  Component,
28
29
  ...componentProps
@@ -70,6 +71,7 @@ const IntentDialogOpener = props => {
70
71
  onCancel={handleDismiss}
71
72
  onTerminate={handleComplete}
72
73
  onReadyToUse={onReadyToUse}
74
+ waitForReadyToUse={waitForReadyToUse}
73
75
  iframeProps={iframeProps}
74
76
  />
75
77
  </Component>
@@ -88,6 +90,8 @@ IntentDialogOpener.propTypes = {
88
90
  onDismiss: PropTypes.func,
89
91
  /** Called when the intent service signals it is ready to use */
90
92
  onReadyToUse: PropTypes.func,
93
+ /** Keep the loading state until the intent service signals it is ready to use */
94
+ waitForReadyToUse: PropTypes.bool,
91
95
  /** Action you want to execute */
92
96
  action: PropTypes.string.isRequired,
93
97
  /** Doctype on which you want to execute the action */
@@ -108,7 +112,8 @@ IntentDialogOpener.defaultProps = {
108
112
  tag: 'span',
109
113
  closable: true,
110
114
  Component: Dialog,
111
- showCloseButton: true
115
+ showCloseButton: true,
116
+ waitForReadyToUse: false
112
117
  }
113
118
 
114
119
  export default IntentDialogOpener
@@ -0,0 +1,44 @@
1
+ import { fireEvent, render } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import IntentDialogOpener from './index'
5
+
6
+ jest.mock('cozy-ui/transpiled/react/CozyDialogs', () => ({
7
+ DialogCloseButton: () => null
8
+ }))
9
+
10
+ jest.mock('cozy-ui/transpiled/react/Dialog', () => ({ children }) => (
11
+ <div>{children}</div>
12
+ ))
13
+
14
+ jest.mock('../IntentIframe', () => ({
15
+ __esModule: true,
16
+ default: ({ waitForReadyToUse }) => (
17
+ <div
18
+ data-testid="intent-iframe"
19
+ data-wait-for-ready-to-use={waitForReadyToUse}
20
+ />
21
+ ),
22
+ iframeProps: require('prop-types').shape({})
23
+ }))
24
+
25
+ describe('IntentDialogOpener', () => {
26
+ it('forwards waitForReadyToUse to the intent iframe', () => {
27
+ const { getByRole, getByTestId } = render(
28
+ <IntentDialogOpener
29
+ action="PICK"
30
+ doctype="io.cozy.files"
31
+ waitForReadyToUse
32
+ >
33
+ <button type="button">Open</button>
34
+ </IntentDialogOpener>
35
+ )
36
+
37
+ fireEvent.click(getByRole('button', { name: 'Open' }))
38
+
39
+ expect(getByTestId('intent-iframe')).toHaveAttribute(
40
+ 'data-wait-for-ready-to-use',
41
+ 'true'
42
+ )
43
+ })
44
+ })
@@ -19,9 +19,14 @@ import utils from '../../docs/utils'
19
19
  onCancel={() => alert('intent cancelled')}
20
20
  onError={(error) => alert('intent has failed with error: ' + error.message)}
21
21
  onTerminate={doc => alert('intent has completed ! ' + JSON.stringify(doc))}
22
+ waitForReadyToUse
22
23
  />
23
24
  ```
24
25
 
26
+ Set `waitForReadyToUse` to keep the intent hidden behind the loading spinner
27
+ until its service sends the `readyToUse` signal. This option is disabled by
28
+ default because older intent services do not send this signal.
29
+
25
30
  ### Within an application side controled Dialog
26
31
 
27
32
  Sometimes you have to render an Intent inside a modal, and handle the modal opening state on the application side. You can then use IntentIframe wrapped in a Dialog for that use case, instead of using IntentDialogOpener component (that is useful to start a new intent modal from a click on a button).
@@ -18,7 +18,7 @@ const DEFAULT_DATA = {
18
18
  }
19
19
 
20
20
  class IntentIframe extends React.Component {
21
- state = { loading: false }
21
+ state = { error: null, frameLoaded: false, readyToUse: false }
22
22
 
23
23
  componentDidMount() {
24
24
  const { action, data, type, onCancel, onError, onTerminate, client } =
@@ -36,7 +36,6 @@ class IntentIframe extends React.Component {
36
36
  create = intents.create
37
37
  }
38
38
 
39
- this.setState({ loading: true })
40
39
  create(action, type, {
41
40
  ...DEFAULT_DATA,
42
41
  ...data
@@ -45,7 +44,7 @@ class IntentIframe extends React.Component {
45
44
  onReady: this.onFrameLoaded,
46
45
  onHideCross: this.props.onHideCross,
47
46
  onShowCross: this.props.onShowCross,
48
- onReadyToUse: this.props.onReadyToUse
47
+ onReadyToUse: this.onReadyToUse
49
48
  })
50
49
  .then(result => {
51
50
  // eslint-disable-next-line promise/always-return
@@ -53,29 +52,51 @@ class IntentIframe extends React.Component {
53
52
  })
54
53
  .catch(error => {
55
54
  onError?.(error)
56
- this.setState({ error: error, loading: false })
57
- this.props.iframeProps?.setIsLoading?.(false)
55
+ this.setState({ error })
56
+ this.setIsLoading(false)
58
57
  })
59
58
  }
60
59
 
61
60
  onFrameLoaded = () => {
62
- this.setState({ loading: false })
63
- this.props.iframeProps?.setIsLoading?.(false)
61
+ this.setState({ frameLoaded: true }, () => {
62
+ if (!this.props.waitForReadyToUse || this.state.readyToUse) {
63
+ this.setIsLoading(false)
64
+ }
65
+ })
66
+ }
67
+
68
+ onReadyToUse = () => {
69
+ this.setState({ readyToUse: true }, () => {
70
+ if (this.props.waitForReadyToUse && this.state.frameLoaded) {
71
+ this.setIsLoading(false)
72
+ }
73
+ })
74
+ this.props.onReadyToUse?.()
75
+ }
76
+
77
+ setIsLoading = isLoading => {
78
+ this.props.iframeProps?.setIsLoading?.(isLoading)
64
79
  }
65
80
 
66
81
  render() {
67
- const { iframeProps } = this.props
68
- const { error, loading } = this.state
82
+ const { iframeProps, waitForReadyToUse } = this.props
83
+ const { error, frameLoaded, readyToUse } = this.state
84
+ const loading =
85
+ error === null && (!frameLoaded || (waitForReadyToUse && !readyToUse))
69
86
 
70
87
  return (
71
88
  <div
72
89
  ref={intentViewer => (this.intentViewer = intentViewer)}
73
90
  className={styles.intentContainer}
74
91
  aria-busy={loading}
92
+ data-iframe-loaded={frameLoaded}
93
+ data-waiting-for-ready-to-use={waitForReadyToUse && !readyToUse}
75
94
  {...get(iframeProps, 'wrapperProps')}
76
95
  >
77
96
  {loading && (
78
- <Spinner size="xxlarge" {...get(iframeProps, 'spinnerProps')} />
97
+ <div className={styles.intentContainer__loader}>
98
+ <Spinner size="xxlarge" {...get(iframeProps, 'spinnerProps')} />
99
+ </div>
79
100
  )}
80
101
  {error && (
81
102
  <div className={styles.intentContainer__error}>{error.message}</div>
@@ -103,11 +124,13 @@ IntentIframe.propTypes = {
103
124
  iframeProps: iframeProps,
104
125
  onHideCross: PropTypes.func,
105
126
  onShowCross: PropTypes.func,
106
- onReadyToUse: PropTypes.func
127
+ onReadyToUse: PropTypes.func,
128
+ waitForReadyToUse: PropTypes.bool
107
129
  }
108
130
 
109
131
  IntentIframe.defaultProps = {
110
- data: {}
132
+ data: {},
133
+ waitForReadyToUse: false
111
134
  }
112
135
 
113
136
  export default withClient(IntentIframe)
@@ -0,0 +1,121 @@
1
+ import { act, render, waitFor } from '@testing-library/react'
2
+ import React from 'react'
3
+
4
+ import IntentIframe from './index'
5
+
6
+ jest.mock('cozy-ui/transpiled/react/Spinner', () => () => (
7
+ <div data-testid="intent-spinner" />
8
+ ))
9
+
10
+ function setup({ startError = null, waitForReadyToUse = false } = {}) {
11
+ let startOptions
12
+ const start = jest.fn((element, options) => {
13
+ startOptions = options
14
+ element.appendChild(document.createElement('iframe'))
15
+ return startError ? Promise.reject(startError) : new Promise(() => {})
16
+ })
17
+ const create = jest.fn(() => ({ start }))
18
+ const onError = jest.fn()
19
+ const onReadyToUse = jest.fn()
20
+ const setIsLoading = jest.fn()
21
+
22
+ const result = render(
23
+ <IntentIframe
24
+ action="PICK"
25
+ client={{}}
26
+ create={create}
27
+ iframeProps={{ setIsLoading }}
28
+ onCancel={jest.fn()}
29
+ onError={onError}
30
+ onReadyToUse={onReadyToUse}
31
+ onTerminate={jest.fn()}
32
+ type="io.cozy.files"
33
+ waitForReadyToUse={waitForReadyToUse}
34
+ />
35
+ )
36
+
37
+ return {
38
+ ...result,
39
+ getStartOptions: () => startOptions,
40
+ onError,
41
+ onReadyToUse,
42
+ setIsLoading
43
+ }
44
+ }
45
+
46
+ describe('IntentIframe', () => {
47
+ beforeEach(() => {
48
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
49
+ })
50
+
51
+ afterEach(() => {
52
+ jest.restoreAllMocks()
53
+ })
54
+
55
+ it('keeps the iframe rendered behind the spinner until it is ready to use', () => {
56
+ const {
57
+ container,
58
+ getStartOptions,
59
+ onReadyToUse,
60
+ queryByTestId,
61
+ setIsLoading
62
+ } = setup({ waitForReadyToUse: true })
63
+
64
+ act(() => getStartOptions().onReady())
65
+
66
+ expect(queryByTestId('intent-spinner')).toBeInTheDocument()
67
+ expect(container.querySelector('iframe')).toBeInTheDocument()
68
+ expect(
69
+ container.querySelector(
70
+ '[data-iframe-loaded="true"][data-waiting-for-ready-to-use="true"]'
71
+ )
72
+ ).toBeInTheDocument()
73
+ expect(setIsLoading).not.toHaveBeenCalled()
74
+
75
+ act(() => getStartOptions().onReadyToUse())
76
+
77
+ expect(queryByTestId('intent-spinner')).toBe(null)
78
+ expect(onReadyToUse).toHaveBeenCalledTimes(1)
79
+ expect(setIsLoading).toHaveBeenCalledWith(false)
80
+ })
81
+
82
+ it('waits for the iframe when readyToUse is received first', () => {
83
+ const { getStartOptions, queryByTestId, setIsLoading } = setup({
84
+ waitForReadyToUse: true
85
+ })
86
+
87
+ act(() => getStartOptions().onReadyToUse())
88
+
89
+ expect(queryByTestId('intent-spinner')).toBeInTheDocument()
90
+ expect(setIsLoading).not.toHaveBeenCalled()
91
+
92
+ act(() => getStartOptions().onReady())
93
+
94
+ expect(queryByTestId('intent-spinner')).toBe(null)
95
+ expect(setIsLoading).toHaveBeenCalledWith(false)
96
+ })
97
+
98
+ it('hides the spinner when the iframe loads by default', () => {
99
+ const { getStartOptions, queryByTestId, setIsLoading } = setup()
100
+
101
+ act(() => getStartOptions().onReady())
102
+ act(() => getStartOptions().onReadyToUse())
103
+
104
+ expect(queryByTestId('intent-spinner')).toBe(null)
105
+ expect(setIsLoading).toHaveBeenCalledTimes(1)
106
+ })
107
+
108
+ it('shows intent errors instead of waiting for readyToUse', async () => {
109
+ const error = new Error('Intent failed')
110
+ const { container, onError, queryByTestId, setIsLoading } = setup({
111
+ startError: error,
112
+ waitForReadyToUse: true
113
+ })
114
+
115
+ await waitFor(() => expect(queryByTestId('intent-spinner')).toBe(null))
116
+
117
+ expect(container).toHaveTextContent('Intent failed')
118
+ expect(onError).toHaveBeenCalledWith(error)
119
+ expect(setIsLoading).toHaveBeenCalledWith(false)
120
+ })
121
+ })
@@ -11,11 +11,31 @@ iframe
11
11
  justify-content center
12
12
  align-items center
13
13
 
14
+ .intentContainer
15
+ position relative
16
+
17
+ .intentContainer__loader
18
+ display flex
19
+ justify-content center
20
+ align-items center
21
+
22
+ .intentContainer[data-iframe-loaded=true] .intentContainer__loader
23
+ position absolute
24
+ z-index 1
25
+ top 0
26
+ right 0
27
+ bottom 0
28
+ left 0
29
+ background-color var(--paperBackgroundColor)
30
+
14
31
  /* Don't mess with the spinner centering until the iframe is fully loaded. */
15
- .intentContainer[aria-busy=true] iframe
32
+ .intentContainer[data-iframe-loaded=false] iframe
16
33
  height 0
17
34
  width 0
18
35
 
36
+ .intentContainer[data-waiting-for-ready-to-use=true] iframe
37
+ visibility hidden
38
+
19
39
  .intentContainer__error
20
40
  color var(--errorColor)
21
41
  font-size 1.5em