@things-factory/auth-ui 10.1.16 → 10.1.18

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.
Files changed (98) hide show
  1. package/dist-client/components/app-binding-detail.d.ts +46 -0
  2. package/dist-client/components/app-binding-detail.js +384 -0
  3. package/dist-client/components/app-binding-detail.js.map +1 -0
  4. package/dist-client/components/appliance-editor.d.ts +37 -0
  5. package/dist-client/components/appliance-editor.js +489 -0
  6. package/dist-client/components/appliance-editor.js.map +1 -0
  7. package/dist-client/components/application-editor.d.ts +31 -0
  8. package/dist-client/components/application-editor.js +491 -0
  9. package/dist-client/components/application-editor.js.map +1 -0
  10. package/dist-client/components/profile-component.js +28 -11
  11. package/dist-client/components/profile-component.js.map +1 -1
  12. package/dist-client/components/secret-field.d.ts +35 -0
  13. package/dist-client/components/secret-field.js +149 -0
  14. package/dist-client/components/secret-field.js.map +1 -0
  15. package/dist-client/constants/application-type-parity.test.js +62 -0
  16. package/dist-client/constants/application-type-parity.test.js.map +1 -0
  17. package/dist-client/constants/application.d.ts +17 -7
  18. package/dist-client/constants/application.js +18 -8
  19. package/dist-client/constants/application.js.map +1 -1
  20. package/dist-client/pages/app-binding/app-binding-list-query.test.js +46 -0
  21. package/dist-client/pages/app-binding/app-binding-list-query.test.js.map +1 -0
  22. package/dist-client/pages/app-binding/app-bindings.d.ts +65 -2
  23. package/dist-client/pages/app-binding/app-bindings.js +217 -117
  24. package/dist-client/pages/app-binding/app-bindings.js.map +1 -1
  25. package/dist-client/pages/appliance/appliance-list-query.test.d.ts +1 -0
  26. package/dist-client/pages/appliance/appliance-list-query.test.js +58 -0
  27. package/dist-client/pages/appliance/appliance-list-query.test.js.map +1 -0
  28. package/dist-client/pages/appliance/home.d.ts +63 -2
  29. package/dist-client/pages/appliance/home.js +219 -123
  30. package/dist-client/pages/appliance/home.js.map +1 -1
  31. package/dist-client/pages/application/applications.d.ts +63 -2
  32. package/dist-client/pages/application/applications.js +212 -129
  33. package/dist-client/pages/application/applications.js.map +1 -1
  34. package/dist-client/pages/auth-provider/auth-provider-management.d.ts +4 -2
  35. package/dist-client/pages/auth-provider/auth-provider-management.js +35 -6
  36. package/dist-client/pages/auth-provider/auth-provider-management.js.map +1 -1
  37. package/dist-client/pages/domain-owner/domain-owner-management.d.ts +2 -0
  38. package/dist-client/pages/domain-owner/domain-owner-management.js +34 -7
  39. package/dist-client/pages/domain-owner/domain-owner-management.js.map +1 -1
  40. package/dist-client/pages/secrets-are-not-printed.test.d.ts +1 -0
  41. package/dist-client/pages/secrets-are-not-printed.test.js +88 -0
  42. package/dist-client/pages/secrets-are-not-printed.test.js.map +1 -0
  43. package/dist-client/route.js +0 -15
  44. package/dist-client/route.js.map +1 -1
  45. package/dist-client/tsconfig.tsbuildinfo +1 -1
  46. package/dist-client/utils/application-status.d.ts +32 -0
  47. package/dist-client/utils/application-status.js +45 -0
  48. package/dist-client/utils/application-status.js.map +1 -0
  49. package/dist-client/utils/application-status.test.d.ts +1 -0
  50. package/dist-client/utils/application-status.test.js +45 -0
  51. package/dist-client/utils/application-status.test.js.map +1 -0
  52. package/dist-client/utils/form-values.d.ts +71 -0
  53. package/dist-client/utils/form-values.js +102 -0
  54. package/dist-client/utils/form-values.js.map +1 -0
  55. package/dist-client/utils/form-values.test.d.ts +1 -0
  56. package/dist-client/utils/form-values.test.js +120 -0
  57. package/dist-client/utils/form-values.test.js.map +1 -0
  58. package/dist-client/utils/list-result.d.ts +62 -0
  59. package/dist-client/utils/list-result.js +92 -0
  60. package/dist-client/utils/list-result.js.map +1 -0
  61. package/dist-client/utils/list-result.test.d.ts +1 -0
  62. package/dist-client/utils/list-result.test.js +160 -0
  63. package/dist-client/utils/list-result.test.js.map +1 -0
  64. package/dist-client/utils/secret-display.d.ts +51 -0
  65. package/dist-client/utils/secret-display.js +61 -0
  66. package/dist-client/utils/secret-display.js.map +1 -0
  67. package/dist-client/utils/secret-display.test.d.ts +1 -0
  68. package/dist-client/utils/secret-display.test.js +59 -0
  69. package/dist-client/utils/secret-display.test.js.map +1 -0
  70. package/dist-client/utils/webauthn-verification.d.ts +56 -0
  71. package/dist-client/utils/webauthn-verification.js +79 -0
  72. package/dist-client/utils/webauthn-verification.js.map +1 -0
  73. package/dist-client/utils/webauthn-verification.test.d.ts +1 -0
  74. package/dist-client/utils/webauthn-verification.test.js +79 -0
  75. package/dist-client/utils/webauthn-verification.test.js.map +1 -0
  76. package/dist-server/tsconfig.tsbuildinfo +1 -1
  77. package/package.json +3 -3
  78. package/things-factory.config.js +0 -5
  79. package/translations/en.json +56 -0
  80. package/translations/ja.json +57 -0
  81. package/translations/ko.json +56 -0
  82. package/translations/ms.json +57 -0
  83. package/translations/zh.json +57 -0
  84. package/dist-client/pages/app-binding/app-binding.d.ts +0 -1
  85. package/dist-client/pages/app-binding/app-binding.js +0 -371
  86. package/dist-client/pages/app-binding/app-binding.js.map +0 -1
  87. package/dist-client/pages/appliance/appliance.d.ts +0 -1
  88. package/dist-client/pages/appliance/appliance.js +0 -392
  89. package/dist-client/pages/appliance/appliance.js.map +0 -1
  90. package/dist-client/pages/appliance/register.js +0 -166
  91. package/dist-client/pages/appliance/register.js.map +0 -1
  92. package/dist-client/pages/application/application.d.ts +0 -1
  93. package/dist-client/pages/application/application.js +0 -377
  94. package/dist-client/pages/application/application.js.map +0 -1
  95. package/dist-client/pages/application/register.js +0 -192
  96. package/dist-client/pages/application/register.js.map +0 -1
  97. /package/dist-client/{pages/appliance/register.d.ts → constants/application-type-parity.test.d.ts} +0 -0
  98. /package/dist-client/pages/{application/register.d.ts → app-binding/app-binding-list-query.test.d.ts} +0 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * How an application's status is shown.
3
+ *
4
+ * ── The rule that matters: a value we do not know is shown as it is ──────────
5
+ * `ApplicationStatus` is DRAFT or ACTIVATED today. A screen that maps those two to friendly
6
+ * words has to decide what to do with a third, and the tempting answer — fall through to one
7
+ * of the two, or to an empty cell — is the one that lies.
8
+ *
9
+ * This is the same mistake the role screen made with privilege axes, where anything that was
10
+ * not `query` was drawn as "write" and so a value first seen on the day it shipped landed in
11
+ * the dangerous bucket by accident rather than by verification. The fix there and here is the
12
+ * same: translate what is declared, and print anything else verbatim.
13
+ *
14
+ * An unknown status is not a failure either — the server is free to add one, and a screen a
15
+ * version behind should say "PENDING" rather than pretend it knows what PENDING means.
16
+ */
17
+ /** The statuses this screen has words for. Everything else is printed as it arrives. */
18
+ export declare const KNOWN_APPLICATION_STATUSES: string[];
19
+ /**
20
+ * The i18n key for a status, or nothing when there is no key for it.
21
+ *
22
+ * Returning nothing rather than a guess lets the caller print the raw value, which is the
23
+ * whole point. `translate` is passed in so this stays testable without i18next.
24
+ */
25
+ export declare function applicationStatusKey(status: string | null | undefined): string | undefined;
26
+ /**
27
+ * What the cell shows.
28
+ *
29
+ * Empty for a missing status — the row genuinely has none, and an empty cell says that more
30
+ * honestly than the word "unknown", which reads as a status of its own.
31
+ */
32
+ export declare function applicationStatusLabel(status: string | null | undefined, translate: (key: string) => string): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * How an application's status is shown.
3
+ *
4
+ * ── The rule that matters: a value we do not know is shown as it is ──────────
5
+ * `ApplicationStatus` is DRAFT or ACTIVATED today. A screen that maps those two to friendly
6
+ * words has to decide what to do with a third, and the tempting answer — fall through to one
7
+ * of the two, or to an empty cell — is the one that lies.
8
+ *
9
+ * This is the same mistake the role screen made with privilege axes, where anything that was
10
+ * not `query` was drawn as "write" and so a value first seen on the day it shipped landed in
11
+ * the dangerous bucket by accident rather than by verification. The fix there and here is the
12
+ * same: translate what is declared, and print anything else verbatim.
13
+ *
14
+ * An unknown status is not a failure either — the server is free to add one, and a screen a
15
+ * version behind should say "PENDING" rather than pretend it knows what PENDING means.
16
+ */
17
+ /** The statuses this screen has words for. Everything else is printed as it arrives. */
18
+ export const KNOWN_APPLICATION_STATUSES = ['DRAFT', 'ACTIVATED'];
19
+ const LABEL = {
20
+ DRAFT: 'label.draft',
21
+ ACTIVATED: 'label.activated'
22
+ };
23
+ /**
24
+ * The i18n key for a status, or nothing when there is no key for it.
25
+ *
26
+ * Returning nothing rather than a guess lets the caller print the raw value, which is the
27
+ * whole point. `translate` is passed in so this stays testable without i18next.
28
+ */
29
+ export function applicationStatusKey(status) {
30
+ return status ? LABEL[status] : undefined;
31
+ }
32
+ /**
33
+ * What the cell shows.
34
+ *
35
+ * Empty for a missing status — the row genuinely has none, and an empty cell says that more
36
+ * honestly than the word "unknown", which reads as a status of its own.
37
+ */
38
+ export function applicationStatusLabel(status, translate) {
39
+ if (!status) {
40
+ return '';
41
+ }
42
+ const key = applicationStatusKey(status);
43
+ return key ? translate(key) : status;
44
+ }
45
+ //# sourceMappingURL=application-status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"application-status.js","sourceRoot":"","sources":["../../client/utils/application-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,wFAAwF;AACxF,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,OAAO,EAAE,WAAW,CAAC,CAAA;AAEhE,MAAM,KAAK,GAA2B;IACpC,KAAK,EAAE,aAAa;IACpB,SAAS,EAAE,iBAAiB;CAC7B,CAAA;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAiC;IACpE,OAAO,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AAC3C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CACpC,MAAiC,EACjC,SAAkC;IAElC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,GAAG,GAAG,oBAAoB,CAAC,MAAM,CAAC,CAAA;IAExC,OAAO,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAA;AACtC,CAAC","sourcesContent":["/**\n * How an application's status is shown.\n *\n * ── The rule that matters: a value we do not know is shown as it is ──────────\n * `ApplicationStatus` is DRAFT or ACTIVATED today. A screen that maps those two to friendly\n * words has to decide what to do with a third, and the tempting answer — fall through to one\n * of the two, or to an empty cell — is the one that lies.\n *\n * This is the same mistake the role screen made with privilege axes, where anything that was\n * not `query` was drawn as \"write\" and so a value first seen on the day it shipped landed in\n * the dangerous bucket by accident rather than by verification. The fix there and here is the\n * same: translate what is declared, and print anything else verbatim.\n *\n * An unknown status is not a failure either — the server is free to add one, and a screen a\n * version behind should say \"PENDING\" rather than pretend it knows what PENDING means.\n */\n\n/** The statuses this screen has words for. Everything else is printed as it arrives. */\nexport const KNOWN_APPLICATION_STATUSES = ['DRAFT', 'ACTIVATED']\n\nconst LABEL: Record<string, string> = {\n DRAFT: 'label.draft',\n ACTIVATED: 'label.activated'\n}\n\n/**\n * The i18n key for a status, or nothing when there is no key for it.\n *\n * Returning nothing rather than a guess lets the caller print the raw value, which is the\n * whole point. `translate` is passed in so this stays testable without i18next.\n */\nexport function applicationStatusKey(status: string | null | undefined): string | undefined {\n return status ? LABEL[status] : undefined\n}\n\n/**\n * What the cell shows.\n *\n * Empty for a missing status — the row genuinely has none, and an empty cell says that more\n * honestly than the word \"unknown\", which reads as a status of its own.\n */\nexport function applicationStatusLabel(\n status: string | null | undefined,\n translate: (key: string) => string\n): string {\n if (!status) {\n return ''\n }\n\n const key = applicationStatusKey(status)\n\n return key ? translate(key) : status\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import { applicationStatusKey, applicationStatusLabel, KNOWN_APPLICATION_STATUSES } from './application-status';
2
+ /**
3
+ * The applications list used to print "draft" for every row, hardcoded, so an activated
4
+ * application read as a draft. The column now comes from the row — and the question that
5
+ * replaces it is what to print for a value this screen has no word for.
6
+ */
7
+ const translate = (key) => ({ 'label.draft': '초안', 'label.activated': '사용 중' })[key] || key;
8
+ describe('the statuses this screen has words for', () => {
9
+ it('translates the two the server declares', () => {
10
+ expect(applicationStatusLabel('DRAFT', translate)).toBe('초안');
11
+ expect(applicationStatusLabel('ACTIVATED', translate)).toBe('사용 중');
12
+ expect(KNOWN_APPLICATION_STATUSES).toEqual(['DRAFT', 'ACTIVATED']);
13
+ });
14
+ });
15
+ describe('a status this screen has never seen', () => {
16
+ it('prints it as it arrived instead of guessing', () => {
17
+ /*
18
+ * ⚠ The case this file exists for. The server may add a status; a client one release
19
+ * behind must not decide what it means. Mapping the unknown onto DRAFT or ACTIVATED
20
+ * would make the screen wrong with full confidence.
21
+ */
22
+ expect(applicationStatusLabel('PENDING', translate)).toBe('PENDING');
23
+ expect(applicationStatusKey('PENDING')).toBeUndefined();
24
+ });
25
+ it('does not fall through to either known word', () => {
26
+ const shown = applicationStatusLabel('SUSPENDED', translate);
27
+ expect(shown).not.toBe('초안');
28
+ expect(shown).not.toBe('사용 중');
29
+ });
30
+ it('is not treated as an error — it is a status, just an unfamiliar one', () => {
31
+ expect(applicationStatusLabel('PENDING', translate)).toBeTruthy();
32
+ });
33
+ });
34
+ describe('a row with no status at all', () => {
35
+ it('shows nothing rather than a word', () => {
36
+ /*
37
+ * Empty, not "unknown". "Unknown" reads as a status the server assigned; empty reads as
38
+ * the absence this actually is.
39
+ */
40
+ expect(applicationStatusLabel(undefined, translate)).toBe('');
41
+ expect(applicationStatusLabel(null, translate)).toBe('');
42
+ expect(applicationStatusLabel('', translate)).toBe('');
43
+ });
44
+ });
45
+ //# sourceMappingURL=application-status.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"application-status.test.js","sourceRoot":"","sources":["../../client/utils/application-status.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,sBAAsB,CAAA;AAE/G;;;;GAIG;AAEH,MAAM,SAAS,GAAG,CAAC,GAAW,EAAE,EAAE,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,GAAG,CAAA;AAEnG,QAAQ,CAAC,wCAAwC,EAAE,GAAG,EAAE;IACtD,EAAE,CAAC,wCAAwC,EAAE,GAAG,EAAE;QAChD,MAAM,CAAC,sBAAsB,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC7D,MAAM,CAAC,sBAAsB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACnE,MAAM,CAAC,0BAA0B,CAAC,CAAC,OAAO,CAAC,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAA;IACpE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,qCAAqC,EAAE,GAAG,EAAE;IACnD,EAAE,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACrD;;;;WAIG;QACH,MAAM,CAAC,sBAAsB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;QACpE,MAAM,CAAC,oBAAoB,CAAC,SAAS,CAAC,CAAC,CAAC,aAAa,EAAE,CAAA;IACzD,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACpD,MAAM,KAAK,GAAG,sBAAsB,CAAC,WAAW,EAAE,SAAS,CAAC,CAAA;QAE5D,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAC5B,MAAM,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IAChC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qEAAqE,EAAE,GAAG,EAAE;QAC7E,MAAM,CAAC,sBAAsB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE,CAAA;IACnE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,6BAA6B,EAAE,GAAG,EAAE;IAC3C,EAAE,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC1C;;;WAGG;QACH,MAAM,CAAC,sBAAsB,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QAC7D,MAAM,CAAC,sBAAsB,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;QACxD,MAAM,CAAC,sBAAsB,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IACxD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { applicationStatusKey, applicationStatusLabel, KNOWN_APPLICATION_STATUSES } from './application-status'\n\n/**\n * The applications list used to print \"draft\" for every row, hardcoded, so an activated\n * application read as a draft. The column now comes from the row — and the question that\n * replaces it is what to print for a value this screen has no word for.\n */\n\nconst translate = (key: string) => ({ 'label.draft': '초안', 'label.activated': '사용 중' })[key] || key\n\ndescribe('the statuses this screen has words for', () => {\n it('translates the two the server declares', () => {\n expect(applicationStatusLabel('DRAFT', translate)).toBe('초안')\n expect(applicationStatusLabel('ACTIVATED', translate)).toBe('사용 중')\n expect(KNOWN_APPLICATION_STATUSES).toEqual(['DRAFT', 'ACTIVATED'])\n })\n})\n\ndescribe('a status this screen has never seen', () => {\n it('prints it as it arrived instead of guessing', () => {\n /*\n * ⚠ The case this file exists for. The server may add a status; a client one release\n * behind must not decide what it means. Mapping the unknown onto DRAFT or ACTIVATED\n * would make the screen wrong with full confidence.\n */\n expect(applicationStatusLabel('PENDING', translate)).toBe('PENDING')\n expect(applicationStatusKey('PENDING')).toBeUndefined()\n })\n\n it('does not fall through to either known word', () => {\n const shown = applicationStatusLabel('SUSPENDED', translate)\n\n expect(shown).not.toBe('초안')\n expect(shown).not.toBe('사용 중')\n })\n\n it('is not treated as an error — it is a status, just an unfamiliar one', () => {\n expect(applicationStatusLabel('PENDING', translate)).toBeTruthy()\n })\n})\n\ndescribe('a row with no status at all', () => {\n it('shows nothing rather than a word', () => {\n /*\n * Empty, not \"unknown\". \"Unknown\" reads as a status the server assigned; empty reads as\n * the absence this actually is.\n */\n expect(applicationStatusLabel(undefined, translate)).toBe('')\n expect(applicationStatusLabel(null, translate)).toBe('')\n expect(applicationStatusLabel('', translate)).toBe('')\n })\n})\n"]}
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Turning a form into the object a mutation takes.
3
+ *
4
+ * ── The bug this exists for ──────────────────────────────────────────────────
5
+ * The application register screen built its input like this:
6
+ *
7
+ * Array.from(formData.entries()).reduce((app, [key, value]) => { app[key] = value; … })
8
+ *
9
+ * `FormData` carries **every named input**, including the ones nobody typed in, each as an
10
+ * empty string. So an untouched optional field went to the server as `''` rather than as
11
+ * absent — and for `email` that is not the same thing at all:
12
+ *
13
+ * NewApplication.email is GraphQLEmailAddress, nullable
14
+ * EmailAddressResolver.parseValue('') → "Value is not a valid email address"
15
+ *
16
+ * Registering an application without a contact email therefore failed, on a field the schema
17
+ * marks optional. The screen said nothing useful about why.
18
+ *
19
+ * ── What "absent" means here ─────────────────────────────────────────────────
20
+ * An empty text input means the person did not fill it in. It does not mean "set this to the
21
+ * empty string" — HTML has no way to say that, and no field in these forms wants it. So an
22
+ * empty value is dropped and the key never appears, which is what "optional" means on the
23
+ * wire.
24
+ *
25
+ * Whitespace counts as empty for the same reason: a space typed by accident is not an answer,
26
+ * and every scalar that rejects `''` rejects `' '` too.
27
+ */
28
+ /** Values a form can carry. Files pass through untouched — there is no empty file to drop. */
29
+ export type FormValue = string | File;
30
+ /**
31
+ * The filled-in fields, as an object.
32
+ *
33
+ * Keys whose value is empty or whitespace are left out entirely rather than sent as `''`.
34
+ */
35
+ export declare function filledValues(formData: FormData): Record<string, FormValue>;
36
+ /**
37
+ * Which of the named fields are still missing.
38
+ *
39
+ * The screen asks before it sends, so the person is told which box to fill rather than
40
+ * watching a mutation fail on a rule the form never mentioned.
41
+ */
42
+ export declare function missingRequired(formData: FormData, required: string[]): string[];
43
+ /**
44
+ * What the person changed, as a patch.
45
+ *
46
+ * ── Why this is not `filledValues` ──────────────────────────────────────────
47
+ * On a create, an empty box means "I did not fill this in" and the key is left out — that is
48
+ * what optional means on the wire, and it is what `filledValues` does.
49
+ *
50
+ * On an **update** that reading is wrong. `AppliancePatch` and `ApplicationPatch` are applied
51
+ * by spreading over the loaded row (`applianceRepository.save({ ...appliance, ...patch })`),
52
+ * so a key that is absent means "leave it alone" and a key set to `''` means "clear it".
53
+ * Those are two different intentions and a form has to tell them apart:
54
+ *
55
+ * netmask was '10.0.0.0/8', box is now empty → the person cleared it → send ''
56
+ * netmask was never set, box is still empty → nothing happened → send nothing
57
+ *
58
+ * `filledValues` cannot express the first one — it drops every empty box — so an update
59
+ * screen built on it can never clear a field. Sending the whole form instead has the opposite
60
+ * fault: every untouched box goes as `''`, which clears fields nobody touched, and for
61
+ * `ApplicationPatch.email` (a GraphQLEmailAddress) `''` is not "no email" but an invalid one,
62
+ * so the whole update is refused over a box the person never visited.
63
+ *
64
+ * Comparing against the record that was loaded is the only thing that tells the two apart.
65
+ *
66
+ * ── The other thing this buys ───────────────────────────────────────────────
67
+ * The patch carries only what moved. A screen that sends every field back writes values it
68
+ * merely displayed, so a row edited from a stale copy silently overwrites whatever someone
69
+ * else changed in between.
70
+ */
71
+ export declare function changedValues(formData: FormData, original: Record<string, any> | null | undefined): Record<string, FormValue>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Turning a form into the object a mutation takes.
3
+ *
4
+ * ── The bug this exists for ──────────────────────────────────────────────────
5
+ * The application register screen built its input like this:
6
+ *
7
+ * Array.from(formData.entries()).reduce((app, [key, value]) => { app[key] = value; … })
8
+ *
9
+ * `FormData` carries **every named input**, including the ones nobody typed in, each as an
10
+ * empty string. So an untouched optional field went to the server as `''` rather than as
11
+ * absent — and for `email` that is not the same thing at all:
12
+ *
13
+ * NewApplication.email is GraphQLEmailAddress, nullable
14
+ * EmailAddressResolver.parseValue('') → "Value is not a valid email address"
15
+ *
16
+ * Registering an application without a contact email therefore failed, on a field the schema
17
+ * marks optional. The screen said nothing useful about why.
18
+ *
19
+ * ── What "absent" means here ─────────────────────────────────────────────────
20
+ * An empty text input means the person did not fill it in. It does not mean "set this to the
21
+ * empty string" — HTML has no way to say that, and no field in these forms wants it. So an
22
+ * empty value is dropped and the key never appears, which is what "optional" means on the
23
+ * wire.
24
+ *
25
+ * Whitespace counts as empty for the same reason: a space typed by accident is not an answer,
26
+ * and every scalar that rejects `''` rejects `' '` too.
27
+ */
28
+ /**
29
+ * The filled-in fields, as an object.
30
+ *
31
+ * Keys whose value is empty or whitespace are left out entirely rather than sent as `''`.
32
+ */
33
+ export function filledValues(formData) {
34
+ const values = {};
35
+ for (const [key, value] of Array.from(formData.entries())) {
36
+ if (typeof value === 'string') {
37
+ const trimmed = value.trim();
38
+ if (trimmed) {
39
+ values[key] = trimmed;
40
+ }
41
+ continue;
42
+ }
43
+ values[key] = value;
44
+ }
45
+ return values;
46
+ }
47
+ /**
48
+ * Which of the named fields are still missing.
49
+ *
50
+ * The screen asks before it sends, so the person is told which box to fill rather than
51
+ * watching a mutation fail on a rule the form never mentioned.
52
+ */
53
+ export function missingRequired(formData, required) {
54
+ const values = filledValues(formData);
55
+ return required.filter(name => !values[name]);
56
+ }
57
+ /**
58
+ * What the person changed, as a patch.
59
+ *
60
+ * ── Why this is not `filledValues` ──────────────────────────────────────────
61
+ * On a create, an empty box means "I did not fill this in" and the key is left out — that is
62
+ * what optional means on the wire, and it is what `filledValues` does.
63
+ *
64
+ * On an **update** that reading is wrong. `AppliancePatch` and `ApplicationPatch` are applied
65
+ * by spreading over the loaded row (`applianceRepository.save({ ...appliance, ...patch })`),
66
+ * so a key that is absent means "leave it alone" and a key set to `''` means "clear it".
67
+ * Those are two different intentions and a form has to tell them apart:
68
+ *
69
+ * netmask was '10.0.0.0/8', box is now empty → the person cleared it → send ''
70
+ * netmask was never set, box is still empty → nothing happened → send nothing
71
+ *
72
+ * `filledValues` cannot express the first one — it drops every empty box — so an update
73
+ * screen built on it can never clear a field. Sending the whole form instead has the opposite
74
+ * fault: every untouched box goes as `''`, which clears fields nobody touched, and for
75
+ * `ApplicationPatch.email` (a GraphQLEmailAddress) `''` is not "no email" but an invalid one,
76
+ * so the whole update is refused over a box the person never visited.
77
+ *
78
+ * Comparing against the record that was loaded is the only thing that tells the two apart.
79
+ *
80
+ * ── The other thing this buys ───────────────────────────────────────────────
81
+ * The patch carries only what moved. A screen that sends every field back writes values it
82
+ * merely displayed, so a row edited from a stale copy silently overwrites whatever someone
83
+ * else changed in between.
84
+ */
85
+ export function changedValues(formData, original) {
86
+ const record = original || {};
87
+ const patch = {};
88
+ for (const [key, value] of Array.from(formData.entries())) {
89
+ if (typeof value !== 'string') {
90
+ /* A file is never "unchanged" — there is nothing on the record to compare it against. */
91
+ patch[key] = value;
92
+ continue;
93
+ }
94
+ const next = value.trim();
95
+ const before = record[key] === null || record[key] === undefined ? '' : String(record[key]).trim();
96
+ if (next !== before) {
97
+ patch[key] = next;
98
+ }
99
+ }
100
+ return patch;
101
+ }
102
+ //# sourceMappingURL=form-values.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-values.js","sourceRoot":"","sources":["../../client/utils/form-values.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAkB;IAC7C,MAAM,MAAM,GAA8B,EAAE,CAAA;IAE5C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;YAE5B,IAAI,OAAO,EAAE,CAAC;gBACZ,MAAM,CAAC,GAAG,CAAC,GAAG,OAAO,CAAA;YACvB,CAAC;YAED,SAAQ;QACV,CAAC;QAED,MAAM,CAAC,GAAG,CAAC,GAAG,KAAa,CAAA;IAC7B,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,QAAkB,EAAE,QAAkB;IACpE,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAA;IAErC,OAAO,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAkB,EAClB,QAAgD;IAEhD,MAAM,MAAM,GAAG,QAAQ,IAAI,EAAE,CAAA;IAC7B,MAAM,KAAK,GAA8B,EAAE,CAAA;IAE3C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,yFAAyF;YACzF,KAAK,CAAC,GAAG,CAAC,GAAG,KAAa,CAAA;YAC1B,SAAQ;QACV,CAAC;QAED,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;QACzB,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,IAAI,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAElG,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAA;QACnB,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC","sourcesContent":["/**\n * Turning a form into the object a mutation takes.\n *\n * ── The bug this exists for ──────────────────────────────────────────────────\n * The application register screen built its input like this:\n *\n * Array.from(formData.entries()).reduce((app, [key, value]) => { app[key] = value; … })\n *\n * `FormData` carries **every named input**, including the ones nobody typed in, each as an\n * empty string. So an untouched optional field went to the server as `''` rather than as\n * absent — and for `email` that is not the same thing at all:\n *\n * NewApplication.email is GraphQLEmailAddress, nullable\n * EmailAddressResolver.parseValue('') → \"Value is not a valid email address\"\n *\n * Registering an application without a contact email therefore failed, on a field the schema\n * marks optional. The screen said nothing useful about why.\n *\n * ── What \"absent\" means here ─────────────────────────────────────────────────\n * An empty text input means the person did not fill it in. It does not mean \"set this to the\n * empty string\" — HTML has no way to say that, and no field in these forms wants it. So an\n * empty value is dropped and the key never appears, which is what \"optional\" means on the\n * wire.\n *\n * Whitespace counts as empty for the same reason: a space typed by accident is not an answer,\n * and every scalar that rejects `''` rejects `' '` too.\n */\n\n/** Values a form can carry. Files pass through untouched — there is no empty file to drop. */\nexport type FormValue = string | File\n\n/**\n * The filled-in fields, as an object.\n *\n * Keys whose value is empty or whitespace are left out entirely rather than sent as `''`.\n */\nexport function filledValues(formData: FormData): Record<string, FormValue> {\n const values: Record<string, FormValue> = {}\n\n for (const [key, value] of Array.from(formData.entries())) {\n if (typeof value === 'string') {\n const trimmed = value.trim()\n\n if (trimmed) {\n values[key] = trimmed\n }\n\n continue\n }\n\n values[key] = value as File\n }\n\n return values\n}\n\n/**\n * Which of the named fields are still missing.\n *\n * The screen asks before it sends, so the person is told which box to fill rather than\n * watching a mutation fail on a rule the form never mentioned.\n */\nexport function missingRequired(formData: FormData, required: string[]): string[] {\n const values = filledValues(formData)\n\n return required.filter(name => !values[name])\n}\n\n/**\n * What the person changed, as a patch.\n *\n * ── Why this is not `filledValues` ──────────────────────────────────────────\n * On a create, an empty box means \"I did not fill this in\" and the key is left out — that is\n * what optional means on the wire, and it is what `filledValues` does.\n *\n * On an **update** that reading is wrong. `AppliancePatch` and `ApplicationPatch` are applied\n * by spreading over the loaded row (`applianceRepository.save({ ...appliance, ...patch })`),\n * so a key that is absent means \"leave it alone\" and a key set to `''` means \"clear it\".\n * Those are two different intentions and a form has to tell them apart:\n *\n * netmask was '10.0.0.0/8', box is now empty → the person cleared it → send ''\n * netmask was never set, box is still empty → nothing happened → send nothing\n *\n * `filledValues` cannot express the first one — it drops every empty box — so an update\n * screen built on it can never clear a field. Sending the whole form instead has the opposite\n * fault: every untouched box goes as `''`, which clears fields nobody touched, and for\n * `ApplicationPatch.email` (a GraphQLEmailAddress) `''` is not \"no email\" but an invalid one,\n * so the whole update is refused over a box the person never visited.\n *\n * Comparing against the record that was loaded is the only thing that tells the two apart.\n *\n * ── The other thing this buys ───────────────────────────────────────────────\n * The patch carries only what moved. A screen that sends every field back writes values it\n * merely displayed, so a row edited from a stale copy silently overwrites whatever someone\n * else changed in between.\n */\nexport function changedValues(\n formData: FormData,\n original: Record<string, any> | null | undefined\n): Record<string, FormValue> {\n const record = original || {}\n const patch: Record<string, FormValue> = {}\n\n for (const [key, value] of Array.from(formData.entries())) {\n if (typeof value !== 'string') {\n /* A file is never \"unchanged\" — there is nothing on the record to compare it against. */\n patch[key] = value as File\n continue\n }\n\n const next = value.trim()\n const before = record[key] === null || record[key] === undefined ? '' : String(record[key]).trim()\n\n if (next !== before) {\n patch[key] = next\n }\n }\n\n return patch\n}\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ import { changedValues, filledValues, missingRequired } from './form-values';
2
+ /**
3
+ * The application register screen sent every input it had, including the untouched ones, each
4
+ * as an empty string. `NewApplication.email` is a GraphQLEmailAddress and rejects `''`, so
5
+ * leaving the optional contact email blank made registration fail.
6
+ */
7
+ function form(entries) {
8
+ const data = new FormData();
9
+ for (const [key, value] of Object.entries(entries)) {
10
+ data.append(key, value);
11
+ }
12
+ return data;
13
+ }
14
+ describe('the fields a person actually filled in', () => {
15
+ it('keeps what was typed', () => {
16
+ expect(filledValues(form({ name: 'Billing', url: 'https://example.com' }))).toEqual({
17
+ name: 'Billing',
18
+ url: 'https://example.com'
19
+ });
20
+ });
21
+ it('leaves an untouched field out instead of sending an empty string', () => {
22
+ /*
23
+ * ⚠ The case this file exists for. `email: ''` is not "no email" to a scalar that
24
+ * validates format — it is an invalid email, and the mutation fails on a field the
25
+ * schema marks optional.
26
+ */
27
+ const values = filledValues(form({ name: 'Billing', email: '', url: '' }));
28
+ expect(values).toEqual({ name: 'Billing' });
29
+ expect('email' in values).toBe(false);
30
+ });
31
+ it('treats whitespace as untouched', () => {
32
+ /* A space typed by accident is not an answer, and the scalars reject it too. */
33
+ expect(filledValues(form({ name: 'Billing', email: ' ' }))).toEqual({ name: 'Billing' });
34
+ });
35
+ it('trims what it keeps', () => {
36
+ expect(filledValues(form({ name: ' Billing ' }))).toEqual({ name: 'Billing' });
37
+ });
38
+ it('keeps a value that only looks empty', () => {
39
+ /* '0' is falsy as a string in some hands. It is an answer. */
40
+ expect(filledValues(form({ name: '0' }))).toEqual({ name: '0' });
41
+ });
42
+ });
43
+ describe('what the form still needs', () => {
44
+ it('names the required field nobody filled in', () => {
45
+ expect(missingRequired(form({ name: '', description: 'x' }), ['name'])).toEqual(['name']);
46
+ });
47
+ it('counts whitespace as not filled in', () => {
48
+ expect(missingRequired(form({ name: ' ' }), ['name'])).toEqual(['name']);
49
+ });
50
+ it('says nothing when the required fields are there', () => {
51
+ expect(missingRequired(form({ name: 'Billing', email: '' }), ['name'])).toEqual([]);
52
+ });
53
+ it('does not require what the schema left optional', () => {
54
+ /*
55
+ * The list comes from the schema — NewApplication marks only `name` non-null. Adding a
56
+ * field here that the server accepts as absent would invent a rule the product does not
57
+ * have.
58
+ */
59
+ expect(missingRequired(form({ name: 'Billing' }), ['name'])).toEqual([]);
60
+ });
61
+ });
62
+ describe('what the person changed, as a patch', () => {
63
+ /*
64
+ * The patch is spread over the loaded row on the server, so an absent key means "leave it
65
+ * alone" and a key set to '' means "clear it". A form can only tell those apart by looking
66
+ * at what it loaded.
67
+ */
68
+ it('sends only what moved', () => {
69
+ const patch = changedValues(form({ name: 'Gate B', brand: 'Acme', model: 'X1' }), {
70
+ name: 'Gate A',
71
+ brand: 'Acme',
72
+ model: 'X1'
73
+ });
74
+ expect(patch).toEqual({ name: 'Gate B' });
75
+ });
76
+ it('sends an empty string for a field the person cleared', () => {
77
+ /*
78
+ * ⚠ The case `filledValues` cannot express. Dropping this key would mean the netmask can
79
+ * never be removed once it is set — the screen would look like it saved and the value
80
+ * would come back on the next load.
81
+ */
82
+ const patch = changedValues(form({ netmask: '' }), { netmask: '10.0.0.0/8' });
83
+ expect(patch).toEqual({ netmask: '' });
84
+ expect('netmask' in patch).toBe(true);
85
+ });
86
+ it('leaves out a field that was empty and stayed empty', () => {
87
+ /*
88
+ * ⚠ The case sending the whole form gets wrong. `ApplicationPatch.email` is a
89
+ * GraphQLEmailAddress: '' is not "no email", it is an invalid one, and the server refuses
90
+ * the whole update over a box nobody visited.
91
+ */
92
+ const patch = changedValues(form({ name: 'Billing', email: '' }), { name: 'Billing', email: null });
93
+ expect(patch).toEqual({});
94
+ expect('email' in patch).toBe(false);
95
+ });
96
+ it('treats an absent field on the record as empty', () => {
97
+ expect(changedValues(form({ description: '' }), {})).toEqual({});
98
+ expect(changedValues(form({ description: 'hello' }), {})).toEqual({ description: 'hello' });
99
+ });
100
+ it('does not count retyping the same value with stray spaces as a change', () => {
101
+ expect(changedValues(form({ name: ' Billing ' }), { name: 'Billing' })).toEqual({});
102
+ });
103
+ it('compares a non-string field value as text', () => {
104
+ /* A record can carry a number or an enum; the box always holds text. */
105
+ expect(changedValues(form({ port: '8080' }), { port: 8080 })).toEqual({});
106
+ expect(changedValues(form({ port: '9090' }), { port: 8080 })).toEqual({ port: '9090' });
107
+ });
108
+ it('sends nothing at all when the person pressed save without touching anything', () => {
109
+ /*
110
+ * The whole point: an untouched form must not write. A screen that sends every field back
111
+ * overwrites whatever someone else changed while this copy was on screen.
112
+ */
113
+ const record = { name: 'Gate A', description: 'front door', brand: 'Acme', netmask: null };
114
+ expect(changedValues(form({ name: 'Gate A', description: 'front door', brand: 'Acme', netmask: '' }), record)).toEqual({});
115
+ });
116
+ it('treats a missing record as everything being new', () => {
117
+ expect(changedValues(form({ name: 'Gate A', netmask: '' }), null)).toEqual({ name: 'Gate A' });
118
+ });
119
+ });
120
+ //# sourceMappingURL=form-values.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-values.test.js","sourceRoot":"","sources":["../../client/utils/form-values.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAE5E;;;;GAIG;AAEH,SAAS,IAAI,CAAC,OAA+B;IAC3C,MAAM,IAAI,GAAG,IAAI,QAAQ,EAAE,CAAA;IAE3B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;IACzB,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC;AAED,QAAQ,CAAC,wCAAwC,EAAE,GAAG,EAAE;IACtD,EAAE,CAAC,sBAAsB,EAAE,GAAG,EAAE;QAC9B,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,qBAAqB,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;YAClF,IAAI,EAAE,SAAS;YACf,GAAG,EAAE,qBAAqB;SAC3B,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC1E;;;;WAIG;QACH,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,CAAC,CAAA;QAE1E,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;QAC3C,MAAM,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACvC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gCAAgC,EAAE,GAAG,EAAE;QACxC,gFAAgF;QAChF,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;IAC5F,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qBAAqB,EAAE,GAAG,EAAE;QAC7B,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC7C,8DAA8D;QAC9D,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAA;IAClE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,2BAA2B,EAAE,GAAG,EAAE;IACzC,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;IAC3F,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oCAAoC,EAAE,GAAG,EAAE;QAC5C,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;IAC3E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IACrF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD;;;;WAIG;QACH,MAAM,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IAC1E,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA;AAEF,QAAQ,CAAC,qCAAqC,EAAE,GAAG,EAAE;IACnD;;;;OAIG;IAEH,EAAE,CAAC,uBAAuB,EAAE,GAAG,EAAE;QAC/B,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE;YAChF,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,MAAM;YACb,KAAK,EAAE,IAAI;SACZ,CAAC,CAAA;QAEF,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAA;IAC3C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC9D;;;;WAIG;QACH,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,OAAO,EAAE,YAAY,EAAE,CAAC,CAAA;QAE7E,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAA;QACtC,MAAM,CAAC,SAAS,IAAI,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACvC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D;;;;WAIG;QACH,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QAEnG,MAAM,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QACzB,MAAM,CAAC,OAAO,IAAI,KAAK,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;IACtC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QAChE,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,CAAC,CAAA;IAC7F,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sEAAsE,EAAE,GAAG,EAAE;QAC9E,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IACvF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,wEAAwE;QACxE,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QACzE,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAA;IACzF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,6EAA6E,EAAE,GAAG,EAAE;QACrF;;;WAGG;QACH,MAAM,MAAM,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;QAE1F,MAAM,CACJ,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,EAAE,MAAM,CAAC,CACvG,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IACf,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iDAAiD,EAAE,GAAG,EAAE;QACzD,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAA;IAChG,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA","sourcesContent":["import { changedValues, filledValues, missingRequired } from './form-values'\n\n/**\n * The application register screen sent every input it had, including the untouched ones, each\n * as an empty string. `NewApplication.email` is a GraphQLEmailAddress and rejects `''`, so\n * leaving the optional contact email blank made registration fail.\n */\n\nfunction form(entries: Record<string, string>): FormData {\n const data = new FormData()\n\n for (const [key, value] of Object.entries(entries)) {\n data.append(key, value)\n }\n\n return data\n}\n\ndescribe('the fields a person actually filled in', () => {\n it('keeps what was typed', () => {\n expect(filledValues(form({ name: 'Billing', url: 'https://example.com' }))).toEqual({\n name: 'Billing',\n url: 'https://example.com'\n })\n })\n\n it('leaves an untouched field out instead of sending an empty string', () => {\n /*\n * ⚠ The case this file exists for. `email: ''` is not \"no email\" to a scalar that\n * validates format — it is an invalid email, and the mutation fails on a field the\n * schema marks optional.\n */\n const values = filledValues(form({ name: 'Billing', email: '', url: '' }))\n\n expect(values).toEqual({ name: 'Billing' })\n expect('email' in values).toBe(false)\n })\n\n it('treats whitespace as untouched', () => {\n /* A space typed by accident is not an answer, and the scalars reject it too. */\n expect(filledValues(form({ name: 'Billing', email: ' ' }))).toEqual({ name: 'Billing' })\n })\n\n it('trims what it keeps', () => {\n expect(filledValues(form({ name: ' Billing ' }))).toEqual({ name: 'Billing' })\n })\n\n it('keeps a value that only looks empty', () => {\n /* '0' is falsy as a string in some hands. It is an answer. */\n expect(filledValues(form({ name: '0' }))).toEqual({ name: '0' })\n })\n})\n\ndescribe('what the form still needs', () => {\n it('names the required field nobody filled in', () => {\n expect(missingRequired(form({ name: '', description: 'x' }), ['name'])).toEqual(['name'])\n })\n\n it('counts whitespace as not filled in', () => {\n expect(missingRequired(form({ name: ' ' }), ['name'])).toEqual(['name'])\n })\n\n it('says nothing when the required fields are there', () => {\n expect(missingRequired(form({ name: 'Billing', email: '' }), ['name'])).toEqual([])\n })\n\n it('does not require what the schema left optional', () => {\n /*\n * The list comes from the schema — NewApplication marks only `name` non-null. Adding a\n * field here that the server accepts as absent would invent a rule the product does not\n * have.\n */\n expect(missingRequired(form({ name: 'Billing' }), ['name'])).toEqual([])\n })\n})\n\ndescribe('what the person changed, as a patch', () => {\n /*\n * The patch is spread over the loaded row on the server, so an absent key means \"leave it\n * alone\" and a key set to '' means \"clear it\". A form can only tell those apart by looking\n * at what it loaded.\n */\n\n it('sends only what moved', () => {\n const patch = changedValues(form({ name: 'Gate B', brand: 'Acme', model: 'X1' }), {\n name: 'Gate A',\n brand: 'Acme',\n model: 'X1'\n })\n\n expect(patch).toEqual({ name: 'Gate B' })\n })\n\n it('sends an empty string for a field the person cleared', () => {\n /*\n * ⚠ The case `filledValues` cannot express. Dropping this key would mean the netmask can\n * never be removed once it is set — the screen would look like it saved and the value\n * would come back on the next load.\n */\n const patch = changedValues(form({ netmask: '' }), { netmask: '10.0.0.0/8' })\n\n expect(patch).toEqual({ netmask: '' })\n expect('netmask' in patch).toBe(true)\n })\n\n it('leaves out a field that was empty and stayed empty', () => {\n /*\n * ⚠ The case sending the whole form gets wrong. `ApplicationPatch.email` is a\n * GraphQLEmailAddress: '' is not \"no email\", it is an invalid one, and the server refuses\n * the whole update over a box nobody visited.\n */\n const patch = changedValues(form({ name: 'Billing', email: '' }), { name: 'Billing', email: null })\n\n expect(patch).toEqual({})\n expect('email' in patch).toBe(false)\n })\n\n it('treats an absent field on the record as empty', () => {\n expect(changedValues(form({ description: '' }), {})).toEqual({})\n expect(changedValues(form({ description: 'hello' }), {})).toEqual({ description: 'hello' })\n })\n\n it('does not count retyping the same value with stray spaces as a change', () => {\n expect(changedValues(form({ name: ' Billing ' }), { name: 'Billing' })).toEqual({})\n })\n\n it('compares a non-string field value as text', () => {\n /* A record can carry a number or an enum; the box always holds text. */\n expect(changedValues(form({ port: '8080' }), { port: 8080 })).toEqual({})\n expect(changedValues(form({ port: '9090' }), { port: 8080 })).toEqual({ port: '9090' })\n })\n\n it('sends nothing at all when the person pressed save without touching anything', () => {\n /*\n * The whole point: an untouched form must not write. A screen that sends every field back\n * overwrites whatever someone else changed while this copy was on screen.\n */\n const record = { name: 'Gate A', description: 'front door', brand: 'Acme', netmask: null }\n\n expect(\n changedValues(form({ name: 'Gate A', description: 'front door', brand: 'Acme', netmask: '' }), record)\n ).toEqual({})\n })\n\n it('treats a missing record as everything being new', () => {\n expect(changedValues(form({ name: 'Gate A', netmask: '' }), null)).toEqual({ name: 'Gate A' })\n })\n})\n"]}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Turning a list query's response into "what the screen may claim".
3
+ *
4
+ * ── Why this is not inline in each page ──────────────────────────────────────
5
+ * Three auth screens had the same two lines each, and all three were wrong the same way:
6
+ *
7
+ * this.items = (await this.fetch()).items
8
+ * …
9
+ * if (!response.errors) { return response.data.applications }
10
+ *
11
+ * The shared client runs queries with `errorPolicy: 'all'`, so a GraphQL error comes back
12
+ * **in the response** instead of throwing. The helper then returned nothing, `.items` threw
13
+ * inside an async lifecycle method with nobody to catch it, and the table stayed empty. A
14
+ * failure and an empty list looked identical to the person reading the screen.
15
+ *
16
+ * The rule is small enough to get wrong in three places and small enough to test once.
17
+ */
18
+ /** What a list query returns when it answers. */
19
+ export type ListPage<T = any> = {
20
+ items: T[];
21
+ total?: number;
22
+ };
23
+ /**
24
+ * What a screen is entitled to say afterwards.
25
+ *
26
+ * `failed` is not "empty". A screen that could not ask has to say so; saying nothing reads
27
+ * as "there are none", which is a claim it has no basis for.
28
+ */
29
+ export type ListOutcome<T = any> = {
30
+ items: T[];
31
+ total: number;
32
+ failed: boolean;
33
+ };
34
+ /**
35
+ * Read a list page out of an Apollo response, or nothing if the list did not arrive.
36
+ *
37
+ * Ways not to arrive:
38
+ * · an error at the query itself (errorPolicy 'all' puts errors here rather than throwing)
39
+ * · `data` missing entirely
40
+ * · `data` present but the field we asked for is not, or is not a page
41
+ *
42
+ * An error *inside* the page is not one of them — see `stoppedTheList`.
43
+ */
44
+ export declare function pageFromResponse<T = any>(response: any, field: string): ListPage<T> | undefined;
45
+ /**
46
+ * The same question for a query that returns a bare array rather than a page.
47
+ *
48
+ * `domainOwners` is one: it answers with a list and no total. The screen was reading it as
49
+ * `response.data?.domainOwners || []`, which turns a refused query into "this domain has no
50
+ * owners" — a sentence with real consequences, since the reader may then go and add one.
51
+ *
52
+ * Returns nothing on failure for the same reason as `pageFromResponse`, so `listOutcome`
53
+ * can tell the two apart.
54
+ */
55
+ export declare function arrayFromResponse<T = any>(response: any, field: string): ListPage<T> | undefined;
56
+ /**
57
+ * What the screen shows, given a page or the absence of one.
58
+ *
59
+ * An absent page yields an empty list **and** `failed`, so the caller cannot accidentally
60
+ * render "none" — the flag is right there next to the rows.
61
+ */
62
+ export declare function listOutcome<T = any>(page: ListPage<T> | undefined | null): ListOutcome<T>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Turning a list query's response into "what the screen may claim".
3
+ *
4
+ * ── Why this is not inline in each page ──────────────────────────────────────
5
+ * Three auth screens had the same two lines each, and all three were wrong the same way:
6
+ *
7
+ * this.items = (await this.fetch()).items
8
+ * …
9
+ * if (!response.errors) { return response.data.applications }
10
+ *
11
+ * The shared client runs queries with `errorPolicy: 'all'`, so a GraphQL error comes back
12
+ * **in the response** instead of throwing. The helper then returned nothing, `.items` threw
13
+ * inside an async lifecycle method with nobody to catch it, and the table stayed empty. A
14
+ * failure and an empty list looked identical to the person reading the screen.
15
+ *
16
+ * The rule is small enough to get wrong in three places and small enough to test once.
17
+ */
18
+ /**
19
+ * Did an error stop the list, or only withhold a field inside it?
20
+ *
21
+ * GraphQL says where an error happened. A gate on a whole query rejects at the root, so the
22
+ * path is the query field itself or nothing at all. A gate on one **column** — and this house
23
+ * has them, `Appliance.accessToken` and `AuthProvider.clientSecret` both carry their own
24
+ * `@privilege` — rejects deeper in, at `[field, 'items', 3, 'clientSecret']`.
25
+ *
26
+ * The difference decides what the screen may show. Treating both as failure blacks out a list
27
+ * that arrived intact apart from one column a viewer may not read; treating both as success
28
+ * renders a refused query as an empty list. Neither is acceptable, so the path is read.
29
+ *
30
+ * An error with no path at all is counted as fatal: unknown scope leans to the safe side.
31
+ */
32
+ function stoppedTheList(response, field) {
33
+ const errors = response?.errors;
34
+ if (!errors || !errors.length) {
35
+ return false;
36
+ }
37
+ return errors.some((error) => {
38
+ const path = error?.path;
39
+ if (!Array.isArray(path) || path.length === 0) {
40
+ return true;
41
+ }
42
+ /* `[field]` alone is the query itself being refused; deeper is one value inside it. */
43
+ return path[0] === field && path.length === 1;
44
+ });
45
+ }
46
+ /**
47
+ * Read a list page out of an Apollo response, or nothing if the list did not arrive.
48
+ *
49
+ * Ways not to arrive:
50
+ * · an error at the query itself (errorPolicy 'all' puts errors here rather than throwing)
51
+ * · `data` missing entirely
52
+ * · `data` present but the field we asked for is not, or is not a page
53
+ *
54
+ * An error *inside* the page is not one of them — see `stoppedTheList`.
55
+ */
56
+ export function pageFromResponse(response, field) {
57
+ if (!response || stoppedTheList(response, field)) {
58
+ return undefined;
59
+ }
60
+ const page = response.data?.[field];
61
+ return page && Array.isArray(page.items) ? page : undefined;
62
+ }
63
+ /**
64
+ * The same question for a query that returns a bare array rather than a page.
65
+ *
66
+ * `domainOwners` is one: it answers with a list and no total. The screen was reading it as
67
+ * `response.data?.domainOwners || []`, which turns a refused query into "this domain has no
68
+ * owners" — a sentence with real consequences, since the reader may then go and add one.
69
+ *
70
+ * Returns nothing on failure for the same reason as `pageFromResponse`, so `listOutcome`
71
+ * can tell the two apart.
72
+ */
73
+ export function arrayFromResponse(response, field) {
74
+ if (!response || stoppedTheList(response, field)) {
75
+ return undefined;
76
+ }
77
+ const rows = response.data?.[field];
78
+ return Array.isArray(rows) ? { items: rows, total: rows.length } : undefined;
79
+ }
80
+ /**
81
+ * What the screen shows, given a page or the absence of one.
82
+ *
83
+ * An absent page yields an empty list **and** `failed`, so the caller cannot accidentally
84
+ * render "none" — the flag is right there next to the rows.
85
+ */
86
+ export function listOutcome(page) {
87
+ if (!page) {
88
+ return { items: [], total: 0, failed: true };
89
+ }
90
+ return { items: page.items, total: page.total ?? page.items.length, failed: false };
91
+ }
92
+ //# sourceMappingURL=list-result.js.map