@fleetbase/ember-core
Version:
Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.
463 lines (385 loc) • 16.9 kB
JavaScript
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { setOwner, getOwner } from '@ember/application';
import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { sameIds } from '../utils/array-utils';
import { scheduleOnce } from '@ember/runloop';
export default class SubjectCustomFields {
store;
subject;
groups = [];
fields = [];
values = Object.create(null);
options = {};
constructor({ owner, subject, options = {} }) {
setOwner(this, getOwner(owner));
this.subject = subject;
this.options = options;
}
get customFieldGroups() {
return this.getGroupedFields();
}
get requiredFields() {
return (this.fields ?? []).filter((cf) => cf.required === true && cf.editable !== false);
}
get isValidRequired() {
return this.validateRequired({ stopEarly: true }).isValid;
}
get missingRequiredFieldIds() {
return this.validateRequired().missing.map((m) => m.fieldId);
}
get missingByGroupName() {
const res = this.validateRequired();
const out = new Map();
for (const [groupId, items] of res.byGroup.entries()) {
const name = this.groups?.find((g) => g.id === groupId)?.name ?? 'Ungrouped';
out.set(name, items);
}
return out;
}
// ---------- Value API (mirrors Ember "set/get" ergonomics) ----------
setFieldValue(value, customField) {
this.setField(customField, value);
}
setField(customFieldOrId, value, valueType) {
const fieldId = typeof customFieldOrId === 'string' ? customFieldOrId : customFieldOrId.id;
const vt = valueType ?? this.#normalizeValueType(customFieldOrId);
this.values = {
...this.values,
[fieldId]: { value, value_type: vt ?? null },
};
}
writeFieldValue(resource, value, customField) {
this.setFieldValue(value, customField);
const fieldId = typeof customField === 'string' ? customField : customField?.id;
const valueType = typeof customField === 'string' ? null : (customField?.valueType ?? customField?.value_type ?? null);
if (!fieldId || !resource) return;
let rec = this.#getLocalValueRecord(resource, fieldId);
const nextVal = value ?? '';
const nextType = valueType ?? null;
if (!rec) {
rec = this.#createLocalValueRecord(fieldId, nextVal, nextType, resource);
this.#addToHasManyOnce(resource, rec);
return;
}
if (rec.value !== nextVal) rec.value = nextVal;
if (rec.value_type !== nextType) rec.value_type = nextType;
this.#addToHasManyOnce(resource, rec);
}
setProperties(entries = []) {
const next = { ...this.values };
for (const entry of entries) {
const { fieldId, value } = entry;
let { value_type } = entry;
if (!value_type) {
const cf = this.store.peekRecord?.('custom-field', fieldId);
value_type = cf ? (cf.valueType ?? cf.value_type ?? null) : null;
}
next[fieldId] = { value, value_type };
}
this.values = next;
}
getValue(customFieldId) {
return this.values?.[customFieldId];
}
getProperties() {
return { ...this.values };
}
clear() {
this.values = Object.create(null);
}
serialize() {
return Object.entries(this.values).map(([custom_field_uuid, { value, value_type }]) => ({
custom_field_uuid,
value,
value_type,
}));
}
// ---------- Definition loading & accessors ----------
/**
* Load groups & fields for this.subject
* @param {{ group?: boolean }} options
* @returns {Promise<{groups: any[], fields: any[]} | any[]>}
*/
async load(options = {}) {
const { group = false, groupedFor = 'custom_field_group', fieldFor = null } = { ...options, ...(this.options?.loadOptions ?? {}) };
const subjectId = this.subject.id;
const groups = await this.store.query('category', {
owner_uuid: subjectId,
for: groupedFor,
});
// If `fieldFor` is provided then we are fetching custom fields for resource and not an instance of something
// this means custom fields can be tied to individual resource instances OR tied to the schema of a resource kind
const fieldsQp = fieldFor ? { for: fieldFor, limit: -1 } : { subject_uuid: subjectId, limit: -1 };
const fields = await this.store.query('custom-field', fieldsQp);
// retain on instance for getGroups/getFields()
this.groups = groups.toArray();
this.fields = fields.toArray();
if (group) {
return this.getGroupedFields();
}
return { groups, fields };
}
/** Raw fields array (last loaded) */
getFields() {
return this.fields ?? [];
}
/** Raw groups array (last loaded) */
getGroups() {
return this.groups ?? [];
}
/**
* Validate that all required fields have effective values (staged or default).
* @param {Object} options
* @param {boolean} [options.includeUngrouped=true] consider fields without a category
* @param {boolean} [options.stopEarly=false] return on first miss
* @param {(cf: any, value: string|null) => string | null} [options.customMessage] custom error text hook
* @returns {{
* isValid: boolean,
* missing: Array<{ field: any, fieldId: string, group: any|null }>,
* errors: Map<string,string>,
* byGroup: Map<string|null, Array<{ field: any, fieldId: string }>>
* }}
*/
validateRequired(options = {}) {
const { includeUngrouped = true, stopEarly = false, customMessage } = { ...options, ...(this.options?.validationOptions ?? {}) };
const groupsById = new Map((this.groups ?? []).map((g) => [g.id, g]));
const missing = [];
const errors = new Map();
const byGroup = new Map();
for (const cf of this.requiredFields) {
const fieldId = cf.id;
const groupId = cf.category_uuid ?? null;
const group = groupsById.get(groupId) ?? null;
if (!includeUngrouped && !group) continue;
const valueType = cf.valueType; // from your computed
const value = this.#resolvedValue(cf);
if (!this.#isPresentForType(value, valueType)) {
missing.push({ field: cf, fieldId, group });
const msg = customMessage?.(cf, value) ?? `${cf.label ?? cf.name ?? 'This field'} is required.`;
errors.set(fieldId, msg);
const list = byGroup.get(groupId) ?? [];
list.push({ field: cf, fieldId });
byGroup.set(groupId, list);
if (stopEarly) {
return { isValid: false, missing, errors, byGroup };
}
}
}
return {
isValid: missing.length === 0,
missing,
errors,
byGroup,
};
}
/**
* Persist staged values as `custom-field-value` records for this.subject.
*
* @param {Model} subject
* @param {Object} options
* @param {boolean} [options.validate=true] Run required-field validation first
* @param {boolean} [options.deleteMissing=false]Delete existing records for fields not present in staged values
* @param {boolean} [options.persist=false] Actually call .save(); if false, just returns unsaved records
* @param {boolean} [options.reloadExisting=false] Query store for existing values if relationship isn't loaded
* @param {Object} [options.adapterOptions] Optional adapterOptions passed to .save({ adapterOptions })
* @returns {Promise<{created: Model[], updated: Model[], deleted: Model[], errors: any[]}>}
*/
async saveTo(subject, options = {}) {
const { validate = true, deleteMissing = false, persist = false, reloadExisting = false, adapterOptions } = { ...options, ...(this.options?.saveOptions ?? {}) };
// Validate requireds
if (validate) {
const vr = this.validateRequired({ stopEarly: true });
if (!vr.isValid) {
return { created: [], updated: [], deleted: [], errors: [vr] };
}
}
// Build a map of existing records by custom_field_uuid
const subjectId = subject.id;
let existingMany = subject.hasMany?.('custom_field_values')?.value?.() ?? null;
// If asked or not loaded, fetch from API (scoped to subject)
if ((reloadExisting || !existingMany) && subjectId) {
try {
existingMany = await this.store.query('custom-field-value', {
subject_uuid: subjectId,
});
} catch (e) {
existingMany = existingMany ?? [];
}
}
// Normalize to array
const existing = isArray(existingMany) ? existingMany : (existingMany?.toArray?.() ?? []);
const byFieldId = new Map();
for (let i = 0; i < existing.length; i++) {
const rec = existing[i];
byFieldId.set(rec.custom_field_uuid, rec);
}
const stagedEntries = Object.entries(this.values ?? {}); // [ [fieldId, {value, value_type}], ... ]
const created = [];
const updated = [];
const deleted = [];
const errors = [];
// Upsert staged values
for (const [fieldId, payload] of stagedEntries) {
const { value, value_type } = payload;
const current = byFieldId.get(fieldId);
if (current) {
// update if changed
let changed = false;
if (current.value !== (value ?? '')) {
current.value = value ?? '';
changed = true;
}
if (current.value_type !== (value_type ?? null)) {
current.value_type = value_type ?? null;
changed = true;
}
if (changed) updated.push(current);
} else {
// create
const record = this.store.createRecord('custom-field-value', {
custom_field_uuid: fieldId,
value: value ?? '',
value_type: value_type ?? null,
});
created.push(record);
}
}
// Optionally delete records for fields we no longer track
if (deleteMissing) {
const stagedIds = new Set(stagedEntries.map(([id]) => id));
for (const rec of existing) {
if (!stagedIds.has(rec.custom_field_uuid)) {
rec.deleteRecord();
deleted.push(rec);
}
}
}
// Persist if requested
if (!persist) {
return { created, updated, deleted, errors };
}
try {
// Save updates first (often fewer)
await Promise.all(updated.map((rec) => rec.save(adapterOptions ? { adapterOptions } : undefined)));
// Then saves for new records
await Promise.all(created.map((rec) => rec.save(adapterOptions ? { adapterOptions } : undefined)));
// Finally deletions
await Promise.all(deleted.map((rec) => rec.save(adapterOptions ? { adapterOptions } : undefined)));
} catch (e) {
errors.push(e);
}
return { created, updated, deleted, errors };
}
/**
* Groups with .customFields attached
* Returns a *new* array of groups to avoid accidental in-place mutation.
*/
getGroupedFields() {
const groups = this.getGroups(); // array of Category models
const fields = this.getFields(); // array of CustomField models
// Build groupId -> fields[] map
const byGroupId = new Map();
for (let i = 0; i < fields.length; i++) {
const cf = fields[i];
const gid = cf.category_uuid || null;
if (!byGroupId.has(gid)) byGroupId.set(gid, []);
byGroupId.get(gid).push(cf);
}
// Don’t mutate now (we’re likely in a render). If a category needs an update,
// schedule the write for afterRender.
// Choose your unique key for comparison: 'id' or 'custom_field_uuid'
const key = 'id'; // change to 'custom_field_uuid' if that’s your canonical id
let needsAnyUpdate = false;
const planned = [];
for (let i = 0; i < groups.length; i++) {
const g = groups[i];
const computed = byGroupId.get(g.id) || [];
const prev = g.customFields; // <- no coercion
const needsInit = !Array.isArray(prev); // undefined / non-array
const changed = !needsInit && !sameIds(prev, computed, key, false);
if (needsInit || changed) {
planned.push({ g, next: computed }); // initialize or update
needsAnyUpdate = true;
}
}
if (needsAnyUpdate) {
scheduleOnce('afterRender', null, () => {
for (let i = 0; i < planned.length; i++) {
const { g, next } = planned[i];
const current = g.customFields; // no coercion
const needsInit = !Array.isArray(current);
const changed = !needsInit && !sameIds(current, next, key, false);
if (needsInit || changed) {
g.customFields = next; // tracked; safe after render
}
}
});
}
// Return the same structure (categories), consumers keep using g.customFields
return groups;
}
getGroupedEntries() {
return this.getGroupedFields().map((g) => ({ group: g, customFields: g.customFields ?? [] }));
}
// ---------- Helpers ----------
#normalizeValueType(customFieldOrId) {
if (!customFieldOrId || typeof customFieldOrId === 'string') return null;
return customFieldOrId.valueType ?? customFieldOrId.value_type ?? null;
}
// Pull the staged value (if any) for a field id (string result or undefined)
#getStagedValue(fieldId) {
return this.values?.[fieldId]?.value;
}
// Resolve the effective value for a field: staged > default_value > null
#resolvedValue(cf) {
const staged = this.#getStagedValue(cf.id);
if (staged !== undefined) return staged; // explicit staging (can be empty string)
if (cf.default_value != null && cf.default_value !== '') return cf.default_value;
return null;
}
#isPresentForType(value, valueType) {
switch (valueType) {
case 'text':
case 'model':
// text/model are strings; treat non-empty (after trim) as present
return typeof value === 'string' && value.trim().length > 0;
case 'date': {
if (value == null || value === '') return false;
const t = Date.parse(value);
return Number.isFinite(t);
}
case 'file':
// your File value is a JSON string; reuse your rule (must look like {...})
return typeof value === 'string' && value.startsWith('{') && value.endsWith('}');
default:
// fallback: any non-nullish, non-empty string
return value != null && value !== '';
}
}
/** Find an existing in-memory CFV record for this resource+field (no fetch). */
#getLocalValueRecord(resource, fieldId) {
const many = resource.get('custom_field_values');
if (!many) return null;
return many.find((r) => r.custom_field_uuid === fieldId) || null;
}
/** Ensure relationship contains rec exactly once. */
#addToHasManyOnce(resource, rec) {
const many = resource.get('custom_field_values');
if (!many) return;
if (!many.includes(rec)) {
many.pushObject(rec);
}
}
/** Create a *new, unsaved* CFV record. */
#createLocalValueRecord(fieldId, value, valueType, resource) {
return this.store.createRecord('custom-field-value', {
company_uuid: resource.company_uuid,
custom_field_uuid: fieldId,
subject_uuid: resource.id,
value: value,
value_type: valueType ?? null,
});
}
}