Skip to content

Commit 6363f39

Browse files
authored
feat(joint-core): predicate form for Cell.toJSON ignoreEmptyAttributes (#3297)
1 parent 2a1fec5 commit 6363f39

4 files changed

Lines changed: 95 additions & 23 deletions

File tree

packages/joint-core/src/dia/Cell.mjs

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
result,
44
merge,
55
forIn,
6-
isObject,
76
isEqual,
87
isString,
98
cloneDeep,
@@ -29,6 +28,7 @@ import {
2928
} from '../util/util.mjs';
3029
import { Model } from '../mvc/Model.mjs';
3130
import { cloneCells } from '../util/cloneCells.mjs';
31+
import { removeEmptyAttributes, removeAtTopLevelOnly } from '../util/removeEmptyAttributes.mjs';
3232
import { attributes } from './attributes/index.mjs';
3333
import * as g from '../g/index.mjs';
3434
import { config } from '../config/index.mjs';
@@ -43,22 +43,6 @@ const attributesMerger = function(a, b) {
4343
}
4444
};
4545

46-
function removeEmptyAttributes(obj) {
47-
48-
// Remove toplevel empty attributes
49-
for (const key in obj) {
50-
51-
const objValue = obj[key];
52-
const isRealObject = isObject(objValue) && !Array.isArray(objValue);
53-
54-
if (!isRealObject) continue;
55-
56-
if (isEmpty(objValue)) {
57-
delete obj[key];
58-
}
59-
}
60-
}
61-
6246
export const Cell = Model.extend({
6347

6448
cidPrefix: 'c',
@@ -88,13 +72,27 @@ export const Cell = Model.extend({
8872
const { ignoreDefaults, ignoreEmptyAttributes = false } = opt || {};
8973
const defaults = result(this.constructor.prototype, 'defaults');
9074

75+
// `ignoreEmptyAttributes`:
76+
// - `false` (default) — keep all empties.
77+
// - `true` — drops top-level empties only, for backwards compatibility.
78+
// TODO(next major): `true` should drop all empties (recursive). Pass
79+
// `removeAtTopLevelOnly` explicitly to preserve the legacy behavior.
80+
// - `(key, path) => boolean` — recursive predicate; truthy drops the key
81+
// bottom-up (so a parent emptied by child removal is itself a candidate).
82+
let removeEmptyPredicate = null;
83+
if (typeof ignoreEmptyAttributes === 'function') {
84+
removeEmptyPredicate = ignoreEmptyAttributes;
85+
} else if (ignoreEmptyAttributes) {
86+
removeEmptyPredicate = removeAtTopLevelOnly;
87+
}
88+
9189
if (ignoreDefaults === false) {
9290
// Return all attributes without omitting the defaults
9391
const finalAttributes = cloneDeep(this.attributes);
9492

95-
if (!ignoreEmptyAttributes) return finalAttributes;
96-
97-
removeEmptyAttributes(finalAttributes);
93+
if (removeEmptyPredicate) {
94+
removeEmptyAttributes(finalAttributes, removeEmptyPredicate);
95+
}
9896

9997
return finalAttributes;
10098
}
@@ -117,8 +115,8 @@ export const Cell = Model.extend({
117115
// Omit `id` and `type` attribute from the defaults since it should be always present
118116
const finalAttributes = objectDifference(attributes, omit(defaultAttributes, 'id', 'type'), { maxDepth: 4 });
119117

120-
if (ignoreEmptyAttributes) {
121-
removeEmptyAttributes(finalAttributes);
118+
if (removeEmptyPredicate) {
119+
removeEmptyAttributes(finalAttributes, removeEmptyPredicate);
122120
}
123121

124122
return finalAttributes;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { isObject, isEmpty } from './utilHelpers.mjs';
2+
3+
// Recursive predicate-driven removal. The predicate `(key, path) => boolean` is
4+
// invoked bottom-up for every empty `{}` encountered; returning truthy drops the
5+
// key. A parent that becomes empty after its children are removed is itself a
6+
// candidate.
7+
export function removeEmptyAttributes(obj, predicate, path) {
8+
9+
for (const key in obj) {
10+
11+
const objValue = obj[key];
12+
const isRealObject = isObject(objValue) && !Array.isArray(objValue);
13+
14+
if (!isRealObject) continue;
15+
16+
const childPath = path ? path.concat(key) : [key];
17+
removeEmptyAttributes(objValue, predicate, childPath);
18+
19+
if (isEmpty(objValue) && predicate(key, childPath)) {
20+
delete obj[key];
21+
}
22+
}
23+
}
24+
25+
// Default predicate for `ignoreEmptyAttributes: true` — drops top-level
26+
// empties only, preserving the original (pre-recursive) behavior.
27+
export const removeAtTopLevelOnly = (_key, path) => path.length === 1;

packages/joint-core/test/jointjs/cell.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,51 @@ QUnit.module('cell', function(hooks) {
797797
size: {}
798798
});
799799
});
800+
801+
QUnit.test('`opt.ignoreEmptyAttributes` accepts a predicate `(key, path) => boolean`', function(assert) {
802+
const El = joint.dia.Element.extend({
803+
defaults: {
804+
type: 'test.Element',
805+
attrs: {
806+
text: { fill: 'red' },
807+
body: { stroke: 'black' }
808+
},
809+
size: { width: 100, height: 50 },
810+
position: { x: 0, y: 0 },
811+
foo: {},
812+
bar: { baz: {}},
813+
extraDefault: { nested: 'value' }
814+
}
815+
});
816+
817+
const el = new El({
818+
id: 'el1',
819+
attrs: {
820+
text: {
821+
textWrap: {}, // new key not in defaults — empty `{}` survives the diff
822+
fill: 'blue' // overrides default
823+
}
824+
}
825+
});
826+
827+
// Drop every empty `{}` EXCEPT those at depth 3 inside `attrs`
828+
// (e.g. `attrs.text.textWrap` should survive).
829+
const result = el.toJSON({
830+
ignoreDefaults: true,
831+
ignoreEmptyAttributes: (key, path) => !(path[0] === 'attrs' && path.length === 3)
832+
});
833+
834+
assert.deepEqual(result, {
835+
id: 'el1',
836+
type: 'test.Element',
837+
attrs: {
838+
text: {
839+
textWrap: {},
840+
fill: 'blue'
841+
}
842+
}
843+
});
844+
});
800845
});
801846

802847
QUnit.module('relative vs absolute points', function() {

packages/joint-core/types/dia.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,9 +612,11 @@ export namespace Cell {
612612
mergeArrays?: boolean;
613613
}
614614

615+
type IgnoreEmptyAttributesCallback = (key: string, path: string[]) => boolean;
616+
615617
interface ExportOptions {
616618
ignoreDefaults?: boolean | string[];
617-
ignoreEmptyAttributes?: boolean;
619+
ignoreEmptyAttributes?: boolean | IgnoreEmptyAttributesCallback;
618620
}
619621

620622
type UnsetCallback<V> = (

0 commit comments

Comments
 (0)