@signaltree/core 7.1.6 → 7.3.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/README.md +392 -0
- package/dist/index.js +2 -1
- package/dist/lib/markers/entity-map.js +25 -4
- package/dist/lib/markers/form.js +310 -0
- package/dist/lib/markers/stored.js +112 -10
- package/dist/lib/signal-tree.js +1 -0
- package/package.json +1 -1
- package/src/index.d.ts +2 -1
- package/src/lib/markers/entity-map.d.ts +17 -2
- package/src/lib/markers/form.d.ts +86 -0
- package/src/lib/markers/index.d.ts +2 -1
- package/src/lib/markers/stored.d.ts +12 -0
- package/src/lib/types.d.ts +2 -1
package/README.md
CHANGED
|
@@ -1029,6 +1029,398 @@ export const myDerived = derivedFrom<AppTreeBase>()(($) => ({
|
|
|
1029
1029
|
|
|
1030
1030
|
**Key point**: `derivedFrom` is **only needed for functions defined in separate files**. Inline functions automatically inherit types from the chain. Note the curried syntax: `derivedFrom<TreeType>()(fn)` - this allows TypeScript to infer the return type while you specify the tree type.
|
|
1031
1031
|
|
|
1032
|
+
## Built-in Markers
|
|
1033
|
+
|
|
1034
|
+
SignalTree provides four built-in markers that handle common state patterns Angular doesn't provide out of the box. All markers are **self-registering** and **tree-shakeable** - only the markers you use are included in your bundle.
|
|
1035
|
+
|
|
1036
|
+
### 9) `entityMap<E, K>()` - Normalized Collections
|
|
1037
|
+
|
|
1038
|
+
Creates a normalized entity collection with O(1) lookups by ID. Includes chainable `.computed()` for derived slices.
|
|
1039
|
+
|
|
1040
|
+
```typescript
|
|
1041
|
+
import { signalTree, entityMap } from '@signaltree/core';
|
|
1042
|
+
|
|
1043
|
+
interface Product {
|
|
1044
|
+
id: number;
|
|
1045
|
+
name: string;
|
|
1046
|
+
category: string;
|
|
1047
|
+
price: number;
|
|
1048
|
+
inStock: boolean;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const tree = signalTree({
|
|
1052
|
+
products: entityMap<Product, number>()
|
|
1053
|
+
.computed('electronics', (all) => all.filter((p) => p.category === 'electronics'))
|
|
1054
|
+
.computed('inStock', (all) => all.filter((p) => p.inStock))
|
|
1055
|
+
.computed('totalValue', (all) => all.reduce((sum, p) => sum + p.price, 0)),
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// EntitySignal API
|
|
1059
|
+
tree.$.products.setMany([
|
|
1060
|
+
{ id: 1, name: 'Laptop', category: 'electronics', price: 999, inStock: true },
|
|
1061
|
+
{ id: 2, name: 'Chair', category: 'furniture', price: 199, inStock: false },
|
|
1062
|
+
]);
|
|
1063
|
+
|
|
1064
|
+
tree.$.products.all(); // Signal<Product[]> - all entities
|
|
1065
|
+
tree.$.products.byId(1); // Signal<Product> | undefined
|
|
1066
|
+
tree.$.products.ids(); // Signal<number[]>
|
|
1067
|
+
tree.$.products.count(); // Signal<number>
|
|
1068
|
+
|
|
1069
|
+
// Computed slices (reactive, type-safe)
|
|
1070
|
+
tree.$.products.electronics(); // Signal<Product[]> - auto-updates
|
|
1071
|
+
tree.$.products.inStock(); // Signal<Product[]>
|
|
1072
|
+
tree.$.products.totalValue(); // Signal<number>
|
|
1073
|
+
|
|
1074
|
+
// CRUD operations
|
|
1075
|
+
tree.$.products.upsertOne({ id: 1, name: 'Updated', category: 'electronics', price: 899, inStock: true });
|
|
1076
|
+
tree.$.products.upsertMany([...]);
|
|
1077
|
+
tree.$.products.removeOne(1);
|
|
1078
|
+
tree.$.products.removeMany([1, 2]);
|
|
1079
|
+
tree.$.products.clear();
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
#### Custom ID Selection
|
|
1083
|
+
|
|
1084
|
+
```typescript
|
|
1085
|
+
interface User {
|
|
1086
|
+
odataId: string; // Not named 'id'
|
|
1087
|
+
email: string;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const tree = signalTree({
|
|
1091
|
+
users: entityMap<User, string>(),
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
// Specify selectId when upserting
|
|
1095
|
+
tree.$.users.upsertOne(user, { selectId: (u) => u.odataId });
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### 10) `status()` - Manual Async State
|
|
1099
|
+
|
|
1100
|
+
Creates a status signal for manual async state management with type-safe error handling.
|
|
1101
|
+
|
|
1102
|
+
```typescript
|
|
1103
|
+
import { signalTree, status, LoadingState } from '@signaltree/core';
|
|
1104
|
+
|
|
1105
|
+
interface ApiError {
|
|
1106
|
+
code: number;
|
|
1107
|
+
message: string;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const tree = signalTree({
|
|
1111
|
+
users: {
|
|
1112
|
+
data: [] as User[],
|
|
1113
|
+
loadStatus: status<ApiError>(), // Generic error type
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Status API
|
|
1118
|
+
tree.$.users.loadStatus.state(); // Signal<LoadingState>
|
|
1119
|
+
tree.$.users.loadStatus.error(); // Signal<ApiError | null>
|
|
1120
|
+
|
|
1121
|
+
// Convenience signals
|
|
1122
|
+
tree.$.users.loadStatus.isNotLoaded(); // Signal<boolean>
|
|
1123
|
+
tree.$.users.loadStatus.isLoading(); // Signal<boolean>
|
|
1124
|
+
tree.$.users.loadStatus.isLoaded(); // Signal<boolean>
|
|
1125
|
+
tree.$.users.loadStatus.isError(); // Signal<boolean>
|
|
1126
|
+
|
|
1127
|
+
// Update methods
|
|
1128
|
+
tree.$.users.loadStatus.setLoading();
|
|
1129
|
+
tree.$.users.loadStatus.setLoaded();
|
|
1130
|
+
tree.$.users.loadStatus.setError({ code: 404, message: 'Not found' });
|
|
1131
|
+
tree.$.users.loadStatus.reset();
|
|
1132
|
+
|
|
1133
|
+
// LoadingState enum
|
|
1134
|
+
LoadingState.NotLoaded; // 'not-loaded'
|
|
1135
|
+
LoadingState.Loading; // 'loading'
|
|
1136
|
+
LoadingState.Loaded; // 'loaded'
|
|
1137
|
+
LoadingState.Error; // 'error'
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
### 11) `stored(key, default, options?)` - localStorage Persistence
|
|
1141
|
+
|
|
1142
|
+
Auto-syncs state to localStorage with versioning and migration support.
|
|
1143
|
+
|
|
1144
|
+
```typescript
|
|
1145
|
+
import { signalTree, stored, createStorageKeys, clearStoragePrefix } from '@signaltree/core';
|
|
1146
|
+
|
|
1147
|
+
// Basic usage
|
|
1148
|
+
const tree = signalTree({
|
|
1149
|
+
theme: stored('app-theme', 'light' as 'light' | 'dark'),
|
|
1150
|
+
lastViewedId: stored('last-viewed', null as number | null),
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
// Auto-loads from localStorage on init
|
|
1154
|
+
// Auto-saves on every .set() or .update()
|
|
1155
|
+
tree.$.theme.set('dark'); // Saved to localStorage immediately
|
|
1156
|
+
|
|
1157
|
+
// StoredSignal API
|
|
1158
|
+
tree.$.theme(); // Get current value
|
|
1159
|
+
tree.$.theme.set('light'); // Set and persist
|
|
1160
|
+
tree.$.theme.clear(); // Remove from storage, reset to default
|
|
1161
|
+
tree.$.theme.reload(); // Force reload from storage
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
#### Versioning and Migrations
|
|
1165
|
+
|
|
1166
|
+
```typescript
|
|
1167
|
+
interface SettingsV1 {
|
|
1168
|
+
darkMode: boolean;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
interface SettingsV2 {
|
|
1172
|
+
theme: 'light' | 'dark' | 'system';
|
|
1173
|
+
fontSize: number;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const tree = signalTree({
|
|
1177
|
+
settings: stored<SettingsV2>(
|
|
1178
|
+
'user-settings',
|
|
1179
|
+
{ theme: 'light', fontSize: 14 },
|
|
1180
|
+
{
|
|
1181
|
+
version: 2,
|
|
1182
|
+
migrate: (oldData, oldVersion) => {
|
|
1183
|
+
if (oldVersion === 1) {
|
|
1184
|
+
// Migrate from V1 to V2
|
|
1185
|
+
const v1 = oldData as SettingsV1;
|
|
1186
|
+
return {
|
|
1187
|
+
theme: v1.darkMode ? 'dark' : 'light',
|
|
1188
|
+
fontSize: 14, // New field with default
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
return oldData as SettingsV2;
|
|
1192
|
+
},
|
|
1193
|
+
clearOnMigrationFailure: true, // Clear storage if migration fails
|
|
1194
|
+
}
|
|
1195
|
+
),
|
|
1196
|
+
});
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
#### Type-Safe Storage Keys
|
|
1200
|
+
|
|
1201
|
+
```typescript
|
|
1202
|
+
// Create namespaced storage keys
|
|
1203
|
+
const STORAGE = createStorageKeys('myApp', {
|
|
1204
|
+
theme: 'theme',
|
|
1205
|
+
user: {
|
|
1206
|
+
settings: 'settings',
|
|
1207
|
+
preferences: 'prefs',
|
|
1208
|
+
},
|
|
1209
|
+
} as const);
|
|
1210
|
+
|
|
1211
|
+
// STORAGE.theme = "myApp:theme"
|
|
1212
|
+
// STORAGE.user.settings = "myApp:user:settings"
|
|
1213
|
+
|
|
1214
|
+
const tree = signalTree({
|
|
1215
|
+
theme: stored(STORAGE.theme, 'light'),
|
|
1216
|
+
settings: stored(STORAGE.user.settings, {}),
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
// Clear all app storage (e.g., on logout)
|
|
1220
|
+
clearStoragePrefix('myApp');
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
#### Advanced Options
|
|
1224
|
+
|
|
1225
|
+
```typescript
|
|
1226
|
+
stored('key', defaultValue, {
|
|
1227
|
+
version: 1, // Schema version
|
|
1228
|
+
migrate: (old, ver) => migrated, // Migration function
|
|
1229
|
+
debounceMs: 100, // Write debounce (default: 100)
|
|
1230
|
+
storage: sessionStorage, // Custom storage backend
|
|
1231
|
+
serialize: (v) => JSON.stringify(v), // Custom serializer
|
|
1232
|
+
deserialize: (s) => JSON.parse(s), // Custom deserializer
|
|
1233
|
+
clearOnMigrationFailure: false, // Clear on failed migration
|
|
1234
|
+
});
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
### 12) `form(config)` - Tree-Integrated Forms
|
|
1238
|
+
|
|
1239
|
+
Creates forms with validation, wizard navigation, and persistence that live inside SignalTree.
|
|
1240
|
+
|
|
1241
|
+
```typescript
|
|
1242
|
+
import { signalTree, form, validators } from '@signaltree/core';
|
|
1243
|
+
|
|
1244
|
+
interface ContactForm {
|
|
1245
|
+
name: string;
|
|
1246
|
+
email: string;
|
|
1247
|
+
phone: string;
|
|
1248
|
+
message: string;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const tree = signalTree({
|
|
1252
|
+
contact: form<ContactForm>({
|
|
1253
|
+
initial: { name: '', email: '', phone: '', message: '' },
|
|
1254
|
+
validators: {
|
|
1255
|
+
name: validators.required('Name is required'),
|
|
1256
|
+
email: [validators.required('Email is required'), validators.email('Invalid email format')],
|
|
1257
|
+
phone: validators.pattern(/^\+?[\d\s-]+$/, 'Invalid phone number'),
|
|
1258
|
+
message: validators.minLength(10, 'Message must be at least 10 characters'),
|
|
1259
|
+
},
|
|
1260
|
+
}),
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// FormSignal API - Field access via $
|
|
1264
|
+
tree.$.contact.$.name(); // Get field value
|
|
1265
|
+
tree.$.contact.$.name.set('Jane'); // Set field value
|
|
1266
|
+
tree.$.contact.$.email();
|
|
1267
|
+
|
|
1268
|
+
// Form-level operations
|
|
1269
|
+
tree.$.contact(); // Get all values: ContactForm
|
|
1270
|
+
tree.$.contact.set({ name: 'Jane', email: 'jane@example.com', phone: '', message: '' });
|
|
1271
|
+
tree.$.contact.patch({ name: 'Updated' }); // Partial update
|
|
1272
|
+
tree.$.contact.reset(); // Reset to initial values
|
|
1273
|
+
tree.$.contact.clear(); // Clear all values
|
|
1274
|
+
|
|
1275
|
+
// Validation signals
|
|
1276
|
+
tree.$.contact.valid(); // Signal<boolean>
|
|
1277
|
+
tree.$.contact.dirty(); // Signal<boolean>
|
|
1278
|
+
tree.$.contact.submitting(); // Signal<boolean>
|
|
1279
|
+
tree.$.contact.touched(); // Signal<Record<keyof T, boolean>>
|
|
1280
|
+
tree.$.contact.errors(); // Signal<Partial<Record<keyof T, string>>>
|
|
1281
|
+
tree.$.contact.errorList(); // Signal<string[]>
|
|
1282
|
+
|
|
1283
|
+
// Validation methods
|
|
1284
|
+
await tree.$.contact.validate(); // Validate all fields
|
|
1285
|
+
await tree.$.contact.validateField('email');
|
|
1286
|
+
tree.$.contact.touch('name'); // Mark field as touched
|
|
1287
|
+
tree.$.contact.touchAll(); // Mark all fields as touched
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
#### Built-in Validators
|
|
1291
|
+
|
|
1292
|
+
```typescript
|
|
1293
|
+
import { validators } from '@signaltree/core';
|
|
1294
|
+
|
|
1295
|
+
validators.required('Field is required')
|
|
1296
|
+
validators.minLength(5, 'Min 5 characters')
|
|
1297
|
+
validators.maxLength(100, 'Max 100 characters')
|
|
1298
|
+
validators.min(0, 'Must be positive')
|
|
1299
|
+
validators.max(100, 'Max 100')
|
|
1300
|
+
validators.email('Invalid email')
|
|
1301
|
+
validators.pattern(/regex/, 'Invalid format')
|
|
1302
|
+
|
|
1303
|
+
// Compose multiple validators
|
|
1304
|
+
validators: {
|
|
1305
|
+
password: [
|
|
1306
|
+
validators.required('Password is required'),
|
|
1307
|
+
validators.minLength(8, 'Min 8 characters'),
|
|
1308
|
+
validators.pattern(/[A-Z]/, 'Must contain uppercase'),
|
|
1309
|
+
validators.pattern(/[0-9]/, 'Must contain number'),
|
|
1310
|
+
],
|
|
1311
|
+
}
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
#### Wizard Navigation
|
|
1315
|
+
|
|
1316
|
+
```typescript
|
|
1317
|
+
const tree = signalTree({
|
|
1318
|
+
listing: form<ListingDraft>({
|
|
1319
|
+
initial: { title: '', description: '', photos: [], price: null, location: '' },
|
|
1320
|
+
validators: {
|
|
1321
|
+
title: validators.required('Title is required'),
|
|
1322
|
+
price: [validators.required('Price required'), validators.min(0, 'Must be positive')],
|
|
1323
|
+
location: validators.required('Location required'),
|
|
1324
|
+
},
|
|
1325
|
+
wizard: {
|
|
1326
|
+
steps: ['details', 'media', 'pricing', 'review'],
|
|
1327
|
+
stepFields: {
|
|
1328
|
+
details: ['title', 'description'],
|
|
1329
|
+
media: ['photos'],
|
|
1330
|
+
pricing: ['price'],
|
|
1331
|
+
review: ['location'],
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
}),
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// Wizard API
|
|
1338
|
+
const wizard = tree.$.listing.wizard!;
|
|
1339
|
+
|
|
1340
|
+
wizard.currentStep(); // Signal<number> - 0-based index
|
|
1341
|
+
wizard.stepName(); // Signal<string> - current step name
|
|
1342
|
+
wizard.steps(); // Signal<string[]> - all step names
|
|
1343
|
+
wizard.canNext(); // Signal<boolean>
|
|
1344
|
+
wizard.canPrev(); // Signal<boolean>
|
|
1345
|
+
wizard.isFirstStep(); // Signal<boolean>
|
|
1346
|
+
wizard.isLastStep(); // Signal<boolean>
|
|
1347
|
+
|
|
1348
|
+
// Navigation (validates current step before proceeding)
|
|
1349
|
+
await wizard.next(); // Returns false if validation fails
|
|
1350
|
+
wizard.prev();
|
|
1351
|
+
await wizard.goTo(2); // Jump to step by index
|
|
1352
|
+
await wizard.goTo('pricing'); // Jump to step by name
|
|
1353
|
+
wizard.reset(); // Go back to first step
|
|
1354
|
+
```
|
|
1355
|
+
|
|
1356
|
+
#### Form Persistence
|
|
1357
|
+
|
|
1358
|
+
```typescript
|
|
1359
|
+
const tree = signalTree({
|
|
1360
|
+
draft: form<EmailDraft>({
|
|
1361
|
+
initial: { subject: '', body: '', to: '' },
|
|
1362
|
+
persist: 'email-draft', // localStorage key
|
|
1363
|
+
persistDebounceMs: 500, // Debounce writes (default: 500ms)
|
|
1364
|
+
validators: {
|
|
1365
|
+
subject: validators.required('Subject required'),
|
|
1366
|
+
to: validators.email('Invalid email'),
|
|
1367
|
+
},
|
|
1368
|
+
}),
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// Form auto-saves to localStorage
|
|
1372
|
+
// On page reload, draft is restored automatically
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
#### Async Validators
|
|
1376
|
+
|
|
1377
|
+
```typescript
|
|
1378
|
+
const tree = signalTree({
|
|
1379
|
+
registration: form<RegistrationForm>({
|
|
1380
|
+
initial: { username: '', email: '' },
|
|
1381
|
+
validators: {
|
|
1382
|
+
username: validators.minLength(3, 'Min 3 characters'),
|
|
1383
|
+
},
|
|
1384
|
+
asyncValidators: {
|
|
1385
|
+
username: async (value) => {
|
|
1386
|
+
const taken = await api.checkUsername(value);
|
|
1387
|
+
return taken ? 'Username already taken' : null;
|
|
1388
|
+
},
|
|
1389
|
+
email: async (value) => {
|
|
1390
|
+
const exists = await api.checkEmail(value);
|
|
1391
|
+
return exists ? 'Email already registered' : null;
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
}),
|
|
1395
|
+
});
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
#### Form Submission
|
|
1399
|
+
|
|
1400
|
+
```typescript
|
|
1401
|
+
async function handleSubmit() {
|
|
1402
|
+
const contactForm = tree.$.contact;
|
|
1403
|
+
|
|
1404
|
+
// Validate all fields first
|
|
1405
|
+
contactForm.touchAll();
|
|
1406
|
+
const isValid = await contactForm.validate();
|
|
1407
|
+
|
|
1408
|
+
if (!isValid) return;
|
|
1409
|
+
|
|
1410
|
+
// Set submitting state
|
|
1411
|
+
contactForm.setSubmitting(true);
|
|
1412
|
+
|
|
1413
|
+
try {
|
|
1414
|
+
await api.submit(contactForm());
|
|
1415
|
+
contactForm.reset();
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
// Handle error
|
|
1418
|
+
} finally {
|
|
1419
|
+
contactForm.setSubmitting(false);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1032
1424
|
## Error handling examples
|
|
1033
1425
|
|
|
1034
1426
|
### Manual async error handling
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ export { ENHANCER_META } from './lib/types.js';
|
|
|
3
3
|
export { derivedFrom, externalDerived } from './lib/internals/derived-types.js';
|
|
4
4
|
export { isDerivedMarker } from './lib/markers/derived.js';
|
|
5
5
|
export { LoadingState, isStatusMarker, status } from './lib/markers/status.js';
|
|
6
|
-
export { isStoredMarker, stored } from './lib/markers/stored.js';
|
|
6
|
+
export { clearStoragePrefix, createStorageKeys, isStoredMarker, stored } from './lib/markers/stored.js';
|
|
7
|
+
export { FORM_MARKER, createFormSignal, form, isFormMarker, validators } from './lib/markers/form.js';
|
|
7
8
|
export { registerMarkerProcessor } from './lib/internals/materialize-markers.js';
|
|
8
9
|
export { composeEnhancers, createLazySignalTree, isAnySignal, isNodeAccessor, toWritableSignal } from './lib/utils.js';
|
|
9
10
|
export { createEditSession } from './lib/edit-session.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { computed } from '@angular/core';
|
|
1
2
|
import { createEntitySignal } from '../entity-signal.js';
|
|
2
3
|
import { registerMarkerProcessor } from '../internals/materialize-markers.js';
|
|
3
4
|
import { isEntityMapMarker } from '../utils.js';
|
|
@@ -8,13 +9,33 @@ function entityMap(config) {
|
|
|
8
9
|
entityMapRegistered = true;
|
|
9
10
|
registerMarkerProcessor(isEntityMapMarker, (marker, notifier, path) => {
|
|
10
11
|
const cfg = marker.__entityMapConfig ?? {};
|
|
11
|
-
|
|
12
|
+
const entitySignal = createEntitySignal(cfg, notifier, path);
|
|
13
|
+
const slices = marker.__computedSlices;
|
|
14
|
+
if (slices) {
|
|
15
|
+
for (const [name, sliceConfig] of Object.entries(slices)) {
|
|
16
|
+
const computedSignal = computed(() => sliceConfig.compute(entitySignal.all()));
|
|
17
|
+
entitySignal[name] = computedSignal;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return entitySignal;
|
|
12
21
|
});
|
|
13
22
|
}
|
|
14
|
-
|
|
23
|
+
const slices = {};
|
|
24
|
+
const combined = {
|
|
15
25
|
__isEntityMap: true,
|
|
16
|
-
__entityMapConfig: config ?? {}
|
|
26
|
+
__entityMapConfig: config ?? {},
|
|
27
|
+
__computedSlices: slices,
|
|
28
|
+
computed(name, compute) {
|
|
29
|
+
slices[name] = {
|
|
30
|
+
compute: compute
|
|
31
|
+
};
|
|
32
|
+
return combined;
|
|
33
|
+
},
|
|
34
|
+
build() {
|
|
35
|
+
return combined;
|
|
36
|
+
}
|
|
17
37
|
};
|
|
38
|
+
return combined;
|
|
18
39
|
}
|
|
19
40
|
|
|
20
|
-
export { entityMap };
|
|
41
|
+
export { entityMap, isEntityMapMarker };
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { signal, computed } from '@angular/core';
|
|
2
|
+
import { registerMarkerProcessor } from '../internals/materialize-markers.js';
|
|
3
|
+
|
|
4
|
+
const FORM_MARKER = Symbol('FORM_MARKER');
|
|
5
|
+
let formRegistered = false;
|
|
6
|
+
function form(config) {
|
|
7
|
+
if (!formRegistered) {
|
|
8
|
+
formRegistered = true;
|
|
9
|
+
registerMarkerProcessor(isFormMarker, createFormSignal);
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
[FORM_MARKER]: true,
|
|
13
|
+
config
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function isFormMarker(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && FORM_MARKER in value && value[FORM_MARKER] === true;
|
|
18
|
+
}
|
|
19
|
+
function createFormSignal(marker) {
|
|
20
|
+
const config = marker.config;
|
|
21
|
+
const initial = config.initial;
|
|
22
|
+
const valuesSignal = signal({
|
|
23
|
+
...initial
|
|
24
|
+
});
|
|
25
|
+
const touchedSignal = signal(Object.keys(initial).reduce((acc, key) => {
|
|
26
|
+
acc[key] = false;
|
|
27
|
+
return acc;
|
|
28
|
+
}, {}));
|
|
29
|
+
const errorsSignal = signal({});
|
|
30
|
+
const submittingSignal = signal(false);
|
|
31
|
+
const dirty = computed(() => {
|
|
32
|
+
const current = valuesSignal();
|
|
33
|
+
const eq = config.equalityFn ?? defaultEquality;
|
|
34
|
+
return Object.keys(initial).some(key => !eq(current[key], initial[key]));
|
|
35
|
+
});
|
|
36
|
+
const valid = computed(() => {
|
|
37
|
+
const errs = errorsSignal();
|
|
38
|
+
return Object.values(errs).every(e => e === null || e === undefined);
|
|
39
|
+
});
|
|
40
|
+
const errorList = computed(() => {
|
|
41
|
+
const errs = errorsSignal();
|
|
42
|
+
return Object.values(errs).filter(e => e !== null && e !== undefined);
|
|
43
|
+
});
|
|
44
|
+
let persistTimeout = null;
|
|
45
|
+
const storage = config.storage !== undefined ? config.storage : typeof window !== 'undefined' ? window.localStorage : null;
|
|
46
|
+
function loadFromStorage() {
|
|
47
|
+
if (!config.persist || !storage) return;
|
|
48
|
+
try {
|
|
49
|
+
const stored = storage.getItem(config.persist);
|
|
50
|
+
if (stored) {
|
|
51
|
+
const parsed = JSON.parse(stored);
|
|
52
|
+
valuesSignal.set({
|
|
53
|
+
...initial,
|
|
54
|
+
...parsed
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
function saveToStorage() {
|
|
60
|
+
if (!config.persist || !storage) return;
|
|
61
|
+
try {
|
|
62
|
+
storage.setItem(config.persist, JSON.stringify(valuesSignal()));
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
function schedulePersist() {
|
|
66
|
+
if (!config.persist) return;
|
|
67
|
+
if (persistTimeout) clearTimeout(persistTimeout);
|
|
68
|
+
persistTimeout = setTimeout(saveToStorage, config.persistDebounceMs ?? 500);
|
|
69
|
+
}
|
|
70
|
+
loadFromStorage();
|
|
71
|
+
function createFieldAccessor(path, getValue, setValue) {
|
|
72
|
+
const accessor = () => getValue();
|
|
73
|
+
accessor.set = v => {
|
|
74
|
+
setValue(v);
|
|
75
|
+
schedulePersist();
|
|
76
|
+
};
|
|
77
|
+
accessor.update = fn => {
|
|
78
|
+
setValue(fn(getValue()));
|
|
79
|
+
schedulePersist();
|
|
80
|
+
};
|
|
81
|
+
return accessor;
|
|
82
|
+
}
|
|
83
|
+
function createFieldsProxy(values) {
|
|
84
|
+
const proxy = {};
|
|
85
|
+
for (const key of Object.keys(values)) {
|
|
86
|
+
const k = key;
|
|
87
|
+
const fieldAccessor = createFieldAccessor(key, () => valuesSignal()[k], v => valuesSignal.update(curr => ({
|
|
88
|
+
...curr,
|
|
89
|
+
[k]: v
|
|
90
|
+
})));
|
|
91
|
+
const value = values[k];
|
|
92
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
93
|
+
const nested = createFieldsProxy(value);
|
|
94
|
+
Object.assign(fieldAccessor, nested);
|
|
95
|
+
}
|
|
96
|
+
proxy[key] = fieldAccessor;
|
|
97
|
+
}
|
|
98
|
+
return proxy;
|
|
99
|
+
}
|
|
100
|
+
const fieldsProxy = createFieldsProxy(initial);
|
|
101
|
+
async function validateField(field) {
|
|
102
|
+
const value = valuesSignal()[field];
|
|
103
|
+
const validators = config.validators?.[field];
|
|
104
|
+
const asyncValidator = config.asyncValidators?.[field];
|
|
105
|
+
let error = null;
|
|
106
|
+
if (validators) {
|
|
107
|
+
const validatorArray = Array.isArray(validators) ? validators : [validators];
|
|
108
|
+
for (const validator of validatorArray) {
|
|
109
|
+
error = validator(value);
|
|
110
|
+
if (error) break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!error && asyncValidator) {
|
|
114
|
+
error = await asyncValidator(value);
|
|
115
|
+
}
|
|
116
|
+
errorsSignal.update(errs => ({
|
|
117
|
+
...errs,
|
|
118
|
+
[field]: error
|
|
119
|
+
}));
|
|
120
|
+
return error === null;
|
|
121
|
+
}
|
|
122
|
+
async function validateAll() {
|
|
123
|
+
const fields = Object.keys(initial);
|
|
124
|
+
const results = await Promise.all(fields.map(validateField));
|
|
125
|
+
return results.every(Boolean);
|
|
126
|
+
}
|
|
127
|
+
let wizard;
|
|
128
|
+
if (config.wizard) {
|
|
129
|
+
const wizardConfig = config.wizard;
|
|
130
|
+
const currentStepSignal = signal(0);
|
|
131
|
+
const stepName = computed(() => wizardConfig.steps[currentStepSignal()] ?? '');
|
|
132
|
+
const canNext = computed(() => currentStepSignal() < wizardConfig.steps.length - 1);
|
|
133
|
+
const canPrev = computed(() => currentStepSignal() > 0);
|
|
134
|
+
const isLastStep = computed(() => currentStepSignal() === wizardConfig.steps.length - 1);
|
|
135
|
+
const isFirstStep = computed(() => currentStepSignal() === 0);
|
|
136
|
+
async function validateCurrentStep() {
|
|
137
|
+
const stepIdx = currentStepSignal();
|
|
138
|
+
const stepNameStr = wizardConfig.steps[stepIdx];
|
|
139
|
+
const stepCfg = wizardConfig.stepConfig?.[stepNameStr];
|
|
140
|
+
if (stepCfg?.validate) {
|
|
141
|
+
const result = await stepCfg.validate();
|
|
142
|
+
if (!result) return false;
|
|
143
|
+
}
|
|
144
|
+
const stepFields = wizardConfig.stepFields?.[stepNameStr] ?? stepCfg?.fields ?? [];
|
|
145
|
+
for (const field of stepFields) {
|
|
146
|
+
const isValid = await validateField(field);
|
|
147
|
+
if (!isValid) return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
wizard = {
|
|
152
|
+
currentStep: currentStepSignal.asReadonly(),
|
|
153
|
+
stepName,
|
|
154
|
+
steps: signal(wizardConfig.steps).asReadonly(),
|
|
155
|
+
canNext,
|
|
156
|
+
canPrev,
|
|
157
|
+
isLastStep,
|
|
158
|
+
isFirstStep,
|
|
159
|
+
async next() {
|
|
160
|
+
const valid = await validateCurrentStep();
|
|
161
|
+
if (!valid) return false;
|
|
162
|
+
const current = currentStepSignal();
|
|
163
|
+
if (current < wizardConfig.steps.length - 1) {
|
|
164
|
+
currentStepSignal.set(current + 1);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
},
|
|
169
|
+
prev() {
|
|
170
|
+
const current = currentStepSignal();
|
|
171
|
+
if (current > 0) {
|
|
172
|
+
currentStepSignal.set(current - 1);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
async goTo(step) {
|
|
176
|
+
const targetIdx = typeof step === 'number' ? step : wizardConfig.steps.indexOf(step);
|
|
177
|
+
if (targetIdx < 0 || targetIdx >= wizardConfig.steps.length) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (targetIdx > currentStepSignal()) {
|
|
181
|
+
const valid = await validateCurrentStep();
|
|
182
|
+
if (!valid) return false;
|
|
183
|
+
}
|
|
184
|
+
currentStepSignal.set(targetIdx);
|
|
185
|
+
return true;
|
|
186
|
+
},
|
|
187
|
+
reset() {
|
|
188
|
+
currentStepSignal.set(0);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const formSignalFn = () => valuesSignal();
|
|
193
|
+
formSignalFn.$ = fieldsProxy;
|
|
194
|
+
formSignalFn.set = values => {
|
|
195
|
+
valuesSignal.update(curr => ({
|
|
196
|
+
...curr,
|
|
197
|
+
...values
|
|
198
|
+
}));
|
|
199
|
+
schedulePersist();
|
|
200
|
+
};
|
|
201
|
+
formSignalFn.patch = values => {
|
|
202
|
+
valuesSignal.update(curr => ({
|
|
203
|
+
...curr,
|
|
204
|
+
...values
|
|
205
|
+
}));
|
|
206
|
+
schedulePersist();
|
|
207
|
+
};
|
|
208
|
+
formSignalFn.reset = () => {
|
|
209
|
+
valuesSignal.set({
|
|
210
|
+
...initial
|
|
211
|
+
});
|
|
212
|
+
touchedSignal.set(Object.keys(initial).reduce((acc, key) => {
|
|
213
|
+
acc[key] = false;
|
|
214
|
+
return acc;
|
|
215
|
+
}, {}));
|
|
216
|
+
errorsSignal.set({});
|
|
217
|
+
wizard?.reset();
|
|
218
|
+
schedulePersist();
|
|
219
|
+
};
|
|
220
|
+
formSignalFn.clear = () => {
|
|
221
|
+
const empty = Object.keys(initial).reduce((acc, key) => {
|
|
222
|
+
const val = initial[key];
|
|
223
|
+
acc[key] = typeof val === 'string' ? '' : typeof val === 'number' ? 0 : Array.isArray(val) ? [] : val === null ? null : typeof val === 'object' ? {} : val;
|
|
224
|
+
return acc;
|
|
225
|
+
}, {});
|
|
226
|
+
valuesSignal.set(empty);
|
|
227
|
+
schedulePersist();
|
|
228
|
+
};
|
|
229
|
+
formSignalFn.valid = valid;
|
|
230
|
+
formSignalFn.dirty = dirty;
|
|
231
|
+
formSignalFn.submitting = submittingSignal.asReadonly();
|
|
232
|
+
formSignalFn.touched = touchedSignal.asReadonly();
|
|
233
|
+
formSignalFn.errors = errorsSignal.asReadonly();
|
|
234
|
+
formSignalFn.errorList = errorList;
|
|
235
|
+
formSignalFn.validate = validateAll;
|
|
236
|
+
formSignalFn.validateField = validateField;
|
|
237
|
+
formSignalFn.touch = field => {
|
|
238
|
+
touchedSignal.update(t => ({
|
|
239
|
+
...t,
|
|
240
|
+
[field]: true
|
|
241
|
+
}));
|
|
242
|
+
};
|
|
243
|
+
formSignalFn.touchAll = () => {
|
|
244
|
+
touchedSignal.update(t => {
|
|
245
|
+
const updated = {
|
|
246
|
+
...t
|
|
247
|
+
};
|
|
248
|
+
for (const key of Object.keys(t)) {
|
|
249
|
+
updated[key] = true;
|
|
250
|
+
}
|
|
251
|
+
return updated;
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
formSignalFn.submit = async handler => {
|
|
255
|
+
submittingSignal.set(true);
|
|
256
|
+
try {
|
|
257
|
+
const isValid = await validateAll();
|
|
258
|
+
if (!isValid) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const result = await handler(valuesSignal());
|
|
262
|
+
return result;
|
|
263
|
+
} finally {
|
|
264
|
+
submittingSignal.set(false);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
formSignalFn.wizard = wizard;
|
|
268
|
+
formSignalFn.persistNow = () => {
|
|
269
|
+
if (persistTimeout) clearTimeout(persistTimeout);
|
|
270
|
+
saveToStorage();
|
|
271
|
+
};
|
|
272
|
+
formSignalFn.reload = () => {
|
|
273
|
+
loadFromStorage();
|
|
274
|
+
};
|
|
275
|
+
formSignalFn.clearStorage = () => {
|
|
276
|
+
if (config.persist && storage) {
|
|
277
|
+
storage.removeItem(config.persist);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
return formSignalFn;
|
|
281
|
+
}
|
|
282
|
+
function defaultEquality(a, b) {
|
|
283
|
+
if (a === b) return true;
|
|
284
|
+
if (a === null || b === null) return false;
|
|
285
|
+
if (typeof a !== typeof b) return false;
|
|
286
|
+
if (typeof a === 'object') {
|
|
287
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
const validators = {
|
|
292
|
+
required: (message = 'This field is required') => value => value === null || value === undefined || value === '' ? message : null,
|
|
293
|
+
minLength: (min, message) => value => typeof value === 'string' && value.length < min ? message ?? `Must be at least ${min} characters` : null,
|
|
294
|
+
maxLength: (max, message) => value => typeof value === 'string' && value.length > max ? message ?? `Must be at most ${max} characters` : null,
|
|
295
|
+
min: (min, message) => value => typeof value === 'number' && value < min ? message ?? `Must be at least ${min}` : null,
|
|
296
|
+
max: (max, message) => value => typeof value === 'number' && value > max ? message ?? `Must be at most ${max}` : null,
|
|
297
|
+
email: (message = 'Invalid email address') => value => typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? message : null,
|
|
298
|
+
pattern: (regex, message = 'Invalid format') => value => typeof value === 'string' && !regex.test(value) ? message : null,
|
|
299
|
+
when: (condition, validator) => (value, form) => {
|
|
300
|
+
if (!form) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
if (condition(form)) {
|
|
304
|
+
return validator(value);
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export { FORM_MARKER, createFormSignal, form, isFormMarker, validators };
|
|
@@ -2,6 +2,31 @@ import { signal } from '@angular/core';
|
|
|
2
2
|
import { registerMarkerProcessor } from '../internals/materialize-markers.js';
|
|
3
3
|
|
|
4
4
|
const STORED_MARKER = Symbol('STORED_MARKER');
|
|
5
|
+
function isVersionedData(value) {
|
|
6
|
+
return value !== null && typeof value === 'object' && '__v' in value && typeof value.__v === 'number' && 'data' in value;
|
|
7
|
+
}
|
|
8
|
+
function createStorageKeys(prefix, keys) {
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const [key, value] of Object.entries(keys)) {
|
|
11
|
+
if (typeof value === 'string') {
|
|
12
|
+
result[key] = `${prefix}:${value}`;
|
|
13
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
14
|
+
result[key] = createStorageKeys(`${prefix}:${key}`, value);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
function clearStoragePrefix(prefix, storage = typeof localStorage !== 'undefined' ? localStorage : null) {
|
|
20
|
+
if (!storage) return;
|
|
21
|
+
const keysToRemove = [];
|
|
22
|
+
for (let i = 0; i < storage.length; i++) {
|
|
23
|
+
const key = storage.key(i);
|
|
24
|
+
if (key && key.startsWith(`${prefix}:`)) {
|
|
25
|
+
keysToRemove.push(key);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
keysToRemove.forEach(key => storage.removeItem(key));
|
|
29
|
+
}
|
|
5
30
|
let storedRegistered = false;
|
|
6
31
|
function stored(key, defaultValue, options = {}) {
|
|
7
32
|
if (!storedRegistered) {
|
|
@@ -25,16 +50,71 @@ function createStoredSignal(marker) {
|
|
|
25
50
|
options: {
|
|
26
51
|
serialize = JSON.stringify,
|
|
27
52
|
deserialize = JSON.parse,
|
|
28
|
-
debounceMs = 100
|
|
53
|
+
debounceMs = 100,
|
|
54
|
+
version = 1,
|
|
55
|
+
migrate,
|
|
56
|
+
clearOnMigrationFailure = false
|
|
29
57
|
}
|
|
30
58
|
} = marker;
|
|
31
59
|
const storage = marker.options.storage !== undefined ? marker.options.storage : typeof localStorage !== 'undefined' ? localStorage : null;
|
|
32
60
|
let initialValue = defaultValue;
|
|
33
61
|
if (storage) {
|
|
34
62
|
try {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
|
|
63
|
+
const storedRaw = storage.getItem(key);
|
|
64
|
+
if (storedRaw !== null) {
|
|
65
|
+
const parsed = deserialize(storedRaw);
|
|
66
|
+
if (isVersionedData(parsed)) {
|
|
67
|
+
const storedVersion = parsed.__v;
|
|
68
|
+
let data = parsed.data;
|
|
69
|
+
if (storedVersion !== version && migrate) {
|
|
70
|
+
try {
|
|
71
|
+
data = migrate(data, storedVersion);
|
|
72
|
+
queueMicrotask(() => {
|
|
73
|
+
try {
|
|
74
|
+
const versionedData = {
|
|
75
|
+
__v: version,
|
|
76
|
+
data
|
|
77
|
+
};
|
|
78
|
+
storage.setItem(key, serialize(versionedData));
|
|
79
|
+
} catch {}
|
|
80
|
+
});
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
83
|
+
console.warn(`SignalTree: Migration failed for "${key}" from v${storedVersion} to v${version}`, e);
|
|
84
|
+
}
|
|
85
|
+
if (clearOnMigrationFailure) {
|
|
86
|
+
storage.removeItem(key);
|
|
87
|
+
}
|
|
88
|
+
data = defaultValue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
initialValue = data;
|
|
92
|
+
} else {
|
|
93
|
+
if (migrate && version > 0) {
|
|
94
|
+
try {
|
|
95
|
+
initialValue = migrate(parsed, 0);
|
|
96
|
+
queueMicrotask(() => {
|
|
97
|
+
try {
|
|
98
|
+
const versionedData = {
|
|
99
|
+
__v: version,
|
|
100
|
+
data: initialValue
|
|
101
|
+
};
|
|
102
|
+
storage.setItem(key, serialize(versionedData));
|
|
103
|
+
} catch {}
|
|
104
|
+
});
|
|
105
|
+
} catch (e) {
|
|
106
|
+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
107
|
+
console.warn(`SignalTree: Migration failed for "${key}" from legacy to v${version}`, e);
|
|
108
|
+
}
|
|
109
|
+
if (clearOnMigrationFailure) {
|
|
110
|
+
storage.removeItem(key);
|
|
111
|
+
}
|
|
112
|
+
initialValue = defaultValue;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
initialValue = parsed;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
38
118
|
}
|
|
39
119
|
} catch (e) {
|
|
40
120
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
@@ -43,14 +123,19 @@ function createStoredSignal(marker) {
|
|
|
43
123
|
}
|
|
44
124
|
}
|
|
45
125
|
const sig = signal(initialValue);
|
|
126
|
+
const currentVersion = version;
|
|
46
127
|
let pendingWrite = null;
|
|
47
128
|
let pendingValue;
|
|
48
129
|
const saveToStorage = value => {
|
|
49
130
|
if (!storage) return;
|
|
131
|
+
const versionedData = {
|
|
132
|
+
__v: currentVersion,
|
|
133
|
+
data: value
|
|
134
|
+
};
|
|
50
135
|
if (debounceMs === 0) {
|
|
51
136
|
queueMicrotask(() => {
|
|
52
137
|
try {
|
|
53
|
-
storage.setItem(key, serialize(
|
|
138
|
+
storage.setItem(key, serialize(versionedData));
|
|
54
139
|
} catch (e) {
|
|
55
140
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
56
141
|
console.warn(`SignalTree: Failed to save "${key}" to storage`, e);
|
|
@@ -67,7 +152,11 @@ function createStoredSignal(marker) {
|
|
|
67
152
|
pendingWrite = null;
|
|
68
153
|
queueMicrotask(() => {
|
|
69
154
|
try {
|
|
70
|
-
|
|
155
|
+
const finalData = {
|
|
156
|
+
__v: currentVersion,
|
|
157
|
+
data: pendingValue
|
|
158
|
+
};
|
|
159
|
+
storage.setItem(key, serialize(finalData));
|
|
71
160
|
} catch (e) {
|
|
72
161
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
73
162
|
console.warn(`SignalTree: Failed to save "${key}" to storage`, e);
|
|
@@ -95,9 +184,14 @@ function createStoredSignal(marker) {
|
|
|
95
184
|
storedSignal.reload = () => {
|
|
96
185
|
if (!storage) return;
|
|
97
186
|
try {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
187
|
+
const storedRaw = storage.getItem(key);
|
|
188
|
+
if (storedRaw !== null) {
|
|
189
|
+
const parsed = deserialize(storedRaw);
|
|
190
|
+
if (isVersionedData(parsed)) {
|
|
191
|
+
sig.set(parsed.data);
|
|
192
|
+
} else {
|
|
193
|
+
sig.set(parsed);
|
|
194
|
+
}
|
|
101
195
|
} else {
|
|
102
196
|
sig.set(defaultValue);
|
|
103
197
|
}
|
|
@@ -105,7 +199,15 @@ function createStoredSignal(marker) {
|
|
|
105
199
|
sig.set(defaultValue);
|
|
106
200
|
}
|
|
107
201
|
};
|
|
202
|
+
Object.defineProperty(storedSignal, 'key', {
|
|
203
|
+
value: key,
|
|
204
|
+
writable: false
|
|
205
|
+
});
|
|
206
|
+
Object.defineProperty(storedSignal, 'version', {
|
|
207
|
+
value: currentVersion,
|
|
208
|
+
writable: false
|
|
209
|
+
});
|
|
108
210
|
return storedSignal;
|
|
109
211
|
}
|
|
110
212
|
|
|
111
|
-
export { STORED_MARKER, createStoredSignal, isStoredMarker, stored };
|
|
213
|
+
export { STORED_MARKER, clearStoragePrefix, createStorageKeys, createStoredSignal, isStoredMarker, stored };
|
package/dist/lib/signal-tree.js
CHANGED
|
@@ -349,6 +349,7 @@ function createBuilder(baseTree) {
|
|
|
349
349
|
});
|
|
350
350
|
Object.defineProperty(builder, 'with', {
|
|
351
351
|
value: function (enhancer) {
|
|
352
|
+
finalize();
|
|
352
353
|
const enhanced = baseTree.with(enhancer);
|
|
353
354
|
const newBuilder = createBuilder(enhanced);
|
|
354
355
|
for (const key of Object.keys(enhanced)) {
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ export { derivedFrom, externalDerived } from './lib/internals/derived-types';
|
|
|
6
6
|
export type { SignalTreeBuilder } from './lib/internals/builder-types';
|
|
7
7
|
export { isDerivedMarker, type DerivedMarker, type DerivedType, } from './lib/markers/derived';
|
|
8
8
|
export { status, isStatusMarker, LoadingState, type StatusMarker, type StatusSignal, type StatusConfig, } from './lib/markers/status';
|
|
9
|
-
export { stored, isStoredMarker, type StoredMarker, type StoredSignal, type StoredOptions, } from './lib/markers/stored';
|
|
9
|
+
export { stored, isStoredMarker, createStorageKeys, clearStoragePrefix, type StoredMarker, type StoredSignal, type StoredOptions, } from './lib/markers/stored';
|
|
10
|
+
export { form, isFormMarker, createFormSignal, validators, FORM_MARKER, type FormMarker, type FormSignal, type FormConfig, type FormFields, type FormWizard, type WizardConfig, type WizardStepConfig, type Validator, type AsyncValidator, } from './lib/markers/form';
|
|
10
11
|
export { registerMarkerProcessor } from './lib/internals/materialize-markers';
|
|
11
12
|
export { equal, deepEqual, isNodeAccessor, isAnySignal, toWritableSignal, parsePath, composeEnhancers, isBuiltInObject, createLazySignalTree, } from './lib/utils';
|
|
12
13
|
export { createEditSession, type EditSession, type UndoRedoHistory, } from './lib/edit-session';
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Signal } from '@angular/core';
|
|
2
|
+
import { isEntityMapMarker } from '../utils';
|
|
3
|
+
export { isEntityMapMarker };
|
|
4
|
+
import type { EntityConfig, EntityMapMarker, EntitySignal } from '../types';
|
|
5
|
+
export interface ComputedSliceConfig<E, R> {
|
|
6
|
+
compute: (entities: E[]) => R;
|
|
7
|
+
}
|
|
8
|
+
export type EntitySignalWithSlices<E, K extends string | number, Slices extends Record<string, unknown>> = EntitySignal<E, K> & {
|
|
9
|
+
[P in keyof Slices]: Signal<Slices[P]>;
|
|
10
|
+
};
|
|
11
|
+
export interface EntityMapBuilder<E, K extends string | number, Slices extends Record<string, unknown> = Record<string, never>> extends EntityMapMarker<E, K> {
|
|
12
|
+
__computedSlices?: EntityMapComputedSlices<E>;
|
|
13
|
+
__sliceTypes?: Slices;
|
|
14
|
+
computed<N extends string, R>(name: N, compute: (entities: E[]) => R): EntityMapBuilder<E, K, Slices & Record<N, R>>;
|
|
15
|
+
build(): EntityMapMarkerWithSlices<E, K, Slices>;
|
|
16
|
+
}
|
|
2
17
|
export declare function entityMap<E, K extends string | number = E extends {
|
|
3
18
|
id: infer I extends string | number;
|
|
4
|
-
} ? I : string>(config?: EntityConfig<E, K>):
|
|
19
|
+
} ? I : string>(config?: EntityConfig<E, K>): EntityMapBuilder<E, K, Record<string, never>>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Signal } from '@angular/core';
|
|
2
|
+
export declare const FORM_MARKER: unique symbol;
|
|
3
|
+
export type Validator<T> = (value: T) => string | null;
|
|
4
|
+
export type AsyncValidator<T> = (value: T) => Promise<string | null>;
|
|
5
|
+
export interface WizardStepConfig {
|
|
6
|
+
fields?: string[];
|
|
7
|
+
validate?: () => Promise<boolean> | boolean;
|
|
8
|
+
canSkip?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface WizardConfig {
|
|
11
|
+
steps: string[];
|
|
12
|
+
stepConfig?: Record<string, WizardStepConfig>;
|
|
13
|
+
stepFields?: Record<string, string[]>;
|
|
14
|
+
}
|
|
15
|
+
export interface FormConfig<T extends Record<string, unknown>> {
|
|
16
|
+
initial: T;
|
|
17
|
+
persist?: string;
|
|
18
|
+
storage?: Storage | null;
|
|
19
|
+
persistDebounceMs?: number;
|
|
20
|
+
validators?: Partial<Record<keyof T, Validator<unknown> | Validator<unknown>[]>>;
|
|
21
|
+
asyncValidators?: Partial<Record<keyof T, AsyncValidator<unknown>>>;
|
|
22
|
+
wizard?: WizardConfig;
|
|
23
|
+
equalityFn?: (a: unknown, b: unknown) => boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface FormMarker<T extends Record<string, unknown>> {
|
|
26
|
+
[FORM_MARKER]: true;
|
|
27
|
+
config: FormConfig<T>;
|
|
28
|
+
}
|
|
29
|
+
export interface FormWizard {
|
|
30
|
+
currentStep: Signal<number>;
|
|
31
|
+
stepName: Signal<string>;
|
|
32
|
+
steps: Signal<string[]>;
|
|
33
|
+
canNext: Signal<boolean>;
|
|
34
|
+
canPrev: Signal<boolean>;
|
|
35
|
+
isLastStep: Signal<boolean>;
|
|
36
|
+
isFirstStep: Signal<boolean>;
|
|
37
|
+
next(): Promise<boolean>;
|
|
38
|
+
prev(): void;
|
|
39
|
+
goTo(step: number | string): Promise<boolean>;
|
|
40
|
+
reset(): void;
|
|
41
|
+
}
|
|
42
|
+
export type FormFields<T> = {
|
|
43
|
+
[K in keyof T]: T[K] extends Record<string, unknown> ? FormFields<T[K]> & {
|
|
44
|
+
(): T[K];
|
|
45
|
+
set(value: T[K]): void;
|
|
46
|
+
} : {
|
|
47
|
+
(): T[K];
|
|
48
|
+
set(value: T[K]): void;
|
|
49
|
+
update(fn: (current: T[K]) => T[K]): void;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export interface FormSignal<T extends Record<string, unknown>> {
|
|
53
|
+
$: FormFields<T>;
|
|
54
|
+
(): T;
|
|
55
|
+
set(values: Partial<T>): void;
|
|
56
|
+
patch(values: Partial<T>): void;
|
|
57
|
+
reset(): void;
|
|
58
|
+
clear(): void;
|
|
59
|
+
valid: Signal<boolean>;
|
|
60
|
+
dirty: Signal<boolean>;
|
|
61
|
+
submitting: Signal<boolean>;
|
|
62
|
+
touched: Signal<Record<keyof T, boolean>>;
|
|
63
|
+
errors: Signal<Partial<Record<keyof T, string | null>>>;
|
|
64
|
+
errorList: Signal<string[]>;
|
|
65
|
+
validate(): Promise<boolean>;
|
|
66
|
+
validateField(field: keyof T): Promise<boolean>;
|
|
67
|
+
touch(field: keyof T): void;
|
|
68
|
+
touchAll(): void;
|
|
69
|
+
submit<R>(handler: (values: T) => Promise<R>): Promise<R | null>;
|
|
70
|
+
wizard?: FormWizard;
|
|
71
|
+
persistNow(): void;
|
|
72
|
+
reload(): void;
|
|
73
|
+
clearStorage(): void;
|
|
74
|
+
}
|
|
75
|
+
export declare function form<T extends Record<string, unknown>>(config: FormConfig<T>): FormMarker<T>;
|
|
76
|
+
export declare function isFormMarker(value: unknown): value is FormMarker<Record<string, unknown>>;
|
|
77
|
+
export declare const validators: {
|
|
78
|
+
required: (message?: string) => (value: unknown) => string | null;
|
|
79
|
+
minLength: (min: number, message?: string) => (value: unknown) => string | null;
|
|
80
|
+
maxLength: (max: number, message?: string) => (value: unknown) => string | null;
|
|
81
|
+
min: (min: number, message?: string) => (value: unknown) => string | null;
|
|
82
|
+
max: (max: number, message?: string) => (value: unknown) => string | null;
|
|
83
|
+
email: (message?: string) => (value: unknown) => string | null;
|
|
84
|
+
pattern: (regex: RegExp, message?: string) => (value: unknown) => string | null;
|
|
85
|
+
when: <T>(condition: (form: T) => boolean, validator: Validator<unknown>) => (value: unknown, form?: T) => string | null;
|
|
86
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { isDerivedMarker, getDerivedMarkerSymbol, type DerivedMarker, type DerivedType, } from './derived';
|
|
2
2
|
export { status, isStatusMarker, createStatusSignal, LoadingState, STATUS_MARKER, type StatusMarker, type StatusSignal, type StatusConfig, } from './status';
|
|
3
|
-
export { stored, isStoredMarker, createStoredSignal, STORED_MARKER, type StoredMarker, type StoredSignal, type StoredOptions, } from './stored';
|
|
3
|
+
export { stored, isStoredMarker, createStoredSignal, createStorageKeys, clearStoragePrefix, STORED_MARKER, type StoredMarker, type StoredSignal, type StoredOptions, type MigrationFn, } from './stored';
|
|
4
|
+
export { form, isFormMarker, createFormSignal, validators, FORM_MARKER, type FormMarker, type FormSignal, type FormConfig, type FormFields, type FormWizard, type WizardConfig, type WizardStepConfig, type Validator, type AsyncValidator, } from './form';
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
export declare const STORED_MARKER: unique symbol;
|
|
2
|
+
export type MigrationFn<T> = (oldData: unknown, oldVersion: number) => T;
|
|
2
3
|
export interface StoredOptions<T> {
|
|
3
4
|
serialize?: (value: T) => string;
|
|
4
5
|
deserialize?: (stored: string) => T;
|
|
5
6
|
storage?: Storage | null;
|
|
6
7
|
debounceMs?: number;
|
|
8
|
+
version?: number;
|
|
9
|
+
migrate?: MigrationFn<T>;
|
|
10
|
+
clearOnMigrationFailure?: boolean;
|
|
7
11
|
}
|
|
12
|
+
type StorageKeyMap<T, Prefix extends string> = {
|
|
13
|
+
[K in keyof T]: T[K] extends string ? `${Prefix}:${T[K] & string}` : T[K] extends object ? StorageKeyMap<T[K], `${Prefix}:${K & string}`> : never;
|
|
14
|
+
};
|
|
15
|
+
export declare function createStorageKeys<T extends object, P extends string>(prefix: P, keys: T): StorageKeyMap<T, P>;
|
|
16
|
+
export declare function clearStoragePrefix(prefix: string, storage?: Storage): void;
|
|
8
17
|
export interface StoredMarker<T> {
|
|
9
18
|
[STORED_MARKER]: true;
|
|
10
19
|
key: string;
|
|
@@ -17,7 +26,10 @@ export interface StoredSignal<T> {
|
|
|
17
26
|
update(fn: (current: T) => T): void;
|
|
18
27
|
clear(): void;
|
|
19
28
|
reload(): void;
|
|
29
|
+
readonly key: string;
|
|
30
|
+
readonly version: number;
|
|
20
31
|
}
|
|
21
32
|
export declare function stored<T>(key: string, defaultValue: T, options?: StoredOptions<T>): StoredMarker<T>;
|
|
22
33
|
export declare function isStoredMarker(value: unknown): value is StoredMarker<unknown>;
|
|
23
34
|
export declare function createStoredSignal<T>(marker: StoredMarker<T>): StoredSignal<T>;
|
|
35
|
+
export {};
|
package/src/lib/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Signal, WritableSignal } from '@angular/core';
|
|
2
|
+
import { FormMarker, FormSignal } from './markers/form';
|
|
2
3
|
import { StatusMarker, StatusSignal } from './markers/status';
|
|
3
4
|
import { StoredMarker, StoredSignal } from './markers/stored';
|
|
4
5
|
import { SecurityValidatorConfig } from './security/security-validator';
|
|
@@ -34,7 +35,7 @@ export interface NodeAccessor<T> {
|
|
|
34
35
|
(updater: (current: T) => T): void;
|
|
35
36
|
}
|
|
36
37
|
export type TreeNode<T> = {
|
|
37
|
-
[K in keyof T]: T[K] extends EntityMapMarker<infer E, infer Key> ? EntitySignal<E, Key> : T[K] extends StatusMarker<infer Err> ? StatusSignal<Err> : T[K] extends StoredMarker<infer V> ? StoredSignal<V> : T[K] extends Primitive ? CallableWritableSignal<T[K]> : T[K] extends readonly unknown[] ? CallableWritableSignal<T[K]> : T[K] extends Date | RegExp | Map<any, any> | Set<any> | Error | ((...args: unknown[]) => unknown) ? CallableWritableSignal<T[K]> : T[K] extends object ? NodeAccessor<T[K]> & TreeNode<T[K]> : CallableWritableSignal<T[K]>;
|
|
38
|
+
[K in keyof T]: T[K] extends EntityMapMarker<infer E, infer Key> ? EntitySignal<E, Key> : T[K] extends StatusMarker<infer Err> ? StatusSignal<Err> : T[K] extends StoredMarker<infer V> ? StoredSignal<V> : T[K] extends FormMarker<infer F> ? FormSignal<F> : T[K] extends Primitive ? CallableWritableSignal<T[K]> : T[K] extends readonly unknown[] ? CallableWritableSignal<T[K]> : T[K] extends Date | RegExp | Map<any, any> | Set<any> | Error | ((...args: unknown[]) => unknown) ? CallableWritableSignal<T[K]> : T[K] extends object ? NodeAccessor<T[K]> & TreeNode<T[K]> : CallableWritableSignal<T[K]>;
|
|
38
39
|
};
|
|
39
40
|
export interface ISignalTree<T> extends NodeAccessor<T> {
|
|
40
41
|
readonly state: TreeNode<T>;
|