@limetech/lime-crm-building-blocks 1.137.0 → 1.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [1.138.0](https://github.com/Lundalogik/lime-crm-building-blocks/compare/v1.137.0...v1.138.0) (2026-06-17)
2
+
3
+ ### Features
4
+
5
+
6
+ * **lime-query-builder:** detect single-property compound filter leaves ([27829e2](https://github.com/Lundalogik/lime-crm-building-blocks/commit/27829e2be852231a56131da5bb2e4262e15b439d))
7
+
1
8
  ## [1.137.0](https://github.com/Lundalogik/lime-crm-building-blocks/compare/v1.136.0...v1.137.0) (2026-06-16)
2
9
 
3
10
  ### Features
@@ -0,0 +1,286 @@
1
+ import { Operator, } from "@limetech/lime-web-components";
2
+ // Intentionally diverges from the same-named map in
3
+ // `lime-query-filter-comparison.tsx` (the comparison-editor's operator
4
+ // vocabulary): that map has no `NOT` entry and maps `IN` to `'in-filter-set'`,
5
+ // whereas here a keyed bare `NOT` reads as `not-equals` and a bare `IN` set
6
+ // reads as `equals` (see `describeKeyed`). They are deliberately not the same
7
+ // table — do not reconcile them into one.
8
+ // Prototype-free (`Object.create(null)`): a junk `op` such as `'__proto__'` or
9
+ // `'constructor'` resolves to `undefined` rather than an inherited
10
+ // `Object.prototype` member, so a malformed expression falls through to "stays
11
+ // a group" instead of being mislabeled.
12
+ const OPERATOR_TO_CONDITION =
13
+ // The `satisfies` keeps compile-time key/value checking on the literal (a
14
+ // typo'd operator or a value outside `FilterCondition` is a build error);
15
+ // wrapping it in `Object.assign(Object.create(null), …)` then strips the
16
+ // prototype without losing that checking.
17
+ Object.assign(Object.create(null), {
18
+ [Operator.EQUALS]: 'equals',
19
+ [Operator.NOT_EQUALS]: 'not-equals',
20
+ // CRM serializes "not equals" / "not empty" as the bare NOT
21
+ // operator on a keyed comparison (`{ key, op: '!', exp }`), not as
22
+ // `!=`.
23
+ [Operator.NOT]: 'not-equals',
24
+ [Operator.GREATER]: 'greater-than',
25
+ [Operator.GREATER_OR_EQUAL]: 'greater-than-or-equal',
26
+ [Operator.LESS]: 'less-than',
27
+ [Operator.LESS_OR_EQUAL]: 'less-than-or-equal',
28
+ [Operator.LIKE]: 'contains',
29
+ [Operator.BEGINS]: 'begins-with',
30
+ [Operator.ENDS]: 'ends-with',
31
+ [Operator.IN]: 'in-filter-set',
32
+ });
33
+ // Ordered comparisons (`greater-than`, `less-than`, …) are intentionally
34
+ // absent: their negation has no single-chip condition in the vocabulary, so a
35
+ // `NOT`-wrapped ordered comparison is left as a group rather than mislabeled.
36
+ // Prototype-free for the same reason as `OPERATOR_TO_CONDITION`: an inner
37
+ // condition that happens to collide with an `Object.prototype` key must not
38
+ // resolve to an inherited member.
39
+ const NEGATED_CONDITION =
40
+ // `satisfies` for the same reason as `OPERATOR_TO_CONDITION` above: both the
41
+ // keys and the negated values are checked against `FilterCondition` at
42
+ // build time, while the wrapper keeps the map prototype-free.
43
+ Object.assign(Object.create(null), {
44
+ contains: 'not-contains',
45
+ 'begins-with': 'not-begins-with',
46
+ 'ends-with': 'not-ends-with',
47
+ equals: 'not-equals',
48
+ 'in-filter-set': 'not-in-filter-set',
49
+ between: 'not-between',
50
+ empty: 'not-empty',
51
+ });
52
+ /**
53
+ * The property key a single-property filter applies to, or `undefined` when
54
+ * the expression spans more than one property (i.e. is a genuine group) or
55
+ * cannot be resolved.
56
+ *
57
+ * This is the safety boundary: a mixed-field `AND`/`OR` returns `undefined`, so
58
+ * separate rules are never folded together.
59
+ * @param expression the expression to inspect
60
+ */
61
+ export function getLeafKey(expression) {
62
+ if (!isObject(expression)) {
63
+ return undefined;
64
+ }
65
+ const expr = expression;
66
+ if (typeof expr.key === 'string') {
67
+ return expr.key;
68
+ }
69
+ if (expr.op === Operator.NOT) {
70
+ return getLeafKey(expr.exp);
71
+ }
72
+ if (expr.op === Operator.AND || expr.op === Operator.OR) {
73
+ const children = expr.exp;
74
+ if (!Array.isArray(children) || children.length === 0) {
75
+ return undefined;
76
+ }
77
+ let key;
78
+ for (const child of children) {
79
+ const childKey = getLeafKey(child);
80
+ if (childKey === undefined) {
81
+ return undefined;
82
+ }
83
+ if (key === undefined) {
84
+ key = childKey;
85
+ }
86
+ else if (key !== childKey) {
87
+ return undefined;
88
+ }
89
+ }
90
+ return key;
91
+ }
92
+ return undefined;
93
+ }
94
+ /**
95
+ * Whether the expression is a single-property filter that is structurally a
96
+ * group (`AND`/`OR`/`NOT`, i.e. has no top-level `key`). These are the
97
+ * expressions that look like groups but should be presented as one chip.
98
+ * @param expression the expression to inspect
99
+ */
100
+ export function isCompoundLeaf(expression) {
101
+ if (!isObject(expression)) {
102
+ return false;
103
+ }
104
+ return (typeof expression.key !== 'string' &&
105
+ getLeafKey(expression) !== undefined);
106
+ }
107
+ /**
108
+ * Whether the expression is a single-property filter simple enough to show as
109
+ * one chip: a flat comparison, an `IN` set, a single flat `AND`/`OR` of
110
+ * comparisons on the same key, a `between` bound pair, or a `NOT` wrapping one
111
+ * of those.
112
+ *
113
+ * A single-property expression that is *not* representable (nested mixed
114
+ * `AND`/`OR`, the `OR[NOT[AND], empty]` shape of "not between", a `NOT` whose
115
+ * condition has no negation, …) returns `false` and is left as a group — this
116
+ * is exactly the set {@link describeLeaf} can describe.
117
+ * @param expression the expression to inspect
118
+ */
119
+ export function isRepresentableAsOneChip(expression) {
120
+ return describeLeaf(expression) !== undefined;
121
+ }
122
+ /**
123
+ * Describe a single-property filter as chip props, or `undefined` when the
124
+ * expression is not a representable single-property leaf (the caller should
125
+ * then treat it as a group).
126
+ * @param expression the expression to inspect
127
+ */
128
+ export function describeLeaf(expression) {
129
+ const key = getLeafKey(expression);
130
+ if (key === undefined) {
131
+ return undefined;
132
+ }
133
+ return describe(expression, key);
134
+ }
135
+ function describe(expression, key) {
136
+ const expr = expression;
137
+ if (typeof expr.key === 'string') {
138
+ return describeKeyed(expr, key);
139
+ }
140
+ if (expr.op === Operator.NOT) {
141
+ const inner = describe(expr.exp, key);
142
+ const condition = inner && NEGATED_CONDITION[inner.condition];
143
+ if (!inner || !condition) {
144
+ return undefined;
145
+ }
146
+ // The inner conjunction is preserved verbatim rather than flipped per
147
+ // De Morgan: it is a display convention (how the chip lists the values),
148
+ // not the boolean joiner. `NOT[ OR[ ~A, ~B ] ]` reads as "does not
149
+ // contain A or B", keeping the `or`; the negation lives in the
150
+ // `condition`, not the conjunction.
151
+ return {
152
+ key,
153
+ condition,
154
+ values: inner.values,
155
+ conjunction: inner.conjunction,
156
+ };
157
+ }
158
+ return describeAndOr(expr, key);
159
+ }
160
+ function describeKeyed(expr, key) {
161
+ if (expr.op === Operator.IN) {
162
+ const values = Array.isArray(expr.exp)
163
+ ? expr.exp
164
+ : [expr.exp];
165
+ return {
166
+ key,
167
+ // A bare `IN` set means "is one of …" — equality against a set,
168
+ // which the chip refines to "Is" for option/set and relation
169
+ // properties. Only a saved-filter reference (`type: 'filter'`) is
170
+ // the distinct `in-filter-set` condition.
171
+ condition: expr.type === 'filter' ? 'in-filter-set' : 'equals',
172
+ values,
173
+ conjunction: values.length > 1 ? 'or' : undefined,
174
+ };
175
+ }
176
+ // A one-sided interval (CRM returns the bare bound when only a min or a max
177
+ // is set) reads as its underlying `>=` / `<=` condition, not `between`.
178
+ const value = expr.exp;
179
+ const condition = conditionForKeyed(expr.op, value);
180
+ if (!condition) {
181
+ return undefined;
182
+ }
183
+ return {
184
+ key,
185
+ condition,
186
+ values: isEmptyValue(value) ? [] : [value],
187
+ };
188
+ }
189
+ function describeAndOr(expr, key) {
190
+ const children = expr.exp;
191
+ const conjunction = expr.op === Operator.AND ? 'and' : 'or';
192
+ const ops = new Set(children.map((child) => readOp(child)));
193
+ // The single-op shortcut (`contains-multi`, `not-equals-multi`, …) only
194
+ // holds when every child is a flat keyed comparison whose `exp` is a scalar
195
+ // value. Anything else — `IN` (array `exp`), a keyless `NOT`/`AND`/`OR`
196
+ // wrapping a sub-expression — would have its non-scalar `exp` silently cast
197
+ // to a scalar and be mislabeled, so it falls through to stay a group.
198
+ // Unlike `conditionForKeyed`, a multi-value group does not collapse an
199
+ // empty `exp` to `empty`/`not-empty`: those conditions are value-less and
200
+ // single-valued, so a multi-value group of empties (`AND[ =name '', =name
201
+ // '' ]`) is a degenerate filter with no faithful single-chip rendering.
202
+ // Keeping it as `equals`/etc. with the literal values preserves it as a
203
+ // group rather than fabricating a value-less condition.
204
+ if (ops.size === 1 && children.every(isFlatKeyedComparison)) {
205
+ const condition = OPERATOR_TO_CONDITION[readOp(children[0])];
206
+ if (!condition) {
207
+ return undefined;
208
+ }
209
+ return {
210
+ key,
211
+ condition,
212
+ // Safe scalar casts: `isFlatKeyedComparison` has verified every
213
+ // child is a keyed simple comparison with a scalar `exp`.
214
+ values: children.map((child) => readExp(child)),
215
+ conjunction,
216
+ };
217
+ }
218
+ if (expr.op === Operator.AND && isBetweenChildren(children)) {
219
+ const [lower, upper] = [...children].sort((a, b) => boundRank(readOp(a)) - boundRank(readOp(b)));
220
+ return {
221
+ key,
222
+ condition: 'between',
223
+ values: [
224
+ readExp(lower),
225
+ readExp(upper),
226
+ ],
227
+ conjunction: 'and',
228
+ };
229
+ }
230
+ return undefined;
231
+ }
232
+ function conditionForKeyed(op, value) {
233
+ if (isEmptyValue(value)) {
234
+ if (op === Operator.EQUALS) {
235
+ return 'empty';
236
+ }
237
+ if (op === Operator.NOT || op === Operator.NOT_EQUALS) {
238
+ return 'not-empty';
239
+ }
240
+ }
241
+ return OPERATOR_TO_CONDITION[op];
242
+ }
243
+ function isBetweenChildren(children) {
244
+ if (children.length !== 2) {
245
+ return false;
246
+ }
247
+ const lowers = children.filter((child) => isLowerBound(readOp(child))).length;
248
+ const uppers = children.filter((child) => isUpperBound(readOp(child))).length;
249
+ return lowers === 1 && uppers === 1;
250
+ }
251
+ function isLowerBound(op) {
252
+ return op === Operator.GREATER || op === Operator.GREATER_OR_EQUAL;
253
+ }
254
+ function isUpperBound(op) {
255
+ return op === Operator.LESS || op === Operator.LESS_OR_EQUAL;
256
+ }
257
+ function boundRank(op) {
258
+ return isLowerBound(op) ? 0 : 1;
259
+ }
260
+ function isEmptyValue(value) {
261
+ return value === '' || value === null || value === undefined;
262
+ }
263
+ /**
264
+ * Whether a child of an `AND`/`OR` is a flat keyed simple comparison: it has a
265
+ * string `key` and its `exp` is a single scalar value (not an array, not a
266
+ * nested expression). This excludes `IN` (whose `exp` is an array) and any
267
+ * keyless `NOT`/`AND`/`OR` wrapper, whose non-scalar `exp` must not be cast to
268
+ * a scalar value.
269
+ * @param expression the child expression to inspect
270
+ */
271
+ function isFlatKeyedComparison(expression) {
272
+ const expr = expression;
273
+ return (typeof expr.key === 'string' &&
274
+ OPERATOR_TO_CONDITION[expr.op] !== undefined &&
275
+ !Array.isArray(expr.exp) &&
276
+ !isObject(expr.exp));
277
+ }
278
+ function readOp(expression) {
279
+ return expression.op;
280
+ }
281
+ function readExp(expression) {
282
+ return expression.exp;
283
+ }
284
+ function isObject(value) {
285
+ return typeof value === 'object' && value !== null;
286
+ }
@@ -0,0 +1,81 @@
1
+ import { Expression, ExpressionValue } from '@limetech/lime-web-components';
2
+ import { FilterChipConjunction, FilterCondition } from '../../filter-chip/filter-chip.types';
3
+ /**
4
+ * A "compound leaf" is a filter on a *single* property that serializes as an
5
+ * `AND`/`OR`/`NOT` expression rather than a flat comparison — for example
6
+ * "Name contains A or B" (`OR[ like A, like B ]`) or "Value is between 10 and
7
+ * 100" (`AND[ >= 10, <= 100 ]`). Structurally it is indistinguishable from a
8
+ * multi-property group; the one thing that sets it apart is that every
9
+ * comparison inside targets the same property.
10
+ *
11
+ * This module detects that case and describes it for display — the single
12
+ * source of truth for "is this expression one property's filter, and if so what
13
+ * does it say?". It is purely structural: it inspects an {@link Expression} and
14
+ * never resolves a property or builds one.
15
+ */
16
+ /**
17
+ * The presentational description of a single-property filter, derived purely
18
+ * from its expression. A consumer pairs this with the property's resolved
19
+ * label and type to render a `limebb-filter-chip`.
20
+ *
21
+ * The `condition` is *normalized*: a few distinct serializations map onto one
22
+ * condition (e.g. both `!=` and the bare `!` operator read as `not-equals`), so
23
+ * a consumer that rebuilds an expression picks one serialization per condition.
24
+ */
25
+ export interface LeafDescription {
26
+ /**
27
+ * The property the filter applies to (the key shared by every comparison).
28
+ */
29
+ key: string;
30
+ /**
31
+ * The UI-agnostic condition the filter represents.
32
+ */
33
+ condition: FilterCondition;
34
+ /**
35
+ * The raw value(s) being filtered on, in order. Empty for value-less
36
+ * conditions such as `empty` / `not-empty`. The consumer formats these.
37
+ */
38
+ values: ExpressionValue[];
39
+ /**
40
+ * How multiple values are joined, when there is more than one.
41
+ */
42
+ conjunction?: FilterChipConjunction;
43
+ }
44
+ /**
45
+ * The property key a single-property filter applies to, or `undefined` when
46
+ * the expression spans more than one property (i.e. is a genuine group) or
47
+ * cannot be resolved.
48
+ *
49
+ * This is the safety boundary: a mixed-field `AND`/`OR` returns `undefined`, so
50
+ * separate rules are never folded together.
51
+ * @param expression the expression to inspect
52
+ */
53
+ export declare function getLeafKey(expression: Expression): string | undefined;
54
+ /**
55
+ * Whether the expression is a single-property filter that is structurally a
56
+ * group (`AND`/`OR`/`NOT`, i.e. has no top-level `key`). These are the
57
+ * expressions that look like groups but should be presented as one chip.
58
+ * @param expression the expression to inspect
59
+ */
60
+ export declare function isCompoundLeaf(expression: Expression): boolean;
61
+ /**
62
+ * Whether the expression is a single-property filter simple enough to show as
63
+ * one chip: a flat comparison, an `IN` set, a single flat `AND`/`OR` of
64
+ * comparisons on the same key, a `between` bound pair, or a `NOT` wrapping one
65
+ * of those.
66
+ *
67
+ * A single-property expression that is *not* representable (nested mixed
68
+ * `AND`/`OR`, the `OR[NOT[AND], empty]` shape of "not between", a `NOT` whose
69
+ * condition has no negation, …) returns `false` and is left as a group — this
70
+ * is exactly the set {@link describeLeaf} can describe.
71
+ * @param expression the expression to inspect
72
+ */
73
+ export declare function isRepresentableAsOneChip(expression: Expression): boolean;
74
+ /**
75
+ * Describe a single-property filter as chip props, or `undefined` when the
76
+ * expression is not a representable single-property leaf (the caller should
77
+ * then treat it as a group).
78
+ * @param expression the expression to inspect
79
+ */
80
+ export declare function describeLeaf(expression: Expression): LeafDescription | undefined;
81
+ //# sourceMappingURL=compound-leaf.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limetech/lime-crm-building-blocks",
3
- "version": "1.137.0",
3
+ "version": "1.138.0",
4
4
  "description": "A home for shared components meant for use with Lime CRM",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.js",
@@ -35,10 +35,10 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@limetech/eslint-config": "^4.0.1",
38
- "@limetech/lime-elements": "^39.34.0",
38
+ "@limetech/lime-elements": "^39.34.1",
39
39
  "@limetech/lime-web-components": "^6.26.0",
40
40
  "@lundalogik/lime-icons8": "^2.41.0",
41
- "@lundalogik/limeclient.js": "^1.108.0",
41
+ "@lundalogik/limeclient.js": "^1.109.0",
42
42
  "@stencil/core": "^4.43.4",
43
43
  "@stencil/sass": "^3.1.9",
44
44
  "@types/jest": "^29.5.14",