Skip to content

Commit 07f3424

Browse files
committed
Variable paths, JSON templates and on-load timing for data-driven apps
Inline text listens to variable paths, string accessors (initial, timeAgo, date) work on predefined variables, downcase is fixed, JSON writes keep typed values, list conditions compare raw lists, on-load actions run after the first frame and setResult merges results. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz
1 parent 34a6c1b commit 07f3424

9 files changed

Lines changed: 360 additions & 50 deletions

‎lib/src/codelessly.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ class Codelessly {
493493
// Initialize Firestore and FirebaseAuth instances.
494494
_firebaseFirestore = FirebaseFirestore.instanceFor(app: _firebaseApp!);
495495
_firebaseAuth = FirebaseAuth.instanceFor(app: _firebaseApp!);
496-
_listenToAppUser();
496+
_listenToAppUser();
497497

498498
_didInitializeFirebase = true;
499499

‎lib/src/functions/action_chain.dart‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@ class ActionChain {
1818
/// The chain the running action belongs to, if any.
1919
static ActionChain? get current => Zone.current[_zoneKey] as ActionChain?;
2020

21-
/// Sets the current chain's result, when an action runs in a chain.
22-
static void setResult(Object? result) => current?.result = result;
21+
/// Sets the current chain's result, when an action runs in a chain. Map
22+
/// results merge into a map result, so values an earlier action produced
23+
/// (e.g. an upload's `url`) stay readable after a later one adds its own.
24+
static void setResult(Object? result) {
25+
final ActionChain? chain = current;
26+
if (chain == null) return;
27+
final Object? previous = chain.result;
28+
chain.result = previous is Map && result is Map
29+
? {...previous, ...result}
30+
: result;
31+
}
2332

2433
/// Runs [body] in a new chain whose `${result}` starts as [result].
25-
static R run<R>(R Function() body, {Object? result}) => runZoned(
26-
body,
27-
zoneValues: {_zoneKey: ActionChain(result: result)},
28-
);
34+
static R run<R>(R Function() body, {Object? result}) =>
35+
runZoned(body, zoneValues: {_zoneKey: ActionChain(result: result)});
2936
}

‎lib/src/functions/app_auth.dart‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ abstract final class AppAuth {
111111

112112
// A different user needs the project's claim before it can read or
113113
// write the project's data.
114-
if (action.operation case AuthOperation.signIn ||
115-
AuthOperation.signUp ||
116-
AuthOperation.signOut) {
114+
if (action.operation
115+
case AuthOperation.signIn ||
116+
AuthOperation.signUp ||
117+
AuthOperation.signOut) {
117118
await codelessly.authManager.reauthenticate();
118119
}
119120
} catch (error) {

‎lib/src/functions/functions_repository.dart‎

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1568,33 +1568,9 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
15681568

15691569
Map<String, dynamic> data = {};
15701570
if (subAction.useRawValue) {
1571-
// Substitute variables in raw value.
1572-
final updatedValue = PropertyValueDelegate.substituteVariables(
1573-
subAction.rawValue,
1574-
nullSubstitutionMode: NullSubstitutionMode.emptyString,
1575-
scopedValues: scopedValues,
1576-
);
1577-
// Parse to JSON.
1578-
final parsed = tryJsonDecode(updatedValue);
1579-
data = switch (parsed) {
1580-
null => {},
1581-
Map map => Map<String, dynamic>.from(map),
1582-
_ => {'data': parsed},
1583-
};
1571+
data = _jsonFromTemplate(subAction.rawValue, scopedValues);
15841572
} else {
1585-
// Substitute variables in value.
1586-
final updatedValue = PropertyValueDelegate.substituteVariables(
1587-
subAction.newValue,
1588-
nullSubstitutionMode: NullSubstitutionMode.emptyString,
1589-
scopedValues: scopedValues,
1590-
);
1591-
// Parse to JSON.
1592-
final parsed = tryJsonDecode(updatedValue);
1593-
data = switch (parsed) {
1594-
null => {},
1595-
Map map => Map<String, dynamic>.from(map),
1596-
_ => {'data': parsed},
1597-
};
1573+
data = _jsonFromTemplate(subAction.newValue, scopedValues);
15981574
}
15991575

16001576
// Pick the id here when generating one, so `${result.id}` can tell the
@@ -1607,8 +1583,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
16071583
value: data,
16081584
documentId: documentId,
16091585
skipCreationIfDocumentExists:
1610-
!subAction.autoGenerateId &&
1611-
subAction.skipCreationIfDocumentExists,
1586+
!subAction.autoGenerateId && subAction.skipCreationIfDocumentExists,
16121587
);
16131588
ActionChain.setResult({'id': documentId, 'path': evaluatedPath});
16141589
return added;
@@ -1619,6 +1594,32 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
16191594
}
16201595
}
16211596

1597+
/// The document data a JSON [template] describes once its variables are
1598+
/// substituted. See [PropertyValueDelegate.substituteVariablesInJson].
1599+
static Map<String, dynamic> _jsonFromTemplate(
1600+
String template,
1601+
ScopedValues scopedValues,
1602+
) {
1603+
final Map<String, dynamic>? data =
1604+
PropertyValueDelegate.substituteVariablesInJson(
1605+
template,
1606+
scopedValues: scopedValues,
1607+
);
1608+
if (data != null) return data;
1609+
final Object? parsed = tryJsonDecode(
1610+
PropertyValueDelegate.substituteVariables(
1611+
template,
1612+
nullSubstitutionMode: NullSubstitutionMode.emptyString,
1613+
scopedValues: scopedValues,
1614+
),
1615+
);
1616+
return switch (parsed) {
1617+
null => {},
1618+
Map map => Map<String, dynamic>.from(map),
1619+
_ => {'data': parsed},
1620+
};
1621+
}
1622+
16221623
static Future<bool> updateDocumentOnCloud(
16231624
UpdateDocumentSubAction subAction,
16241625
ScopedValues scopedValues,
@@ -1642,14 +1643,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
16421643
);
16431644

16441645
if (subAction.useRawValue) {
1645-
// Substitute variables in raw value.
1646-
final updatedValue = PropertyValueDelegate.substituteVariables(
1646+
final Map<String, dynamic> data = _jsonFromTemplate(
16471647
subAction.rawValue,
1648-
nullSubstitutionMode: NullSubstitutionMode.emptyString,
1649-
scopedValues: scopedValues,
1648+
scopedValues,
16501649
);
1651-
// Parse to JSON.
1652-
final Map<String, dynamic> data = jsonDecode(updatedValue);
16531650

16541651
return await cloudDatabase.updateDocument(
16551652
evaluatedPath,

‎lib/src/transformers/node_transformers/passive_canvas_transformer.dart‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
77
import 'package:provider/provider.dart';
88

99
import '../../../codelessly_sdk.dart';
10+
import '../../functions/action_chain.dart';
1011

1112
class PassiveCanvasTransformer extends NodeWidgetTransformer<CanvasNode> {
1213
static const String name = 'PassiveCanvasWidget';
@@ -475,7 +476,13 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
475476
'Performing actions on canvas load',
476477
name: PassiveCanvasTransformer.name,
477478
);
478-
executeActionAt(0, onLoadActions);
479+
// After the first frame, so actions that navigate or show dialogs (e.g.
480+
// an if/else that redirects signed out users) don't run mid-build. The
481+
// actions share one chain so `${result}` flows between them.
482+
WidgetsBinding.instance.addPostFrameCallback((_) {
483+
if (!mounted) return;
484+
ActionChain.run(() => executeActionAt(0, onLoadActions));
485+
});
479486
}
480487

481488
void executeActionAt(int index, List<ActionModel> actions) {

‎lib/src/transformers/passive_transformer_manager.dart‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,17 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager {
181181

182182
// Traverse through all the multi-variables that the node properties are attached
183183
// to.
184-
for (final String variablePath
185-
in node.multipleVariables.values.expand((path) => path).toSet()) {
184+
// Text written with inline `${...}` paths listens to them too, so text
185+
// stays live whether or not its multipleVariables were filled in.
186+
final Set<String> multiplePaths = {
187+
...node.multipleVariables.values.expand((path) => path),
188+
if (node is TextNode)
189+
...variablePathRegex
190+
.allMatches(node.characters)
191+
.map((match) => match.namedGroup('value'))
192+
.nonNulls,
193+
};
194+
for (final String variablePath in multiplePaths) {
186195
final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax());
187196
if (match == null) continue;
188197

‎lib/src/transformers/utils/condition_evaluator.dart‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ class ConditionEvaluator<R extends Object>
5959

6060
@override
6161
Object? visitVariablePart(VariablePart part) {
62+
// A part that is exactly one path compares by its value as is, so lists
63+
// and maps keep their type for operators like contains and isEmpty.
64+
final String path = part.valueString.trim();
65+
if (RegExp(r'^\$\{[^{}]+\}$').hasMatch(path)) {
66+
final Object? raw =
67+
PropertyValueDelegate.getVariableValueFromPath<Object>(
68+
path,
69+
scopedValues: scopedValues,
70+
);
71+
if (raw is List || raw is Map) return raw;
72+
}
6273
final value = part.valueString.splitMapJoinRegex(
6374
variablePathRegex,
6475
onMatch: (match) {

‎lib/src/transformers/utils/property_value_delegate.dart‎

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,55 @@ class PropertyValueDelegate {
156156
/// evaluates to null. See [NullSubstitutionMode] for more details.
157157
///
158158
/// [scopedValues] is required to retrieve the data from various sources.
159+
/// Substitutes variables in a JSON [template] value by value, so the
160+
/// result stays valid JSON whatever the values contain: a string that is
161+
/// exactly one `${path}` becomes the path's value as is (a list, a number,
162+
/// null, ...), and other strings get their paths substituted as text.
163+
///
164+
/// Returns null when [template] isn't JSON before substitution, e.g. when a
165+
/// path stands unquoted for a number.
166+
static Map<String, dynamic>? substituteVariablesInJson(
167+
String template, {
168+
required ScopedValues scopedValues,
169+
}) {
170+
final Object? parsed = tryJsonDecode(template.trim());
171+
if (parsed is! Map) return null;
172+
return Map<String, dynamic>.from(
173+
_substituteJsonValue(parsed, scopedValues) as Map,
174+
);
175+
}
176+
177+
static Object? _substituteJsonValue(
178+
Object? value,
179+
ScopedValues scopedValues,
180+
) {
181+
return switch (value) {
182+
String() =>
183+
RegExp(r'^\$\{[^{}]+\}$').hasMatch(value.trim())
184+
? _retrieveVariableValue(value.trim(), scopedValues: scopedValues)
185+
: substituteVariables(
186+
value,
187+
scopedValues: scopedValues,
188+
nullSubstitutionMode: NullSubstitutionMode.emptyString,
189+
),
190+
List() => [
191+
for (final item in value) _substituteJsonValue(item, scopedValues),
192+
],
193+
Map() => {
194+
for (final MapEntry(:key, :value) in value.entries)
195+
substituteVariables(
196+
key.toString(),
197+
scopedValues: scopedValues,
198+
nullSubstitutionMode: NullSubstitutionMode.emptyString,
199+
): _substituteJsonValue(
200+
value,
201+
scopedValues,
202+
),
203+
},
204+
_ => value,
205+
};
206+
}
207+
159208
static String substituteVariables(
160209
String text, {
161210
required ScopedValues scopedValues,
@@ -215,7 +264,9 @@ class PropertyValueDelegate {
215264

216265
if (variable == null) return null;
217266

218-
VariableType accessorType = match.hasAccessor
267+
// Predefined variables like item hold any type, so read accessors for
268+
// the value they hold (an item can be a string, a map, ...).
269+
VariableType accessorType = match.hasAccessor || match.isPredefinedVariable
219270
? VariableType.fromObjectType(value)
220271
: variable.type;
221272
List<Accessor> accessors = variable.name.startsWith('api')
@@ -431,11 +482,37 @@ class PropertyValueDelegate {
431482
final JsonPath jsonPath = JsonPath(path);
432483
// Retrieve values from JSON that match the path.
433484
final values = jsonPath.read(data);
434-
if (values.isEmpty) return (null, null);
485+
if (values.isEmpty) return _substituteComputedProperty(text, data);
435486
// Return the first value.
436487
return (values.first.value, values.first.pointer);
437488
}
438489

490+
/// Resolves a path that ends in a computed property of a nested value, like
491+
/// `${item.memberIds.length}` or `${item.createdAt.timeAgo}`, which plain
492+
/// JSON paths can't read: the rest of the path is resolved first, then the
493+
/// property is read from [getVariableProperties] of that value.
494+
static (Object?, JsonPointer?) _substituteComputedProperty(
495+
String text,
496+
Map<String, dynamic> data,
497+
) {
498+
final String path = text.substring(2, text.length - 1);
499+
final int dot = path.lastIndexOf('.');
500+
if (dot <= 0) return (null, null);
501+
final String property = path.substring(dot + 1);
502+
if (!RegExp(r'^[a-zA-Z]+$').hasMatch(property)) return (null, null);
503+
final (Object? parent, _) = substituteJsonPath(
504+
path.substring(0, dot),
505+
data,
506+
);
507+
if (parent == null) return (null, null);
508+
final Map<String, dynamic> properties = getVariableProperties(
509+
parent,
510+
VariableType.fromObjectType(parent),
511+
);
512+
if (!properties.containsKey(property)) return (null, null);
513+
return (properties[property], null);
514+
}
515+
439516
static void putValueInJsonPath(
440517
String path,
441518
Object? value,
@@ -501,6 +578,13 @@ class PropertyValueDelegate {
501578
'upcase': value.toUpperCase(),
502579
'downcase': value.toLowerCase(),
503580
'capitalize': value.capitalized,
581+
'initial': value.trim().isEmpty
582+
? ''
583+
: value.trim().characters.first.toUpperCase(),
584+
if (DateTime.tryParse(value) case final DateTime date) ...{
585+
'timeAgo': formatTimeAgo(date),
586+
'date': formatDate(date),
587+
},
504588
},
505589
int() => {
506590
'isEven': value.isEven,
@@ -576,7 +660,7 @@ List<Accessor> getAccessorsForType(VariableType type) {
576660
Accessor(
577661
name: 'downcase',
578662
type: VariableType.text,
579-
getValue: (value) => value?.toString().toUpperCase(),
663+
getValue: (value) => value?.toString().toLowerCase(),
580664
),
581665
Accessor(
582666
name: 'capitalize',
@@ -598,6 +682,30 @@ List<Accessor> getAccessorsForType(VariableType type) {
598682
type: VariableType.boolean,
599683
getValue: (value) => value?.toString().trim().isEmpty,
600684
),
685+
Accessor(
686+
name: 'initial',
687+
type: VariableType.text,
688+
getValue: (value) {
689+
final String text = value?.toString().trim() ?? '';
690+
return text.isEmpty ? '' : text.characters.first.toUpperCase();
691+
},
692+
),
693+
Accessor(
694+
name: 'timeAgo',
695+
type: VariableType.text,
696+
getValue: (value) => switch (DateTime.tryParse('${value ?? ''}')) {
697+
final DateTime date => formatTimeAgo(date),
698+
null => null,
699+
},
700+
),
701+
Accessor(
702+
name: 'date',
703+
type: VariableType.text,
704+
getValue: (value) => switch (DateTime.tryParse('${value ?? ''}')) {
705+
final DateTime date => formatDate(date),
706+
null => null,
707+
},
708+
),
601709
];
602710
case VariableType.integer:
603711
return [
@@ -911,3 +1019,29 @@ List<Accessor> getCloudDataVariableAccessorsForType(VariableType dataType) => [
9111019
getValue: (value) => value.toMap()?['error'],
9121020
),
9131021
];
1022+
1023+
const List<String> _monthNames = [
1024+
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', //
1025+
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
1026+
];
1027+
1028+
/// Formats [date] like `Oct 14, 2026`, in local time.
1029+
String formatDate(DateTime date) {
1030+
final DateTime local = date.toLocal();
1031+
return '${_monthNames[local.month - 1]} ${local.day}, ${local.year}';
1032+
}
1033+
1034+
/// Formats [date] relative to now: `Just now`, `5 min ago`, `2 hours ago`,
1035+
/// `3 days ago`, then [formatDate] after a week.
1036+
String formatTimeAgo(DateTime date, {DateTime? now}) {
1037+
final Duration diff = (now ?? DateTime.now()).difference(date);
1038+
if (diff.inMinutes < 1) return 'Just now';
1039+
if (diff.inHours < 1) return '${diff.inMinutes} min ago';
1040+
if (diff.inDays < 1) {
1041+
return '${diff.inHours} ${diff.inHours == 1 ? 'hour' : 'hours'} ago';
1042+
}
1043+
if (diff.inDays < 7) {
1044+
return '${diff.inDays} ${diff.inDays == 1 ? 'day' : 'days'} ago';
1045+
}
1046+
return formatDate(date);
1047+
}

0 commit comments

Comments
 (0)