@reventlessdev/trait-attachments 1.0.0-alpha.3 → 1.0.0-alpha.4
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/package.json +3 -3
- package/src/Attachments.res +50 -0
- package/src/Attachments_Conformance.res +84 -1
- package/src/Attachments_Conformance.res.mjs +40 -0
- package/src/Attachments_Rules.res +121 -14
- package/src/Attachments_Rules.res.mjs +92 -36
- package/src/Attachments_Scaffold.res +342 -65
- package/src/Attachments_Scaffold.res.mjs +364 -151
|
@@ -51,6 +51,18 @@ type config = {
|
|
|
51
51
|
noun: string,
|
|
52
52
|
/** The attachment field, named for the store it draws from: `"productImage"`. */
|
|
53
53
|
file: string,
|
|
54
|
+
/**
|
|
55
|
+
How many attachments this host's entity may hold. Absent ⇒ `Many`, so a graft
|
|
56
|
+
written before this key existed emits exactly what it emitted before.
|
|
57
|
+
|
|
58
|
+
It is not one rule among the emitted text: it changes which commands exist.
|
|
59
|
+
`Single` emits `Set{Entity}{Noun}` (which replaces), a `Remove{Entity}{Noun}`
|
|
60
|
+
that names no reference — there is only one, and asking a caller to name it is
|
|
61
|
+
asking them to repeat what the row already says — and no primary command at
|
|
62
|
+
all, because a set of one has nothing to choose between. The view field
|
|
63
|
+
changes with them: one captioned image, not an array of them.
|
|
64
|
+
*/
|
|
65
|
+
cardinality?: Attachments_Rules.cardinality,
|
|
54
66
|
/** The host event that brings the entity into existence: `"ProductAdded"`. */
|
|
55
67
|
created: string,
|
|
56
68
|
/** Whether that event carries the entity id. `false` for a payload-less
|
|
@@ -58,6 +70,17 @@ type config = {
|
|
|
58
70
|
createdCarriesEntityId?: bool,
|
|
59
71
|
/** The view whose lifecycle states `transition` names: `"Products"`. */
|
|
60
72
|
view: string,
|
|
73
|
+
/**
|
|
74
|
+
The name of that view's lifecycle enum *type*: `"shelfStatus"`. Only read when
|
|
75
|
+
`transition` is given, because only then is a `lifecycleState` binding emitted.
|
|
76
|
+
|
|
77
|
+
Carried rather than derived: the enum is a type on the host's view and nothing
|
|
78
|
+
in the other names reaches it — `Products` has a `shelfStatus`, but a host may
|
|
79
|
+
call its own `state`, `stage` or `phase`. Absent leaves a `TODO(graft)` marker
|
|
80
|
+
in its place, which does not compile; that is deliberate, and better than a
|
|
81
|
+
guess that compiles against the wrong type.
|
|
82
|
+
*/
|
|
83
|
+
lifecycleType?: string,
|
|
61
84
|
/** The semantic type of the reference. `Reventless.UploadableImage.t` unless
|
|
62
85
|
the host attaches something other than pictures. */
|
|
63
86
|
refType?: string,
|
|
@@ -108,11 +131,20 @@ type names = {
|
|
|
108
131
|
notAttached: string,
|
|
109
132
|
}
|
|
110
133
|
|
|
134
|
+
let cardinalityOf = (c: config): Attachments_Rules.cardinality =>
|
|
135
|
+
c.cardinality->Option.getOr(Many)
|
|
136
|
+
|
|
137
|
+
let isSingle = (c: config): bool => cardinalityOf(c) == Single
|
|
138
|
+
|
|
111
139
|
let namesOf = (c: config): names => {
|
|
112
140
|
let subject = c.entity ++ c.noun
|
|
113
141
|
{
|
|
114
142
|
slice: subject ++ "s",
|
|
115
|
-
|
|
143
|
+
// `Set` rather than `Attach` for a bounded set: the command replaces what is
|
|
144
|
+
// there, and `Attach` would name the wrong half of what it does. The *event*
|
|
145
|
+
// stays `Attached` at both cardinalities — an image was attached is the fact,
|
|
146
|
+
// and a replacement is that fact preceded by a removal.
|
|
147
|
+
attachCmd: (isSingle(c) ? "Set" : "Attach") ++ subject,
|
|
116
148
|
removeCmd: "Remove" ++ subject,
|
|
117
149
|
setPrimaryCmd: "SetPrimary" ++ subject,
|
|
118
150
|
setAltTextCmd: "Set" ++ subject ++ "AltText",
|
|
@@ -127,6 +159,62 @@ let namesOf = (c: config): names => {
|
|
|
127
159
|
|
|
128
160
|
let refTypeOf = (c: config) => c.refType->Option.getOr("Reventless.UploadableImage.t")
|
|
129
161
|
|
|
162
|
+
// What the VIEW holds, as against what an event's reference field holds. An
|
|
163
|
+
// event names a file; a view holds that file together with the text that goes
|
|
164
|
+
// with it, because a cell renderer is handed a field and a value and never the
|
|
165
|
+
// row — so a caption in a sibling field is a caption no cell can draw.
|
|
166
|
+
//
|
|
167
|
+
// Read off the reference type by name, as `contentArgOf` is. A host attaching
|
|
168
|
+
// something the vocabulary has no composite for keeps the bare reference, which
|
|
169
|
+
// is the shape it has today rather than a guess at one it does not.
|
|
170
|
+
let viewTypeOf = (c: config): string =>
|
|
171
|
+
refTypeOf(c)->String.includes("Image") ? "Reventless.CaptionedImage.t" : refTypeOf(c)
|
|
172
|
+
|
|
173
|
+
// The collection field a member-selecting command picks out of. Named for the
|
|
174
|
+
// plural of the attachment field, which is what the view calls it.
|
|
175
|
+
let setFieldOf = (c: config): string => c.file ++ "s"
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
The type a command field takes when it means *select one of the ones I have*
|
|
179
|
+
rather than *here is a new file*.
|
|
180
|
+
|
|
181
|
+
This is the distinction the whole graft used to be unable to make. Remove,
|
|
182
|
+
choose-primary and caption all name a reference the row already holds, and all
|
|
183
|
+
three were typed as the uploadable they select among — so a UI reading the
|
|
184
|
+
declaration bound an upload input to each of them, which on a remove command
|
|
185
|
+
offers the caller the one thing it cannot do.
|
|
186
|
+
|
|
187
|
+
`Single` declares no such field at all, so this is only ever reached for the
|
|
188
|
+
commands where a choice genuinely exists.
|
|
189
|
+
|
|
190
|
+
Spelled as a reference to a binding rather than inline: an `@s.matches(…)`
|
|
191
|
+
attribute has to fit on one line to parse, and the call with both names in it
|
|
192
|
+
does not — which is also the better shape, since the collection is one answer
|
|
193
|
+
and three inline copies are three chances for one to name a field that has moved.
|
|
194
|
+
*/
|
|
195
|
+
let selectionBinding = "selected"
|
|
196
|
+
|
|
197
|
+
let selectionTypeOf = (_c: config): string => `@s.matches(${selectionBinding}) string`
|
|
198
|
+
|
|
199
|
+
// What the members are, as the selection's own declaration states it — so a form
|
|
200
|
+
// drawing a picker shows thumbnails rather than a list of paths, without having
|
|
201
|
+
// to reach the collection field's element type.
|
|
202
|
+
//
|
|
203
|
+
// Read off the reference type by name, which is the only thing this module ever
|
|
204
|
+
// does. A host attaching something the vocabulary has no word for gets no
|
|
205
|
+
// content stated, and a reader falls back to its own rules — which is the honest
|
|
206
|
+
// answer rather than a guess with a default in it.
|
|
207
|
+
let contentArgOf = (c: config): string => {
|
|
208
|
+
let ref = refTypeOf(c)
|
|
209
|
+
if ref->String.includes("Image") {
|
|
210
|
+
`~content=Reventless.Semantic.Id.imageRef, `
|
|
211
|
+
} else if ref->String.includes("File") {
|
|
212
|
+
`~content=Reventless.Semantic.Id.fileRef, `
|
|
213
|
+
} else {
|
|
214
|
+
""
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
130
218
|
// `@authorize` is the host's policy, so an absent one emits nothing at all rather
|
|
131
219
|
// than a permissive default — a graft that silently declared "anyone" would be
|
|
132
220
|
// worse than one that declares nothing.
|
|
@@ -150,14 +238,31 @@ let commandAttributes = (c: config): string =>
|
|
|
150
238
|
// typechecker and matched as strings at assembly; in `Guards([Products.Listed])` they are
|
|
151
239
|
// constructor references the compiler resolves, so a config typo is a build
|
|
152
240
|
// error naming it. Same input, and the difference is only who checks it.
|
|
153
|
-
|
|
241
|
+
// Every command this graft declares, in declaration order. `SetPrimary` is
|
|
242
|
+
// absent for a bounded set — the one thing that changes the *shape* of the
|
|
243
|
+
// emitted surface rather than a rule inside it — and every emission below reads
|
|
244
|
+
// this list rather than repeating the condition.
|
|
245
|
+
let commandNames = (c: config): array<string> => {
|
|
154
246
|
let n = namesOf(c)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
247
|
+
isSingle(c)
|
|
248
|
+
? [n.attachCmd, n.removeCmd, n.setAltTextCmd]
|
|
249
|
+
: [n.attachCmd, n.removeCmd, n.setPrimaryCmd, n.setAltTextCmd]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let commandTransitionBinding = (c: config): array<string> => {
|
|
253
|
+
let arms = commandNames(c)->Array.map(cmd => ` | ${cmd}(_)`)->Array.join("\n")
|
|
254
|
+
// The enum's *type*, which `lifecycleType` names. Absent leaves the marker,
|
|
255
|
+
// and the marker does not compile — which is the honest outcome for a graft
|
|
256
|
+
// that declared the states its commands are legal in and not the type they
|
|
257
|
+
// are constructors of. A guess here would compile against whatever enum the
|
|
258
|
+
// view happens to have first.
|
|
259
|
+
let lifecycleType = switch c.lifecycleType {
|
|
260
|
+
| Some(t) => `${c.view}.${t}`
|
|
261
|
+
| None => `${c.view}.<lifecycle> // TODO(graft): the enum's name`
|
|
262
|
+
}
|
|
158
263
|
switch c.transition {
|
|
159
264
|
| Some(states) => [
|
|
160
|
-
`type lifecycleState = ${
|
|
265
|
+
`type lifecycleState = ${lifecycleType}`,
|
|
161
266
|
`let commandTransition = (command: command): Reventless.Transition.t<lifecycleState> => {`,
|
|
162
267
|
` open Reventless.Transition`,
|
|
163
268
|
` switch command {`,
|
|
@@ -192,15 +297,31 @@ let lines = (ls: array<string>) => ls->Array.join("\n")
|
|
|
192
297
|
let sliceSpec = (c: config): string => {
|
|
193
298
|
let n = namesOf(c)
|
|
194
299
|
let ref = refTypeOf(c)
|
|
300
|
+
let sel = selectionTypeOf(c)
|
|
301
|
+
let single = isSingle(c)
|
|
195
302
|
let attrs = commandAttributes(c)
|
|
196
303
|
let createdArm =
|
|
197
304
|
c.createdCarriesEntityId->Option.getOr(true)
|
|
198
305
|
? ` | ${c.created}({ ${c.entityId}: string})`
|
|
199
306
|
: ` | ${c.created}`
|
|
307
|
+
// Present only where a choice exists. A bounded set has one member, so
|
|
308
|
+
// nothing consumes or emits a primary — and the trait's rules never produce
|
|
309
|
+
// the fact, so listing the event would declare one nothing can write.
|
|
310
|
+
let primaryArm = (prefix: string) =>
|
|
311
|
+
single ? [] : [` | ${n.primarySet}({ ${prefix}${c.file}: ${ref}})`]
|
|
312
|
+
let headline =
|
|
313
|
+
single
|
|
314
|
+
? [
|
|
315
|
+
`// ${n.slice} StateChangeSlice: ${c.entity}'s single attachment — set it, remove`,
|
|
316
|
+
`// it, caption it. A graft of the Attachments trait; the set's rules are the`,
|
|
317
|
+
]
|
|
318
|
+
: [
|
|
319
|
+
`// ${n.slice} StateChangeSlice: ${c.entity}'s attachment set — attach, remove,`,
|
|
320
|
+
`// choose the primary, caption. A graft of the Attachments trait; the rules are`,
|
|
321
|
+
]
|
|
200
322
|
lines([
|
|
201
|
-
|
|
202
|
-
`//
|
|
203
|
-
`// are the trait's and are asserted by its conformance suite, bound in the tests.`,
|
|
323
|
+
...headline,
|
|
324
|
+
`// trait's and are asserted by its conformance suite, bound in the tests.`,
|
|
204
325
|
`//`,
|
|
205
326
|
`// Emitted by the trait. Everything below is this host's own vocabulary, so it is`,
|
|
206
327
|
`// ordinary source from here on — edit it freely.`,
|
|
@@ -212,17 +333,45 @@ let sliceSpec = (c: config): string => {
|
|
|
212
333
|
createdArm,
|
|
213
334
|
` | ${n.attached}({ ${c.file}: ${ref}})`,
|
|
214
335
|
` | ${n.removed}({ ${c.file}: ${ref}})`,
|
|
215
|
-
|
|
336
|
+
...primaryArm(""),
|
|
216
337
|
` | ${n.altTextSet}({ ${c.file}: ${ref}, altText: string})`,
|
|
217
338
|
` // TODO(graft): add the events this host's own refusal turns on — whatever`,
|
|
218
339
|
` // moves it into a state where attachments may not be changed.`,
|
|
219
340
|
``,
|
|
341
|
+
...(single
|
|
342
|
+
? [
|
|
343
|
+
`// One reference field, on ${n.attachCmd}, and it accepts a new file — so`,
|
|
344
|
+
`// it is typed as the uploadable it is and a form binds an upload input to it.`,
|
|
345
|
+
`// Neither other command names a reference: with one attachment there is`,
|
|
346
|
+
`// nothing to choose between, and asking a caller to name it would be asking`,
|
|
347
|
+
`// them to repeat what the row already says.`,
|
|
348
|
+
]
|
|
349
|
+
: [
|
|
350
|
+
`// The reference fields divide into two kinds, and the division is the point.`,
|
|
351
|
+
`// The one on ${n.attachCmd} accepts a new file, so it is typed as the`,
|
|
352
|
+
`// uploadable it is and a form binds an upload input to it. The others name a`,
|
|
353
|
+
`// file the row ALREADY holds, so they are typed as selections out of`,
|
|
354
|
+
`// \`${setFieldOf(c)}\` and a form offers those instead of an uploader.`,
|
|
355
|
+
`//`,
|
|
356
|
+
`// Bound once rather than spelled three times: the collection is one answer,`,
|
|
357
|
+
`// and three copies are three chances for one to name a field that has moved.`,
|
|
358
|
+
`let ${selectionBinding} = Reventless.MemberRef.of_(~view="${c.view}", ${contentArgOf(
|
|
359
|
+
c,
|
|
360
|
+
)}~field="${setFieldOf(c)}")`,
|
|
361
|
+
``,
|
|
362
|
+
]),
|
|
220
363
|
`@schema`,
|
|
221
364
|
`type command =`,
|
|
222
365
|
`${attrs}${n.attachCmd}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
366
|
+
// The bounded set's remove names nothing. This is the reported defect in its
|
|
367
|
+
// purest form — the old command asked for an upload in order to delete.
|
|
368
|
+
single
|
|
369
|
+
? `${attrs}${n.removeCmd}({ ${c.entityId}: string})`
|
|
370
|
+
: `${attrs}${n.removeCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`,
|
|
371
|
+
...(single ? [] : [`${attrs}${n.setPrimaryCmd}({ ${c.entityId}: string, ${c.file}: ${sel}})`]),
|
|
372
|
+
single
|
|
373
|
+
? `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, altText: string})`
|
|
374
|
+
: `${attrs}${n.setAltTextCmd}({ ${c.entityId}: string, ${c.file}: ${sel}, altText: string})`,
|
|
226
375
|
``,
|
|
227
376
|
`@schema`,
|
|
228
377
|
`type error =`,
|
|
@@ -234,7 +383,7 @@ let sliceSpec = (c: config): string => {
|
|
|
234
383
|
`type event =`,
|
|
235
384
|
` | ${n.attached}({ ${c.entityId}: string, ${c.file}: ${ref}, altText?: string})`,
|
|
236
385
|
` | ${n.removed}({ ${c.entityId}: string, ${c.file}: ${ref}})`,
|
|
237
|
-
|
|
386
|
+
...primaryArm(`${c.entityId}: string, `),
|
|
238
387
|
` | ${n.altTextSet}({ ${c.entityId}: string, ${c.file}: ${ref}, altText: string})`,
|
|
239
388
|
``,
|
|
240
389
|
...commandTransitionBinding(c),
|
|
@@ -250,6 +399,7 @@ let sliceSpec = (c: config): string => {
|
|
|
250
399
|
|
|
251
400
|
let sliceBehavior = (c: config): string => {
|
|
252
401
|
let n = namesOf(c)
|
|
402
|
+
let single = isSingle(c)
|
|
253
403
|
lines([
|
|
254
404
|
`@@reventless.behavior`,
|
|
255
405
|
``,
|
|
@@ -264,10 +414,17 @@ let sliceBehavior = (c: config): string => {
|
|
|
264
414
|
`let evolve = (state, event) => {`,
|
|
265
415
|
` let fold = fact => {...state, attachments: state.attachments->Attachments.evolve(fact)}`,
|
|
266
416
|
` switch event {`,
|
|
267
|
-
|
|
417
|
+
// A creation event that carries no id is a bare constructor, so a wildcard
|
|
418
|
+
// payload does not compile against it. The spec above already branches on
|
|
419
|
+
// this; the fold has to branch with it.
|
|
420
|
+
c.createdCarriesEntityId->Option.getOr(true)
|
|
421
|
+
? ` | ${c.created}(_) => {...state, exists: true}`
|
|
422
|
+
: ` | ${c.created} => {...state, exists: true}`,
|
|
268
423
|
` | ${n.attached}({${c.file}}) => fold(Attached({ref: ${c.file}, altText: None}))`,
|
|
269
424
|
` | ${n.removed}({${c.file}}) => fold(Removed({ref: ${c.file}}))`,
|
|
270
|
-
|
|
425
|
+
...(single
|
|
426
|
+
? []
|
|
427
|
+
: [` | ${n.primarySet}({${c.file}}) => fold(PrimarySet({ref: ${c.file}}))`]),
|
|
271
428
|
` | ${n.altTextSet}({${c.file}, altText}) => fold(AltTextSet({ref: ${c.file}, altText}))`,
|
|
272
429
|
` // TODO(graft): fold this host's own events into its own state.`,
|
|
273
430
|
` }`,
|
|
@@ -279,28 +436,62 @@ let sliceBehavior = (c: config): string => {
|
|
|
279
436
|
` ${c.entityId},`,
|
|
280
437
|
` Attachments.Attach({ref: ${c.file}, altText}),`,
|
|
281
438
|
` )`,
|
|
282
|
-
`
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
439
|
+
// `Clear` is what a ref-less remove maps onto: the op that empties the set,
|
|
440
|
+
// whatever it holds. Nothing here has to look the member up.
|
|
441
|
+
...(single
|
|
442
|
+
? [` | ${n.removeCmd}({${c.entityId}}) => (${c.entityId}, Attachments.Clear)`]
|
|
443
|
+
: [
|
|
444
|
+
` | ${n.removeCmd}({${c.entityId}, ${c.file}}) => (`,
|
|
445
|
+
` ${c.entityId},`,
|
|
446
|
+
` Attachments.Remove({ref: ${c.file}}),`,
|
|
447
|
+
` )`,
|
|
448
|
+
` | ${n.setPrimaryCmd}({${c.entityId}, ${c.file}}) => (`,
|
|
449
|
+
` ${c.entityId},`,
|
|
450
|
+
` Attachments.SetPrimary({ref: ${c.file}}),`,
|
|
451
|
+
` )`,
|
|
452
|
+
]),
|
|
453
|
+
...(single
|
|
454
|
+
? [
|
|
455
|
+
` | ${n.setAltTextCmd}({${c.entityId}, altText}) => (`,
|
|
456
|
+
` ${c.entityId},`,
|
|
457
|
+
// Not punned: a single-field inline record whose field shares its name
|
|
458
|
+
// with the variable filling it is read as a record copy, and the
|
|
459
|
+
// anonymous type then escapes its constructor.
|
|
460
|
+
` Attachments.SetPrimaryAltText({altText: altText}),`,
|
|
461
|
+
` )`,
|
|
462
|
+
]
|
|
463
|
+
: [
|
|
464
|
+
` | ${n.setAltTextCmd}({${c.entityId}, ${c.file}, altText}) => (`,
|
|
465
|
+
` ${c.entityId},`,
|
|
466
|
+
` Attachments.SetAltText({ref: ${c.file}, altText}),`,
|
|
467
|
+
` )`,
|
|
468
|
+
]),
|
|
294
469
|
` }`,
|
|
295
470
|
``,
|
|
471
|
+
// `Some`/`None` for a bounded set only. `fact` is the trait's type and so
|
|
472
|
+
// lists a primary at both cardinalities, but a graft with no primary command
|
|
473
|
+
// can never decide one — and an arm that fabricated some other event to keep
|
|
474
|
+
// the switch total would be writing a fact nothing happened.
|
|
296
475
|
`let toEvent = (${c.entityId}, fact) =>`,
|
|
297
476
|
` switch fact {`,
|
|
298
477
|
` | Attachments.Attached({ref, altText}) =>`,
|
|
299
|
-
` ${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})
|
|
300
|
-
|
|
301
|
-
|
|
478
|
+
` ${single ? "Some(" : ""}${n.attached}({${c.entityId}, ${c.file}: ref, altText: ?altText})${single
|
|
479
|
+
? ")"
|
|
480
|
+
: ""}`,
|
|
481
|
+
` | Attachments.Removed({ref}) => ${single
|
|
482
|
+
? `Some(${n.removed}({${c.entityId}, ${c.file}: ref}))`
|
|
483
|
+
: `${n.removed}({${c.entityId}, ${c.file}: ref})`}`,
|
|
484
|
+
...(single
|
|
485
|
+
? [
|
|
486
|
+
` // Unreachable: no command of this graft chooses a primary, because a set`,
|
|
487
|
+
` // of one has nothing to choose between. It contributes no event.`,
|
|
488
|
+
` | Attachments.PrimarySet(_) => None`,
|
|
489
|
+
]
|
|
490
|
+
: [` | Attachments.PrimarySet({ref}) => ${n.primarySet}({${c.entityId}, ${c.file}: ref})`]),
|
|
302
491
|
` | Attachments.AltTextSet({ref, altText}) =>`,
|
|
303
|
-
` ${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})
|
|
492
|
+
` ${single ? "Some(" : ""}${n.altTextSet}({${c.entityId}, ${c.file}: ref, altText})${single
|
|
493
|
+
? ")"
|
|
494
|
+
: ""}`,
|
|
304
495
|
` }`,
|
|
305
496
|
``,
|
|
306
497
|
`let decide = (state, command) =>`,
|
|
@@ -311,10 +502,13 @@ let sliceBehavior = (c: config): string => {
|
|
|
311
502
|
` // an \`else if\` returning the error added above. A graft with no extra refusal`,
|
|
312
503
|
` // is a complete graft, so leaving this is legitimate.`,
|
|
313
504
|
` let (${c.entityId}, op) = toOp(command)`,
|
|
314
|
-
|
|
505
|
+
single
|
|
506
|
+
? ` switch state.attachments->Attachments.decide(~cardinality=Single, op) {`
|
|
507
|
+
: ` switch state.attachments->Attachments.decide(op) {`,
|
|
315
508
|
` | Error(#NotAttached) => Error(${n.notAttached})`,
|
|
316
|
-
|
|
317
|
-
|
|
509
|
+
single
|
|
510
|
+
? ` | Ok(facts) => Ok(facts->Array.filterMap(toEvent(${c.entityId}, _)))`
|
|
511
|
+
: ` | Ok(facts) => Ok(facts->Array.map(toEvent(${c.entityId}, _)))`,
|
|
318
512
|
` }`,
|
|
319
513
|
` }`,
|
|
320
514
|
``,
|
|
@@ -329,6 +523,7 @@ let sliceBehavior = (c: config): string => {
|
|
|
329
523
|
|
|
330
524
|
let conformanceBinding = (c: config): string => {
|
|
331
525
|
let n = namesOf(c)
|
|
526
|
+
let single = isSingle(c)
|
|
332
527
|
let id = "e1"
|
|
333
528
|
let refA = c.refA->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000001/a`)
|
|
334
529
|
let refB = c.refB->Option.getOr(`/uploads/00000000-0000-4000-8000-000000000002/b`)
|
|
@@ -352,27 +547,42 @@ let conformanceBinding = (c: config): string => {
|
|
|
352
547
|
` let created: array<${n.slice}.consumedEvent> = [${createdValue}]`,
|
|
353
548
|
` let attachedC = (ref): ${n.slice}.consumedEvent => ${n.attached}({ ${c.file}: ref})`,
|
|
354
549
|
` let removedC = (ref): ${n.slice}.consumedEvent => ${n.removed}({ ${c.file}: ref})`,
|
|
355
|
-
|
|
550
|
+
...(single
|
|
551
|
+
? []
|
|
552
|
+
: [
|
|
553
|
+
` let primarySetC = (ref): ${n.slice}.consumedEvent => ${n.primarySet}({ ${c.file}: ref})`,
|
|
554
|
+
]),
|
|
356
555
|
` let altTextSetC = (ref, altText): ${n.slice}.consumedEvent =>`,
|
|
357
556
|
` ${n.altTextSet}({ ${c.file}: ref, altText})`,
|
|
358
557
|
``,
|
|
359
558
|
` let attach = ref => ${n.slice}.${n.attachCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
559
|
+
...(single
|
|
560
|
+
? [` let clear = ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}"})`]
|
|
561
|
+
: [
|
|
562
|
+
` let remove = ref => ${n.slice}.${n.removeCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
|
|
563
|
+
` let setPrimary = ref =>`,
|
|
564
|
+
` ${n.slice}.${n.setPrimaryCmd}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
|
|
565
|
+
]),
|
|
566
|
+
single
|
|
567
|
+
? ` let setAltText = altText => ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", altText})`
|
|
568
|
+
: ` let setAltText = (ref, altText) =>\n ${n.slice}.${n.setAltTextCmd}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
|
|
365
569
|
``,
|
|
366
570
|
` let attached = ref => ${n.slice}.${n.attached}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
|
|
367
571
|
` let removed = ref => ${n.slice}.${n.removed}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
|
|
368
|
-
|
|
369
|
-
|
|
572
|
+
...(single
|
|
573
|
+
? []
|
|
574
|
+
: [
|
|
575
|
+
` let primarySet = ref =>`,
|
|
576
|
+
` ${n.slice}.${n.primarySet}({ ${c.entityId}: "${id}", ${c.file}: ref})`,
|
|
577
|
+
]),
|
|
370
578
|
` let altTextSet = (ref, altText) =>`,
|
|
371
579
|
` ${n.slice}.${n.altTextSet}({ ${c.entityId}: "${id}", ${c.file}: ref, altText})`,
|
|
372
580
|
` let notAttached = ${n.slice}.${n.notAttached}`,
|
|
373
581
|
`}`,
|
|
374
582
|
``,
|
|
375
|
-
|
|
583
|
+
single
|
|
584
|
+
? `module Conformance = TraitAttachments.Attachments_Conformance.MakeSingle(Binding)`
|
|
585
|
+
: `module Conformance = TraitAttachments.Attachments_Conformance.Make(Binding)`,
|
|
376
586
|
``,
|
|
377
587
|
`Conformance.register()`,
|
|
378
588
|
``,
|
|
@@ -385,50 +595,117 @@ let conformanceBinding = (c: config): string => {
|
|
|
385
595
|
// `switch` the host wrote. Placing an arm in it is the one part of a graft this
|
|
386
596
|
// module deliberately does not automate.
|
|
387
597
|
|
|
388
|
-
|
|
598
|
+
// The bounded set's projection, which is a different patch rather than the same
|
|
599
|
+
// one with a branch in it: with one attachment the view carries the value itself
|
|
600
|
+
// and no collection at all, so there is no set to fold over and no primary to
|
|
601
|
+
// put first. Three assignments.
|
|
602
|
+
//
|
|
603
|
+
// The removal and the caption both guard on the reference they name. A
|
|
604
|
+
// replacement decides two facts — the old leaves, then the new arrives — and the
|
|
605
|
+
// guard is what makes each arm depend only on the row it finds rather than on
|
|
606
|
+
// those two reaching the projection in the order they were decided.
|
|
607
|
+
let singleProjectionPatch = (c: config): patch => {
|
|
389
608
|
let n = namesOf(c)
|
|
390
609
|
{
|
|
391
610
|
into: `StateViewSliceStream/${c.view}_Projection.res`,
|
|
392
|
-
at: `the projection's \`switch\`, and
|
|
611
|
+
at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
|
|
393
612
|
contents: lines([
|
|
394
|
-
`// On the view's state,
|
|
395
|
-
`//
|
|
396
|
-
`//
|
|
613
|
+
`// On the view's state, one field — the reference and its text, in one value.`,
|
|
614
|
+
`// No collection: this entity holds one attachment, so the field a card, a`,
|
|
615
|
+
`// gallery tile and a list cell read IS the whole of what it has.`,
|
|
397
616
|
`//`,
|
|
398
|
-
`// ${c.file}
|
|
399
|
-
`// ${c.file}?: ${refTypeOf(c)},`,
|
|
617
|
+
`// ${c.file}?: ${viewTypeOf(c)},`,
|
|
400
618
|
``,
|
|
401
619
|
`| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
|
|
402
620
|
` Update(${c.entityId}, state => {`,
|
|
403
|
-
`
|
|
404
|
-
`
|
|
621
|
+
` ...state,`,
|
|
622
|
+
` ${c.file}: {ref: ${c.file}, altText: ?altText},`,
|
|
405
623
|
` })`,
|
|
406
624
|
`| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
|
|
625
|
+
` Update(${c.entityId}, state =>`,
|
|
626
|
+
` // Guarded on the reference: a removal that names something this row no`,
|
|
627
|
+
` // longer holds — the first half of a replacement, arriving late — must not`,
|
|
628
|
+
` // blank the one it does.`,
|
|
629
|
+
` heldRef(state) == Some(${c.file}) ? {...state, ${c.file}: ?None} : state`,
|
|
630
|
+
` )`,
|
|
631
|
+
`| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
|
|
632
|
+
` Update(${c.entityId}, state =>`,
|
|
633
|
+
` switch state.${c.file} {`,
|
|
634
|
+
` | Some(held) if held.ref == ${c.file} => {...state, ${c.file}: {...held, altText}}`,
|
|
635
|
+
` | _ => state`,
|
|
636
|
+
` }`,
|
|
637
|
+
` )`,
|
|
638
|
+
``,
|
|
639
|
+
`// The reference this row holds, if it holds one. Named because both guards`,
|
|
640
|
+
`// above ask the same question of a value that is no longer the reference itself.`,
|
|
641
|
+
`let heldRef = (state: ${c.view}.state) => state.${c.file}->Option.map(held => held.ref)`,
|
|
642
|
+
]),
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
let manyProjectionPatch = (c: config): patch => {
|
|
647
|
+
let n = namesOf(c)
|
|
648
|
+
let set = setFieldOf(c)
|
|
649
|
+
{
|
|
650
|
+
into: `StateViewSliceStream/${c.view}_Projection.res`,
|
|
651
|
+
at: `the projection's \`switch\`, and one field on \`${c.view}\`'s state`,
|
|
652
|
+
contents: lines([
|
|
653
|
+
`// On the view's state, one field — the set, primary first.`,
|
|
654
|
+
`//`,
|
|
655
|
+
`// The primary is the FIRST member rather than a scalar beside the set. That is`,
|
|
656
|
+
`// what a card, a gallery tile and a list cell read, so there is no second field`,
|
|
657
|
+
`// to keep in step with the set and no arm that can forget to. The text rides`,
|
|
658
|
+
`// inside each member for the same reason: a cell renderer is handed a field and`,
|
|
659
|
+
`// a value and never the row, so a caption in a sibling field is one no cell can`,
|
|
660
|
+
`// draw.`,
|
|
661
|
+
`//`,
|
|
662
|
+
`// What it costs, stated plainly: attachment order stops being readable off the`,
|
|
663
|
+
`// view. The log still has it.`,
|
|
664
|
+
`//`,
|
|
665
|
+
`// ${set}: array<${viewTypeOf(c)}>,`,
|
|
666
|
+
``,
|
|
667
|
+
`// Appended, so the first attached is the primary until one is chosen.`,
|
|
668
|
+
`| ${n.attached}({${c.entityId}, ${c.file}, altText: ?altText}) =>`,
|
|
669
|
+
` Update(${c.entityId}, state =>`,
|
|
670
|
+
` state.${set}->Array.some(m => m.ref == ${c.file})`,
|
|
671
|
+
` ? state`,
|
|
672
|
+
` : {`,
|
|
673
|
+
` ...state,`,
|
|
674
|
+
` ${set}: state.${set}->Array.concat([{ref: ${c.file}, altText: ?altText}]),`,
|
|
675
|
+
` }`,
|
|
676
|
+
` )`,
|
|
677
|
+
`// Removing the head promotes the next member with no arm to say so.`,
|
|
678
|
+
`| ${n.removed}({${c.entityId}, ${c.file}}) =>`,
|
|
407
679
|
` Update(${c.entityId}, state => {`,
|
|
408
|
-
`
|
|
409
|
-
`
|
|
680
|
+
` ...state,`,
|
|
681
|
+
` ${set}: state.${set}->Array.filter(m => m.ref != ${c.file}),`,
|
|
410
682
|
` })`,
|
|
411
683
|
`| ${n.primarySet}({${c.entityId}, ${c.file}}) =>`,
|
|
412
|
-
` Update(${c.entityId}, state =>
|
|
684
|
+
` Update(${c.entityId}, state => primaryFirst(state, ${c.file}))`,
|
|
413
685
|
`| ${n.altTextSet}({${c.entityId}, ${c.file}, altText}) =>`,
|
|
414
686
|
` Update(${c.entityId}, state => {`,
|
|
415
687
|
` ...state,`,
|
|
416
|
-
` ${
|
|
417
|
-
` m.${c.file} == ${c.file} ? {...m, altText} : m`,
|
|
418
|
-
` ),`,
|
|
688
|
+
` ${set}: state.${set}->Array.map(m => m.ref == ${c.file} ? {...m, altText} : m),`,
|
|
419
689
|
` })`,
|
|
420
690
|
``,
|
|
421
|
-
`//
|
|
422
|
-
`// the
|
|
423
|
-
|
|
424
|
-
`
|
|
691
|
+
`// Choosing the primary is moving it to the front — the trait's rule, applied`,
|
|
692
|
+
`// over the view's rows. The only arm that reorders; the rest leave the head`,
|
|
693
|
+
`// where they found it.`,
|
|
694
|
+
`let primaryFirst = (state: ${c.view}.state, chosen) => {`,
|
|
695
|
+
` ...state,`,
|
|
696
|
+
` ${set}: TraitAttachments.Attachments_Rules.primaryFirst(`,
|
|
425
697
|
` ~chosen,`,
|
|
426
|
-
` ~
|
|
427
|
-
`
|
|
698
|
+
` ~members=state.${set},`,
|
|
699
|
+
` ~ref=m => m.ref,`,
|
|
700
|
+
` ),`,
|
|
701
|
+
`}`,
|
|
428
702
|
]),
|
|
429
703
|
}
|
|
430
704
|
}
|
|
431
705
|
|
|
706
|
+
let projectionPatch = (c: config): patch =>
|
|
707
|
+
isSingle(c) ? singleProjectionPatch(c) : manyProjectionPatch(c)
|
|
708
|
+
|
|
432
709
|
/**
|
|
433
710
|
Emit a graft.
|
|
434
711
|
|