@fleetbase/ember-ui
Version:
Fleetbase UI provides all the interface components, helpers, services and utilities for building a Fleetbase extension into the Console.
264 lines (211 loc) • 6.67 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { guidFor } from '@ember/object/internals';
import { underscore } from '@ember/string';
import { isEmpty } from '@ember/utils';
function coerceValue(value, targetType) {
switch (targetType) {
case 'number': {
if (isEmpty(value)) return '';
const num = Number(value);
return isNaN(num) ? '' : num;
}
case 'boolean':
return Boolean(value);
case 'text':
default:
return value == null ? '' : String(value);
}
}
class MetadataRow {
key = '';
value = '';
type = 'text';
error = null;
constructor(data = {}) {
this.id = guidFor(this);
this.key = data.key || '';
this.value = data.value || '';
this.type = data.type || this.#getValueType(data.value) || 'text';
this.error = null;
}
updateKey(newKey) {
this.key = newKey;
}
updateValue(newValue) {
this.value = newValue;
}
updateType(newType) {
this.type = newType;
this.value = coerceValue(this.value, newType);
}
setError(error) {
this.error = error;
}
get isValid() {
return !this.error;
}
toOutput() {
if (!this.isValid) return null;
return {
key: this.key,
value: coerceValue(this.value, this.type),
};
}
#getValueType(value) {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
return 'text';
}
}
export default class MetadataEditorComponent extends Component {
modalsManager;
rows = [];
filterText = '';
preservedData = {};
constructor(owner, args) {
super(owner, args);
this.initializeFromValue();
}
get label() {
return this.args.label ?? 'Metadata';
}
get allowBoolean() {
return this.args.allowBoolean !== false;
}
get filteredRows() {
if (isEmpty(this.filterText)) {
return this.rows;
}
const filter = this.filterText.toLowerCase();
return this.rows.filter((row) => row.key.toLowerCase().includes(filter));
}
get hasErrors() {
return this.rows.some((row) => !row.isValid);
}
initializeFromValue() {
const inputValue = this.args.value || {};
const newRows = [];
const preserved = {};
Object.entries(inputValue).forEach(([key, value]) => {
if (this.#isPrimitive(value)) {
newRows.push(new MetadataRow({ key, value }));
} else {
preserved[key] = value;
}
});
this.rows = newRows;
this.preservedData = preserved;
this.validateAllRows();
// this.emitChange();
}
validateAllRows() {
const allKeys = this.rows.map((row) => ({ key: row.key, id: row.id }));
this.rows.forEach((row) => {
const error = this.#validateKey(row.key, allKeys, row.id);
row.setError(error);
});
}
emitChange() {
const output = this.getOutput();
if (typeof this.args.onChange === 'function') {
this.args.onChange(output);
}
return output;
}
getOutput() {
const output = { ...this.preservedData };
this.rows.forEach((row) => {
const rowOutput = row.toOutput();
if (rowOutput && (row.type !== 'boolean' || this.allowBoolean)) {
output[rowOutput.key] = rowOutput.value;
}
});
return output;
}
addRow() {
const newRow = new MetadataRow();
this.rows = [...this.rows, newRow];
this.validateAllRows();
this.emitChange();
}
removeRow(targetRow) {
this.rows = this.rows.filter((row) => row !== targetRow);
this.validateAllRows();
this.emitChange();
}
onKeyInput(row, event) {
const rawInput = event.target.value || '';
const snakeCaseKey = this.#toSnakeCase(rawInput);
// Update the DOM input to show snake_case formatting
event.target.value = snakeCaseKey;
// Update the row
row.updateKey(snakeCaseKey);
// Validate and emit
this.validateAllRows();
this.emitChange();
}
onValueInput(row, event) {
let newValue;
if (event.target.type === 'checkbox') {
newValue = event.target.checked;
} else {
newValue = event.target.value;
}
// Apply type coercion for numbers
if (row.type === 'number') {
newValue = coerceValue(newValue, 'number');
}
row.updateValue(newValue);
this.emitChange();
}
onTypeChange(row, event) {
const newType = event.target.value;
row.updateType(newType);
this.emitChange();
}
onFilter(event) {
this.filterText = event.target.value;
}
clearAll() {
this.rows = [];
this.emitChange();
}
preview() {
this.modalsManager.show('modals/view-raw-metadata', {
title: 'Preview metadata',
metadata: this.getOutput(),
acceptButtonText: 'Done',
hideDeclineButton: true,
});
}
#toSnakeCase(input) {
if (isEmpty(input)) return '';
// Use Ember's underscore utility which handles spaces, camelCase, etc.
let result = underscore(String(input));
// Ensure it starts with a letter, prefix with 'k_' if it starts with a number
if (result && /^\d/.test(result)) {
result = `k_${result}`;
}
return result;
}
#isPrimitive(value) {
const type = typeof value;
return type === 'string' || type === 'number' || type === 'boolean';
}
#validateKey(key, allKeys, currentId) {
if (isEmpty(key)) {
return 'Key is required';
}
if (!/^[a-z][a-z0-9_]*$/.test(key)) {
return 'Must be snake_case (start with a letter, a-z0-9_)';
}
const duplicateExists = allKeys.some(({ key: otherKey, id }) => id !== currentId && otherKey === key);
if (duplicateExists) {
return 'Key must be unique';
}
return null;
}
}