By Michael Lange
@DingoEatingFuzz
Frameworks call your code
You call library code
Frameworks
are boxes
Libraries
are bags
There's an inversion
of ownership.
Not so great for nested views decoupled from the router
A custom answer to ED not dirtying "parent" models
Image from http://www.52kitchenadventures.com/2011/09/12/melted-crayon-art-tutorial/
// Converts the composite segment into a copy of the first child segment
collapseSegment: function() {
const collapsing = this.get('model');
let onlyChild = collapsing.get('children.firstObject');
// TODO dependency: This is a bug in ED, who knows if it's been fixed by now
if (!collapsing.get('children.length')) {
onlyChild = null;
}
// To maintain the root segment's ID, it must take on the operator and
// children/fields of the only child
if (this.get('isRoot')) {
if (!onlyChild) {
// Revert to an undefined type if there are no children
collapsing.set('operator', null);
} else if (!onlyChild.get('isNamed')) {
const type = onlyChild.get('type');
// Replace the collapsed segment with its only child by converting to a rule, or
// copying its children (or just clearing them if the segment has no type yet)
if (type === 'rule') {
collapsing.setProperties(onlyChild.getProperties([
'type',
'operator',
'fieldName',
'fieldKey',
'isInverted'
]));
collapsing.get('values').setObjects(onlyChild.get('values').toArray());
collapsing.get('children').clear();
} else if (type === 'composite') {
collapsing.get('children').setObjects(onlyChild.get('children').toArray());
} else {
collapsing.get('children').clear();
collapsing.set('type', 'rule');
}
}
} else {
const siblings = this.get('parentSegment.children');
// Replace the collapsed segment with its only child
siblings.replace(siblings.indexOf(collapsing), 1, [ onlyChild ]);
}
},{{!-- This is the top-level container that is used to expand the root segment,
which only allows the segment tree to be expanded to the max depth --}}
{{#segments/node-container
selectedField=selectedField
model=model
availableSegments=availableSegments
showAddButtons=(lt model.maxDepth maxAllowedDepth)
alwaysExpand=true
class="top-level"}}
{{#if model.isComposite}}
{{segments/node-composite
model=model
availableSegments=availableSegments
totalSize=totalSize
class="item segment"}}
{{else if model.isRule}}
{{segments/node-rule
model=model
totalSize=totalSize
class="item segment"}}
{{/if}}
{{/segments/node-container}}didLoad: function() {
// Consider the following scenario:
//
// 1. A segment is created
// 2. The app is reloaded
// 3. A batch segment size request includes the id of the new segment
// 4. The segment's size is returned as `null` since it hasn't been cached
//
// What needs to happen is for the segment's size to be reloaded using the
// non-caching API. However, at this point only the segment size serializer
// knows that the given segment doesn't have a cached size. So the size
// could be reloaded 1) in the serializer or 2) here in the model. Note that
// no matter how it is reloaded, the segment size adapter must use the
// correct API based on the fact that the record has already loaded.
//
// I present to you the lesser of two evils.
if (!isPresent(this.get('value'))) {
this.reload();
}
}.observes('value')Domain specific problems won't be solved by add-ons alone.
>70,000 LOC (not counting tests), and who is to say this problem wouldn't follow us?
This wasn't about time to first paint and it couldn't be fixed by service workers.
So tempting, but it defies the three virtues of a great programmer. Notably #3, hubris.
(i.e., refactoring time)
Abstract Syntax Tree
export default BaseModel.extend(Eventable, {
name : DS.attr('string', { defaultValue: '' }),
description : DS.attr('string', { defaultValue: '' }),
isPublic : DS.attr('boolean', { defaultValue: false }),
slug : DS.attr('string', { defaultValue: '' }),
modifiedAt : DS.attr('date'),
kind : DS.attr('string', { defaultValue: 'segment' }),
tags : DS.attr(),
isServerSideInvalid : DS.attr('boolean', { defaultValue: false }),
schema : DS.belongsTo('schema', { async: false }),
cachedSize : DS.belongsTo('segment_size', { async: true }),
// A SegmentDefinition object
definition : DS.attr(),
// The segments that rely on this segment
dependents : DS.hasMany('segment', { async: false, inverse: 'dependencies' }),
// The segments that this segment relies on
dependencies : DS.hasMany('segment', { async: false, inverse: 'dependents' }),
works : DS.hasMany('work', { async: true }),
trend : DS.belongsTo('segment-trend', { async: true }),
// Since it is possible to receive `null` for the cached size from the API,
// a fallback is needed.
size: function() {
if (this.get('isServerSideInvalid')) { return null; }
const cachedSize = this.get('cachedSize');
if (cachedSize.get('value') != null || cachedSize.get('isLoading')) {
return cachedSize;
}
return sizeForDefinition(this.store, this.get('definition'));
}.property('cachedSize.value', 'isServerSideInvalid', 'definition'),
// ...
}normalize(type, hash, prop) {
// The 'table' field is the schema FK
hash.schema_id = hash.table;
// The segment id is the segment_trend FK
if (hash.id) {
hash.trend_id = hash.id;
hash.cached_size_id = hash.id;
}
// Keep track of all other segments this segment utilizes
hash.dependency_ids = dependenciesForAST(hash.ast);
// Convert the ast into a modelish
hash.definition = SegmentDefinition.create({
ast: hash.ast,
store: this.store
});
return this._super(type, hash, prop);
},export default function sizeForDefinition(store, definition, schema = 'user') {
if (schema instanceof Schema) { schema = schema.get('name'); }
return store.findRecord('segment_definition_size', JSON.stringify({
table: schema,
ast: definition instanceof SegmentDefinition ? definition.get('ast') : definition
}));
}normalizeSingleResponse(store, type, payload, recordId) {
const sizeArray = payload.data;
const size = Array.isArray(sizeArray) ? sizeArray[0] : null;
const record = JSON.parse(recordId);
const data = {
id: recordId,
schema_id: record.table,
ast: record.ast,
value: size
};
return this._super(store, type, data, recordId);
},utils/segments/size-for-definition.js
serializers/segment-definition-size.js
findRecord(store, type, id, snapshot) {
const params = { segments: `[${id}]` };
return this.ajax(this.buildURL(type.modelName, id, snapshot), 'GET', { data: params });
},adapters/segment-definition-size.js
Segment
SegmentDefinition
SegmentDefinitionSize
ED model
custom
custom
JSONSerializer
RESTAdapter
replace imperative property changes with object diffing
invertSegment() {
const store = this.get('store');
const siblings = this.get('parentSegment.children');
let inverter, original;
if (this.get('model.isInverter')) {
inverter = this.get('model');
original = inverter.get('children.firstObject');
// Replace the inverter segment with its only child
siblings.replace(siblings.indexOf(inverter), 1, [ original ]);
} else {
original = this.get('model');
inverter = store.createRecord('segment', {
operator: 'not',
schema: original.get('schema')
});
// Add the original segment as the first child of the new inverter segment
inverter.get('children').addObject(original);
// Replace the original segment with the new inverter segment
siblings.replace(siblings.indexOf(original), 1, [ inverter ]);
}
},invertSegment() {
const model = this.get('model');
if (model.get('isInverted')) {
model.transform(model.get('children.firstObject').clone());
} else {
model.transform(S('not', model.clone()));
}
}Before
After
🔥
Imperative code describes what a program does
Declarative code describes what a program is
model.transform(S('or',
S('and',
S('>', 'field_name', 50),
S('include', 'segment_name')
),
S('>=', 'field_two', 99)
));Declarative Segment Definition transforms make model code a detail rather than a concern
addChild(child) {
const targetModel = this.get('targetModel');
const operator = targetModel.get('operator');
if (this.get('depth') >= this.get('maxDepth')) {
targetModel.transform(S(operator,
...targetModel.get('children').mapBy('ast'),
child
));
} else {
targetModel.transform(S(operator,
...targetModel.get('children').mapBy('ast'),
S(operator === 'and' ? 'or' : 'and', child)
));
}
},Fitting a custom model implementation into Ember Data
Replacing common imperative get/set code with declarative transformations
Making Handlebars more powerful
In the strategy pattern, behaviors are defined as separate interfaces and specific classes that implement these interfaces. This allows better decoupling between the behavior and the class that uses the behavior.
{{#each childrenForBuilder as |child|}}
{{#if child.builder}}
{{component (concat "segments/builders/builder-" (or child.builder "empty"))
definition=child.definition
depth=nextDepth
maxDepth=maxDepth
blacklist=blacklist
schema=schema}}
{{/if}}
<div class="segment-toggle">
<div class="bar"></div>
{{and-or-toggle
value=viewOperator
disabled=(lte targetModel.children.length 1)
action="toggleOperator"
toggleClass="and-or-toggle action-toggle segment-operator"}}
</div>
{{/each}}builder-composite.hbs
each child gets rendered using the appropriate component
{{#if model.hasSiblings}}
<div class="segment-rule-controls">
<a class="segment-remove action-remove" {{action "removeSegment"}}>
{{lio-icon "trash"}}
</a>
</div>
{{/if}}
<div class="segment-definition">
<span class="label label-default label-invert">Existing</span>
<span class="segment-name">{{targetModel.reference.name}}</span>
<div class="segment-controls">
<div class="segment-invert">
<small>
<button class="btn btn-xs {{if isExcluded "btn-secondary"}} action-toggle" {{action "toggleExcluded"}}>
{{if isExcluded "excluded" "included"}}
</button>
</small>
</div>
{{segment-size segment=model}}
</div>
</div>builder-reference.hbs
simple component knows nothing about how it got here
import { typeFromOperator } from '../../models/segment-definition';
/**
* Segment Builder Stategies
*
* A strategy takes the form of [ componentName, predicate ]
*/
const strategies = {
rule: [
[ 'rule', () => true ],
],
composite: [
[ 'rule', ast => ast.op === 'not' && typeFromOperator(ast.args[0].op) === 'rule' ],
[ 'composite', ast => ast.op === 'not' && typeFromOperator(ast.args[0].op) === 'composite' ],
[ 'reference', ast => ast.op === 'not' && typeFromOperator(ast.args[0].op) === 'reference' ],
[ 'composite', () => true ],
],
reference: [
[ 'reference', () => true ],
],
all: [
[ 'all', () => true ],
],
};
export default function componentForSegmentDefinition(definition) {
const strategiesForType = strategies[typeFromOperator(definition.get('operator'))];
return strategiesForType.find(strategy => strategy[1](definition.get('ast')))[0];
}builder-strategies.js
an extensible set of predicates used to determine a builder
API surface area is finite, but the design space is unbounded
225 PR comments
76 commits
116 files changed
3,292 insertions
2,522 deletions
¯\_(ツ)_/¯
New Segment
Builder
Content Segment
Builder
No problems
(in this code)
1: Rich Hickey on Hammock Time: https://www.youtube.com/watch?v=f84n5oFoZBc
twitter: @DingoEatingFuzz
github: DingoEatingFuzz