@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.
|
|
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.
|
|
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.
|
|
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",
|