@mindful-web/marko-web-identity-x 1.87.1 → 1.89.1

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/CLAUDE.md CHANGED
@@ -138,6 +138,49 @@ seam Omeda uses for rapid-identify + subscription sync. **Note:** `onLogout` cur
138
138
  in the tokenless logout branch — a "fire `onLogout` always" change would let the `g_state` clear
139
139
  become a hook rather than inline route code.
140
140
 
141
+ ## Boolean questions have three states — added 2026-08
142
+
143
+ `AppUserCustomBooleanFieldAnswer.answer` is a **nullable** `Boolean`, and the schema says so:
144
+ *"A null value signifies a non answer. It's up to the implementing components to account for this."*
145
+ `hasAnswered` is literally `answer != null`. The three states are `true`, `false`, and *never
146
+ answered* — and on a subscription question the third is not a "no", it is silence.
147
+
148
+ The write side has no null: `UpdateAppUserCustomBooleanAnswer { fieldId: String!, value: Boolean! }`
149
+ on both identity-x and idx-compat. **Omission is the encoding for "no statement"**, and it is
150
+ load-bearing at every layer below this package: an absent entry in the `answers` array is a no-op,
151
+ `marko-web-omeda-identity-x/rapid-identify.js` skips `hasAnswered: false` answers *and* anything
152
+ outside `restrictAnswersToFieldIds`, and the Omeda resolver does `if (optedIn == null) return;`.
153
+
154
+ **The profile form used to break this in two places, and no longer does.**
155
+
156
+ - `browser/form/fields/custom-boolean-yes-no.vue` — the profile form's subscription control. Two
157
+ radios, and a `null` value selects **neither**, so unanswered is visually distinct from answered-
158
+ no. Emits the boolean, never null: a user can move between yes and no but cannot un-answer.
159
+ `required` is set on both radios and the browser validates them as a group — strictly better than
160
+ the checkbox it replaced, which could only ever be satisfied by opting *in*.
161
+ `browser/profile.vue`'s `onCustomBooleanChange` **assigns** the emitted answer; it used to
162
+ `!answer`, which on a `null` would read a "no" click as `true`.
163
+ - `utils/build-boolean-answers-input.js` — `routes/profile.js` used to map every rendered answer
164
+ with `value: Boolean(fieldAnswer.answer)` and push every field id into `submittedFieldIds`. A
165
+ member who opened /user/profile to fix a typo and never touched the subscription block therefore
166
+ sent `optedIn: false` / `Receive: 0` for every question they had never answered. The util drops
167
+ `answer == null` before both the mutation and `submittedFieldIds`, so the Omeda hook's
168
+ `restrictAnswersToFieldIds` drops them too. Spec:
169
+ `test/utils/build-boolean-answers-input.spec.js`.
170
+
171
+ **This is fleet-wide and unconditional** — no config flag; it arrives on a `mindful-web` dep bump.
172
+
173
+ **Scope is `/user/profile` only.** `custom-boolean.vue` (the checkbox) is untouched and still
174
+ renders in `custom-column.vue` (register/login required rows), `access.vue`/`download.vue` (gating),
175
+ and the theme's `idx-newsletter-form/*`. Those forms only ever create opt-ins on user creation, and
176
+ a signup box reads better as a checkbox.
177
+
178
+ **What this cannot fix.** For channel-backed booleans (`_kind: EMAIL|MAGAZINE`), idx-compat derives
179
+ `answer` from the member's stored `subscriptions[].active`
180
+ (`idx-compat/src/resolvers/app-user.ts:76-101`) — so once *any* subscription doc exists,
181
+ `active: false` is indistinguishable from an explicit no and renders with **No** pre-selected.
182
+ Channels have no unanswered state. This prevents new damage; it cannot un-answer history.
183
+
141
184
  ## Google Sign-In (One-Tap + button) — folded in 2026-07
142
185
 
143
186
  Verifies a Google Identity Services (GIS) credential server-side and drops the user into the
@@ -348,7 +391,7 @@ configurations that are each a user-facing trap:
348
391
  | Boot check | Why the runtime failure is worse than a failed deploy |
349
392
  | --- | --- |
350
393
  | `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
351
- | `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer. Re-allow only if the gate ever supports optional columns or explicit yes/no booleans. |
394
+ | `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer. The profile form now uses an explicit yes/no control that would satisfy this (see *Boolean questions have three states*), but it is not wired into `custom-column.vue` and the check still stands — re-allow only once the gate actually renders that control. |
352
395
  | any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
353
396
  | id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
354
397
  | rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
@@ -439,8 +482,8 @@ wholesale: 18 of the first 48 production submitters on athleticbusiness had thei
439
482
  subscription killed (`Receive: 0`, "Controlled Kills") at their exact submit second — the
440
483
  2026-08-19 incident. The spec asserting the payload shape lives in
441
484
  `marko-web-omeda-identity-x/test/rapid-identify.spec.js`. The profile route (`routes/profile.js`)
442
- still passes booleans deliberately — its form renders the subscription checkboxes — but it shares
443
- the divergence exposure; see the incident follow-ups in the rollout doc.
485
+ still passes booleans deliberately — its form renders the subscription questions — but it now only
486
+ passes the ones the member has actually answered; see *Boolean questions have three states*.
444
487
 
445
488
  ### Bot protection — verify, then discard the write rather than the visitor
446
489
 
@@ -0,0 +1,132 @@
1
+ <template>
2
+ <form-group class-name="custom-boolean-yes-no">
3
+ <fieldset class="custom-boolean-yes-no__fieldset">
4
+ <legend class="custom-boolean-yes-no__legend">
5
+ <span v-html="message" />
6
+ <strong v-if="required" class="text-danger">*</strong>
7
+ </legend>
8
+ <div class="custom-boolean-yes-no__options">
9
+ <div
10
+ v-for="option in options"
11
+ :key="option.value"
12
+ class="custom-control custom-radio custom-control-inline"
13
+ >
14
+ <input
15
+ :id="`custom-boolean-${id}-${option.value}`"
16
+ :name="`custom-boolean-${id}`"
17
+ :checked="value === option.answer"
18
+ :disabled="disabled"
19
+ :required="required"
20
+ :value="option.value"
21
+ type="radio"
22
+ class="custom-control-input"
23
+ @change="$emit('input', option.answer)"
24
+ >
25
+ <label
26
+ class="custom-control-label"
27
+ :for="`custom-boolean-${id}-${option.value}`"
28
+ >{{ option.label }}</label>
29
+ </div>
30
+ </div>
31
+ </fieldset>
32
+ </form-group>
33
+ </template>
34
+
35
+ <script>
36
+ import FormGroup from '../common/form-group.vue';
37
+
38
+ /**
39
+ * An explicit yes/no answer for a boolean (subscription) question.
40
+ *
41
+ * Unlike `custom-boolean.vue` — the checkbox this replaces on the profile form — a `null` value
42
+ * selects NEITHER radio, so "never answered" is visually distinct from "answered no". The route
43
+ * then omits unanswered questions from the write rather than sending them as `false`, which is
44
+ * what keeps a profile save from unsubscribing someone who never touched the question.
45
+ *
46
+ * A user can move between yes and no but cannot return a question to unanswered; that is
47
+ * deliberate. `required` is set on both radios, which the browser validates as a group — strictly
48
+ * better than a required checkbox, which can only ever be satisfied by opting IN.
49
+ *
50
+ * The asterisk is inlined rather than reusing `form/common/form-label.vue`: the question here
51
+ * labels a radio GROUP, so it belongs in a `<legend>`, and that component always renders a
52
+ * `<label>` element.
53
+ */
54
+ export default {
55
+ components: { FormGroup },
56
+ props: {
57
+ id: {
58
+ type: String,
59
+ required: true,
60
+ },
61
+ disabled: {
62
+ type: Boolean,
63
+ default: false,
64
+ },
65
+ /**
66
+ * `true`, `false`, or `null` when the question has never been answered.
67
+ * Deliberately untyped so `null` passes through without a Vue type warning.
68
+ */
69
+ value: {
70
+ default: null,
71
+ validator: (v) => v === null || typeof v === 'boolean',
72
+ },
73
+ required: {
74
+ type: Boolean,
75
+ default: false,
76
+ },
77
+ message: {
78
+ type: String,
79
+ required: true,
80
+ },
81
+ yesLabel: {
82
+ type: String,
83
+ default: 'Yes',
84
+ },
85
+ noLabel: {
86
+ type: String,
87
+ default: 'No',
88
+ },
89
+ },
90
+ computed: {
91
+ options() {
92
+ return [
93
+ { value: 'yes', answer: true, label: this.yesLabel },
94
+ { value: 'no', answer: false, label: this.noLabel },
95
+ ];
96
+ },
97
+ },
98
+ };
99
+ </script>
100
+
101
+ <!--
102
+ Structural resets ship with the component, not the theme, because this renders on every theme.
103
+ Bootstrap Reboot styles a bare `legend` at `font-size: 1.5rem` and full width, so without these
104
+ a subscription question renders half again as large as the rest of the form. `theme-default`
105
+ (allured) has no identity-x stylesheet at all, so a theme-only rule would miss it entirely.
106
+ Cosmetic overrides — radio roundness, wider inline spacing — stay in theme-monorail.
107
+ Global rather than scoped, matching form/common/checkbox-group.vue; the class names are unique.
108
+ -->
109
+ <style>
110
+ .custom-boolean-yes-no__fieldset {
111
+ border: 0;
112
+ padding: 0;
113
+ margin: 0;
114
+ min-width: 0;
115
+ }
116
+ .custom-boolean-yes-no__legend {
117
+ float: none;
118
+ width: auto;
119
+ max-width: 100%;
120
+ padding: 0;
121
+ margin-bottom: 0.25rem;
122
+ font-size: 1rem;
123
+ line-height: 1.4;
124
+ }
125
+ .custom-boolean-yes-no__legend strong {
126
+ line-height: inherit;
127
+ }
128
+ .custom-boolean-yes-no__options {
129
+ display: flex;
130
+ flex-wrap: wrap;
131
+ }
132
+ </style>
@@ -173,12 +173,14 @@
173
173
  :key="fieldAnswer.id"
174
174
  class="col-12"
175
175
  >
176
- <custom-boolean
176
+ <custom-boolean-yes-no
177
177
  :id="fieldAnswer.id"
178
178
  :message="fieldAnswer.field.label"
179
179
  :required="fieldAnswer.field.required"
180
180
  :value="fieldAnswer.answer"
181
- @input="onCustomBooleanChange(fieldAnswer.id)"
181
+ :yes-label="translate({ key: 'Yes' })"
182
+ :no-label="translate({ key: 'No' })"
183
+ @input="onCustomBooleanChange(fieldAnswer.id, $event)"
182
184
  />
183
185
  </div>
184
186
  </div>
@@ -264,7 +266,7 @@ import regionCountryCodes from './utils/region-country-codes';
264
266
 
265
267
  import AddressBlock from './form/address-block.vue';
266
268
  import FormConsent from './form/consent.vue';
267
- import CustomBoolean from './form/fields/custom-boolean.vue';
269
+ import CustomBooleanYesNo from './form/fields/custom-boolean-yes-no.vue';
268
270
  import CustomSelect from './form/fields/custom-select.vue';
269
271
  import CustomText from './form/fields/custom-text.vue';
270
272
  import GivenName from './form/fields/given-name.vue';
@@ -285,7 +287,7 @@ const { isArray } = Array;
285
287
  export default {
286
288
  components: {
287
289
  AddressBlock,
288
- CustomBoolean,
290
+ CustomBooleanYesNo,
289
291
  CustomSelect,
290
292
  CustomText,
291
293
  GivenName,
@@ -631,9 +633,13 @@ export default {
631
633
  /**
632
634
  *
633
635
  */
634
- onCustomBooleanChange(id) {
636
+ /**
637
+ * Assigns the answer the control emitted. Deliberately NOT a toggle of the stored value:
638
+ * the yes/no control has three input states and `!null` would read a "no" click as `true`.
639
+ */
640
+ onCustomBooleanChange(id, answer) {
635
641
  const objIndex = this.customBooleanFieldAnswers.findIndex(((obj) => obj.id === id));
636
- const answer = !this.customBooleanFieldAnswers[objIndex].answer;
642
+ if (objIndex === -1) return;
637
643
  this.customBooleanFieldAnswers[objIndex].answer = answer;
638
644
 
639
645
  this.user.customBooleanFieldAnswers = this.customBooleanFieldAnswers;
@@ -652,6 +658,8 @@ export default {
652
658
  'To continue modifying your profile,': 'Para continuar editando seu perfil,',
653
659
  'To return to the home page, ': 'Para voltar à página inicial, ',
654
660
  'You must be logged in to modify your user profile.': 'Você precisa estar logado para modificar seu perfil de usuário.',
661
+ Yes: 'Sim',
662
+ No: 'Não',
655
663
  },
656
664
  es: {
657
665
  'An error occurred': 'Se produjo un error:',
@@ -663,6 +671,8 @@ export default {
663
671
  'To continue modifying your profile,': 'Para continuar modificando tu perfil, ',
664
672
  'To return to the home page, ': 'Para volver a la página de inicio, ',
665
673
  'You must be logged in to modify your user profile.': 'Você precisa estar logado para modificar seu perfil de usuário.',
674
+ Yes: 'Sí',
675
+ No: 'No',
666
676
  },
667
677
  en: {
668
678
  'An error occurred: ': 'An error occurred: ',
@@ -674,6 +684,8 @@ export default {
674
684
  'To continue modifying your profile,': 'To continue modifying your profile,',
675
685
  'Your profile has been saved.': 'Your profile has been saved.',
676
686
  'You must be logged in to modify your user profile.': 'You must be logged in to modify your user profile.',
687
+ Yes: 'Yes',
688
+ No: 'No',
677
689
  },
678
690
  };
679
691
  const value = (i18n[lang] && i18n[lang][key]) ? i18n[lang][key] : i18n.en[key] || '';
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/access.marko",
6
6
  marko_component = require("./access.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -53,7 +53,7 @@ marko_template._ = marko_renderer(render, {
53
53
  }, marko_component);
54
54
 
55
55
  marko_template.meta = {
56
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/access.marko",
56
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/access.marko",
57
57
  component: "./access.marko",
58
58
  tags: [
59
59
  "@mindful-web/marko-core/components/resolve.marko"
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/comment-stream.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/comment-stream.marko",
6
6
  marko_component = require("./comment-stream.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -91,7 +91,7 @@ marko_template._ = marko_renderer(render, {
91
91
  }, marko_component);
92
92
 
93
93
  marko_template.meta = {
94
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/comment-stream.marko",
94
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/comment-stream.marko",
95
95
  component: "./comment-stream.marko",
96
96
  tags: [
97
97
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/context.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/context.marko",
6
6
  marko_component = require("./context.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
@@ -49,7 +49,7 @@ marko_template._ = marko_renderer(render, {
49
49
  }, marko_component);
50
50
 
51
51
  marko_template.meta = {
52
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/context.marko",
52
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/context.marko",
53
53
  component: "./context.marko",
54
54
  tags: [
55
55
  "@mindful-web/marko-core/components/resolve.marko"
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-access.marko",
6
6
  marko_component = require("./form-access.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
@@ -179,7 +179,7 @@ marko_template._ = marko_renderer(render, {
179
179
  }, marko_component);
180
180
 
181
181
  marko_template.meta = {
182
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-access.marko",
182
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-access.marko",
183
183
  component: "./form-access.marko",
184
184
  tags: [
185
185
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-authenticate.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-authenticate.marko",
6
6
  marko_component = require("./form-authenticate.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -81,7 +81,7 @@ marko_template._ = marko_renderer(render, {
81
81
  }, marko_component);
82
82
 
83
83
  marko_template.meta = {
84
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-authenticate.marko",
84
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-authenticate.marko",
85
85
  component: "./form-authenticate.marko",
86
86
  tags: [
87
87
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-change-email.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-change-email.marko",
6
6
  marko_component = require("./form-change-email.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -59,7 +59,7 @@ marko_template._ = marko_renderer(render, {
59
59
  }, marko_component);
60
60
 
61
61
  marko_template.meta = {
62
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-change-email.marko",
62
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-change-email.marko",
63
63
  component: "./form-change-email.marko",
64
64
  tags: [
65
65
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-login.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-login.marko",
6
6
  marko_component = require("./form-login.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -92,7 +92,7 @@ marko_template._ = marko_renderer(render, {
92
92
  }, marko_component);
93
93
 
94
94
  marko_template.meta = {
95
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-login.marko",
95
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-login.marko",
96
96
  component: "./form-login.marko",
97
97
  tags: [
98
98
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-logout.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-logout.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
8
8
  defaultValue = module_defaultValue.default || module_defaultValue,
@@ -29,7 +29,7 @@ marko_template._ = marko_renderer(render, {
29
29
  });
30
30
 
31
31
  marko_template.meta = {
32
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-logout.marko",
32
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-logout.marko",
33
33
  tags: [
34
34
  "@mindful-web/marko-web/components/browser-component.marko"
35
35
  ]
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-profile.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-profile.marko",
6
6
  marko_component = require("./form-profile.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -84,7 +84,7 @@ marko_template._ = marko_renderer(render, {
84
84
  }, marko_component);
85
85
 
86
86
  marko_template.meta = {
87
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-profile.marko",
87
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-profile.marko",
88
88
  component: "./form-profile.marko",
89
89
  tags: [
90
90
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/form-register.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/form-register.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  marko_assign = require("marko/dist/runtime/helpers/assign"),
8
8
  marko_web_identity_x_form_login_template = require("./form-login.marko"),
@@ -25,7 +25,7 @@ marko_template._ = marko_renderer(render, {
25
25
  });
26
26
 
27
27
  marko_template.meta = {
28
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/form-register.marko",
28
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/form-register.marko",
29
29
  tags: [
30
30
  "./form-login.marko"
31
31
  ]
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/google-init.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/google-init.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  marko_attr = require("marko/dist/runtime/html/helpers/attr"),
8
8
  marko_str = require("marko/dist/runtime/helpers/to-string");
@@ -239,5 +239,5 @@ marko_template._ = marko_renderer(render, {
239
239
  });
240
240
 
241
241
  marko_template.meta = {
242
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/google-init.marko"
242
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/google-init.marko"
243
243
  };
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/google-sign-in-button.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/google-sign-in-button.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
8
8
  marko_escapeXml = helpers_escape_xml.x,
@@ -40,5 +40,5 @@ marko_template._ = marko_renderer(render, {
40
40
  });
41
41
 
42
42
  marko_template.meta = {
43
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/google-sign-in-button.marko"
43
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/google-sign-in-button.marko"
44
44
  };
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/identify.marko",
6
6
  marko_component = require("./identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -50,7 +50,7 @@ marko_template._ = marko_renderer(render, {
50
50
  }, marko_component);
51
51
 
52
52
  marko_template.meta = {
53
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/identify.marko",
53
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/identify.marko",
54
54
  component: "./identify.marko",
55
55
  tags: [
56
56
  "@mindful-web/marko-web-gtm/components/push.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/non-auth-identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/non-auth-identify.marko",
6
6
  marko_component = require("./non-auth-identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
@@ -45,7 +45,7 @@ marko_template._ = marko_renderer(render, {
45
45
  }, marko_component);
46
46
 
47
47
  marko_template.meta = {
48
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/non-auth-identify.marko",
48
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/non-auth-identify.marko",
49
49
  component: "./non-auth-identify.marko",
50
50
  tags: [
51
51
  "@mindful-web/marko-core/components/resolve.marko"
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/progressive-profile-form.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/progressive-profile-form.marko",
6
6
  marko_component = require("./progressive-profile-form.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
@@ -137,7 +137,7 @@ marko_template._ = marko_renderer(render, {
137
137
  }, marko_component);
138
138
 
139
139
  marko_template.meta = {
140
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/progressive-profile-form.marko",
140
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/progressive-profile-form.marko",
141
141
  component: "./progressive-profile-form.marko",
142
142
  tags: [
143
143
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.87.1/components/subscribe.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.1/components/subscribe.marko",
6
6
  marko_component = require("./subscribe.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -73,7 +73,7 @@ marko_template._ = marko_renderer(render, {
73
73
  }, marko_component);
74
74
 
75
75
  marko_template.meta = {
76
- id: "/@mindful-web/marko-web-identity-x$1.87.1/components/subscribe.marko",
76
+ id: "/@mindful-web/marko-web-identity-x$1.89.1/components/subscribe.marko",
77
77
  component: "./subscribe.marko",
78
78
  tags: [
79
79
  "@mindful-web/marko-web/components/browser-component.marko",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-identity-x",
3
- "version": "1.87.1",
3
+ "version": "1.89.1",
4
4
  "description": "Marko Wrapper for IdentityX",
5
5
  "repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-identity-x",
6
6
  "author": "Josh Worden <josh@parameter1.com>",
@@ -43,5 +43,5 @@
43
43
  "chai": "^4.3.7",
44
44
  "mocha": "^6.2.3"
45
45
  },
46
- "gitHead": "c9fef30dc3d3dbf2c538e3850fae61f629b8791d"
46
+ "gitHead": "4d832561c21206813db5d5f53bc275503cbefe35"
47
47
  }
package/routes/profile.js CHANGED
@@ -3,6 +3,7 @@ const { asyncRoute } = require('@mindful-web/utils');
3
3
  const { getAsArray, getAsObject } = require('@mindful-web/object-path');
4
4
  const userFragment = require('../api/fragments/active-user');
5
5
  const callHooksFor = require('../utils/call-hooks-for');
6
+ const buildBooleanAnswersInput = require('../utils/build-boolean-answers-input');
6
7
  const overlaySubmittedBooleanAnswers = require('../utils/overlay-submitted-boolean-answers');
7
8
 
8
9
  const mutation = gql`
@@ -125,18 +126,18 @@ module.exports = asyncRoute(async (req, res) => {
125
126
  // guards against).
126
127
  const submittedBooleanValues = new Map();
127
128
 
128
- if (customBooleanFieldAnswers.length) {
129
+ // An answer can be true, false or null. A null is a NON-answer and is DROPPED here rather than
130
+ // written as `false` — see build-boolean-answers-input for why that distinction is the whole
131
+ // difference between "the user said no" and "we unsubscribed someone who never answered".
132
+ // Unanswered ids never reach `submittedFieldIds` either, so the Omeda hook's
133
+ // `restrictAnswersToFieldIds` drops them from rapid-identify too.
134
+ const customBooleanFieldsInput = buildBooleanAnswersInput(
135
+ customBooleanFieldAnswers,
136
+ customFieldIds,
137
+ );
138
+
139
+ if (customBooleanFieldsInput.length) {
129
140
  // only update custom questions when there some :)
130
- const customBooleanFieldsInput = customBooleanFieldAnswers.map((fieldAnswer) => ({
131
- fieldId: fieldAnswer.field.id,
132
- // can either be true, false or null. convert null to false.
133
- // the form submit is effectively answers the question.
134
- value: Boolean(fieldAnswer.answer),
135
- })).filter(
136
- customFieldIds.length > 0
137
- ? ({ fieldId }) => customFieldIds.includes(fieldId)
138
- : () => true,
139
- );
140
141
  submittedFieldIds.push(...customBooleanFieldsInput.map(({ fieldId }) => fieldId));
141
142
  customBooleanFieldsInput.forEach(({ fieldId, value }) => {
142
143
  submittedBooleanValues.set(fieldId, value);
@@ -0,0 +1,62 @@
1
+ const { describe, it } = require('mocha');
2
+ const { expect } = require('chai');
3
+ const build = require('../../utils/build-boolean-answers-input');
4
+
5
+ const answer = ({ id, value = null }) => ({
6
+ id: `ans-${id}`,
7
+ hasAnswered: value != null,
8
+ answer: value,
9
+ value,
10
+ field: { id, active: true },
11
+ });
12
+
13
+ describe('utils/build-boolean-answers-input', () => {
14
+ it('omits an unanswered question rather than writing it as false', () => {
15
+ // The whole point: this is what stops /user/profile from unsubscribing someone who never
16
+ // touched the subscription block.
17
+ expect(build([answer({ id: 'chan-mag' })])).to.deep.equal([]);
18
+ });
19
+
20
+ it('preserves an explicit no as false', () => {
21
+ expect(build([answer({ id: 'chan-mag', value: false })]))
22
+ .to.deep.equal([{ fieldId: 'chan-mag', value: false }]);
23
+ });
24
+
25
+ it('preserves an explicit yes as true', () => {
26
+ expect(build([answer({ id: 'chan-mag', value: true })]))
27
+ .to.deep.equal([{ fieldId: 'chan-mag', value: true }]);
28
+ });
29
+
30
+ it('keeps answered questions when mixed with unanswered ones', () => {
31
+ const answers = [
32
+ answer({ id: 'a', value: true }),
33
+ answer({ id: 'b' }),
34
+ answer({ id: 'c', value: false }),
35
+ ];
36
+ expect(build(answers)).to.deep.equal([
37
+ { fieldId: 'a', value: true },
38
+ { fieldId: 'c', value: false },
39
+ ]);
40
+ });
41
+
42
+ it('applies the writable field id allowlist', () => {
43
+ const answers = [answer({ id: 'a', value: true }), answer({ id: 'b', value: false })];
44
+ expect(build(answers, ['b'])).to.deep.equal([{ fieldId: 'b', value: false }]);
45
+ });
46
+
47
+ it('writes every answered field when the allowlist is empty', () => {
48
+ const answers = [answer({ id: 'a', value: true }), answer({ id: 'b', value: false })];
49
+ expect(build(answers, [])).to.have.lengthOf(2);
50
+ });
51
+
52
+ it('ignores answers carrying no field', () => {
53
+ expect(build([{ answer: true }, answer({ id: 'a', value: true })]))
54
+ .to.deep.equal([{ fieldId: 'a', value: true }]);
55
+ });
56
+
57
+ it('returns an empty array for missing or empty input', () => {
58
+ expect(build()).to.deep.equal([]);
59
+ expect(build(null)).to.deep.equal([]);
60
+ expect(build([])).to.deep.equal([]);
61
+ });
62
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Marshals submitted boolean field answers into `UpdateAppUserCustomBooleanAnswer` input.
3
+ *
4
+ * A boolean answer has THREE states, not two: `true`, `false`, and `null` (never answered —
5
+ * `hasAnswered` is `answer != null`). This drops the third rather than collapsing it to `false`.
6
+ *
7
+ * Why that matters: an unanswered question written as `false` is a *statement*. The profile route
8
+ * feeds its returned field ids to `onUserProfileUpdate` as `restrictAnswersToFieldIds`, and
9
+ * `marko-web-omeda-identity-x/rapid-identify.js` turns a deployment-type boolean into
10
+ * `optedIn: false` and a product boolean into `receive: false` (`Receive: 0`, a Controlled Kill).
11
+ * So a member who opened /user/profile to fix a typo and never touched the subscription block
12
+ * would unsubscribe themselves from every question they had never answered.
13
+ *
14
+ * Omission is the supported encoding for "no statement" at every layer below this one: the
15
+ * mutation input is `value: Boolean!` (there is no null to send), an absent entry in the `answers`
16
+ * array is a no-op, rapid-identify skips anything outside `restrictAnswersToFieldIds`, and the
17
+ * Omeda resolver does `if (optedIn == null) return;`.
18
+ *
19
+ * @param {object[]} answers The posted `customBooleanFieldAnswers`.
20
+ * @param {string[]} [customFieldIds] When non-empty, the allowlist of writable field ids.
21
+ * @returns {{ fieldId: string, value: boolean }[]} Answered, writable answers only.
22
+ */
23
+ module.exports = (answers, customFieldIds = []) => (Array.isArray(answers) ? answers : [])
24
+ // A null answer is a NON-answer. Never coerce it to `false`.
25
+ .filter((fieldAnswer) => fieldAnswer && fieldAnswer.field && fieldAnswer.answer != null)
26
+ .map((fieldAnswer) => ({
27
+ fieldId: fieldAnswer.field.id,
28
+ value: Boolean(fieldAnswer.answer),
29
+ }))
30
+ .filter(customFieldIds.length > 0
31
+ ? ({ fieldId }) => customFieldIds.includes(fieldId)
32
+ : () => true);