Compare commits

...

6 Commits

Author SHA1 Message Date
Ron
0666bc611c Merge pull request #509 from fleetbase/fix/extensions-generator-empty-extensions
fix: always generate extension loader files even when no extensions are installed
2026-03-03 21:29:07 +08:00
Ron
8244fdce90 Merge pull request #511 from fleetbase/feature/template-models
feat: add Template and TemplateQuery Ember Data models
2026-03-03 21:28:50 +08:00
Ronald A Richardson
e0f9aeb5a9 refactor: remove redundant defaultValue from content array attr 2026-03-03 06:34:06 -05:00
Ronald A Richardson
b2fe2b909b refactor: remove unnecessary contextTypeLabel computed property from Template model 2026-03-03 06:33:35 -05:00
Ronald A Richardson
3fcd21ec28 feat: add Template and TemplateQuery Ember Data models
Adds two Ember Data models to the console app to support the new
template builder system introduced in fleetbase/core-api#198 and
fleetbase/ember-ui#121.

## Models

### template
- Full attribute set matching the backend Template model
- Computed: isDraft, isPublished, contextTypeLabel, dimensionLabel
- hasMany: template-query (inverse)
- Supports: name, description, context_type, paper_size, orientation,
  width, height, unit, background_color, content (array), is_default, status

### template-query
- Full attribute set matching the backend TemplateQuery model
- Computed: variableName, variableToken, resourceTypeLabel
- belongsTo: template (inverse)
- Supports: name, label, description, resource_type, filters (object),
  sort_by, sort_direction, limit

Both models follow the existing Fleetbase model conventions:
- @ember-data/model with attr/belongsTo/hasMany decorators
- Standard date computed properties (updatedAgo, updatedAt, createdAt, etc.)
- date-fns for date formatting

Refs: fleetbase/core-api#198, fleetbase/ember-ui#121
2026-03-03 06:30:03 -05:00
Ronald A. Richardson
0bcd9ec165 fix: always generate extension loader files even when no extensions are installed
When no extensions are installed, generateExtensionFiles() previously
returned early after cleaning up the app/extensions directory. This left
the directory absent, causing a fatal module resolution error at runtime:

  Error: Could not find module `@fleetbase/console/extensions`
  imported from `@fleetbase/ember-core/services/universe/extension-manager`

The extension-manager service in @fleetbase/ember-core has a hard static
import:

  import { getExtensionLoader } from '@fleetbase/console/extensions';

Ember's module resolver maps this to app/extensions/index.js. Because
that file was never created in the zero-extension scenario, the entire
application failed to boot.

Changes:
- Remove the early return when extensions.length === 0.
- Restructure generateExtensionFiles() so that generateExtensionLoaders(),
  generateRouter(), and generateExtensionsManifest() are always called,
  regardless of whether any extensions are discovered.
- generateExtensionShims() is still only called when extensions > 0, as
  there are no shim files to write in the empty case.
- When no extensions are found, generateExtensionLoaders() writes an
  app/extensions/index.js with an empty EXTENSION_LOADERS = {} object,
  satisfying the module dependency without registering any loaders.
- generateExtensionsManifest() writes public/extensions.json with an
  empty array [], which is the correct state for zero extensions.

Additionally, add null-guards to the recast AST visitor inside
generateRouter() to prevent a potential TypeError when traversing call
expressions whose callee is not a MemberExpression (e.g. bare require()
or import() calls that have no .property).
2026-02-28 03:44:37 -05:00
3 changed files with 180 additions and 19 deletions

View File

@@ -0,0 +1,90 @@
import Model, { attr, belongsTo } from '@ember-data/model';
import { computed } from '@ember/object';
import { format, formatDistanceToNow } from 'date-fns';
export default class TemplateQueryModel extends Model {
/** @ids */
@attr('string') public_id;
@attr('string') uuid;
@attr('string') company_uuid;
@attr('string') template_uuid;
/** @relationships */
@belongsTo('template', { async: false, inverse: 'queries' }) template;
/** @attributes */
@attr('string') name;
@attr('string') label;
@attr('string') description;
/**
* The fully-qualified PHP model class this query runs against.
* e.g. "Fleetbase\\Models\\Order", "Fleetbase\\Ledger\\Models\\Invoice"
*/
@attr('string') resource_type;
/**
* JSON object of filter conditions applied to the query.
* e.g. { "status": "completed", "created_at_gte": "2024-12-01" }
*/
@attr('object', { defaultValue: () => ({}) }) filters;
@attr('string') sort_by;
@attr('string', { defaultValue: 'desc' }) sort_direction;
@attr('number') limit;
/** @dates */
@attr('date') deleted_at;
@attr('date') created_at;
@attr('date') updated_at;
/** @computed */
/**
* The variable name available in the template for iterating this query's results.
* Derived from the `name` field — lowercased and underscored.
* e.g. name "December Orders" → variable "{december_orders}"
*/
@computed('name') get variableName() {
if (!this.name) return '';
return this.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
@computed('variableName') get variableToken() {
return `{${this.variableName}}`;
}
@computed('resource_type') get resourceTypeLabel() {
if (!this.resource_type) return '';
// Extract the short class name from the fully-qualified namespace
const parts = this.resource_type.split('\\');
return parts[parts.length - 1];
}
@computed('updated_at') get updatedAgo() {
return formatDistanceToNow(this.updated_at);
}
@computed('updated_at') get updatedAt() {
return format(this.updated_at, 'yyyy-MM-dd HH:mm');
}
@computed('updated_at') get updatedAtShort() {
return format(this.updated_at, 'PP');
}
@computed('created_at') get createdAgo() {
return formatDistanceToNow(this.created_at);
}
@computed('created_at') get createdAt() {
return format(this.created_at, 'yyyy-MM-dd HH:mm');
}
@computed('created_at') get createdAtShort() {
return format(this.created_at, 'PP');
}
}

View File

@@ -0,0 +1,74 @@
import Model, { attr, hasMany } from '@ember-data/model';
import { computed } from '@ember/object';
import { format, formatDistanceToNow } from 'date-fns';
export default class TemplateModel extends Model {
/** @ids */
@attr('string') public_id;
@attr('string') uuid;
@attr('string') company_uuid;
@attr('string') created_by_uuid;
/** @relationships */
@hasMany('template-query', { async: false, inverse: 'template' }) queries;
/** @attributes */
@attr('string') name;
@attr('string') description;
@attr('string') context_type;
@attr('string', { defaultValue: 'A4' }) paper_size;
@attr('string', { defaultValue: 'portrait' }) orientation;
@attr('number') width;
@attr('number') height;
@attr('string', { defaultValue: 'mm' }) unit;
@attr('string', { defaultValue: '#ffffff' }) background_color;
@attr('array') content;
@attr('boolean', { defaultValue: false }) is_default;
@attr('string', { defaultValue: 'draft' }) status;
@attr('object') meta;
/** @dates */
@attr('date') deleted_at;
@attr('date') created_at;
@attr('date') updated_at;
/** @computed */
@computed('status') get isDraft() {
return this.status === 'draft';
}
@computed('status') get isPublished() {
return this.status === 'published';
}
@computed('paper_size', 'orientation') get dimensionLabel() {
if (this.paper_size === 'custom') {
return `${this.width} × ${this.height} ${this.unit}`;
}
return `${this.paper_size} (${this.orientation})`;
}
@computed('updated_at') get updatedAgo() {
return formatDistanceToNow(this.updated_at);
}
@computed('updated_at') get updatedAt() {
return format(this.updated_at, 'yyyy-MM-dd HH:mm');
}
@computed('updated_at') get updatedAtShort() {
return format(this.updated_at, 'PP');
}
@computed('created_at') get createdAgo() {
return formatDistanceToNow(this.created_at);
}
@computed('created_at') get createdAt() {
return format(this.created_at, 'yyyy-MM-dd HH:mm');
}
@computed('created_at') get createdAtShort() {
return format(this.created_at, 'PP');
}
}

View File

@@ -59,27 +59,24 @@ module.exports = {
const extensions = await this.getExtensions();
if (extensions.length === 0) {
console.log('[Fleetbase] No extensions found');
return;
if (extensions.length > 0) {
console.log(`[Fleetbase] Discovered ${extensions.length} extension(s)`);
extensions.forEach((ext) => {
console.log(`[Fleetbase] - ${ext.name} (v${ext.version})`);
});
console.log('');
// Generate extension shims (only needed when extensions are present)
this.generateExtensionShims(extensions);
} else {
console.log('[Fleetbase] No extensions found — generating empty extension loader to satisfy module dependencies.');
}
console.log(`[Fleetbase] Discovered ${extensions.length} extension(s)`);
extensions.forEach((ext) => {
console.log(`[Fleetbase] - ${ext.name} (v${ext.version})`);
});
console.log('');
// Generate extension shims
this.generateExtensionShims(extensions);
// Generate extension loaders
// Always generate loaders, router, and manifest so that
// @fleetbase/console/extensions (app/extensions/index.js) always exists
// and the build does not fail when zero extensions are installed.
this.generateExtensionLoaders(extensions);
// Generate router
this.generateRouter(extensions);
// Generate manifest
this.generateExtensionsManifest(extensions);
},
@@ -227,7 +224,7 @@ export default getExtensionLoader;
recast.visit(ast, {
visitCallExpression(path) {
if (path.value.type === 'CallExpression' && path.value.callee.property.name === 'route' && path.value.arguments[0].value === 'console') {
if (path.value.type === 'CallExpression' && path.value.callee.property && path.value.callee.property.name === 'route' && path.value.arguments[0] && path.value.arguments[0].value === 'console') {
let functionExpression;
// Find the function expression
@@ -270,7 +267,7 @@ export default getExtensionLoader;
}
}
if (path.value.type === 'CallExpression' && path.value.callee.property.name === 'map') {
if (path.value.type === 'CallExpression' && path.value.callee.property && path.value.callee.property.name === 'map') {
let functionExpression;
path.value.arguments.forEach((arg) => {