@uxf/core 11.126.0 → 11.127.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,78 @@ cookie.set("cookie-name", "value", /* ttl in seconds (optional) */, /* options (
88
88
  cookie.delete("cookie-name", /* options (optional) */);
89
89
  ```
90
90
 
91
+ ## Money
92
+
93
+ `Money` is the monetary value type used across the packages — the amount is a **string**, so it can carry
94
+ more precision than a JS number holds.
95
+
96
+ ```tsx
97
+ import { Currency, Money } from "@uxf/core/money";
98
+ import { currencies } from "@uxf/core/money/currencies";
99
+ import { getCurrencySymbol } from "@uxf/core/money/get-currency-symbol";
100
+
101
+ const price: Money = { amount: "1000", currency: "CZK" };
102
+
103
+ getCurrencySymbol("CZK"); /* returns "Kč" */
104
+ ```
105
+
106
+ ### normalizeMoneyAmount
107
+
108
+ Rewrites an amount into its canonical decimal form. Returns `null` when the input does not describe a
109
+ decimal number.
110
+
111
+ ```tsx
112
+ import { normalizeMoneyAmount } from "@uxf/core/money/normalize-money";
113
+
114
+ normalizeMoneyAmount("1000.00"); /* returns "1000" */
115
+ normalizeMoneyAmount("15.50"); /* returns "15.5" */
116
+ normalizeMoneyAmount("015"); /* returns "15" */
117
+ normalizeMoneyAmount(".5"); /* returns "0.5" */
118
+ normalizeMoneyAmount("+15"); /* returns "15" */
119
+ normalizeMoneyAmount("-0.00"); /* returns "0" */
120
+ normalizeMoneyAmount("abc"); /* returns null */
121
+ normalizeMoneyAmount("1e5"); /* returns null - exponent notation is not a decimal amount */
122
+ ```
123
+
124
+ The rewriting is textual, never a `Number()` round-trip, so an amount a double cannot hold exactly
125
+ survives intact: `"9007199254740993"` and `"100000000000000000000000"` come back unchanged rather than as
126
+ a different number or as `"1e+23"`. This is also why exponent notation is rejected instead of expanded.
127
+
128
+ Related but **not** a substitute: [`trimTrailingZeros`](#trimtrailingzeros) only strips trailing zeros
129
+ from a fractional part and leaves leading zeros, signs and `".5"` alone.
130
+
131
+ ### normalizeMoney
132
+
133
+ Normalizes a whole `Money` value: the amount goes through `normalizeMoneyAmount` and any extra properties
134
+ (a GraphQL `__typename`, for instance) are dropped. Returns `null` for a nullish value or an amount that
135
+ is not a number.
136
+
137
+ ```tsx
138
+ import { normalizeMoney } from "@uxf/core/money/normalize-money";
139
+
140
+ normalizeMoney({ amount: "1000.00", currency: "CZK" }); /* returns { amount: "1000", currency: "CZK" } */
141
+ normalizeMoney({ __typename: "Money", amount: "1000", currency: "CZK" }); /* drops __typename */
142
+ normalizeMoney({ amount: "", currency: "CZK" }); /* returns null */
143
+ normalizeMoney(null); /* returns null */
144
+ ```
145
+
146
+ **Why you need this in a form.** react-hook-form decides dirtiness with its own `deepEqual`, which
147
+ compares `Object.keys().length` first and then every leaf. A `Money` field therefore reads as dirty as
148
+ soon as its _shape_ drifts from the default value — an extra `__typename`, or an amount retyped as
149
+ `"1000.00"` where the default says `"1000"` — even though the visible value is identical, and the
150
+ unsaved-changes bar never goes away.
151
+
152
+ A component can only make what it **emits** canonical; the default values are built by the consuming
153
+ app's mappers, which no component can reach. So run both sides through the same normalizer:
154
+
155
+ ```tsx
156
+ const formApi = useForm<FormData>({
157
+ defaultValues: { price: normalizeMoney(data.price) },
158
+ });
159
+ ```
160
+
161
+ [`@uxf/form/money-input`](../form/money-input/README.md) already normalizes what it emits, on blur.
162
+
91
163
  ## Utils
92
164
 
93
165
  ### adjustTextareaHeight
@@ -597,6 +669,33 @@ nonEmptyStringOrNull("test"); /* returns "test" */
597
669
  nonEmptyStringOrNull(" "); /* returns " " - non-empty string */
598
670
  ```
599
671
 
672
+ ## normalizeSelectableIds
673
+
674
+ Sorts a multi-choice value into a canonical order. Returns a new array (the input is never mutated), or
675
+ `null` for a nullish value.
676
+
677
+ ```tsx
678
+ import { normalizeSelectableIds } from "@uxf/core/utils/normalize-selectable-ids";
679
+
680
+ normalizeSelectableIds([3, 1, 2]); /* returns [1, 2, 3] */
681
+ normalizeSelectableIds(["b", "a"]); /* returns ["a", "b"] */
682
+ normalizeSelectableIds(null); /* returns null */
683
+ ```
684
+
685
+ Multi-choice inputs treat their value as a set but emit it as an array whose order follows the order the
686
+ user clicked, and react-hook-form compares arrays index by index — so unticking an option and ticking it
687
+ again leaves a semantically unchanged form reading as dirty. Run the form's `defaultValues` through this
688
+ too, so both sides agree:
689
+
690
+ ```tsx
691
+ const formApi = useForm<FormData>({
692
+ defaultValues: { tags: normalizeSelectableIds(data.tagIds) },
693
+ });
694
+ ```
695
+
696
+ [`@uxf/ui/checkbox-list`](../ui/checkbox-list/README.md) and
697
+ [`@uxf/ui/multi-select`](../ui/multi-select/README.md) already normalize what they emit.
698
+
600
699
  ## nullishToEmptyString
601
700
 
602
701
  Converts `null` or `undefined` values to an empty string, leaving all other strings unchanged. Useful for safely displaying nullable string values in UI components.
@@ -0,0 +1,20 @@
1
+ import { Money } from "@uxf/core/money";
2
+ /**
3
+ * Rewrites an amount into its canonical decimal form: `"1000.00"` and `"015"` both become `"1000"` and
4
+ * `"15"`. Returns `null` when the input does not describe a decimal number.
5
+ *
6
+ * The rewriting is textual on purpose. `Money.amount` is a string so that an amount can carry more
7
+ * precision than a double holds, and a `Number()` round-trip would throw that away — `"9007199254740993"`
8
+ * comes back as a different number and `"100000000000000000000000"` comes back as `"1e+23"`.
9
+ */
10
+ export declare function normalizeMoneyAmount(amount: string): string | null;
11
+ /**
12
+ * Normalizes a money value into a canonical shape: the amount is rewritten by `normalizeMoneyAmount` and
13
+ * any extra properties (a GraphQL `__typename`, for instance) are dropped.
14
+ *
15
+ * `Money` is an object, and react-hook-form compares objects by key count and then leaf by leaf, so a
16
+ * default value carrying an extra key — or an amount typed as `"1000.00"` where the default says
17
+ * `"1000"` — reads as dirty forever. Run both the form's default values and the emitted value through
18
+ * this function and the comparison lines up.
19
+ */
20
+ export declare function normalizeMoney(value: Money | null | undefined): Money | null;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeMoneyAmount = normalizeMoneyAmount;
4
+ exports.normalizeMoney = normalizeMoney;
5
+ const is_empty_1 = require("@uxf/core/utils/is-empty");
6
+ const is_nil_1 = require("@uxf/core/utils/is-nil");
7
+ // An optional sign, then digits and/or a fractional part. Deliberately no exponent notation: an amount
8
+ // is a plain decimal string everywhere in this stack, and accepting `1e+23` would mean expanding it,
9
+ // which is the round-trip through a double that this function exists to avoid.
10
+ const DECIMAL_AMOUNT = /^([+-]?)(\d*)(?:\.(\d*))?$/;
11
+ /**
12
+ * Rewrites an amount into its canonical decimal form: `"1000.00"` and `"015"` both become `"1000"` and
13
+ * `"15"`. Returns `null` when the input does not describe a decimal number.
14
+ *
15
+ * The rewriting is textual on purpose. `Money.amount` is a string so that an amount can carry more
16
+ * precision than a double holds, and a `Number()` round-trip would throw that away — `"9007199254740993"`
17
+ * comes back as a different number and `"100000000000000000000000"` comes back as `"1e+23"`.
18
+ */
19
+ function normalizeMoneyAmount(amount) {
20
+ const match = DECIMAL_AMOUNT.exec(amount.trim());
21
+ if ((0, is_nil_1.isNil)(match)) {
22
+ return null;
23
+ }
24
+ const [, sign, integerDigits, fractionDigits = ""] = match;
25
+ // The pattern matches an empty string and a bare "." as well, neither of which is a number.
26
+ if ((0, is_empty_1.isEmpty)(integerDigits) && (0, is_empty_1.isEmpty)(fractionDigits)) {
27
+ return null;
28
+ }
29
+ const integer = integerDigits.replace(/^0+(?=\d)/, "") || "0";
30
+ const fraction = fractionDigits.replace(/0+$/, "");
31
+ const digits = (0, is_empty_1.isEmpty)(fraction) ? integer : `${integer}.${fraction}`;
32
+ return sign === "-" && digits !== "0" ? `-${digits}` : digits;
33
+ }
34
+ /**
35
+ * Normalizes a money value into a canonical shape: the amount is rewritten by `normalizeMoneyAmount` and
36
+ * any extra properties (a GraphQL `__typename`, for instance) are dropped.
37
+ *
38
+ * `Money` is an object, and react-hook-form compares objects by key count and then leaf by leaf, so a
39
+ * default value carrying an extra key — or an amount typed as `"1000.00"` where the default says
40
+ * `"1000"` — reads as dirty forever. Run both the form's default values and the emitted value through
41
+ * this function and the comparison lines up.
42
+ */
43
+ function normalizeMoney(value) {
44
+ if ((0, is_nil_1.isNil)(value)) {
45
+ return null;
46
+ }
47
+ const amount = normalizeMoneyAmount(value.amount);
48
+ return (0, is_nil_1.isNil)(amount) ? null : { amount, currency: value.currency };
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const normalize_money_1 = require("./normalize-money");
4
+ test("normalizes an amount into its canonical decimal form", () => {
5
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1000")).toBe("1000");
6
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1000.00")).toBe("1000");
7
+ expect((0, normalize_money_1.normalizeMoneyAmount)("15.50")).toBe("15.5");
8
+ expect((0, normalize_money_1.normalizeMoneyAmount)("015")).toBe("15");
9
+ expect((0, normalize_money_1.normalizeMoneyAmount)(".5")).toBe("0.5");
10
+ expect((0, normalize_money_1.normalizeMoneyAmount)("-0")).toBe("0");
11
+ expect((0, normalize_money_1.normalizeMoneyAmount)(" 12 ")).toBe("12");
12
+ });
13
+ test("returns null for an amount that is not a decimal number", () => {
14
+ expect((0, normalize_money_1.normalizeMoneyAmount)("")).toBeNull();
15
+ expect((0, normalize_money_1.normalizeMoneyAmount)(" ")).toBeNull();
16
+ expect((0, normalize_money_1.normalizeMoneyAmount)("abc")).toBeNull();
17
+ expect((0, normalize_money_1.normalizeMoneyAmount)(".")).toBeNull();
18
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1 000")).toBeNull();
19
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1e5")).toBeNull();
20
+ expect((0, normalize_money_1.normalizeMoneyAmount)("Infinity")).toBeNull();
21
+ });
22
+ test("keeps an amount a double cannot hold exactly", () => {
23
+ // The whole reason `Money.amount` is a string: a `Number()` round-trip returns "9007199254740992"
24
+ // for the first one and "1e+23" for the second.
25
+ expect((0, normalize_money_1.normalizeMoneyAmount)("9007199254740993")).toBe("9007199254740993");
26
+ expect((0, normalize_money_1.normalizeMoneyAmount)("100000000000000000000000")).toBe("100000000000000000000000");
27
+ expect((0, normalize_money_1.normalizeMoneyAmount)("0.0000001")).toBe("0.0000001");
28
+ expect((0, normalize_money_1.normalizeMoneyAmount)("1.005000")).toBe("1.005");
29
+ });
30
+ test("drops a redundant sign", () => {
31
+ expect((0, normalize_money_1.normalizeMoneyAmount)("+15")).toBe("15");
32
+ expect((0, normalize_money_1.normalizeMoneyAmount)("-15.50")).toBe("-15.5");
33
+ expect((0, normalize_money_1.normalizeMoneyAmount)("-0.00")).toBe("0");
34
+ });
35
+ test("normalizing an amount is idempotent", () => {
36
+ var _a, _b;
37
+ expect((0, normalize_money_1.normalizeMoneyAmount)((_a = (0, normalize_money_1.normalizeMoneyAmount)("1000.00")) !== null && _a !== void 0 ? _a : "")).toBe("1000");
38
+ expect((0, normalize_money_1.normalizeMoneyAmount)((_b = (0, normalize_money_1.normalizeMoneyAmount)("-000.5000")) !== null && _b !== void 0 ? _b : "")).toBe("-0.5");
39
+ });
40
+ test("keeps the currency and drops extra properties", () => {
41
+ var _a;
42
+ const fromApi = { __typename: "Money", amount: "1000.00", currency: "CZK" };
43
+ expect((0, normalize_money_1.normalizeMoney)(fromApi)).toEqual({ amount: "1000", currency: "CZK" });
44
+ expect(Object.keys((_a = (0, normalize_money_1.normalizeMoney)(fromApi)) !== null && _a !== void 0 ? _a : {})).toEqual(["amount", "currency"]);
45
+ });
46
+ test("returns null for an empty value", () => {
47
+ expect((0, normalize_money_1.normalizeMoney)(null)).toBeNull();
48
+ expect((0, normalize_money_1.normalizeMoney)(undefined)).toBeNull();
49
+ expect((0, normalize_money_1.normalizeMoney)({ amount: "", currency: "CZK" })).toBeNull();
50
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxf/core",
3
- "version": "11.126.0",
3
+ "version": "11.127.0",
4
4
  "description": "UXF Core",
5
5
  "author": "Petr Vejvoda <vejvoda@uxf.cz>",
6
6
  "homepage": "https://gitlab.com/uxf-npm/core#readme",
@@ -0,0 +1,11 @@
1
+ import { SelectableId } from "@uxf/core/types";
2
+ /**
3
+ * Sorts a multi-choice value into a canonical order.
4
+ *
5
+ * Multi-choice inputs treat their value as a set, but they emit it as an array whose order depends on
6
+ * the order the user clicked. react-hook-form compares arrays index by index, so a value that is
7
+ * semantically unchanged still reads as dirty once an item has been unticked and ticked again. Run both
8
+ * the form's default values and the emitted value through this function and the comparison lines up.
9
+ */
10
+ export declare function normalizeSelectableIds<T extends SelectableId>(value: T[]): T[];
11
+ export declare function normalizeSelectableIds<T extends SelectableId>(value: T[] | null | undefined): T[] | null;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeSelectableIds = normalizeSelectableIds;
4
+ const is_nil_1 = require("@uxf/core/utils/is-nil");
5
+ function compareIds(a, b) {
6
+ if (typeof a === "number" && typeof b === "number") {
7
+ return a - b;
8
+ }
9
+ return String(a) < String(b) ? -1 : 1;
10
+ }
11
+ function normalizeSelectableIds(value) {
12
+ if ((0, is_nil_1.isNil)(value)) {
13
+ return null;
14
+ }
15
+ return [...value].sort(compareIds);
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const normalize_selectable_ids_1 = require("./normalize-selectable-ids");
4
+ test("sorts numeric ids numerically", () => {
5
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)([10, 9, 100])).toEqual([9, 10, 100]);
6
+ });
7
+ test("sorts string ids", () => {
8
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)(["b", "c", "a"])).toEqual(["a", "b", "c"]);
9
+ });
10
+ test("does not depend on the order the items were picked", () => {
11
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)([1, 3, 2])).toEqual((0, normalize_selectable_ids_1.normalizeSelectableIds)([3, 2, 1]));
12
+ });
13
+ test("is idempotent", () => {
14
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)((0, normalize_selectable_ids_1.normalizeSelectableIds)([3, 1, 2]))).toEqual([1, 2, 3]);
15
+ });
16
+ test("does not mutate the input", () => {
17
+ const value = [3, 1, 2];
18
+ (0, normalize_selectable_ids_1.normalizeSelectableIds)(value);
19
+ expect(value).toEqual([3, 1, 2]);
20
+ });
21
+ test("keeps an empty value", () => {
22
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)(null)).toBeNull();
23
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)(undefined)).toBeNull();
24
+ expect((0, normalize_selectable_ids_1.normalizeSelectableIds)([])).toEqual([]);
25
+ });