@ankhorage/contracts 1.10.0 → 1.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/CHANGELOG.md +12 -0
- package/dist/bindings.d.ts +80 -25
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js.map +1 -1
- package/dist/types.d.ts +4 -79
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/ui.d.ts +37 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- package/src/bindings.test.ts +290 -70
- package/src/bindings.ts +101 -25
- package/src/contracts.test.ts +0 -172
- package/src/types.ts +4 -118
- package/src/ui.test.ts +169 -0
- package/src/ui.ts +53 -0
package/src/bindings.test.ts
CHANGED
|
@@ -1,96 +1,316 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
AppManifest,
|
|
5
|
+
BindingInputMap,
|
|
4
6
|
BindingValueSource,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
ComponentDataBinding,
|
|
8
|
+
ComponentDataBindingRegistry,
|
|
9
|
+
EventBinding,
|
|
10
|
+
PropBinding,
|
|
8
11
|
} from './index';
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
const node: UiNode = {
|
|
21
|
-
id: 'firstname-input',
|
|
22
|
-
type: 'Input',
|
|
13
|
+
function assertSerializable<TValue>(value: TValue): void {
|
|
14
|
+
expect(JSON.parse(JSON.stringify(value))).toEqual(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('component data-binding contracts', () => {
|
|
18
|
+
it('serializes a component prop bound to an operation response path', () => {
|
|
19
|
+
const binding: ComponentDataBinding = {
|
|
20
|
+
componentId: 'hero-title',
|
|
21
|
+
componentType: 'Text',
|
|
23
22
|
props: {
|
|
24
|
-
|
|
23
|
+
children: {
|
|
24
|
+
source: {
|
|
25
|
+
kind: 'operation',
|
|
26
|
+
operation: {
|
|
27
|
+
dataSourceId: 'cms',
|
|
28
|
+
endpointId: 'pages',
|
|
29
|
+
operationId: 'pages.getHome',
|
|
30
|
+
},
|
|
31
|
+
path: '$.data.title',
|
|
32
|
+
},
|
|
33
|
+
fallback: {
|
|
34
|
+
value: 'Untitled',
|
|
35
|
+
},
|
|
36
|
+
loading: {
|
|
37
|
+
state: 'loading',
|
|
38
|
+
fallback: {
|
|
39
|
+
value: 'Loading…',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
error: {
|
|
43
|
+
state: 'error',
|
|
44
|
+
message: 'Could not load title.',
|
|
45
|
+
fallback: {
|
|
46
|
+
value: 'Untitled',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
transforms: ['trim'],
|
|
50
|
+
},
|
|
25
51
|
},
|
|
26
|
-
bindings: [binding],
|
|
27
52
|
};
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
expect(
|
|
54
|
+
assertSerializable(binding);
|
|
55
|
+
expect(binding.props?.children?.source.kind).toBe('operation');
|
|
31
56
|
});
|
|
32
57
|
|
|
33
|
-
it('serializes
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
it('serializes literal, context, event, state, and operation value sources', () => {
|
|
59
|
+
const sources: readonly BindingValueSource[] = [
|
|
60
|
+
{ kind: 'literal', value: { fallback: 'Untitled' } },
|
|
61
|
+
{ kind: 'context', path: 'auth.user.displayName' },
|
|
62
|
+
{ kind: 'event', path: 'payload.values.search' },
|
|
63
|
+
{ kind: 'state', path: 'forms.search.query' },
|
|
64
|
+
{
|
|
65
|
+
kind: 'operation',
|
|
66
|
+
operation: {
|
|
67
|
+
dataSourceId: 'search-api',
|
|
68
|
+
operationId: 'search.query',
|
|
69
|
+
},
|
|
70
|
+
path: '$.results',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
assertSerializable(sources);
|
|
75
|
+
expect(sources.map((source) => source.kind)).toEqual([
|
|
76
|
+
'literal',
|
|
77
|
+
'context',
|
|
78
|
+
'event',
|
|
79
|
+
'state',
|
|
80
|
+
'operation',
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('serializes an event binding that executes a data-source operation', () => {
|
|
85
|
+
const input: BindingInputMap = {
|
|
86
|
+
email: {
|
|
87
|
+
kind: 'source',
|
|
88
|
+
source: {
|
|
89
|
+
kind: 'event',
|
|
90
|
+
path: 'payload.values.email',
|
|
91
|
+
},
|
|
92
|
+
transforms: ['trim', 'lowercase'],
|
|
93
|
+
},
|
|
94
|
+
message: {
|
|
95
|
+
kind: 'source',
|
|
96
|
+
source: {
|
|
97
|
+
kind: 'event',
|
|
98
|
+
path: 'payload.values.message',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
source: {
|
|
102
|
+
kind: 'literal',
|
|
103
|
+
value: 'contact-form',
|
|
104
|
+
},
|
|
105
|
+
metadata: {
|
|
106
|
+
kind: 'object',
|
|
107
|
+
fields: {
|
|
108
|
+
userId: {
|
|
109
|
+
kind: 'source',
|
|
110
|
+
source: {
|
|
111
|
+
kind: 'context',
|
|
112
|
+
path: 'auth.user.id',
|
|
113
|
+
},
|
|
52
114
|
},
|
|
53
115
|
},
|
|
54
|
-
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const binding: EventBinding = {
|
|
120
|
+
target: {
|
|
121
|
+
kind: 'operation',
|
|
122
|
+
operation: {
|
|
123
|
+
dataSourceId: 'contact-api',
|
|
124
|
+
endpointId: 'messages',
|
|
125
|
+
operationId: 'messages.create',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
input,
|
|
129
|
+
when: {
|
|
130
|
+
source: {
|
|
131
|
+
kind: 'event',
|
|
132
|
+
path: 'payload.values.email',
|
|
133
|
+
},
|
|
134
|
+
operator: 'exists',
|
|
135
|
+
},
|
|
55
136
|
};
|
|
56
137
|
|
|
57
|
-
|
|
58
|
-
expect(
|
|
138
|
+
assertSerializable(binding);
|
|
139
|
+
expect(binding.target.kind).toBe('operation');
|
|
140
|
+
expect(binding.input?.email?.kind).toBe('source');
|
|
59
141
|
});
|
|
60
142
|
|
|
61
|
-
it('
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
143
|
+
it('serializes an event binding that targets a named action', () => {
|
|
144
|
+
const binding: EventBinding = {
|
|
145
|
+
target: {
|
|
146
|
+
kind: 'action',
|
|
147
|
+
type: 'toast.show',
|
|
148
|
+
},
|
|
149
|
+
input: {
|
|
150
|
+
message: {
|
|
151
|
+
kind: 'literal',
|
|
152
|
+
value: 'Saved successfully.',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
67
156
|
|
|
68
|
-
|
|
69
|
-
expect(
|
|
157
|
+
assertSerializable(binding);
|
|
158
|
+
expect(binding.target.kind).toBe('action');
|
|
70
159
|
});
|
|
71
160
|
|
|
72
|
-
it('
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
161
|
+
it('serializes component data-binding registries', () => {
|
|
162
|
+
const bindings: ComponentDataBindingRegistry = {
|
|
163
|
+
'submit-button': {
|
|
164
|
+
componentId: 'submit-button',
|
|
165
|
+
componentType: 'Button',
|
|
166
|
+
props: {
|
|
167
|
+
children: {
|
|
168
|
+
source: {
|
|
169
|
+
kind: 'literal',
|
|
170
|
+
value: 'Send',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
disabled: {
|
|
174
|
+
source: {
|
|
175
|
+
kind: 'state',
|
|
176
|
+
path: 'forms.contact.isSubmitting',
|
|
177
|
+
},
|
|
83
178
|
},
|
|
84
179
|
},
|
|
85
|
-
|
|
86
|
-
|
|
180
|
+
events: {
|
|
181
|
+
press: [
|
|
182
|
+
{
|
|
183
|
+
target: {
|
|
184
|
+
kind: 'operation',
|
|
185
|
+
operation: {
|
|
186
|
+
dataSourceId: 'contact-api',
|
|
187
|
+
operationId: 'messages.create',
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
};
|
|
87
195
|
|
|
88
|
-
|
|
89
|
-
expect(bindings.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
196
|
+
assertSerializable(bindings);
|
|
197
|
+
expect(bindings['submit-button']?.events?.press?.[0]?.target.kind).toBe('operation');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('serializes app-level dataSources and dataBindings on the manifest', () => {
|
|
201
|
+
const propBinding: PropBinding = {
|
|
202
|
+
source: {
|
|
203
|
+
kind: 'operation',
|
|
204
|
+
operation: {
|
|
205
|
+
dataSourceId: 'cms',
|
|
206
|
+
endpointId: 'pages',
|
|
207
|
+
operationId: 'pages.getHome',
|
|
208
|
+
},
|
|
209
|
+
path: '$.data.heroTitle',
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const manifest: AppManifest = {
|
|
214
|
+
metadata: {
|
|
215
|
+
name: 'Demo',
|
|
216
|
+
slug: 'demo',
|
|
217
|
+
version: '1.0.0',
|
|
218
|
+
themeId: 'default',
|
|
219
|
+
},
|
|
220
|
+
themes: [
|
|
221
|
+
{
|
|
222
|
+
id: 'default',
|
|
223
|
+
name: 'Default',
|
|
224
|
+
light: {
|
|
225
|
+
primaryColor: '#3366ff',
|
|
226
|
+
harmony: 'analogous',
|
|
227
|
+
},
|
|
228
|
+
dark: {
|
|
229
|
+
primaryColor: '#3366ff',
|
|
230
|
+
harmony: 'analogous',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
activeThemeId: 'default',
|
|
235
|
+
infra: {
|
|
236
|
+
plugins: [],
|
|
237
|
+
},
|
|
238
|
+
navigator: {
|
|
239
|
+
type: 'stack',
|
|
240
|
+
routes: [
|
|
241
|
+
{
|
|
242
|
+
name: 'home',
|
|
243
|
+
screenId: 'home',
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
screens: {
|
|
248
|
+
home: {
|
|
249
|
+
id: 'home',
|
|
250
|
+
name: 'Home',
|
|
251
|
+
root: {
|
|
252
|
+
id: 'screen-root',
|
|
253
|
+
type: 'Screen',
|
|
254
|
+
children: [
|
|
255
|
+
{
|
|
256
|
+
id: 'hero-title',
|
|
257
|
+
type: 'Text',
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
dataSources: {
|
|
264
|
+
cms: {
|
|
265
|
+
id: 'cms',
|
|
266
|
+
kind: 'rest',
|
|
267
|
+
baseUrl: 'https://cms.example.com',
|
|
268
|
+
endpoints: {
|
|
269
|
+
pages: {
|
|
270
|
+
id: 'pages',
|
|
271
|
+
kind: 'http',
|
|
272
|
+
path: '/pages/home',
|
|
273
|
+
operations: {
|
|
274
|
+
'pages.getHome': {
|
|
275
|
+
id: 'pages.getHome',
|
|
276
|
+
endpointId: 'pages',
|
|
277
|
+
protocol: 'http',
|
|
278
|
+
intent: 'read',
|
|
279
|
+
method: 'GET',
|
|
280
|
+
path: '/pages/home',
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
dataBindings: {
|
|
288
|
+
'hero-title': {
|
|
289
|
+
componentId: 'hero-title',
|
|
290
|
+
componentType: 'Text',
|
|
291
|
+
props: {
|
|
292
|
+
children: propBinding,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
settings: {
|
|
297
|
+
localization: {
|
|
298
|
+
defaultLocale: 'en',
|
|
299
|
+
locales: ['en'],
|
|
300
|
+
},
|
|
301
|
+
authFlow: {
|
|
302
|
+
signInRoute: '/sign-in',
|
|
303
|
+
signUpRoute: '/sign-up',
|
|
304
|
+
signOutRoute: '/sign-out',
|
|
305
|
+
forgotPasswordRoute: '/forgot-password',
|
|
306
|
+
postSignInRoute: '/',
|
|
307
|
+
unauthorizedRoute: '/sign-in',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
assertSerializable(manifest);
|
|
313
|
+
expect(manifest.dataBindings?.['hero-title']?.props?.children?.source.kind).toBe('operation');
|
|
314
|
+
expect(manifest.dataSources?.cms?.kind).toBe('rest');
|
|
95
315
|
});
|
|
96
316
|
});
|
package/src/bindings.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DataSourceId, EndpointId, OperationId } from './data';
|
|
2
|
+
|
|
3
|
+
export type ComponentInstanceId = string;
|
|
4
|
+
export type ComponentTypeId = string;
|
|
2
5
|
|
|
3
6
|
export type BindingValue =
|
|
4
7
|
| string
|
|
@@ -10,45 +13,118 @@ export type BindingValue =
|
|
|
10
13
|
readonly [key: string]: BindingValue;
|
|
11
14
|
};
|
|
12
15
|
|
|
13
|
-
export type
|
|
16
|
+
export type BindingDataPath = string;
|
|
17
|
+
|
|
18
|
+
export type BindingValueTransform = 'lowercase' | 'trim' | 'uppercase';
|
|
14
19
|
|
|
15
|
-
export interface
|
|
16
|
-
readonly
|
|
17
|
-
readonly
|
|
18
|
-
readonly
|
|
19
|
-
readonly filters?: readonly DbFilter[];
|
|
20
|
-
readonly sort?: readonly DbSort[];
|
|
21
|
-
readonly page?: DbPage;
|
|
22
|
-
readonly refresh?: BindingRefreshMode;
|
|
23
|
-
readonly pollIntervalMs?: number;
|
|
20
|
+
export interface BindingOperationRef {
|
|
21
|
+
readonly dataSourceId: DataSourceId;
|
|
22
|
+
readonly operationId: OperationId;
|
|
23
|
+
readonly endpointId?: EndpointId;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export type BindingValueSource =
|
|
27
27
|
| {
|
|
28
|
-
readonly
|
|
29
|
-
readonly path:
|
|
28
|
+
readonly kind: 'context';
|
|
29
|
+
readonly path: BindingDataPath;
|
|
30
30
|
}
|
|
31
31
|
| {
|
|
32
|
-
readonly
|
|
33
|
-
readonly
|
|
32
|
+
readonly kind: 'event';
|
|
33
|
+
readonly path: BindingDataPath;
|
|
34
34
|
}
|
|
35
35
|
| {
|
|
36
|
-
readonly
|
|
37
|
-
readonly
|
|
36
|
+
readonly kind: 'literal';
|
|
37
|
+
readonly value: BindingValue;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: 'operation';
|
|
41
|
+
readonly operation: BindingOperationRef;
|
|
42
|
+
readonly path?: BindingDataPath;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: 'state';
|
|
46
|
+
readonly path: BindingDataPath;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export interface BindingValueExpression {
|
|
50
|
+
readonly source: BindingValueSource;
|
|
51
|
+
readonly transforms?: readonly BindingValueTransform[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BindingFallback {
|
|
55
|
+
readonly value?: BindingValue;
|
|
56
|
+
readonly source?: BindingValueSource;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type BindingLifecycleState = 'empty' | 'error' | 'loading';
|
|
60
|
+
|
|
61
|
+
export interface BindingLifecycleBehavior {
|
|
62
|
+
readonly state: BindingLifecycleState;
|
|
63
|
+
readonly fallback?: BindingFallback;
|
|
64
|
+
readonly message?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PropBinding {
|
|
68
|
+
readonly source: BindingValueSource;
|
|
69
|
+
readonly fallback?: BindingFallback;
|
|
70
|
+
readonly loading?: BindingLifecycleBehavior;
|
|
71
|
+
readonly error?: BindingLifecycleBehavior;
|
|
72
|
+
readonly empty?: BindingLifecycleBehavior;
|
|
73
|
+
readonly transforms?: readonly BindingValueTransform[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type BindingInputValue =
|
|
77
|
+
| {
|
|
78
|
+
readonly kind: 'array';
|
|
79
|
+
readonly items: readonly BindingInputValue[];
|
|
38
80
|
}
|
|
39
81
|
| {
|
|
40
|
-
readonly
|
|
82
|
+
readonly kind: 'literal';
|
|
41
83
|
readonly value: BindingValue;
|
|
42
84
|
}
|
|
43
85
|
| {
|
|
44
|
-
readonly
|
|
45
|
-
readonly
|
|
86
|
+
readonly kind: 'object';
|
|
87
|
+
readonly fields: Readonly<Record<string, BindingInputValue>>;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly kind: 'source';
|
|
91
|
+
readonly source: BindingValueSource;
|
|
92
|
+
readonly transforms?: readonly BindingValueTransform[];
|
|
46
93
|
};
|
|
47
94
|
|
|
48
|
-
export type
|
|
95
|
+
export type BindingInputMap = Readonly<Record<string, BindingInputValue>>;
|
|
96
|
+
|
|
97
|
+
export type BindingConditionOperator = 'eq' | 'exists' | 'neq' | 'notExists';
|
|
49
98
|
|
|
50
|
-
export interface
|
|
51
|
-
readonly
|
|
52
|
-
readonly
|
|
53
|
-
readonly
|
|
99
|
+
export interface BindingCondition {
|
|
100
|
+
readonly source: BindingValueSource;
|
|
101
|
+
readonly operator: BindingConditionOperator;
|
|
102
|
+
readonly value?: BindingValue;
|
|
54
103
|
}
|
|
104
|
+
|
|
105
|
+
export type EventBindingTarget =
|
|
106
|
+
| {
|
|
107
|
+
readonly kind: 'action';
|
|
108
|
+
readonly type: string;
|
|
109
|
+
}
|
|
110
|
+
| {
|
|
111
|
+
readonly kind: 'operation';
|
|
112
|
+
readonly operation: BindingOperationRef;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export interface EventBinding {
|
|
116
|
+
readonly target: EventBindingTarget;
|
|
117
|
+
readonly input?: BindingInputMap;
|
|
118
|
+
readonly when?: BindingCondition;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ComponentDataBinding {
|
|
122
|
+
readonly componentId: ComponentInstanceId;
|
|
123
|
+
readonly componentType?: ComponentTypeId;
|
|
124
|
+
readonly props?: Readonly<Record<string, PropBinding>>;
|
|
125
|
+
readonly events?: Readonly<Record<string, readonly EventBinding[]>>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type ComponentDataBindingRegistry = Readonly<
|
|
129
|
+
Record<ComponentInstanceId, ComponentDataBinding>
|
|
130
|
+
>;
|