@proveanything/smartlinks 1.9.22 → 1.10.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/dist/api/appObjects.d.ts +61 -1
- package/dist/api/appObjects.js +88 -8
- package/dist/docs/API_SUMMARY.md +162 -4
- package/dist/docs/app-objects.md +266 -2
- package/dist/docs/records-admin-pattern.md +32 -13
- package/dist/docs/ui-utils.md +3 -3
- package/dist/openapi.yaml +1073 -808
- package/dist/types/appObjects.d.ts +223 -2
- package/docs/API_SUMMARY.md +162 -4
- package/docs/app-objects.md +266 -2
- package/docs/records-admin-pattern.md +32 -13
- package/docs/ui-utils.md +3 -3
- package/openapi.yaml +1073 -808
- package/package.json +1 -1
package/dist/docs/app-objects.md
CHANGED
|
@@ -432,7 +432,7 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
432
432
|
|
|
433
433
|
## Records
|
|
434
434
|
|
|
435
|
-
**Records** are the most flexible object type — use them for structured data with time-based lifecycles, hierarchies, or custom schemas.
|
|
435
|
+
**Records** are the most flexible object type — use them for structured data with time-based lifecycles, hierarchies, or custom schemas. Records also support **structured scoping** — each record can declare which products, variants, batches, proofs, or facet values it applies to, and the platform can match records against a runtime context.
|
|
436
436
|
|
|
437
437
|
### When to Use Records
|
|
438
438
|
|
|
@@ -444,15 +444,279 @@ const productComments = await app.threads.list(collectionId, appId, {
|
|
|
444
444
|
- **Usage logs** — record product usage metrics
|
|
445
445
|
- **Audit trails** — immutable logs of actions
|
|
446
446
|
- **Loyalty points** — track points earned/redeemed
|
|
447
|
+
- **Per-product / per-facet configuration** — scoped data that varies by product axis (see [records-admin-pattern.md](records-admin-pattern.md))
|
|
447
448
|
|
|
448
449
|
### Key Features
|
|
449
450
|
|
|
450
451
|
- **Record types** — `recordType` field for categorization (required)
|
|
451
452
|
- **Time windows** — `startsAt` and `expiresAt` for time-based data
|
|
453
|
+
- **Scoped targeting** — `scope` object restricts which products/variants/facets the record applies to
|
|
454
|
+
- **Specificity scoring** — `specificity` enables "best match" resolution across multiple scoped records
|
|
452
455
|
- **Parent linking** — attach to products, proofs, contacts, etc.
|
|
453
456
|
- **Author tracking** — `authorId` + `authorType`
|
|
454
457
|
- **Status lifecycle** — custom statuses (default `'active'`)
|
|
455
|
-
- **References** — optional `ref` field for external IDs
|
|
458
|
+
- **References** — optional `ref` field for external IDs; auto-derived from scope if omitted
|
|
459
|
+
|
|
460
|
+
### Scoped Records
|
|
461
|
+
|
|
462
|
+
Every record has a `scope` object. An empty scope (`{}`) means the record is universal — it applies everywhere. A populated scope restricts which context the record applies to.
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
import { app } from '@proveanything/smartlinks';
|
|
466
|
+
|
|
467
|
+
// A nutrition record scoped to a specific product
|
|
468
|
+
await app.records.create(collectionId, appId, {
|
|
469
|
+
recordType: 'nutrition',
|
|
470
|
+
scope: { productId: 'prod_abc' },
|
|
471
|
+
data: { calories: 250, protein: 12.5 },
|
|
472
|
+
}, true);
|
|
473
|
+
|
|
474
|
+
// A nutrition record for a specific variant, overriding the product-level record
|
|
475
|
+
await app.records.create(collectionId, appId, {
|
|
476
|
+
recordType: 'nutrition',
|
|
477
|
+
scope: { productId: 'prod_abc', variantId: 'var_500ml' },
|
|
478
|
+
data: { calories: 260, protein: 12.5 },
|
|
479
|
+
}, true);
|
|
480
|
+
|
|
481
|
+
// A record scoped to a facet value (applies to all products with tier=gold)
|
|
482
|
+
await app.records.create(collectionId, appId, {
|
|
483
|
+
recordType: 'loyalty_promo',
|
|
484
|
+
scope: {
|
|
485
|
+
facets: [{ key: 'tier', valueKeys: ['gold', 'platinum'] }]
|
|
486
|
+
},
|
|
487
|
+
data: { discountPercent: 15 },
|
|
488
|
+
}, true);
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
The `ref` field is derived automatically when `scope` is provided and `ref` is omitted:
|
|
492
|
+
|
|
493
|
+
```
|
|
494
|
+
scope: { productId: 'prod_abc' } → ref: 'product:prod_abc'
|
|
495
|
+
scope: { productId: 'prod_abc', variantId: 'var_x' } → ref: 'product:prod_abc/variant:var_x'
|
|
496
|
+
scope: {} → ref: '' (universal)
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
#### Specificity scores
|
|
500
|
+
|
|
501
|
+
When multiple scoped records match a context, they are ordered by `specificity`. Higher = more specific:
|
|
502
|
+
|
|
503
|
+
| Scope element | Points |
|
|
504
|
+
|---------------|--------|
|
|
505
|
+
| `proofId` | +1000 |
|
|
506
|
+
| `batchId` | +500 |
|
|
507
|
+
| `variantId` | +250 |
|
|
508
|
+
| `productId` | +100 |
|
|
509
|
+
| Per facet clause | +10 |
|
|
510
|
+
| Per facet value key | +1 |
|
|
511
|
+
|
|
512
|
+
### Matching Records Against a Context
|
|
513
|
+
|
|
514
|
+
Use `app.records.match()` to find records whose scope is satisfied by a runtime target:
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
// Find all nutrition records that apply for this product + facet context
|
|
518
|
+
const { records } = await app.records.match(collectionId, appId, {
|
|
519
|
+
target: {
|
|
520
|
+
productId: 'prod_abc',
|
|
521
|
+
facets: { tier: ['gold'] },
|
|
522
|
+
},
|
|
523
|
+
recordType: 'nutrition',
|
|
524
|
+
}, true);
|
|
525
|
+
// records is ordered by specificity descending — most specific first
|
|
526
|
+
// each record carries a `matchedAt` field indicating which scope dimension matched
|
|
527
|
+
|
|
528
|
+
// Or use strategy: 'best' to get the single winner per recordType
|
|
529
|
+
const { best } = await app.records.match(collectionId, appId, {
|
|
530
|
+
target: { productId: 'prod_abc', variantId: 'var_500ml' },
|
|
531
|
+
strategy: 'best',
|
|
532
|
+
}, true);
|
|
533
|
+
// best.nutrition → the highest-specificity nutrition record for this context
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Facet matching rules:
|
|
537
|
+
- Multiple `facets` clauses are **ANDed** — all must be satisfied
|
|
538
|
+
- Values within a single clause are **ORed** — any matching value satisfies it
|
|
539
|
+
- A record with no facet clauses is satisfied by any target
|
|
540
|
+
|
|
541
|
+
#### `matchedAt` — match attribution
|
|
542
|
+
|
|
543
|
+
Every record in the response includes a `matchedAt` field indicating **which scope dimension caused the match**. Use it to render attribution labels without inspecting scope fields:
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
const { records } = await app.records.match(collectionId, appId, { target, recordType: 'nutrition' }, true);
|
|
547
|
+
|
|
548
|
+
for (const record of records) {
|
|
549
|
+
switch (record.matchedAt) {
|
|
550
|
+
case 'proof': /* "Scan-specific" */ break;
|
|
551
|
+
case 'batch': /* "Batch-specific" */ break;
|
|
552
|
+
case 'variant': /* "Size-specific" */ break;
|
|
553
|
+
case 'product': /* "Inherited from product" */ break;
|
|
554
|
+
case 'facet': /* "Tier-specific" */ break;
|
|
555
|
+
case 'universal': /* "Default" */ break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
Precedence follows specificity order: `proof > batch > variant > product > facet > universal`.
|
|
561
|
+
|
|
562
|
+
#### React — `useResolvedRecord`
|
|
563
|
+
|
|
564
|
+
For React consumers, the `useResolvedRecord` hook in `@proveanything/smartlinks-utils-ui` wraps `records.match()` and returns the best-matching record with loading and error states. The raw `records.match()` API exists for non-React consumers and custom resolution logic.
|
|
565
|
+
|
|
566
|
+
### Upsert
|
|
567
|
+
|
|
568
|
+
Create-or-update a record by `ref` in a single call:
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
const { created } = await app.records.upsert(collectionId, appId, {
|
|
572
|
+
ref: 'product:prod_abc',
|
|
573
|
+
recordType: 'nutrition',
|
|
574
|
+
scope: { productId: 'prod_abc' },
|
|
575
|
+
data: { calories: 250, protein: 12.5 },
|
|
576
|
+
});
|
|
577
|
+
// created: true if new, false if updated
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Bulk Operations
|
|
581
|
+
|
|
582
|
+
Upsert or delete large sets of records efficiently:
|
|
583
|
+
|
|
584
|
+
```typescript
|
|
585
|
+
// Bulk upsert up to 500 records in one transaction
|
|
586
|
+
const result = await app.records.bulkUpsert(collectionId, appId, [
|
|
587
|
+
{ ref: 'product:prod_abc', recordType: 'nutrition', scope: { productId: 'prod_abc' }, data: { calories: 250 } },
|
|
588
|
+
{ ref: 'product:prod_xyz', recordType: 'nutrition', scope: { productId: 'prod_xyz' }, data: { calories: 180 } },
|
|
589
|
+
]);
|
|
590
|
+
// result: { saved: 2, failed: 0, results: [...] }
|
|
591
|
+
|
|
592
|
+
// Bulk delete by explicit refs
|
|
593
|
+
await app.records.bulkDelete(collectionId, appId, {
|
|
594
|
+
refs: ['product:prod_abc', 'product:prod_xyz'],
|
|
595
|
+
recordType: 'nutrition',
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Bulk delete by scope anchor (all records under a product)
|
|
599
|
+
await app.records.bulkDelete(collectionId, appId, {
|
|
600
|
+
scope: { productId: 'prod_abc' },
|
|
601
|
+
});
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Soft-Delete Semantics
|
|
605
|
+
|
|
606
|
+
`delete` and `bulkDelete` **soft-delete** records: the row is retained with a non-null `deletedAt` and excluded from all queries by default. Records are **recoverable indefinitely** — there is no expiry on `deletedAt`.
|
|
607
|
+
|
|
608
|
+
```typescript
|
|
609
|
+
// Single-record restore
|
|
610
|
+
const restored = await app.records.restore(collectionId, appId, recordId);
|
|
611
|
+
|
|
612
|
+
// List including deleted records (admin only)
|
|
613
|
+
const all = await app.records.list(collectionId, appId, {
|
|
614
|
+
recordType: 'nutrition',
|
|
615
|
+
includeDeleted: true,
|
|
616
|
+
}, true);
|
|
617
|
+
// all.data includes records with non-null deletedAt
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
`bulkDelete` is fully reversible: rows survive with their IDs intact. Restore individually via `restore`, or re-write via `bulkUpsert` (which will find the existing row by `ref` and update it, clearing `deletedAt` in the process).
|
|
621
|
+
|
|
622
|
+
### Text Search
|
|
623
|
+
|
|
624
|
+
The `q` parameter on `GET /records` performs a **case-insensitive substring match** (`ILIKE`) on `data->>'label'`. It works on both admin and public list endpoints today:
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
const results = await app.records.list(collectionId, appId, {
|
|
628
|
+
recordType: 'product',
|
|
629
|
+
q: 'premium',
|
|
630
|
+
}, true);
|
|
631
|
+
// returns records where data.label contains 'premium' (case-insensitive)
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
> `q` is not a full-text index and does not return ranked results. For ranked relevance search over large corpora, use the Elasticsearch integration.
|
|
635
|
+
|
|
636
|
+
### External ID / ETL Workflow
|
|
637
|
+
|
|
638
|
+
`customId` and `sourceSystem` provide a stable external key pair for loading records from external systems (CMS, ERP, PIM, etc.):
|
|
639
|
+
|
|
640
|
+
- Both fields are **indexed** via a composite index on `(sourceSystem, customIdNormalized)`.
|
|
641
|
+
- `customId` is **filterable** on `GET /records?customId=x&sourceSystem=y`.
|
|
642
|
+
- The pair is **not unique** — the same external ID can exist across different `recordType` values by design (a CMS slug can appear in both a `content` and a `nutrition` record).
|
|
643
|
+
- `upsert` currently keys on `ref`, not `customId`. The recommended ETL pattern is to derive a stable `ref` from the external ID and pass `customId` alongside:
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
// Idiomatic ETL upsert: ref is derived from the external key, customId carries it too
|
|
647
|
+
await app.records.upsert(collectionId, appId, {
|
|
648
|
+
ref: `cms:${slug}`, // stable find-or-create key
|
|
649
|
+
customId: slug,
|
|
650
|
+
sourceSystem: 'contentful',
|
|
651
|
+
recordType: 'content_page',
|
|
652
|
+
scope: { productId },
|
|
653
|
+
data: { title, body },
|
|
654
|
+
});
|
|
655
|
+
// upsert finds-or-creates by ref deterministically,
|
|
656
|
+
// customId is stored for later reverse-lookup via ?customId=&sourceSystem=
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
### Counts by Record Type
|
|
660
|
+
|
|
661
|
+
`aggregate()` returns counts grouped by `record_type` in a single round-trip — no separate endpoint needed:
|
|
662
|
+
|
|
663
|
+
```typescript
|
|
664
|
+
const stats = await app.records.aggregate(collectionId, appId, {
|
|
665
|
+
groupBy: ['record_type'],
|
|
666
|
+
metrics: ['count'],
|
|
667
|
+
// Optionally narrow the corpus:
|
|
668
|
+
filters: { status: 'active' },
|
|
669
|
+
}, true);
|
|
670
|
+
// stats.groups → [{ record_type: 'nutrition', count: 42 }, { record_type: 'loyalty_promo', count: 7 }, ...]
|
|
671
|
+
// Ordered by count descending
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
You can also combine with other filters:
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
// Counts per type for a specific product
|
|
678
|
+
await app.records.aggregate(collectionId, appId, {
|
|
679
|
+
groupBy: ['record_type'],
|
|
680
|
+
metrics: ['count'],
|
|
681
|
+
filters: { product_id: 'prod_abc' },
|
|
682
|
+
}, true);
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### Canonical Ref Format
|
|
686
|
+
|
|
687
|
+
The `ref` field is **server-derived** when you provide a `scope` and omit `ref`. Clients should never construct ref strings manually. The authoritative grammar is slash-joined:
|
|
688
|
+
|
|
689
|
+
```
|
|
690
|
+
[facet:{key}={val1}+{val2}/][product:{productId}/][variant:{variantId}/][batch:{batchId}/][proof:{proofId}]
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
Examples:
|
|
694
|
+
|
|
695
|
+
| Scope | Derived ref |
|
|
696
|
+
|-------|-------------|
|
|
697
|
+
| `{ productId: 'prod_abc' }` | `product:prod_abc` |
|
|
698
|
+
| `{ productId: 'prod_abc', variantId: 'var_500ml' }` | `product:prod_abc/variant:var_500ml` |
|
|
699
|
+
| `{ batchId: 'batch_q1' }` | `batch:batch_q1` |
|
|
700
|
+
| `{ facets: [{ key: 'tier', valueKeys: ['gold'] }] }` | `facet:tier=gold` |
|
|
701
|
+
| `{}` | `''` (universal) |
|
|
702
|
+
|
|
703
|
+
`parseRef` / `buildRef` in `data/refs.ts` should be used for **display and URL round-tripping only**, never as upsert keys. For ETL use cases, set an explicit `ref` using a stable external key (see [External ID / ETL Workflow](#external-id--etl-workflow)).
|
|
704
|
+
|
|
705
|
+
`startsAt` and `expiresAt` control record active windows. The list and match endpoints respect scheduling by default (only returning currently-active records). Override with:
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
// Include future records and expired records
|
|
709
|
+
await app.records.list(collectionId, appId, {
|
|
710
|
+
includeScheduled: true,
|
|
711
|
+
includeExpired: true,
|
|
712
|
+
}, true);
|
|
713
|
+
|
|
714
|
+
// Preview what records will be active at a future point in time
|
|
715
|
+
await app.records.match(collectionId, appId, {
|
|
716
|
+
target: { productId: 'prod_abc' },
|
|
717
|
+
at: '2026-06-01T00:00:00Z',
|
|
718
|
+
}, true);
|
|
719
|
+
```
|
|
456
720
|
|
|
457
721
|
### Example: Product Registration
|
|
458
722
|
|
|
@@ -30,7 +30,7 @@ Without a shared pattern, every app reinvents:
|
|
|
30
30
|
- CSV import/export
|
|
31
31
|
- bulk operations
|
|
32
32
|
|
|
33
|
-
The result is drift: each app feels different and admins have to re-learn the model. This guide locks the model down at the SDK level so the matching UI primitives in `@proveanything/
|
|
33
|
+
The result is drift: each app feels different and admins have to re-learn the model. This guide locks the model down at the SDK level so the matching UI primitives in `@proveanything/smartlinks-utils-ui` (see the [companion guide](ui-utils.md)) can stay simple.
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
@@ -71,20 +71,35 @@ variant:<productId>:<variantId>
|
|
|
71
71
|
batch:<productId>:<batchId>
|
|
72
72
|
proof:<proofId>
|
|
73
73
|
facet:<facetKey>:<valueKey>
|
|
74
|
-
|
|
74
|
+
'' (universal / collection-wide fallback)
|
|
75
75
|
```
|
|
76
76
|
|
|
77
77
|
Notes:
|
|
78
78
|
|
|
79
79
|
- Variants and batches are **always nested under a product** in the SDK, so their refs include `productId`.
|
|
80
80
|
- `facet:` refs are **collection-wide** — facets cross products by design.
|
|
81
|
-
- `
|
|
81
|
+
- `''` (empty ref) is the **universal** record — one per `(app, recordType)` with no scope restrictions; the collection-wide fallback.
|
|
82
82
|
- Refs are opaque to the SDK. Apps parse them. A helper module (`@proveanything/smartlinks-utils-ui/records-admin`) exports `parseRef`/`buildRef` so all apps agree on syntax. See Appendix A for the full implementation.
|
|
83
83
|
|
|
84
84
|
### Adding scopes later
|
|
85
85
|
|
|
86
86
|
If a new axis appears (e.g. `region:eu`), pick a new prefix and document it. Never reuse a prefix with different semantics.
|
|
87
87
|
|
|
88
|
+
### Writing records
|
|
89
|
+
|
|
90
|
+
When creating or upserting a record, send a structured **`RecordScope`** — the server derives `ref` from it:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
await app.records.upsert(collectionId, appId, {
|
|
94
|
+
recordType: 'nutrition',
|
|
95
|
+
scope: { productId: 'prod_abc', variantId: 'var_500ml' }, // server → ref: 'product:prod_abc/variant:var_500ml'
|
|
96
|
+
data: { calories: 260 },
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `ref` is for **display, URL routing, and resolution output only** — never construct one to use as an upsert key.
|
|
101
|
+
- `customId` / `sourceSystem` are for external references (filterable via `list()`) but are **not unique** — the same external ID can exist across `recordType` values. Do not upsert on `customId` either.
|
|
102
|
+
|
|
88
103
|
---
|
|
89
104
|
|
|
90
105
|
## 4. Resolution order (REQUIRED)
|
|
@@ -92,7 +107,7 @@ If a new axis appears (e.g. `region:eu`), pick a new prefix and document it. Nev
|
|
|
92
107
|
When the **public** side of an app needs "the data that applies to this proof / product / context", it walks the chain from most-specific to least-specific and returns the first match:
|
|
93
108
|
|
|
94
109
|
```
|
|
95
|
-
proof → batch → variant → product → facet(*) →
|
|
110
|
+
proof → batch → variant → product → facet(*) → universal
|
|
96
111
|
```
|
|
97
112
|
|
|
98
113
|
`facet(*)` means: walk every facet attached to the product in a deterministic order (alphabetical by `facetKey`, then `valueKey`) and use the first matching facet record.
|
|
@@ -208,7 +223,7 @@ If you are using `<RecordsAdminShell>`, the bulk actions menu is included and wi
|
|
|
208
223
|
|
|
209
224
|
## 9. CSV import / export
|
|
210
225
|
|
|
211
|
-
|
|
226
|
+
If your app exposes CSV import/export, use this column shape so files round-trip across apps:
|
|
212
227
|
|
|
213
228
|
```
|
|
214
229
|
scope,scopeRef,<field1>,<field2>,...
|
|
@@ -220,18 +235,22 @@ facet,bread_type/sourdough,240,11.0,...
|
|
|
220
235
|
|
|
221
236
|
- `scope` is the kind; `scopeRef` is the human-readable reference (the shell maps it to the canonical `ref`).
|
|
222
237
|
- Validation errors return a downloadable annotated CSV with an `error` column appended.
|
|
223
|
-
|
|
238
|
+
|
|
239
|
+
#### If you ship CSV
|
|
240
|
+
|
|
241
|
+
- Round-tripping (export → reimport unchanged) must be a no-op.
|
|
224
242
|
|
|
225
243
|
---
|
|
226
244
|
|
|
227
245
|
## 10. Public-side hook contract
|
|
228
246
|
|
|
229
|
-
To keep widgets consistent across apps, expose one hook per record type (
|
|
247
|
+
To keep widgets consistent across apps, expose one hook per record type (from `@proveanything/smartlinks-utils-ui`):
|
|
230
248
|
|
|
231
249
|
```ts
|
|
232
250
|
import { useResolvedRecord } from '@proveanything/smartlinks-utils-ui/records-admin';
|
|
233
251
|
|
|
234
252
|
const { data, source, isLoading } = useResolvedRecord({
|
|
253
|
+
SL,
|
|
235
254
|
appId,
|
|
236
255
|
recordType: 'nutrition',
|
|
237
256
|
// any combination of these — the hook walks the chain:
|
|
@@ -239,13 +258,13 @@ const { data, source, isLoading } = useResolvedRecord({
|
|
|
239
258
|
});
|
|
240
259
|
```
|
|
241
260
|
|
|
242
|
-
`source` is one of `'proof' | 'batch' | 'variant' | 'product' | 'facet' | '
|
|
261
|
+
`source` is one of `'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'universal' | null`. UI can show a badge ("Showing batch-specific values") when useful.
|
|
243
262
|
|
|
244
263
|
---
|
|
245
264
|
|
|
246
265
|
## 11. Telemetry
|
|
247
266
|
|
|
248
|
-
All admin shells emit these events. The `<RecordsAdminShell>` from `@proveanything/
|
|
267
|
+
All admin shells emit these events. The `<RecordsAdminShell>` from `@proveanything/smartlinks-utils-ui` wires them automatically using `interactions.appendEvent` from the SDK — apps don't call this themselves.
|
|
249
268
|
|
|
250
269
|
| Event | Props |
|
|
251
270
|
|------------------------|------------------------------------------------------|
|
|
@@ -278,11 +297,11 @@ All admin shells emit these events. The `<RecordsAdminShell>` from `@proveanythi
|
|
|
278
297
|
|
|
279
298
|
## Appendix A — `ref` parser reference
|
|
280
299
|
|
|
281
|
-
This is the canonical implementation. Copy it into your app or import from `@proveanything/
|
|
300
|
+
This is the canonical implementation. Copy it into your app or import from `@proveanything/smartlinks-utils-ui/records-admin`.
|
|
282
301
|
|
|
283
302
|
```ts
|
|
284
303
|
type ParsedRef =
|
|
285
|
-
| { kind: '
|
|
304
|
+
| { kind: 'universal' }
|
|
286
305
|
| { kind: 'product'; productId: string }
|
|
287
306
|
| { kind: 'variant'; productId: string; variantId: string }
|
|
288
307
|
| { kind: 'batch'; productId: string; batchId: string }
|
|
@@ -291,7 +310,7 @@ type ParsedRef =
|
|
|
291
310
|
|
|
292
311
|
export const buildRef = (p: ParsedRef): string => {
|
|
293
312
|
switch (p.kind) {
|
|
294
|
-
case '
|
|
313
|
+
case 'universal': return '';
|
|
295
314
|
case 'product': return `product:${p.productId}`;
|
|
296
315
|
case 'variant': return `variant:${p.productId}:${p.variantId}`;
|
|
297
316
|
case 'batch': return `batch:${p.productId}:${p.batchId}`;
|
|
@@ -301,7 +320,7 @@ export const buildRef = (p: ParsedRef): string => {
|
|
|
301
320
|
};
|
|
302
321
|
|
|
303
322
|
export const parseRef = (ref: string): ParsedRef | null => {
|
|
304
|
-
if (ref
|
|
323
|
+
if (!ref) return { kind: 'universal' };
|
|
305
324
|
const [head, ...rest] = ref.split(':');
|
|
306
325
|
switch (head) {
|
|
307
326
|
case 'product': return rest.length === 1 ? { kind: 'product', productId: rest[0] } : null;
|
package/dist/docs/ui-utils.md
CHANGED
|
@@ -82,7 +82,7 @@ import * as SL from '@proveanything/smartlinks';
|
|
|
82
82
|
scopes={['facet', 'product', 'variant', 'batch']}
|
|
83
83
|
contextScope={{ productId, variantId, batchId }} // from iframe URL — optional
|
|
84
84
|
defaultData={() => ({})}
|
|
85
|
-
csvSchema={{ columns: [/* ... */] }}
|
|
85
|
+
csvSchema={{ columns: [/* ... */] }} // optional — omit to disable CSV import/export
|
|
86
86
|
renderEditor={(ctx) => <NutritionForm ctx={ctx} />}
|
|
87
87
|
renderPreview={({ resolved }) => <pre>{JSON.stringify(resolved, null, 2)}</pre>}
|
|
88
88
|
/>
|
|
@@ -97,7 +97,7 @@ import * as SL from '@proveanything/smartlinks';
|
|
|
97
97
|
- **Collection-aware tabs**: calls `collection.get` and hides Variants / Batches tabs unless `collection.variants` / `collection.batches` are true — no flicker
|
|
98
98
|
- **Server-side pagination** via `useInfiniteQuery` — handles thousands of products with a "Load more" button
|
|
99
99
|
- **Context-aware**: pass `contextScope` from your iframe URL (`productId` / `variantId` / `batchId`) and the browser is constrained to that subtree with the right tab auto-selected
|
|
100
|
-
- **CSV import / export**
|
|
100
|
+
- **CSV import / export** (optional — provide `csvSchema` to enable); failed rows come back as an annotated CSV
|
|
101
101
|
- **Bulk actions menu** (apply-to-many, copy-from, clear) via `bulkUpsert` / `bulkDelete`
|
|
102
102
|
- **Telemetry hook** (`onTelemetry`) emits typed events for save, delete, scope change, CSV import/export, bulk apply
|
|
103
103
|
- **i18n strings** fully overridable
|
|
@@ -136,7 +136,7 @@ const { data, source, isLoading } = useResolvedRecord({
|
|
|
136
136
|
batchId, // optional
|
|
137
137
|
proofId, // optional
|
|
138
138
|
});
|
|
139
|
-
// source: 'proof' | 'batch' | 'variant' | 'product' | 'facet' | '
|
|
139
|
+
// source: 'proof' | 'batch' | 'variant' | 'product' | 'facet' | 'universal' | null
|
|
140
140
|
```
|
|
141
141
|
|
|
142
142
|
Walks the resolution chain defined in [records-admin-pattern.md §4](records-admin-pattern.md#4-resolution-order-required). Use this on the **public widget side** to read the correct value for a given context.
|