16 KiB
Phase 4: Variants Integration
Overview
Extend the existing Variants system to support breakpoint-specific values. When a user creates a variant (e.g., "Big Blue Button"), they should be able to define different margin/padding/width values for each breakpoint within that variant.
Estimate: 1-2 days
Dependencies: Phases 1-3
Goals
- Add
breakpointParametersto VariantModel - Extend variant editing UI to show breakpoint selector
- Implement value resolution hierarchy: Variant breakpoint → Variant base → Node base
- Ensure variant updates propagate to all nodes using that variant
Value Resolution Hierarchy
When a node uses a variant, values are resolved in this order:
1. Node instance breakpointParameters[currentBreakpoint][property]
↓ (if undefined)
2. Node instance parameters[property]
↓ (if undefined)
3. Variant breakpointParameters[currentBreakpoint][property]
↓ (if undefined, cascade to larger breakpoints)
4. Variant parameters[property]
↓ (if undefined)
5. Node type default
Example
// Variant "Big Blue Button"
{
name: 'Big Blue Button',
typename: 'net.noodl.visual.controls.button',
parameters: {
paddingLeft: '24px', // base padding
paddingRight: '24px'
},
breakpointParameters: {
tablet: { paddingLeft: '16px', paddingRight: '16px' },
phone: { paddingLeft: '12px', paddingRight: '12px' }
}
}
// Node instance using this variant
{
variantName: 'Big Blue Button',
parameters: {}, // no instance overrides
breakpointParameters: {
phone: { paddingLeft: '8px' } // only override phone left padding
}
}
// Resolution for paddingLeft on phone:
// 1. Check node.breakpointParameters.phone.paddingLeft → '8px' ✓ (use this)
// Resolution for paddingRight on phone:
// 1. Check node.breakpointParameters.phone.paddingRight → undefined
// 2. Check node.parameters.paddingRight → undefined
// 3. Check variant.breakpointParameters.phone.paddingRight → '12px' ✓ (use this)
Implementation Steps
Step 1: Extend VariantModel
File: packages/noodl-editor/src/editor/src/models/VariantModel.ts
export class VariantModel extends Model {
name: string;
typename: string;
parameters: Record<string, any>;
stateParameters: Record<string, Record<string, any>>;
stateTransitions: Record<string, any>;
defaultStateTransitions: any;
// NEW
breakpointParameters: Record<string, Record<string, any>>;
constructor(args) {
super();
this.name = args.name;
this.typename = args.typename;
this.parameters = {};
this.stateParameters = {};
this.stateTransitions = {};
this.breakpointParameters = {}; // NEW
}
// NEW methods
hasBreakpointParameter(name: string, breakpoint: string): boolean {
return this.breakpointParameters?.[breakpoint]?.[name] !== undefined;
}
getBreakpointParameter(name: string, breakpoint: string): any {
return this.breakpointParameters?.[breakpoint]?.[name];
}
setBreakpointParameter(name: string, value: any, breakpoint: string, args?: any) {
if (!this.breakpointParameters) this.breakpointParameters = {};
if (!this.breakpointParameters[breakpoint]) this.breakpointParameters[breakpoint] = {};
const oldValue = this.breakpointParameters[breakpoint][name];
if (value === undefined) {
delete this.breakpointParameters[breakpoint][name];
} else {
this.breakpointParameters[breakpoint][name] = value;
}
this.notifyListeners('variantParametersChanged', {
name,
value,
breakpoint
});
// Undo support
if (args?.undo) {
const undo = typeof args.undo === 'object' ? args.undo : UndoQueue.instance;
undo.push({
label: args.label || 'change variant breakpoint parameter',
do: () => this.setBreakpointParameter(name, value, breakpoint),
undo: () => this.setBreakpointParameter(name, oldValue, breakpoint)
});
}
}
// Extend getParameter to support breakpoint context
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
let value;
// Check breakpoint-specific value
if (args?.breakpoint && args.breakpoint !== 'desktop') {
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
if (value !== undefined) return value;
}
// Check state-specific value (existing logic)
if (args?.state && args.state !== 'neutral') {
if (this.stateParameters?.[args.state]?.[name] !== undefined) {
value = this.stateParameters[args.state][name];
}
if (value !== undefined) return value;
}
// Check base parameters
value = this.parameters[name];
if (value !== undefined) return value;
// Get default from port
const port = this.getPort(name, 'input');
return port?.default;
}
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
// Check current breakpoint
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
return this.breakpointParameters[breakpoint][name];
}
// Cascade to larger breakpoints
const settings = ProjectModel.instance.getBreakpointSettings();
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
const currentIndex = cascadeOrder.indexOf(breakpoint);
for (let i = currentIndex - 1; i >= 0; i--) {
const bp = cascadeOrder[i];
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
return this.breakpointParameters[bp][name];
}
}
return undefined;
}
// Extend updateFromNode to include breakpoint parameters
updateFromNode(node) {
_merge(this.parameters, node.parameters);
// Merge breakpoint parameters
if (node.breakpointParameters) {
if (!this.breakpointParameters) this.breakpointParameters = {};
for (const breakpoint in node.breakpointParameters) {
if (!this.breakpointParameters[breakpoint]) {
this.breakpointParameters[breakpoint] = {};
}
_merge(this.breakpointParameters[breakpoint], node.breakpointParameters[breakpoint]);
}
}
// ... existing state parameter merging
this.notifyListeners('variantParametersChanged');
}
// Extend toJSON
toJSON() {
return {
name: this.name,
typename: this.typename,
parameters: this.parameters,
stateParameters: this.stateParameters,
stateTransitions: this.stateTransitions,
defaultStateTransitions: this.defaultStateTransitions,
breakpointParameters: this.breakpointParameters // NEW
};
}
}
Step 2: Extend Runtime Variant Handling
File: packages/noodl-runtime/src/models/graphmodel.js
// Add method to update variant breakpoint parameters
GraphModel.prototype.updateVariantBreakpointParameter = function(
variantName,
variantTypeName,
parameterName,
parameterValue,
breakpoint
) {
const variant = this.getVariant(variantTypeName, variantName);
if (!variant) {
console.log("updateVariantBreakpointParameter: can't find variant", variantName, variantTypeName);
return;
}
if (!variant.breakpointParameters) {
variant.breakpointParameters = {};
}
if (!variant.breakpointParameters[breakpoint]) {
variant.breakpointParameters[breakpoint] = {};
}
if (parameterValue === undefined) {
delete variant.breakpointParameters[breakpoint][parameterName];
} else {
variant.breakpointParameters[breakpoint][parameterName] = parameterValue;
}
this.emit('variantUpdated', variant);
};
Step 3: Extend ModelProxy for Variant Editing
File: packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts
export class ModelProxy {
// ... existing properties
getParameter(name: string) {
const source = this.editMode === 'variant' ? this.model.variant : this.model;
const port = this.model.getPort(name, 'input');
// Breakpoint handling
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
// Check for breakpoint-specific value in source
const breakpointValue = source.getBreakpointParameter?.(name, this.breakpoint);
if (breakpointValue !== undefined) return breakpointValue;
// Cascade logic...
}
// ... existing visual state and base parameter logic
return source.getParameter(name, {
state: this.visualState,
breakpoint: this.breakpoint
});
}
setParameter(name: string, value: any, args: any = {}) {
const port = this.model.getPort(name, 'input');
const target = this.editMode === 'variant' ? this.model.variant : this.model;
// If setting a breakpoint-specific value
if (port?.allowBreakpoints && this.breakpoint && this.breakpoint !== 'desktop') {
target.setBreakpointParameter(name, value, this.breakpoint, {
...args,
undo: args.undo
});
return;
}
// ... existing parameter setting logic
}
isBreakpointValueInherited(name: string): boolean {
if (!this.breakpoint || this.breakpoint === 'desktop') return false;
const source = this.editMode === 'variant' ? this.model.variant : this.model;
return !source.hasBreakpointParameter?.(name, this.breakpoint);
}
}
Step 4: Update Variant Editor UI
File: packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx
// Add breakpoint selector to variant editing mode
export class VariantsEditor extends React.Component<VariantsEditorProps, State> {
// ... existing implementation
renderEditMode() {
const hasBreakpointPorts = this.hasBreakpointAwarePorts();
return (
<div style={{ width: '100%' }}>
<div className="variants-edit-mode-header">Edit variant</div>
{/* Show breakpoint selector in variant edit mode */}
{hasBreakpointPorts && (
<BreakpointSelector
breakpoints={this.getBreakpoints()}
selectedBreakpoint={this.state.breakpoint || 'desktop'}
overrideCounts={this.calculateVariantOverrideCounts()}
onBreakpointChange={this.onBreakpointChanged.bind(this)}
/>
)}
<div className="variants-section">
<label>{this.state.variant.name}</label>
<button
className="variants-button teal"
style={{ marginLeft: 'auto', width: '78px' }}
onClick={this.onDoneEditingVariant.bind(this)}
>
Close
</button>
</div>
</div>
);
}
onBreakpointChanged(breakpoint: string) {
this.setState({ breakpoint });
this.props.onBreakpointChanged?.(breakpoint);
}
calculateVariantOverrideCounts(): Record<string, number> {
const counts: Record<string, number> = {};
const variant = this.state.variant;
const settings = ProjectModel.instance.getBreakpointSettings();
for (const bp of settings.breakpoints) {
if (bp.id === settings.defaultBreakpoint) continue;
const overrides = variant.breakpointParameters?.[bp.id];
counts[bp.id] = overrides ? Object.keys(overrides).length : 0;
}
return counts;
}
hasBreakpointAwarePorts(): boolean {
const type = NodeLibrary.instance.getNodeTypeWithName(this.state.variant?.typename);
if (!type?.ports) return false;
return type.ports.some(p => p.allowBreakpoints);
}
}
Step 5: Update NodeGraphNode Value Resolution
File: packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts
getParameter(name: string, args?: { state?: string; breakpoint?: string }): any {
let value;
// 1. Check instance breakpoint parameters
if (args?.breakpoint && args.breakpoint !== 'desktop') {
value = this.getBreakpointParameterWithCascade(name, args.breakpoint);
if (value !== undefined) return value;
}
// 2. Check instance base parameters
value = this.parameters[name];
if (value !== undefined) return value;
// 3. Check variant (if has one)
if (this.variant) {
value = this.variant.getParameter(name, args);
if (value !== undefined) return value;
}
// 4. Get port default
const port = this.getPort(name, 'input');
return port?.default;
}
getBreakpointParameterWithCascade(name: string, breakpoint: string): any {
// Check current breakpoint
if (this.breakpointParameters?.[breakpoint]?.[name] !== undefined) {
return this.breakpointParameters[breakpoint][name];
}
// Cascade to larger breakpoints (instance level)
const settings = ProjectModel.instance.getBreakpointSettings();
const cascadeOrder = settings.breakpoints.map(bp => bp.id);
const currentIndex = cascadeOrder.indexOf(breakpoint);
for (let i = currentIndex - 1; i >= 0; i--) {
const bp = cascadeOrder[i];
if (this.breakpointParameters?.[bp]?.[name] !== undefined) {
return this.breakpointParameters[bp][name];
}
}
// Check variant breakpoint parameters
if (this.variant) {
return this.variant.getBreakpointParameterWithCascade(name, breakpoint);
}
return undefined;
}
Step 6: Sync Variant Changes to Runtime
File: packages/noodl-editor/src/editor/src/models/projectmodel.ts
// When variant breakpoint parameters change, sync to runtime
onVariantParametersChanged(variant: VariantModel, changeInfo: any) {
// ... existing sync logic
// If breakpoint parameter changed, notify runtime
if (changeInfo.breakpoint) {
this.graphModel.updateVariantBreakpointParameter(
variant.name,
variant.typename,
changeInfo.name,
changeInfo.value,
changeInfo.breakpoint
);
}
}
Files to Modify
| File | Changes |
|---|---|
packages/noodl-editor/src/editor/src/models/VariantModel.ts |
Add breakpointParameters field and methods |
packages/noodl-editor/src/editor/src/models/nodegraphmodel/NodeGraphNode.ts |
Update value resolution to check variant breakpoints |
packages/noodl-editor/src/editor/src/views/panels/propertyeditor/models/modelProxy.ts |
Handle variant breakpoint context |
packages/noodl-editor/src/editor/src/views/panels/propertyeditor/components/VariantStates/variantseditor.tsx |
Add breakpoint selector to variant edit mode |
packages/noodl-runtime/src/models/graphmodel.js |
Add variant breakpoint parameter update method |
Testing Checklist
- Can create variant with breakpoint-specific values
- Variant breakpoint values are saved to project JSON
- Variant breakpoint values are loaded from project JSON
- Node instance inherits variant breakpoint values correctly
- Node instance can override specific variant breakpoint values
- Cascade works: variant tablet inherits from variant desktop
- Editing variant in "Edit variant" mode shows breakpoint selector
- Changes to variant breakpoint values propagate to all instances
- Undo/redo works for variant breakpoint changes
- Runtime applies variant breakpoint values correctly
Success Criteria
- ✅ Variants can have different values per breakpoint
- ✅ Node instances inherit variant breakpoint values
- ✅ Node instances can selectively override variant values
- ✅ UI allows editing variant breakpoint values
- ✅ Runtime correctly resolves variant + breakpoint hierarchy
Gotchas & Notes
-
Resolution Order: The hierarchy is complex. Make sure tests cover all combinations:
- Instance breakpoint override > Instance base > Variant breakpoint > Variant base > Type default
-
Variant Edit Mode: When editing a variant, the breakpoint selector edits the VARIANT's breakpoint values, not the instance's.
-
Variant Update Propagation: When a variant's breakpoint values change, ALL nodes using that variant need to update. This could be performance-sensitive.
-
State + Breakpoint + Variant: The full combination is: variant/instance × state × breakpoint. For simplicity, we might NOT support visual state variations within variant breakpoint values (e.g., no "variant hover on tablet"). Confirm this is acceptable.
-
Migration: Existing variants won't have breakpointParameters. Handle gracefully (undefined → empty object).
Complexity Note
This phase adds a third dimension to the value resolution:
- Visual States: hover, pressed, disabled
- Breakpoints: desktop, tablet, phone
- Variants: named style variations
The full matrix can get complex. For this phase, we're keeping visual states and breakpoints as independent axes (they don't interact with each other within variants). A future phase could add combined state+breakpoint support if needed.