goblin-laboratory 4.11.2 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -34,6 +34,11 @@ class Channel {
34
34
  this._send('DISPATCH_IN_APP', {action, _data: action});
35
35
  }
36
36
 
37
+ sendEvent(topic, data) {
38
+ data = {topic, data};
39
+ this._send('NEW_EVENT', {data, _data: data});
40
+ }
41
+
37
42
  beginRender(labId, tokens) {
38
43
  this._send('BEGIN_RENDER', {labId, tokens, _data: labId});
39
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goblin-laboratory",
3
- "version": "4.11.2",
3
+ "version": "4.12.0",
4
4
  "description": "Laboratory",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1"
@@ -43,12 +43,12 @@
43
43
  "immutable": "4.0.0-rc.14",
44
44
  "linked-list": "^1.0.4",
45
45
  "obj-to-css": "^1.0.1",
46
+ "path-to-regexp": "^1.9.0",
46
47
  "prettier": "2.0.4",
47
48
  "prop-types": "^15.5.10",
48
49
  "react": "^17.0.1",
49
50
  "react-dom": "^17.0.1",
50
51
  "react-redux": "^8.1.3",
51
- "react-router": "^5.0.0",
52
52
  "redux-thunk": "^2.3.0",
53
53
  "safe-stable-stringify": "^2.5.0",
54
54
  "xcraft-core-shredder": "^5.3.0",
@@ -1,16 +1,51 @@
1
1
  import C, {ConnectedProp} from './c.js';
2
2
 
3
+ function mergeInFuncs(inFunc1, inFunc2) {
4
+ if (inFunc1) {
5
+ if (inFunc2) {
6
+ return (...args) => inFunc2(inFunc1(...args));
7
+ }
8
+ return inFunc1;
9
+ }
10
+ return inFunc2;
11
+ }
12
+
13
+ function mergeOutFuncs(outFunc1, outFunc2, inFunc1) {
14
+ if (outFunc1) {
15
+ if (outFunc2) {
16
+ if (outFunc1.length > 1 || outFunc2.length > 1) {
17
+ if (inFunc1) {
18
+ return (newValue, oldValue, ...oldValues) =>
19
+ outFunc1(
20
+ outFunc2(newValue, inFunc1(oldValue, ...oldValues)),
21
+ oldValue,
22
+ ...oldValues
23
+ );
24
+ }
25
+ return (newValue, oldValue, ...oldValues) =>
26
+ outFunc1(
27
+ outFunc2(newValue, oldValue, ...oldValues),
28
+ oldValue,
29
+ ...oldValues
30
+ );
31
+ }
32
+ return (newValue) => outFunc1(outFunc2(newValue));
33
+ }
34
+ return outFunc1;
35
+ }
36
+ if (outFunc2 && outFunc2.length > 1 && inFunc1) {
37
+ return (newValue, oldValue, ...oldValues) =>
38
+ outFunc2(newValue, inFunc1(oldValue, ...oldValues));
39
+ }
40
+ return outFunc2;
41
+ }
42
+
3
43
  export default function mapC(value, inFunc, outFunc) {
4
44
  if (value instanceof ConnectedProp) {
5
45
  return C(
6
46
  value.path,
7
- value.inFunc ? (...args) => inFunc(value.inFunc(...args)) : inFunc,
8
- value.outFunc
9
- ? value.outFunc.length > 1 || outFunc.length > 1
10
- ? (newValue, ...oldValues) =>
11
- value.outFunc(outFunc(newValue, ...oldValues), ...oldValues)
12
- : (newValue) => value.outFunc(outFunc(newValue))
13
- : outFunc
47
+ mergeInFuncs(value.inFunc, inFunc),
48
+ mergeOutFuncs(value.outFunc, outFunc, value.inFunc)
14
49
  );
15
50
  }
16
51
  return inFunc(value);
@@ -0,0 +1,13 @@
1
+ import C from './c.js';
2
+
3
+ export default function pickC(...fields) {
4
+ for (const field of fields) {
5
+ if (field.lastIndexOf('.') !== 0) {
6
+ throw new Error(`Unsupported field ${field}`);
7
+ }
8
+ }
9
+ const fieldNames = fields.map((field) => field.slice(1));
10
+ return C(fields, (...values) =>
11
+ Object.fromEntries(fieldNames.map((name, i) => [name, values[i]]))
12
+ );
13
+ }
@@ -27,13 +27,13 @@ function isShredderOrImmutable(obj) {
27
27
  * ```
28
28
  * And then the prop "value" can be connected to the state using:
29
29
  * ```javascript
30
- * <TextFieldNC
30
+ * <TextField
31
31
  * value={C('.age')}
32
32
  * />
33
33
  * ```
34
34
  * Two functions can be applied, when reading and writing to the state:
35
35
  * ```javascript
36
- * <TextFieldNC
36
+ * <TextField
37
37
  * value={C('.age', age => age + '', age => Number(age))}
38
38
  * />
39
39
  * ```
@@ -53,8 +53,24 @@ function isShredderOrImmutable(obj) {
53
53
  *
54
54
  * It is possible to connect a prop to multiple values in the state:
55
55
  * ```javascript
56
- * <TextFieldNC
57
- * value={C(['.age', '.limit'], (age, limit) => age > limit ? age : limit)}
56
+ * <Label
57
+ * text={C(['.firstname', '.lastname'], (firstname, lastname) => `${firstname} ${lastname}`)}
58
+ * />
59
+ * ```
60
+ *
61
+ * When multiple values are connected, the write action is "patch"
62
+ * and the output function must return a patch object:
63
+ * ```javascript
64
+ * <TextFieldTyped
65
+ * type="date"
66
+ * value={C(
67
+ * ['.year', '.month', '.day'],
68
+ * (year, month, day) => `${year}-${month}-${day}`,
69
+ * (value) => {
70
+ * const [year, month, day] = value.split('-');
71
+ * return {year, month, day};
72
+ * }
73
+ * )}
58
74
  * />
59
75
  * ```
60
76
  *
@@ -79,18 +95,17 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
79
95
  const ConnectedPropsMapper = (props) => {
80
96
  let {_connectedProps, _connectedProp, _model, ...otherProps} = props;
81
97
  const newProps = {};
82
- for (const prop of _connectedProps) {
98
+ for (const {name, prop, fullPath} of _connectedProps) {
83
99
  const inFunc = prop.inFunc;
84
100
  if (inFunc) {
85
- const name = prop.name;
86
101
  if (name === '_connectedProp') {
87
- if (Array.isArray(prop.fullPath)) {
102
+ if (Array.isArray(fullPath)) {
88
103
  _connectedProp = inFunc(..._connectedProp);
89
104
  } else {
90
105
  _connectedProp = inFunc(_connectedProp);
91
106
  }
92
107
  } else {
93
- if (Array.isArray(prop.fullPath)) {
108
+ if (Array.isArray(fullPath)) {
94
109
  newProps[name] = inFunc(...props[name]);
95
110
  } else {
96
111
  newProps[name] = inFunc(props[name]);
@@ -104,10 +119,10 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
104
119
  }
105
120
  if (modelProp) {
106
121
  const connectedModelProp = _connectedProps.find(
107
- (prop) => prop.name === modelProp
122
+ ({name}) => name === modelProp
108
123
  );
109
124
  if (connectedModelProp) {
110
- const path = connectedModelProp.path;
125
+ const path = connectedModelProp.prop.path;
111
126
  const model = Array.isArray(path) ? path[0] : path;
112
127
  return (
113
128
  <WithModel model={model}>
@@ -124,8 +139,7 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
124
139
  const ConnectedComponent = Widget.connect(
125
140
  (state, props) => {
126
141
  const newProps = {};
127
- for (const prop of props._connectedProps) {
128
- const fullPath = prop.fullPath;
142
+ for (const {name, fullPath} of props._connectedProps) {
129
143
  let value;
130
144
  if (Array.isArray(fullPath)) {
131
145
  value = fullPath.map((p) => state.get(p));
@@ -134,7 +148,7 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
134
148
  } else {
135
149
  value = state.get(fullPath);
136
150
  }
137
- newProps[prop.name] = value;
151
+ newProps[name] = value;
138
152
  }
139
153
  return newProps;
140
154
  },
@@ -163,7 +177,32 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
163
177
  }
164
178
 
165
179
  handlePropChange(propName, value) {
166
- const path = this.addContextToPath(this.props[propName].path);
180
+ const propPath = this.props[propName].path;
181
+
182
+ if (Array.isArray(propPath)) {
183
+ const [root, id, ...pathArray] = (
184
+ this.props._model || this.context.model
185
+ ).split('.');
186
+ if (pathArray.length > 0) {
187
+ throw new Error(`Patch with a path is not supported`);
188
+ }
189
+ if (root === 'backend') {
190
+ this.doFor(id, 'patch', {patch: value});
191
+ return;
192
+ } else if (root === 'widgets') {
193
+ this.dispatchTo(id, {
194
+ type: 'PATCH',
195
+ patch: value,
196
+ });
197
+ return;
198
+ } else {
199
+ throw new Error(
200
+ `Model path starting with '${root}' is not supported.`
201
+ );
202
+ }
203
+ }
204
+
205
+ const path = this.addContextToPath(propPath);
167
206
  if (!path) {
168
207
  throw new Error(`Path is not defined`);
169
208
  }
@@ -203,22 +242,22 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
203
242
 
204
243
  for (const name of this.connectedPropNames) {
205
244
  const prop = this.props[name];
206
- prop.name = name;
207
245
 
208
246
  // Add context to path
247
+ let fullPath;
209
248
  if (Array.isArray(prop.path)) {
210
249
  // Handle array of paths (for extra arguments to inFunc)
211
- prop.fullPath = prop.path.map(this.addContextToPath);
250
+ fullPath = prop.path.map(this.addContextToPath);
212
251
  } else {
213
- prop.fullPath = this.addContextToPath(prop.path);
252
+ fullPath = this.addContextToPath(prop.path);
214
253
  }
215
254
 
216
- if (prop.fullPath === null || prop.fullPath === undefined) {
255
+ if (fullPath === null || fullPath === undefined) {
217
256
  // No path, the prop will receive 'undefined'
218
257
  undefinedProps[name] = undefined;
219
258
  } else {
220
259
  // There is a path, add the prop to the list of connected props
221
- connectedProps.push(prop);
260
+ connectedProps.push({name, prop, fullPath});
222
261
 
223
262
  // Setup a dispatch prop to change the prop value
224
263
  if (name in dispatchProps) {
@@ -226,16 +265,22 @@ export default function withC(Component, dispatchProps = {}, {modelProp} = {}) {
226
265
  const outFunc = prop.outFunc;
227
266
  if (outFunc) {
228
267
  if (outFunc.length > 1) {
229
- let currentValues;
230
- if (Array.isArray(prop.fullPath)) {
231
- currentValues = prop.fullPath.map((path) =>
232
- this.getState(path)
233
- );
268
+ if (Array.isArray(fullPath)) {
269
+ onChangeProps[dispatchPropName] = (value) => {
270
+ const currentValues = fullPath.map((path) =>
271
+ this.getState(path)
272
+ );
273
+ this.handlePropChange(
274
+ name,
275
+ outFunc(value, ...currentValues)
276
+ );
277
+ };
234
278
  } else {
235
- currentValues = [this.getState(prop.fullPath)];
279
+ onChangeProps[dispatchPropName] = (value) => {
280
+ const currentValue = this.getState(fullPath);
281
+ this.handlePropChange(name, outFunc(value, currentValue));
282
+ };
236
283
  }
237
- onChangeProps[dispatchPropName] = (value) =>
238
- this.handlePropChange(name, outFunc(value, ...currentValues));
239
284
  } else {
240
285
  onChangeProps[dispatchPropName] = (value) =>
241
286
  this.handlePropChange(name, outFunc(value));
@@ -0,0 +1,39 @@
1
+ class DesktopEvents {
2
+ #events = new Map();
3
+
4
+ /**
5
+ * Subscribe to a topic.
6
+ * @param {string} topic
7
+ * @param {Function} callback
8
+ * @returns {Function} unsub
9
+ */
10
+ sub(topic, callback) {
11
+ /** @type {Set} */
12
+ let callbacks = this.#events.get(topic);
13
+ if (!callbacks) {
14
+ callbacks = new Set();
15
+ this.#events.set(topic, callbacks);
16
+ }
17
+ callbacks.add(callback);
18
+ /* unsub */
19
+ return () => {
20
+ callbacks.delete(callback);
21
+ };
22
+ }
23
+
24
+ emit(topic, data) {
25
+ const callbacks = this.#events.get(topic);
26
+ if (!callbacks) {
27
+ return; /* discard event */
28
+ }
29
+ for (const callback of callbacks) {
30
+ try {
31
+ callback(data);
32
+ } catch (ex) {
33
+ console.error(ex.stack || ex.message || ex);
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ export default new DesktopEvents();
@@ -52,6 +52,9 @@ class BrowsersRenderer extends Renderer {
52
52
  case 'NEW_BACKEND_STATE':
53
53
  this.newBackendState(data.transitState);
54
54
  break;
55
+ case 'NEW_EVENT':
56
+ this.emitEvent(data.topic, data.data);
57
+ break;
55
58
  case 'BEGIN_RENDER':
56
59
  super.main(data.labId);
57
60
  //persist for future handshaking
@@ -84,6 +84,9 @@ class ElectronRendererWS extends Renderer {
84
84
  case 'NEW_BACKEND_STATE':
85
85
  this.newBackendState(data.transitState);
86
86
  break;
87
+ case 'NEW_EVENT':
88
+ this.emitEvent(data.topic, data.data);
89
+ break;
87
90
  case 'BEGIN_RENDER':
88
91
  super.main(labId);
89
92
  break;
@@ -56,6 +56,10 @@ class ElectronRenderer extends Renderer {
56
56
  this.newBackendState(transitState)
57
57
  );
58
58
 
59
+ ipcRenderer.on('NEW_EVENT', (event, data) =>
60
+ this.emitEvent(data.topic, data.data)
61
+ );
62
+
59
63
  ipcRenderer.on('BEGIN_RENDER', () => {
60
64
  return super.main(labId);
61
65
  });
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import ReactDOM from 'react-dom';
3
3
  import Root from 'goblin-laboratory/widgets/root';
4
4
  import configureStore from 'goblin-laboratory/widgets/store/store';
5
+ import desktopEvents from './desktop-events.js';
5
6
 
6
7
  class Renderer {
7
8
  constructor(send, options = {}) {
@@ -51,6 +52,10 @@ class Renderer {
51
52
  );
52
53
  }
53
54
 
55
+ emitEvent(topic, data) {
56
+ desktopEvents.emit(topic, data);
57
+ }
58
+
54
59
  main(labId) {
55
60
  //PUT LABID IN WINDOW STATE
56
61
  //USEFULL IN SOME CONNECT()
@@ -2,16 +2,17 @@ import React from 'react';
2
2
  import _ from 'lodash';
3
3
  import PropTypes from 'prop-types';
4
4
  import Shredder from 'xcraft-core-shredder';
5
- import {flushToStyleTag} from 'aphrodite/no-important';
5
+ import {flushToStyleTag} from 'aphrodite/no-important.js';
6
6
  import importer from 'goblin_importer';
7
- import shallowEqualShredder from './utils/shallowEqualShredder';
8
- import _connect from './utils/connect';
9
- import connectWidget from './utils/connectWidget';
10
- import connectBackend from './utils/connectBackend';
11
- import * as widgetsActions from './utils/widgets-actions';
7
+ import shallowEqualShredder from './utils/shallowEqualShredder.js';
8
+ import _connect from './utils/connect.js';
9
+ import connectWidget from './utils/connectWidget.js';
10
+ import connectBackend from './utils/connectBackend.js';
11
+ import * as widgetsActions from './utils/widgets-actions.js';
12
12
  import mergeStyleDefinitions from './style/merge-style-definitions.js';
13
13
  import buildStyle from './style/build-style.js';
14
14
  import joinModels from '../connect-helpers/join-models.js';
15
+ import desktopEvents from '../desktop-events.js';
15
16
 
16
17
  const stylesImporter = importer('styles');
17
18
  const reducerImporter = importer('reducer');
@@ -143,6 +144,10 @@ class Widget extends React.Component {
143
144
  return this.props.id || this.context.nearestParentId;
144
145
  }
145
146
 
147
+ get events() {
148
+ return desktopEvents;
149
+ }
150
+
146
151
  // Styles
147
152
 
148
153
  get styles() {
@@ -311,7 +316,8 @@ class Widget extends React.Component {
311
316
  do(action, args) {
312
317
  return this.doAs(this.name, action, args);
313
318
  }
314
- /** @deprecated Replace by doFor.
319
+ /**
320
+ * @deprecated Replace by doFor.
315
321
  * It's possible to have a mismatch between service name and serviceId.
316
322
  * Prefer to use doFor with the service id.
317
323
  */
@@ -0,0 +1,67 @@
1
+ import pathToRegexp from 'path-to-regexp';
2
+
3
+ const cache = {};
4
+ const cacheLimit = 10000;
5
+ let cacheCount = 0;
6
+
7
+ function compilePath(path, options) {
8
+ const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
9
+ const pathCache = cache[cacheKey] || (cache[cacheKey] = {});
10
+
11
+ if (pathCache[path]) return pathCache[path];
12
+
13
+ const keys = [];
14
+ const regexp = pathToRegexp(path, keys, options);
15
+ const result = {regexp, keys};
16
+
17
+ if (cacheCount < cacheLimit) {
18
+ pathCache[path] = result;
19
+ cacheCount++;
20
+ }
21
+
22
+ return result;
23
+ }
24
+
25
+ /**
26
+ * Public API for matching a URL pathname to a path.
27
+ */
28
+ function matchPath(pathname, options = {}) {
29
+ if (typeof options === 'string' || Array.isArray(options)) {
30
+ options = {path: options};
31
+ }
32
+
33
+ const {path, exact = false, strict = false, sensitive = false} = options;
34
+
35
+ const paths = [].concat(path);
36
+
37
+ return paths.reduce((matched, path) => {
38
+ if (!path && path !== '') return null;
39
+ if (matched) return matched;
40
+
41
+ const {regexp, keys} = compilePath(path, {
42
+ end: exact,
43
+ strict,
44
+ sensitive,
45
+ });
46
+ const match = regexp.exec(pathname);
47
+
48
+ if (!match) return null;
49
+
50
+ const [url, ...values] = match;
51
+ const isExact = pathname === url;
52
+
53
+ if (exact && !isExact) return null;
54
+
55
+ return {
56
+ path, // the path used to match
57
+ url: path === '/' && url === '' ? '/' : url, // the matched portion of the URL
58
+ isExact, // whether or not we matched exactly
59
+ params: keys.reduce((memo, key, index) => {
60
+ memo[key.name] = values[index];
61
+ return memo;
62
+ }, {}),
63
+ };
64
+ }, null);
65
+ }
66
+
67
+ export default matchPath;
@@ -1,8 +1,8 @@
1
1
  import {connect} from 'react-redux';
2
2
  import Shredder from 'xcraft-core-shredder';
3
- import {matchPath} from 'react-router';
4
3
  import {getParameter} from '../../lib/helpers.js';
5
4
  import shallowEqualShredder from '../widget/utils/shallowEqualShredder.js';
5
+ import matchPath from './matchPath.js';
6
6
 
7
7
  export function withRoute(path, watchedParams, watchedSearchs, watchHash) {
8
8
  return connect(