ember-tribe 3.0.3 → 3.0.5

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.
@@ -0,0 +1,837 @@
1
+ import Service from '@ember/service';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+ import ENV from '<%= dasherizedPackageName %>/config/environment';
5
+ import { TrackedArray, TrackedObject } from 'tracked-built-ins';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Utility helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Convert a camelCase or dasherized string to snake_case.
13
+ */
14
+ function underscore(str) {
15
+ return str
16
+ .replace(/::/g, '/')
17
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
18
+ .replace(/([a-z\d])([A-Z])/g, '$1_$2')
19
+ .replace(/-/g, '_')
20
+ .toLowerCase();
21
+ }
22
+
23
+ /**
24
+ * Convert snake_case or dasherized to camelCase.
25
+ */
26
+ function camelize(str) {
27
+ return str
28
+ .replace(/[-_](.)/g, (_, c) => c.toUpperCase())
29
+ .replace(/^(.)/, (_, c) => c.toLowerCase());
30
+ }
31
+
32
+ /**
33
+ * Convert between model name representations:
34
+ * 'blogPost' | 'blog-post' | 'blog_post' → canonical snake_case 'blog_post'
35
+ */
36
+ function normalizeType(type) {
37
+ return underscore(type.replace(/-/g, '_'));
38
+ }
39
+
40
+ /**
41
+ * Dasherize for path segments: 'blog_post' → 'blog-post'
42
+ */
43
+ function dasherize(str) {
44
+ return underscore(str).replace(/_/g, '-');
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // RecordArray — a tracked array-like that also carries `.meta`
49
+ // ---------------------------------------------------------------------------
50
+
51
+ class RecordArray {
52
+ @tracked _records;
53
+ @tracked meta;
54
+
55
+ constructor(records = [], meta = {}) {
56
+ this._records = new TrackedArray(records);
57
+ this.meta = meta;
58
+ }
59
+
60
+ // MutableArray compat
61
+ get length() { return this._records.length; }
62
+
63
+ objectAt(idx) { return this._records[idx]; }
64
+
65
+ forEach(fn) { this._records.forEach(fn); }
66
+ map(fn) { return this._records.map(fn); }
67
+ filter(fn) { return this._records.filter(fn); }
68
+ find(fn) { return this._records.find(fn); }
69
+ reduce(fn, init) { return this._records.reduce(fn, init); }
70
+ some(fn) { return this._records.some(fn); }
71
+ every(fn) { return this._records.every(fn); }
72
+ includes(r) { return this._records.includes(r); }
73
+ indexOf(r) { return this._records.indexOf(r); }
74
+ slice(...a) { return this._records.slice(...a); }
75
+ toArray() { return [...this._records]; }
76
+ get firstObject() { return this._records[0]; }
77
+ get lastObject() { return this._records[this._records.length - 1]; }
78
+
79
+ push(record) { this._records.push(record); }
80
+ pushObject(record) { this._records.push(record); }
81
+ removeObject(record) {
82
+ const idx = this._records.indexOf(record);
83
+ if (idx !== -1) this._records.splice(idx, 1);
84
+ }
85
+
86
+ /** ES iterator support — allows `for...of` and spread. */
87
+ [Symbol.iterator]() { return this._records[Symbol.iterator](); }
88
+
89
+ /** Internal: wholesale replace content. */
90
+ _replace(records, meta) {
91
+ this._records.splice(0, this._records.length, ...records);
92
+ if (meta !== undefined) this.meta = meta;
93
+ }
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Record — a reactive proxy object representing a single resource
98
+ // ---------------------------------------------------------------------------
99
+
100
+ let _nextClientId = 1;
101
+
102
+ class Record {
103
+ // ---- internal bookkeeping (not enumerable) ----
104
+ @tracked _type;
105
+ @tracked _id;
106
+ @tracked _clientId;
107
+ @tracked _attributes; // TrackedObject of current attrs
108
+ @tracked _originalAttrs; // snapshot at last clean state
109
+ @tracked _relationships; // TrackedObject { key: id | [ids] }
110
+ @tracked _errors; // TrackedArray
111
+ @tracked _isNew;
112
+ @tracked _isDeleted;
113
+ @tracked _isSaving;
114
+ @tracked _store; // back-reference
115
+
116
+ constructor(store, type, id, attributes = {}, relationships = {}, isNew = false) {
117
+ this._store = store;
118
+ this._type = type;
119
+ this._id = id;
120
+ this._clientId = `client-${_nextClientId++}`;
121
+ this._attributes = new TrackedObject({ ...attributes });
122
+ this._originalAttrs = { ...attributes };
123
+ this._relationships = new TrackedObject({ ...relationships });
124
+ this._errors = new TrackedArray([]);
125
+ this._isNew = isNew;
126
+ this._isDeleted = false;
127
+ this._isSaving = false;
128
+
129
+ // Return a Proxy so arbitrary attribute access works: record.title, record.slug, etc.
130
+ return new Proxy(this, {
131
+ get(target, prop, receiver) {
132
+ // Prioritise explicit Record properties / methods
133
+ if (prop in target || typeof prop === 'symbol') {
134
+ return Reflect.get(target, prop, receiver);
135
+ }
136
+ // Relationship?
137
+ const schema = store._schemaFor(type);
138
+ if (schema && schema.relationships && schema.relationships[prop]) {
139
+ return target._resolveRelationship(prop);
140
+ }
141
+ // Attribute?
142
+ if (target._attributes && prop in target._attributes) {
143
+ return target._attributes[prop];
144
+ }
145
+ return undefined;
146
+ },
147
+
148
+ set(target, prop, value) {
149
+ if (prop in target || typeof prop === 'symbol' || prop.startsWith('_')) {
150
+ target[prop] = value;
151
+ return true;
152
+ }
153
+ // Relationship?
154
+ const schema = store._schemaFor(type);
155
+ if (schema && schema.relationships && schema.relationships[prop]) {
156
+ target._setRelationship(prop, value);
157
+ return true;
158
+ }
159
+ // Attribute
160
+ target._attributes[prop] = value;
161
+ return true;
162
+ },
163
+
164
+ has(target, prop) {
165
+ if (prop in target) return true;
166
+ if (target._attributes && prop in target._attributes) return true;
167
+ return false;
168
+ },
169
+
170
+ ownKeys(target) {
171
+ const attrKeys = target._attributes ? Object.keys(target._attributes) : [];
172
+ return [...new Set([...Reflect.ownKeys(target), ...attrKeys])];
173
+ },
174
+
175
+ getOwnPropertyDescriptor(target, prop) {
176
+ if (target._attributes && prop in target._attributes) {
177
+ return { configurable: true, enumerable: true, value: target._attributes[prop] };
178
+ }
179
+ return Reflect.getOwnPropertyDescriptor(target, prop);
180
+ },
181
+ });
182
+ }
183
+
184
+ // ---- public computed-style accessors ----
185
+ get id() { return this._id; }
186
+ set id(v) { this._id = v; }
187
+
188
+ get isNew() { return this._isNew; }
189
+ get isDeleted() { return this._isDeleted; }
190
+ get isSaving() { return this._isSaving; }
191
+ get errors() { return this._errors; }
192
+
193
+ get hasDirtyAttributes() {
194
+ const keys = new Set([
195
+ ...Object.keys(this._attributes),
196
+ ...Object.keys(this._originalAttrs),
197
+ ]);
198
+ for (const k of keys) {
199
+ if (this._attributes[k] !== this._originalAttrs[k]) return true;
200
+ }
201
+ return false;
202
+ }
203
+
204
+ changedAttributes() {
205
+ const diff = {};
206
+ const keys = new Set([
207
+ ...Object.keys(this._attributes),
208
+ ...Object.keys(this._originalAttrs),
209
+ ]);
210
+ for (const k of keys) {
211
+ if (this._attributes[k] !== this._originalAttrs[k]) {
212
+ diff[k] = [this._originalAttrs[k], this._attributes[k]];
213
+ }
214
+ }
215
+ return diff;
216
+ }
217
+
218
+ rollbackAttributes() {
219
+ for (const k of Object.keys(this._attributes)) {
220
+ if (!(k in this._originalAttrs)) {
221
+ delete this._attributes[k];
222
+ }
223
+ }
224
+ for (const [k, v] of Object.entries(this._originalAttrs)) {
225
+ this._attributes[k] = v;
226
+ }
227
+ if (this._isNew) {
228
+ this._store._unloadRecord(this);
229
+ }
230
+ this._isDeleted = false;
231
+ this._errors.splice(0, this._errors.length);
232
+ }
233
+
234
+ // ---- persistence ----
235
+
236
+ async save() {
237
+ this._isSaving = true;
238
+ this._errors.splice(0, this._errors.length);
239
+ try {
240
+ if (this._isDeleted) {
241
+ await this._store._deleteRemote(this);
242
+ this._store._unloadRecord(this);
243
+ } else if (this._isNew) {
244
+ await this._store._createRemote(this);
245
+ this._isNew = false;
246
+ } else {
247
+ await this._store._updateRemote(this);
248
+ }
249
+ // Snapshot clean state
250
+ this._originalAttrs = { ...this._attributes };
251
+ } finally {
252
+ this._isSaving = false;
253
+ }
254
+ return this;
255
+ }
256
+
257
+ deleteRecord() {
258
+ this._isDeleted = true;
259
+ }
260
+
261
+ async destroyRecord() {
262
+ this.deleteRecord();
263
+ return this.save();
264
+ }
265
+
266
+ // ---- relationships (resolved lazily) ----
267
+
268
+ _resolveRelationship(name) {
269
+ const schema = this._store._schemaFor(this._type);
270
+ const rel = schema.relationships[name];
271
+ const raw = this._relationships[name];
272
+
273
+ if (rel.kind === 'belongsTo') {
274
+ if (!raw) return null;
275
+ // Return a promise that resolves to the related record
276
+ const relType = rel.type;
277
+ const cached = this._store.peekRecord(relType, raw);
278
+ if (cached) return Promise.resolve(cached);
279
+ return this._store.findRecord(relType, raw);
280
+ }
281
+
282
+ // hasMany — return a promise resolving to a RecordArray
283
+ const ids = Array.isArray(raw) ? raw : [];
284
+ const loaded = ids
285
+ .map((rid) => this._store.peekRecord(rel.type, rid))
286
+ .filter(Boolean);
287
+ if (loaded.length === ids.length) {
288
+ return Promise.resolve(new RecordArray(loaded));
289
+ }
290
+ // Fetch any missing
291
+ return Promise.all(
292
+ ids.map((rid) => {
293
+ const cached = this._store.peekRecord(rel.type, rid);
294
+ return cached ? cached : this._store.findRecord(rel.type, rid);
295
+ }),
296
+ ).then((records) => new RecordArray(records));
297
+ }
298
+
299
+ _setRelationship(name, value) {
300
+ const schema = this._store._schemaFor(this._type);
301
+ const rel = schema.relationships[name];
302
+
303
+ if (rel.kind === 'belongsTo') {
304
+ if (value === null) {
305
+ this._relationships[name] = null;
306
+ } else {
307
+ this._relationships[name] = value._id ?? value.id ?? value;
308
+ }
309
+ } else {
310
+ // hasMany — accept array of records or ids
311
+ if (Array.isArray(value)) {
312
+ this._relationships[name] = value.map((v) => v._id ?? v.id ?? v);
313
+ }
314
+ }
315
+ }
316
+
317
+ // Serialise for the network
318
+ toJSON() {
319
+ return { ...this._attributes };
320
+ }
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Store Service
325
+ // ---------------------------------------------------------------------------
326
+
327
+ export default class StoreService extends Service {
328
+ // Identity map: Map<normalizedType, Map<id, Record>>
329
+ _cache = new Map();
330
+
331
+ // Live arrays returned by peekAll (kept in sync)
332
+ _liveArrays = new Map();
333
+
334
+ // Schema registry derived from the Tribe blueprint
335
+ // Map<normalizedType, { attributes: { slug: varType }, relationships: { slug: { kind, type, inverse } } }>
336
+ _schemas = new Map();
337
+
338
+ // Metadata cache per type (from last server response)
339
+ _meta = new Map();
340
+
341
+ // Base networking config (mirrors the old adapter)
342
+ get _host() { return ENV.TribeENV.API_URL; }
343
+ get _namespace() { return 'api.php'; }
344
+ get _headers() {
345
+ return {
346
+ Authorization: `Bearer ${ENV.TribeENV.API_KEY}`,
347
+ 'Content-Type': 'application/json',
348
+ Accept: 'application/vnd.api+json',
349
+ };
350
+ }
351
+
352
+ // ===========================================================================
353
+ // Schema / Blueprint
354
+ // ===========================================================================
355
+
356
+ /**
357
+ * Must be called once at boot (the `types` service can invoke this).
358
+ * Parses the Tribe webapp blueprint and registers schemas.
359
+ */
360
+ loadBlueprint(webappPayload) {
361
+ if (!webappPayload || !webappPayload.modules) return;
362
+
363
+ for (const [typeSlug, typeData] of Object.entries(webappPayload.modules)) {
364
+ if (
365
+ typeSlug === 'webapp' ||
366
+ typeSlug === 'deleted_record' ||
367
+ typeSlug === 'platform_record' ||
368
+ typeSlug === 'blueprint_record' ||
369
+ typeSlug === 'file_record' ||
370
+ typeSlug === 'apikey_record' ||
371
+ !typeData.modules ||
372
+ !Array.isArray(typeData.modules)
373
+ ) {
374
+ continue;
375
+ }
376
+
377
+ const attributes = {};
378
+ const relationships = {};
379
+
380
+ typeData.modules.forEach((mod) => {
381
+ const slug = mod.input_slug;
382
+ if (mod.linked_type) {
383
+ // This field is a relationship
384
+ const relType = normalizeType(mod.linked_type);
385
+ const kind = mod.var_type === 'array' || mod.var_type === 'has_many' ? 'hasMany' : 'belongsTo';
386
+ relationships[slug] = { kind, type: relType, inverse: mod.inverse ?? null };
387
+ } else {
388
+ attributes[slug] = mod.var_type ?? 'string';
389
+ }
390
+ });
391
+
392
+ this._schemas.set(normalizeType(typeSlug), { attributes, relationships });
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Register a schema manually (for types not in the blueprint).
398
+ */
399
+ registerSchema(type, schema) {
400
+ this._schemas.set(normalizeType(type), schema);
401
+ }
402
+
403
+ _schemaFor(type) {
404
+ return this._schemas.get(normalizeType(type)) || null;
405
+ }
406
+
407
+ // ===========================================================================
408
+ // URL building
409
+ // ===========================================================================
410
+
411
+ _urlForType(type) {
412
+ return `${this._host}/${this._namespace}/${underscore(normalizeType(type))}`;
413
+ }
414
+
415
+ _urlForRecord(type, id) {
416
+ return `${this._urlForType(type)}/${id}`;
417
+ }
418
+
419
+ // ===========================================================================
420
+ // Network helpers
421
+ // ===========================================================================
422
+
423
+ async _fetch(url, options = {}) {
424
+ const res = await fetch(url, {
425
+ ...options,
426
+ headers: { ...this._headers, ...(options.headers || {}) },
427
+ });
428
+
429
+ if (!res.ok) {
430
+ const body = await res.json().catch(() => ({}));
431
+ const err = new Error(`HTTP ${res.status}`);
432
+ err.status = res.status;
433
+ err.payload = body;
434
+ throw err;
435
+ }
436
+
437
+ // 204 No Content (typical for DELETE)
438
+ if (res.status === 204) return null;
439
+ return res.json();
440
+ }
441
+
442
+ // ===========================================================================
443
+ // JSON:API normalisation (response → internal)
444
+ // ===========================================================================
445
+
446
+ /**
447
+ * Normalise a JSON:API document and push all resources into the cache.
448
+ * Returns { data: Record | [Record], meta }.
449
+ */
450
+ _normalizeAndPush(payload) {
451
+ if (!payload) return { data: null, meta: {} };
452
+
453
+ const meta = payload.meta || {};
454
+
455
+ // Side-load included resources first
456
+ if (Array.isArray(payload.included)) {
457
+ payload.included.forEach((resource) => this._pushResource(resource));
458
+ }
459
+
460
+ let data;
461
+ if (Array.isArray(payload.data)) {
462
+ data = payload.data.map((r) => this._pushResource(r));
463
+ } else if (payload.data) {
464
+ data = this._pushResource(payload.data);
465
+ } else {
466
+ data = null;
467
+ }
468
+
469
+ return { data, meta };
470
+ }
471
+
472
+ /**
473
+ * Push a single JSON:API resource object into the identity map.
474
+ */
475
+ _pushResource(resource) {
476
+ const type = normalizeType(resource.type);
477
+ const id = String(resource.id);
478
+
479
+ // Deserialise attributes (snake_case → camelCase keys kept as-is;
480
+ // the Tribe API already uses snake_case which matches model slugs)
481
+ const attrs = {};
482
+ if (resource.attributes) {
483
+ for (const [k, v] of Object.entries(resource.attributes)) {
484
+ attrs[k] = v;
485
+ }
486
+ }
487
+
488
+ // Deserialise relationships → store ids
489
+ const rels = {};
490
+ if (resource.relationships) {
491
+ for (const [k, v] of Object.entries(resource.relationships)) {
492
+ if (v.data === null || v.data === undefined) {
493
+ rels[k] = null;
494
+ } else if (Array.isArray(v.data)) {
495
+ rels[k] = v.data.map((d) => String(d.id));
496
+ } else {
497
+ rels[k] = String(v.data.id);
498
+ }
499
+ }
500
+ }
501
+
502
+ // Upsert into identity map
503
+ const existing = this._peekById(type, id);
504
+ if (existing) {
505
+ // Merge into existing record (preserves object identity)
506
+ Object.assign(existing._attributes, attrs);
507
+ existing._originalAttrs = { ...existing._attributes };
508
+ Object.assign(existing._relationships, rels);
509
+ existing._isNew = false;
510
+ return existing;
511
+ }
512
+
513
+ const record = new Record(this, type, id, attrs, rels, false);
514
+ this._cacheRecord(record);
515
+ return record;
516
+ }
517
+
518
+ // ===========================================================================
519
+ // JSON:API serialisation (internal → request payload)
520
+ // ===========================================================================
521
+
522
+ _serialise(record) {
523
+ const type = normalizeType(record._type);
524
+ const schema = this._schemaFor(type);
525
+
526
+ const attributes = {};
527
+ for (const [k, v] of Object.entries(record._attributes)) {
528
+ // Use underscore keys on the wire (matches existing serialiser)
529
+ attributes[underscore(k)] = v;
530
+ }
531
+
532
+ const relationships = {};
533
+ if (schema && schema.relationships) {
534
+ for (const [k, rel] of Object.entries(schema.relationships)) {
535
+ const raw = record._relationships[k];
536
+ if (raw === undefined) continue;
537
+ if (rel.kind === 'belongsTo') {
538
+ relationships[underscore(k)] = {
539
+ data: raw ? { type: underscore(rel.type), id: String(raw) } : null,
540
+ };
541
+ } else {
542
+ relationships[underscore(k)] = {
543
+ data: (Array.isArray(raw) ? raw : []).map((rid) => ({
544
+ type: underscore(rel.type),
545
+ id: String(rid),
546
+ })),
547
+ };
548
+ }
549
+ }
550
+ }
551
+
552
+ const payload = {
553
+ data: {
554
+ type: underscore(type),
555
+ attributes,
556
+ },
557
+ };
558
+
559
+ if (record._id) payload.data.id = String(record._id);
560
+ if (Object.keys(relationships).length) payload.data.relationships = relationships;
561
+
562
+ return payload;
563
+ }
564
+
565
+ // ===========================================================================
566
+ // Cache primitives
567
+ // ===========================================================================
568
+
569
+ _cacheRecord(record) {
570
+ const type = normalizeType(record._type);
571
+ if (!this._cache.has(type)) this._cache.set(type, new Map());
572
+ this._cache.get(type).set(String(record._id ?? record._clientId), record);
573
+
574
+ // Update live array
575
+ const live = this._liveArrays.get(type);
576
+ if (live && !live.includes(record)) {
577
+ live.push(record);
578
+ }
579
+ }
580
+
581
+ _peekById(type, id) {
582
+ const bucket = this._cache.get(normalizeType(type));
583
+ return bucket ? bucket.get(String(id)) || null : null;
584
+ }
585
+
586
+ _unloadRecord(record) {
587
+ const type = normalizeType(record._type);
588
+ const bucket = this._cache.get(type);
589
+ if (bucket) {
590
+ bucket.delete(String(record._id ?? record._clientId));
591
+ }
592
+ const live = this._liveArrays.get(type);
593
+ if (live) live.removeObject(record);
594
+ }
595
+
596
+ // ===========================================================================
597
+ // CRUD — remote operations (called by Record.save())
598
+ // ===========================================================================
599
+
600
+ async _createRemote(record) {
601
+ const url = this._urlForType(record._type);
602
+ const payload = this._serialise(record);
603
+ const json = await this._fetch(url, { method: 'POST', body: JSON.stringify(payload) });
604
+ if (json) {
605
+ const { data } = this._normalizeAndPush(json);
606
+ // The server may assign an id
607
+ if (data && data._id) {
608
+ // Re-key in cache
609
+ const type = normalizeType(record._type);
610
+ const bucket = this._cache.get(type);
611
+ if (bucket) {
612
+ bucket.delete(record._clientId);
613
+ }
614
+ record._id = data._id;
615
+ this._cacheRecord(record);
616
+ }
617
+ }
618
+ }
619
+
620
+ async _updateRemote(record) {
621
+ const url = this._urlForRecord(record._type, record._id);
622
+ const payload = this._serialise(record);
623
+ const json = await this._fetch(url, { method: 'PATCH', body: JSON.stringify(payload) });
624
+ if (json) this._normalizeAndPush(json);
625
+ }
626
+
627
+ async _deleteRemote(record) {
628
+ const url = this._urlForRecord(record._type, record._id);
629
+ await this._fetch(url, { method: 'DELETE' });
630
+ }
631
+
632
+ // ===========================================================================
633
+ // Public API — Finding Records
634
+ // ===========================================================================
635
+
636
+ /**
637
+ * store.findRecord('post', 1) → GET /api.php/post/1
638
+ * store.findRecord('post', 1, { include: 'comments' })
639
+ */
640
+ async findRecord(type, id, options = {}) {
641
+ let url = this._urlForRecord(type, id);
642
+ const params = this._buildQueryParams(options);
643
+ if (params) url += `?${params}`;
644
+ const json = await this._fetch(url);
645
+ const { data, meta } = this._normalizeAndPush(json);
646
+ if (meta) this._meta.set(normalizeType(type), meta);
647
+ return data;
648
+ }
649
+
650
+ /**
651
+ * store.peekRecord('post', 1) → from cache only, no network
652
+ */
653
+ peekRecord(type, id) {
654
+ return this._peekById(type, id);
655
+ }
656
+
657
+ /**
658
+ * store.findAll('post') → GET /api.php/post
659
+ * store.findAll('post', { include: 'comments' })
660
+ */
661
+ async findAll(type, options = {}) {
662
+ let url = this._urlForType(type);
663
+ const params = this._buildQueryParams(options);
664
+ if (params) url += `?${params}`;
665
+ const json = await this._fetch(url);
666
+ const { data, meta } = this._normalizeAndPush(json);
667
+ const records = Array.isArray(data) ? data : data ? [data] : [];
668
+ // Update or create live array
669
+ const nType = normalizeType(type);
670
+ let live = this._liveArrays.get(nType);
671
+ if (!live) {
672
+ live = new RecordArray(records, meta);
673
+ this._liveArrays.set(nType, live);
674
+ } else {
675
+ live._replace(records, meta);
676
+ }
677
+ return live;
678
+ }
679
+
680
+ /**
681
+ * store.peekAll('post') → RecordArray from cache, no network
682
+ */
683
+ peekAll(type) {
684
+ const nType = normalizeType(type);
685
+ if (!this._liveArrays.has(nType)) {
686
+ const bucket = this._cache.get(nType);
687
+ const records = bucket ? [...bucket.values()] : [];
688
+ this._liveArrays.set(nType, new RecordArray(records));
689
+ }
690
+ return this._liveArrays.get(nType);
691
+ }
692
+
693
+ /**
694
+ * store.query('person', { filter: { name: 'Peter' } })
695
+ */
696
+ async query(type, params = {}) {
697
+ let url = this._urlForType(type);
698
+ const qs = this._buildQueryParams(params);
699
+ if (qs) url += `?${qs}`;
700
+ const json = await this._fetch(url);
701
+ const { data, meta } = this._normalizeAndPush(json);
702
+ const records = Array.isArray(data) ? data : data ? [data] : [];
703
+ return new RecordArray(records, meta);
704
+ }
705
+
706
+ /**
707
+ * store.queryRecord('user', { ... }) → returns a single record
708
+ */
709
+ async queryRecord(type, params = {}) {
710
+ let url = this._urlForType(type);
711
+ const qs = this._buildQueryParams(params);
712
+ if (qs) url += `?${qs}`;
713
+ const json = await this._fetch(url);
714
+ const { data, meta } = this._normalizeAndPush(json);
715
+ if (Array.isArray(data)) return data[0] || null;
716
+ return data;
717
+ }
718
+
719
+ // ===========================================================================
720
+ // Public API — Creating Records
721
+ // ===========================================================================
722
+
723
+ /**
724
+ * store.createRecord('post', { title: 'Hello', body: '...' })
725
+ */
726
+ createRecord(type, attrs = {}) {
727
+ const nType = normalizeType(type);
728
+ const schema = this._schemaFor(nType);
729
+
730
+ // Separate relationships from plain attributes
731
+ const plainAttrs = {};
732
+ const rels = {};
733
+
734
+ for (const [k, v] of Object.entries(attrs)) {
735
+ if (schema && schema.relationships && schema.relationships[k]) {
736
+ // Accept a record or an id
737
+ const rel = schema.relationships[k];
738
+ if (rel.kind === 'belongsTo') {
739
+ rels[k] = v && typeof v === 'object' ? (v._id ?? v.id ?? v) : v;
740
+ } else {
741
+ rels[k] = Array.isArray(v) ? v.map((r) => (r && typeof r === 'object' ? (r._id ?? r.id ?? r) : r)) : v;
742
+ }
743
+ } else {
744
+ plainAttrs[k] = v;
745
+ }
746
+ }
747
+
748
+ const record = new Record(this, nType, null, plainAttrs, rels, true);
749
+ this._cacheRecord(record);
750
+ return record;
751
+ }
752
+
753
+ // ===========================================================================
754
+ // Public API — Pushing Records
755
+ // ===========================================================================
756
+
757
+ /**
758
+ * store.push(jsonApiDocument)
759
+ * Accepts a JSON:API-shaped document with `data` (and optional `included`).
760
+ */
761
+ push(jsonApiDoc) {
762
+ const { data } = this._normalizeAndPush(jsonApiDoc);
763
+ return data;
764
+ }
765
+
766
+ /**
767
+ * store.pushPayload(rawPayload)
768
+ * Accepts a REST-style payload keyed by type name, normalises to JSON:API, and pushes.
769
+ * Example: { posts: [{ id: 1, title: '...' }] }
770
+ */
771
+ pushPayload(rawPayload) {
772
+ const resources = [];
773
+ for (const [key, items] of Object.entries(rawPayload)) {
774
+ const type = normalizeType(key);
775
+ const list = Array.isArray(items) ? items : [items];
776
+ for (const item of list) {
777
+ const { id, ...attrs } = item;
778
+ resources.push({ id: String(id), type, attributes: attrs });
779
+ }
780
+ }
781
+ return this.push({ data: resources });
782
+ }
783
+
784
+ // ===========================================================================
785
+ // Public API — Unload
786
+ // ===========================================================================
787
+
788
+ unloadRecord(record) {
789
+ this._unloadRecord(record);
790
+ }
791
+
792
+ unloadAll(type) {
793
+ if (type) {
794
+ const nType = normalizeType(type);
795
+ this._cache.delete(nType);
796
+ const live = this._liveArrays.get(nType);
797
+ if (live) live._replace([]);
798
+ } else {
799
+ this._cache.clear();
800
+ this._liveArrays.forEach((live) => live._replace([]));
801
+ }
802
+ }
803
+
804
+ // ===========================================================================
805
+ // Public API — Metadata
806
+ // ===========================================================================
807
+
808
+ /**
809
+ * store.metadataFor('post') → last meta received for this type
810
+ */
811
+ metadataFor(type) {
812
+ return this._meta.get(normalizeType(type)) || {};
813
+ }
814
+
815
+ // ===========================================================================
816
+ // Query-param builder
817
+ // ===========================================================================
818
+
819
+ _buildQueryParams(options) {
820
+ if (!options || typeof options !== 'object') return '';
821
+ const parts = [];
822
+
823
+ const serialize = (obj, prefix) => {
824
+ for (const [k, v] of Object.entries(obj)) {
825
+ const key = prefix ? `${prefix}[${k}]` : k;
826
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
827
+ serialize(v, key);
828
+ } else {
829
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`);
830
+ }
831
+ }
832
+ };
833
+
834
+ serialize(options);
835
+ return parts.join('&');
836
+ }
837
+ }
@@ -3,53 +3,40 @@ import ENV from '<%= dasherizedPackageName %>/config/environment';
3
3
  import { service } from '@ember/service';
4
4
  import { action } from '@ember/object';
5
5
  import { tracked } from '@glimmer/tracking';
6
- import Model, { attr } from '@ember-data/model';
7
- import { getOwner } from '@ember/application';
8
6
 
9
7
  export default class TypesService extends Service {
10
8
  @service store;
11
- @tracked json = this.store.peekRecord('webapp', 0, {
12
- include: ['total_objects'],
13
- });
9
+ @tracked json = null;
10
+ @tracked simplifiedJson = null;
14
11
 
15
12
  @action
16
13
  async fetchAgain() {
17
14
  if (ENV.TribeENV.API_URL !== undefined && ENV.TribeENV.API_URL != '') {
15
+ // First fetch — get the webapp blueprint (no includes yet)
18
16
  this.json = await this.store.findRecord('webapp', 0, {});
19
- let owner = getOwner(this);
20
17
 
21
- Object.entries(this.json.modules).forEach(([modelName, modelData]) => {
22
- const modelDynamicName = modelName.replace(/_/g, '-');
23
-
24
- class DynamicModel extends Model {
25
- @attr slug;
26
- @attr modules;
27
- }
28
-
29
- if (!owner.hasRegistration(`model:${modelDynamicName}`)) {
30
- owner.register(`model:${modelDynamicName}`, DynamicModel);
31
- }
32
- });
18
+ // Feed the blueprint into the store so it knows every type's schema
19
+ this.store.loadBlueprint(this.json);
33
20
 
21
+ // Second fetch — now with total_objects included
34
22
  this.json = await this.store.findRecord('webapp', 0, {
35
- include: ['total_objects'],
23
+ include: 'total_objects',
36
24
  });
37
- this.json = this.json;
25
+
26
+ // Re-load blueprint with the enriched response
27
+ this.store.loadBlueprint(this.json);
28
+
38
29
  this.simplifiedJson = this.convertTypesToSimplified(this.json);
39
- //console.log(this.simplifiedJson);
40
30
  }
41
31
  }
42
32
 
43
33
  convertTypesToSimplified = (typesJson) => {
44
- // Create the basic structure with a types object
45
34
  const simplifiedTypes = {
46
- project_description: typesJson.modules.webapp.project_description ?? "",
35
+ project_description: typesJson.modules?.webapp?.project_description ?? '',
47
36
  types: {},
48
37
  };
49
38
 
50
- // Iterate through each content type in the original file
51
- for (const [typeSlug, typeData] of Object.entries(typesJson.modules)) {
52
- // Skip the webapp info and any types without modules
39
+ for (const [typeSlug, typeData] of Object.entries(typesJson.modules || {})) {
53
40
  if (
54
41
  typeSlug === 'webapp' ||
55
42
  typeSlug === 'deleted_record' ||
@@ -63,36 +50,29 @@ export default class TypesService extends Service {
63
50
  continue;
64
51
  }
65
52
 
66
- // Create a new object for this type
67
53
  simplifiedTypes.types[typeSlug] = {};
68
54
 
69
- // Process each module in the content type
70
55
  typeData.modules.forEach((module) => {
71
56
  const slug = module.input_slug;
72
- let varType = (module.var_type ?? "string") + (module.linked_type ? " | *"+module.linked_type : "");
57
+ let varType =
58
+ (module.var_type ?? 'string') +
59
+ (module.linked_type ? ' | *' + module.linked_type : '');
73
60
 
74
- // Handle select options if they exist
75
61
  if (
76
62
  module.input_options &&
77
63
  Array.isArray(module.input_options) &&
78
64
  module.input_options.length > 0
79
65
  ) {
80
- // Extract all option slugs
81
- const optionSlugs = module.input_options.map(
82
- (option) => option.slug,
83
- );
84
-
85
- // Add the piped extension to the var_type
66
+ const optionSlugs = module.input_options.map((option) => option.slug);
86
67
  if (optionSlugs.length > 0) {
87
68
  varType += ` | ${optionSlugs.join(', ')}`;
88
69
  }
89
70
  }
90
71
 
91
- // Add the module to the simplified type
92
72
  simplifiedTypes.types[typeSlug][slug] = varType;
93
73
  });
94
74
  }
95
75
 
96
76
  return simplifiedTypes;
97
- }
77
+ };
98
78
  }
@@ -31,5 +31,5 @@ $spacers: (
31
31
  10: $spacer * 12,
32
32
  ) !default;
33
33
 
34
- @import "../../node_modules/bootstrap/scss/bootstrap";
35
- @import "../../node_modules/animate.css/animate";
34
+ @import "../../../node_modules/bootstrap/scss/bootstrap";
35
+ @import "../../../node_modules/animate.css/animate";
@@ -45,10 +45,7 @@ module.exports = {
45
45
  { name: 'ember-math-helpers' },
46
46
  { name: 'ember-cli-string-helpers' },
47
47
  { name: 'ember-promise-helpers' },
48
- { name: 'ember-tag-input' },
49
48
  { name: 'ember-file-upload' },
50
- { name: 'ember-data' },
51
- { name: 'ember-basic-dropdown' },
52
49
  { name: 'ember-power-select' },
53
50
  { name: 'ember-click-outside' },
54
51
  { name: 'ember-keyboard' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ember-tribe",
3
- "version": "3.0.3",
3
+ "version": "3.0.5",
4
4
  "description": "The default blueprint for ember-cli addons.",
5
5
  "keywords": [
6
6
  "ember-addon"
@@ -1,15 +0,0 @@
1
- import { JSONAPIAdapter } from '@warp-drive/legacy/adapter/json-api';
2
- import ENV from '<%= dasherizedPackageName %>/config/environment';
3
- import { underscore } from '@ember/string';
4
-
5
- export default class ApplicationAdapter extends JSONAPIAdapter {
6
- host = ENV.TribeENV.API_URL;
7
- namespace = 'api.php';
8
- headers = {
9
- Authorization: `Bearer ${ENV.TribeENV.API_KEY}`,
10
- };
11
-
12
- pathForType(type) {
13
- return underscore(type);
14
- }
15
- }
@@ -1,12 +0,0 @@
1
- import { JSONAPISerializer } from '@warp-drive/legacy/serializer/json-api';
2
- import { underscore } from '@ember/string';
3
-
4
- export default class ApplicationSerializer extends JSONAPISerializer {
5
- keyForAttribute(attr) {
6
- return underscore(attr);
7
- }
8
-
9
- payloadKeyFromModelName(key) {
10
- return underscore(key);
11
- }
12
- }