initial ux ui improvements and revised dashboard

This commit is contained in:
Richard Osborne
2025-12-31 09:34:27 +01:00
parent ae7d3b8a8b
commit 73b5a42122
109 changed files with 13583 additions and 1111 deletions

View File

@@ -565,7 +565,12 @@ function generateNodeLibrary(nodeRegister) {
},
{
name: 'BYOB Data',
items: ['noodl.byob.QueryData']
items: [
'noodl.byob.QueryData',
'noodl.byob.CreateRecord',
'noodl.byob.UpdateRecord',
'noodl.byob.DeleteRecord'
]
}
]
},

View File

@@ -0,0 +1,540 @@
/**
* BYOB Create Record Node
*
* Creates a new record in a BYOB backend collection.
* Supports Directus system tables and user collections.
*
* @module noodl-runtime
* @since 2.0.0
*/
const ByobUtils = require('./byob-utils');
console.log('[BYOB Create Record] 📦 Module loaded');
var CreateRecordNode = {
name: 'noodl.byob.CreateRecord',
displayNodeName: 'Create Record',
docs: 'https://docs.noodl.net/nodes/data/byob/create-record',
category: 'Data',
color: 'data',
searchTags: ['byob', 'create', 'insert', 'add', 'data', 'database', 'records', 'directus', 'api', 'backend'],
initialize: function () {
console.log('[BYOB Create Record] 🚀 INITIALIZE called');
this._internal.fieldValues = {};
this._internal.loading = false;
this._internal.apiPathMode = 'items';
},
getInspectInfo() {
if (!this._internal.lastResult) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.lastResult };
},
inputs: {
create: {
type: 'signal',
displayName: 'Create',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Create Record] ⚡ CREATE SIGNAL RECEIVED');
this.scheduleCreate();
}
}
},
outputs: {
record: {
type: 'object',
displayName: 'Record',
group: 'Results',
getter: function () {
return this._internal.record;
}
},
recordId: {
type: 'string',
displayName: 'Record ID',
group: 'Results',
getter: function () {
return this._internal.recordId;
}
},
loading: {
type: 'boolean',
displayName: 'Loading',
group: 'Status',
getter: function () {
return this._internal.loading;
}
},
error: {
type: 'object',
displayName: 'Error',
group: 'Status',
getter: function () {
return this._internal.error;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
prototypeExtensions: {
/**
* Store field value (for dynamic field inputs)
*/
_storeFieldValue: function (name, value) {
this._internal.fieldValues[name] = value;
},
/**
* Resolve the backend configuration from metadata
*/
resolveBackend: function () {
const backendId = this._internal.backendId || '_active_';
return ByobUtils.resolveBackend(backendId);
},
scheduleCreate: function () {
console.log('[BYOB Create Record] scheduleCreate called');
if (this._internal.hasScheduledCreate) {
console.log('[BYOB Create Record] Already scheduled, skipping');
return;
}
this._internal.hasScheduledCreate = true;
this.scheduleAfterInputsHaveUpdated(this.doCreate.bind(this));
},
doCreate: function () {
console.log('[BYOB Create Record] doCreate executing');
this._internal.hasScheduledCreate = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Create Record] No backend configured');
this._internal.error = {
message: 'No backend configured. Please add a backend in the Backend Services panel.'
};
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
const collection = this._internal.collection;
const apiPathMode = this._internal.apiPathMode || 'items';
if (!collection) {
console.log('[BYOB Create Record] No collection specified');
this._internal.error = { message: 'Collection is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build URL
const url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode);
if (!url) {
console.log('[BYOB Create Record] Failed to build URL');
this._internal.error = { message: 'Failed to build request URL' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build headers
const headers = ByobUtils.buildHeaders(backendConfig.token);
// Get field schema for value normalization
const fieldSchema = this._internal.fieldSchema || {};
// Collect field values from dynamic inputs and normalize them (especially dates)
const body = {};
for (const [fieldName, value] of Object.entries(this._internal.fieldValues)) {
body[fieldName] = ByobUtils.normalizeValue(value, fieldSchema[fieldName]);
}
console.log('[BYOB Create Record] Request:', {
url,
backendType: backendConfig.type,
fieldCount: Object.keys(body).length
});
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Perform fetch
fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
return response.json();
})
.then((data) => {
console.log('[BYOB Create Record] Response received');
// Directus response format: { data: {...} }
this._internal.record = data.data || data;
this._internal.recordId = this._internal.record?.id || null;
this._internal.error = null;
this._internal.loading = false;
// Store for inspect
this._internal.lastResult = {
url,
collection,
record: this._internal.record
};
// Flag outputs dirty
this.flagOutputDirty('record');
this.flagOutputDirty('recordId');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Create Record] Error:', error);
this._internal.loading = false;
this._internal.record = null;
this._internal.recordId = null;
// Format error for output
if (error.body && error.body.errors) {
this._internal.error = {
status: error.status,
message: error.body.errors.map((e) => e.message).join(', '),
errors: error.body.errors
};
} else {
this._internal.error = {
status: error.status || 0,
message: error.message || error.statusText || 'Network error'
};
}
// Store for inspect
this._internal.lastResult = {
url,
collection,
error: this._internal.error
};
this.flagOutputDirty('record');
this.flagOutputDirty('recordId');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of configuration input names to their setters
const configSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
}
};
// Register configuration inputs
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
// Register dynamic field inputs (field_<fieldname>)
if (name.startsWith('field_')) {
const fieldName = name.substring(6); // Remove 'field_' prefix
return this.registerInput(name, {
set: (value) => {
this._internal.fieldValues[fieldName] = value;
}
});
}
}
}
};
/**
* Update dynamic ports based on node configuration
*/
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get backend services metadata
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
// Backend selection dropdown
const backendEnums = [{ label: 'Active Backend', value: '_active_' }];
backends.forEach((b) => {
backendEnums.push({ label: b.name, value: b.id });
});
ports.push({
name: 'backendId',
displayName: 'Backend',
type: {
name: 'enum',
enums: backendEnums,
allowEditOnly: true
},
default: '_active_',
plug: 'input',
group: 'Backend'
});
// Resolve the selected backend
const selectedBackendId =
parameters.backendId === '_active_' || !parameters.backendId
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Configuration'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
ports.push({
name: 'collection',
displayName: 'Collection',
type: {
name: 'enum',
enums: collectionEnums,
allowEditOnly: true
},
plug: 'input',
group: 'Configuration'
});
// Dynamic field inputs based on selected collection schema
const selectedCollection = allCollections.find((c) => c.name === parameters.collection);
const fields = selectedCollection?.fields || [];
// Read-only fields that should never be editable
const readOnlyFields = ['id', 'date_created', 'date_updated', 'user_created', 'user_updated'];
fields.forEach((field) => {
// Skip read-only fields
if (readOnlyFields.includes(field.name)) {
return;
}
// Skip presentation elements and hidden fields
if (!ByobUtils.shouldShowField(field)) {
return;
}
// Get enhanced field type (with enum support, placeholders, etc.)
const fieldType = ByobUtils.getEnhancedFieldType(field);
ports.push({
name: `field_${field.name}`,
displayName: field.displayName || field.name,
type: fieldType.type,
plug: 'input',
group: 'Fields'
});
});
// NOTE: 'create' signal is defined in static inputs
// Outputs
ports.push({
name: 'record',
displayName: 'Record',
type: 'object',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'recordId',
displayName: 'Record ID',
type: 'string',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'loading',
displayName: 'Loading',
type: 'boolean',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'error',
displayName: 'Error',
type: 'object',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'success',
displayName: 'Success',
type: 'signal',
plug: 'output',
group: 'Events'
});
ports.push({
name: 'failure',
displayName: 'Failure',
type: 'signal',
plug: 'output',
group: 'Events'
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: CreateRecordNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
// Store field schema in node for value normalization
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters || {}, context.editorConnection, graphModel);
node.on('parameterUpdated', function () {
// Update field schema when collection changes
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.CreateRecord', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.CreateRecord')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,422 @@
/**
* BYOB Delete Record Node
*
* Deletes a record from a BYOB backend collection.
* Supports Directus system tables and user collections.
*
* @module noodl-runtime
* @since 2.0.0
*/
const ByobUtils = require('./byob-utils');
console.log('[BYOB Delete Record] 📦 Module loaded');
var DeleteRecordNode = {
name: 'noodl.byob.DeleteRecord',
displayNodeName: 'Delete Record',
docs: 'https://docs.noodl.net/nodes/data/byob/delete-record',
category: 'Data',
color: 'data',
searchTags: ['byob', 'delete', 'remove', 'data', 'database', 'records', 'directus', 'api', 'backend'],
initialize: function () {
console.log('[BYOB Delete Record] 🚀 INITIALIZE called');
this._internal.loading = false;
this._internal.apiPathMode = 'items';
},
getInspectInfo() {
if (!this._internal.lastResult) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.lastResult };
},
inputs: {
delete: {
type: 'signal',
displayName: 'Delete',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Delete Record] ⚡ DELETE SIGNAL RECEIVED');
this.scheduleDelete();
}
}
},
outputs: {
loading: {
type: 'boolean',
displayName: 'Loading',
group: 'Status',
getter: function () {
return this._internal.loading;
}
},
error: {
type: 'object',
displayName: 'Error',
group: 'Status',
getter: function () {
return this._internal.error;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
prototypeExtensions: {
/**
* Resolve the backend configuration from metadata
*/
resolveBackend: function () {
const backendId = this._internal.backendId || '_active_';
return ByobUtils.resolveBackend(backendId);
},
scheduleDelete: function () {
console.log('[BYOB Delete Record] scheduleDelete called');
if (this._internal.hasScheduledDelete) {
console.log('[BYOB Delete Record] Already scheduled, skipping');
return;
}
this._internal.hasScheduledDelete = true;
this.scheduleAfterInputsHaveUpdated(this.doDelete.bind(this));
},
doDelete: function () {
console.log('[BYOB Delete Record] doDelete executing');
this._internal.hasScheduledDelete = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Delete Record] No backend configured');
this._internal.error = {
message: 'No backend configured. Please add a backend in the Backend Services panel.'
};
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
const collection = this._internal.collection;
const recordId = this._internal.recordId;
const apiPathMode = this._internal.apiPathMode || 'items';
if (!collection) {
console.log('[BYOB Delete Record] No collection specified');
this._internal.error = { message: 'Collection is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
if (!recordId) {
console.log('[BYOB Delete Record] No record ID specified');
this._internal.error = { message: 'Record ID is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build URL with record ID
const url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode, recordId);
if (!url) {
console.log('[BYOB Delete Record] Failed to build URL');
this._internal.error = { message: 'Failed to build request URL' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build headers
const headers = ByobUtils.buildHeaders(backendConfig.token);
console.log('[BYOB Delete Record] Request:', {
url,
backendType: backendConfig.type,
recordId
});
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Perform fetch
fetch(url, {
method: 'DELETE',
headers: headers
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
// DELETE may return 204 No Content or empty response
if (response.status === 204) {
return null;
}
return response.json().catch(() => null);
})
.then(() => {
console.log('[BYOB Delete Record] Delete successful');
this._internal.error = null;
this._internal.loading = false;
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
deleted: true
};
// Flag outputs dirty
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Delete Record] Error:', error);
this._internal.loading = false;
// Format error for output
if (error.body && error.body.errors) {
this._internal.error = {
status: error.status,
message: error.body.errors.map((e) => e.message).join(', '),
errors: error.body.errors
};
} else {
this._internal.error = {
status: error.status || 0,
message: error.message || error.statusText || 'Network error'
};
}
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
error: this._internal.error
};
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of configuration input names to their setters
const configSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
recordId: (value) => {
this._internal.recordId = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
}
};
// Register configuration inputs
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
}
}
};
/**
* Update dynamic ports based on node configuration
*/
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get backend services metadata
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
// Backend selection dropdown
const backendEnums = [{ label: 'Active Backend', value: '_active_' }];
backends.forEach((b) => {
backendEnums.push({ label: b.name, value: b.id });
});
ports.push({
name: 'backendId',
displayName: 'Backend',
type: {
name: 'enum',
enums: backendEnums,
allowEditOnly: true
},
default: '_active_',
plug: 'input',
group: 'Backend'
});
// Resolve the selected backend
const selectedBackendId =
parameters.backendId === '_active_' || !parameters.backendId
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Configuration'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
ports.push({
name: 'collection',
displayName: 'Collection',
type: {
name: 'enum',
enums: collectionEnums,
allowEditOnly: true
},
plug: 'input',
group: 'Configuration'
});
// Record ID input (required for delete)
ports.push({
name: 'recordId',
displayName: 'Record ID',
type: 'string',
plug: 'input',
group: 'Configuration'
});
// NOTE: 'delete' signal is defined in static inputs
// Outputs
ports.push({
name: 'loading',
displayName: 'Loading',
type: 'boolean',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'error',
displayName: 'Error',
type: 'object',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'success',
displayName: 'Success',
type: 'signal',
plug: 'output',
group: 'Events'
});
ports.push({
name: 'failure',
displayName: 'Failure',
type: 'signal',
plug: 'output',
group: 'Events'
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: DeleteRecordNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
updatePorts(node.id, node.parameters || {}, context.editorConnection, graphModel);
node.on('parameterUpdated', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.DeleteRecord', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.DeleteRecord')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -11,6 +11,7 @@
*/
const NoodlRuntime = require('../../../../noodl-runtime');
const ByobUtils = require('./byob-utils');
console.log('[BYOB Query Data] 📦 Module loaded');
@@ -29,6 +30,7 @@ var QueryDataNode = {
this._internal.records = [];
this._internal.totalCount = 0;
this._internal.inspectData = null;
this._internal.apiPathMode = 'items'; // 'items' or 'system'
},
getInspectInfo() {
@@ -133,38 +135,8 @@ var QueryDataNode = {
* Returns { url, token, type } or null if not found
*/
resolveBackend: function () {
// Get metadata from NoodlRuntime (same pattern as cloudstore.js uses for cloudservices)
const backendServices = NoodlRuntime.instance.getMetaData('backendServices');
if (!backendServices || !backendServices.backends) {
console.log('[BYOB Query Data] No backend services metadata found');
console.log('[BYOB Query Data] Available metadata keys:', Object.keys(NoodlRuntime.instance.metadata || {}));
return null;
}
const backendId = this._internal.backendId || '_active_';
const backends = backendServices.backends || [];
// Resolve the backend
let backend;
if (backendId === '_active_') {
backend = backends.find((b) => b.id === backendServices.activeBackendId);
} else {
backend = backends.find((b) => b.id === backendId);
}
if (!backend) {
console.log('[BYOB Query Data] Backend not found:', backendId);
return null;
}
// Return backend config (using publicToken for runtime, NOT adminToken)
return {
url: backend.url,
token: backend.auth?.publicToken || '',
type: backend.type,
endpoints: backend.endpoints
};
return ByobUtils.resolveBackend(backendId);
},
scheduleFetch: function () {
@@ -178,17 +150,19 @@ var QueryDataNode = {
},
buildUrl: function (backendConfig) {
const baseUrl = backendConfig?.url || '';
const collection = this._internal.collection || '';
const apiPathMode = this._internal.apiPathMode || 'items';
if (!baseUrl || !collection) {
if (!backendConfig?.url || !collection) {
return null;
}
// TODO: Use backendConfig.type for backend-specific URL formats (Supabase, Appwrite, etc.)
// Currently only Directus format is implemented
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
let url = `${cleanBaseUrl}/items/${collection}`;
// Build base URL with system table support
let url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode);
if (!url) {
return null;
}
// Build query parameters
const params = new URLSearchParams();
@@ -242,16 +216,7 @@ var QueryDataNode = {
},
buildHeaders: function (backendConfig) {
const headers = {
'Content-Type': 'application/json'
};
const authToken = backendConfig?.token;
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
return ByobUtils.buildHeaders(backendConfig?.token);
},
doFetch: function () {
@@ -404,6 +369,9 @@ var QueryDataNode = {
collection: (value) => {
this._internal.collection = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
},
filter: (value) => {
this._internal.filter = value;
},
@@ -612,11 +580,34 @@ function updatePorts(nodeId, parameters, editorConnection, graphModel) {
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const collections = selectedBackend?.schema?.collections || [];
const allCollections = selectedBackend?.schema?.collections || [];
// Collection dropdown (populated from schema)
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Query'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
collections.forEach((c) => {
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
@@ -632,8 +623,8 @@ function updatePorts(nodeId, parameters, editorConnection, graphModel) {
group: 'Query'
});
// Sort field dropdown (populated from selected collection's fields)
const selectedCollection = collections.find((c) => c.name === parameters.collection);
// Get selected collection for field-based dropdowns
const selectedCollection = allCollections.find((c) => c.name === parameters.collection);
// Filter port - uses Visual Filter Builder when schema is available
ports.push({

View File

@@ -0,0 +1,537 @@
/**
* BYOB Update Record Node
*
* Updates an existing record in a BYOB backend collection.
* Supports Directus system tables and user collections.
*
* @module noodl-runtime
* @since 2.0.0
*/
const ByobUtils = require('./byob-utils');
console.log('[BYOB Update Record] 📦 Module loaded');
var UpdateRecordNode = {
name: 'noodl.byob.UpdateRecord',
displayNodeName: 'Update Record',
docs: 'https://docs.noodl.net/nodes/data/byob/update-record',
category: 'Data',
color: 'data',
searchTags: ['byob', 'update', 'edit', 'modify', 'data', 'database', 'records', 'directus', 'api', 'backend'],
initialize: function () {
console.log('[BYOB Update Record] 🚀 INITIALIZE called');
this._internal.fieldValues = {};
this._internal.loading = false;
this._internal.apiPathMode = 'items';
},
getInspectInfo() {
if (!this._internal.lastResult) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.lastResult };
},
inputs: {
update: {
type: 'signal',
displayName: 'Update',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Update Record] ⚡ UPDATE SIGNAL RECEIVED');
this.scheduleUpdate();
}
}
},
outputs: {
record: {
type: 'object',
displayName: 'Record',
group: 'Results',
getter: function () {
return this._internal.record;
}
},
loading: {
type: 'boolean',
displayName: 'Loading',
group: 'Status',
getter: function () {
return this._internal.loading;
}
},
error: {
type: 'object',
displayName: 'Error',
group: 'Status',
getter: function () {
return this._internal.error;
}
},
success: {
type: 'signal',
displayName: 'Success',
group: 'Events'
},
failure: {
type: 'signal',
displayName: 'Failure',
group: 'Events'
}
},
prototypeExtensions: {
/**
* Resolve the backend configuration from metadata
*/
resolveBackend: function () {
const backendId = this._internal.backendId || '_active_';
return ByobUtils.resolveBackend(backendId);
},
scheduleUpdate: function () {
console.log('[BYOB Update Record] scheduleUpdate called');
if (this._internal.hasScheduledUpdate) {
console.log('[BYOB Update Record] Already scheduled, skipping');
return;
}
this._internal.hasScheduledUpdate = true;
this.scheduleAfterInputsHaveUpdated(this.doUpdate.bind(this));
},
doUpdate: function () {
console.log('[BYOB Update Record] doUpdate executing');
this._internal.hasScheduledUpdate = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Update Record] No backend configured');
this._internal.error = {
message: 'No backend configured. Please add a backend in the Backend Services panel.'
};
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
const collection = this._internal.collection;
const recordId = this._internal.recordId;
const apiPathMode = this._internal.apiPathMode || 'items';
if (!collection) {
console.log('[BYOB Update Record] No collection specified');
this._internal.error = { message: 'Collection is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
if (!recordId) {
console.log('[BYOB Update Record] No record ID specified');
this._internal.error = { message: 'Record ID is required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build URL with record ID
const url = ByobUtils.buildUrl(backendConfig, collection, apiPathMode, recordId);
if (!url) {
console.log('[BYOB Update Record] Failed to build URL');
this._internal.error = { message: 'Failed to build request URL' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Build headers
const headers = ByobUtils.buildHeaders(backendConfig.token);
// Get field schema for value normalization
const fieldSchema = this._internal.fieldSchema || {};
// Collect field values from dynamic inputs and normalize them (especially dates)
const body = {};
for (const [fieldName, value] of Object.entries(this._internal.fieldValues)) {
body[fieldName] = ByobUtils.normalizeValue(value, fieldSchema[fieldName]);
}
console.log('[BYOB Update Record] Request:', {
url,
backendType: backendConfig.type,
recordId,
fieldCount: Object.keys(body).length
});
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Perform fetch
fetch(url, {
method: 'PATCH',
headers: headers,
body: JSON.stringify(body)
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
return response.json();
})
.then((data) => {
console.log('[BYOB Update Record] Response received');
// Directus response format: { data: {...} }
this._internal.record = data.data || data;
this._internal.error = null;
this._internal.loading = false;
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
record: this._internal.record
};
// Flag outputs dirty
this.flagOutputDirty('record');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Update Record] Error:', error);
this._internal.loading = false;
this._internal.record = null;
// Format error for output
if (error.body && error.body.errors) {
this._internal.error = {
status: error.status,
message: error.body.errors.map((e) => e.message).join(', '),
errors: error.body.errors
};
} else {
this._internal.error = {
status: error.status || 0,
message: error.message || error.statusText || 'Network error'
};
}
// Store for inspect
this._internal.lastResult = {
url,
collection,
recordId,
error: this._internal.error
};
this.flagOutputDirty('record');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of configuration input names to their setters
const configSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
recordId: (value) => {
this._internal.recordId = value;
},
apiPathMode: (value) => {
this._internal.apiPathMode = value;
}
};
// Register configuration inputs
if (configSetters[name]) {
return this.registerInput(name, {
set: configSetters[name]
});
}
// Register dynamic field inputs (field_<fieldname>)
if (name.startsWith('field_')) {
const fieldName = name.substring(6); // Remove 'field_' prefix
return this.registerInput(name, {
set: (value) => {
this._internal.fieldValues[fieldName] = value;
}
});
}
}
}
};
/**
* Update dynamic ports based on node configuration
*/
function updatePorts(nodeId, parameters, editorConnection, graphModel) {
const ports = [];
// Get backend services metadata
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
// Backend selection dropdown
const backendEnums = [{ label: 'Active Backend', value: '_active_' }];
backends.forEach((b) => {
backendEnums.push({ label: b.name, value: b.id });
});
ports.push({
name: 'backendId',
displayName: 'Backend',
type: {
name: 'enum',
enums: backendEnums,
allowEditOnly: true
},
default: '_active_',
plug: 'input',
group: 'Backend'
});
// Resolve the selected backend
const selectedBackendId =
parameters.backendId === '_active_' || !parameters.backendId
? backendServices.activeBackendId
: parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
// API Path Mode dropdown - MUST come before Collection for proper UX
const isSystemTable = ByobUtils.isSystemCollection(parameters.collection);
ports.push({
name: 'apiPathMode',
displayName: 'API Path',
type: {
name: 'enum',
enums: [
{ label: 'Items (User Collections)', value: 'items' },
{ label: 'System (Directus Tables)', value: 'system' }
],
allowEditOnly: true
},
default: isSystemTable ? 'system' : 'items',
plug: 'input',
group: 'Configuration'
});
// Filter collections based on selected API path mode
const apiPathMode = parameters.apiPathMode || (isSystemTable ? 'system' : 'items');
const filteredCollections = ByobUtils.filterCollectionsByMode(allCollections, apiPathMode);
// Collection dropdown (filtered by API path mode)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
filteredCollections.forEach((c) => {
collectionEnums.push({ label: c.displayName || c.name, value: c.name });
});
ports.push({
name: 'collection',
displayName: 'Collection',
type: {
name: 'enum',
enums: collectionEnums,
allowEditOnly: true
},
plug: 'input',
group: 'Configuration'
});
// Record ID input (required for update)
ports.push({
name: 'recordId',
displayName: 'Record ID',
type: 'string',
plug: 'input',
group: 'Configuration'
});
// Dynamic field inputs based on selected collection schema
const selectedCollection = allCollections.find((c) => c.name === parameters.collection);
const fields = selectedCollection?.fields || [];
// Read-only fields that should never be editable
const readOnlyFields = ['id', 'date_created', 'user_created'];
fields.forEach((field) => {
// Skip read-only fields
if (readOnlyFields.includes(field.name)) {
return;
}
// Skip presentation elements and hidden fields
if (!ByobUtils.shouldShowField(field)) {
return;
}
// Get enhanced field type (with enum support, placeholders, etc.)
const fieldType = ByobUtils.getEnhancedFieldType(field);
ports.push({
name: `field_${field.name}`,
displayName: field.displayName || field.name,
type: fieldType.type,
plug: 'input',
group: 'Fields'
});
});
// NOTE: 'update' signal is defined in static inputs
// Outputs
ports.push({
name: 'record',
displayName: 'Record',
type: 'object',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'loading',
displayName: 'Loading',
type: 'boolean',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'error',
displayName: 'Error',
type: 'object',
plug: 'output',
group: 'Status'
});
ports.push({
name: 'success',
displayName: 'Success',
type: 'signal',
plug: 'output',
group: 'Events'
});
ports.push({
name: 'failure',
displayName: 'Failure',
type: 'signal',
plug: 'output',
group: 'Events'
});
editorConnection.sendDynamicPorts(nodeId, ports);
}
module.exports = {
node: UpdateRecordNode,
setup: function (context, graphModel) {
if (!context.editorConnection || !context.editorConnection.isRunningLocally()) {
return;
}
function _managePortsForNode(node) {
// Store field schema in node for value normalization
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters || {}, context.editorConnection, graphModel);
node.on('parameterUpdated', function () {
// Update field schema when collection changes
const backendServices = graphModel.getMetaData('backendServices') || { backends: [] };
const backends = backendServices.backends || [];
const selectedBackendId =
node.parameters.backendId === '_active_' || !node.parameters.backendId
? backendServices.activeBackendId
: node.parameters.backendId;
const selectedBackend = backends.find((b) => b.id === selectedBackendId);
const allCollections = selectedBackend?.schema?.collections || [];
const selectedCollection = allCollections.find((c) => c.name === node.parameters.collection);
if (selectedCollection && selectedCollection.fields) {
const fieldSchema = {};
selectedCollection.fields.forEach((field) => {
fieldSchema[field.name] = field;
});
// Ensure _internal exists before setting properties
if (!node._internal) {
node._internal = {};
}
node._internal.fieldSchema = fieldSchema;
}
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.UpdateRecord', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.UpdateRecord')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -0,0 +1,291 @@
/**
* BYOB Utilities
*
* Shared utilities for all BYOB (Bring Your Own Backend) data nodes.
* Provides common functionality for backend resolution, URL building,
* and Directus system table handling.
*
* @module noodl-runtime
* @since 2.0.0
*/
const NoodlRuntime = require('../../../../noodl-runtime');
/**
* Directus system collection endpoint mappings
* Maps internal collection names to their API endpoints
*/
const SYSTEM_ENDPOINTS = {
directus_users: 'users',
directus_roles: 'roles',
directus_files: 'files',
directus_folders: 'folders',
directus_activity: 'activity',
directus_permissions: 'permissions',
directus_settings: 'settings',
directus_webhooks: 'webhooks',
directus_flows: 'flows',
directus_operations: 'operations',
directus_panels: 'panels',
directus_dashboards: 'dashboards',
directus_notifications: 'notifications',
directus_shares: 'shares',
directus_presets: 'presets',
directus_revisions: 'revisions',
directus_translations: 'translations'
};
/**
* Resolve backend configuration from metadata
* @param {string} backendId - Backend ID or '_active_' for active backend
* @returns {Object|null} Backend config with { url, token, type, endpoints } or null
*/
function resolveBackend(backendId) {
const backendServices = NoodlRuntime.instance.getMetaData('backendServices');
if (!backendServices || !backendServices.backends) {
console.log('[BYOB Utils] No backend services metadata found');
return null;
}
const backends = backendServices.backends || [];
let backend;
if (backendId === '_active_') {
backend = backends.find((b) => b.id === backendServices.activeBackendId);
} else {
backend = backends.find((b) => b.id === backendId);
}
if (!backend) {
console.log('[BYOB Utils] Backend not found:', backendId);
return null;
}
return {
url: backend.url,
token: backend.auth?.publicToken || '',
type: backend.type,
endpoints: backend.endpoints
};
}
/**
* Build endpoint path for a collection
* @param {string} collection - Collection name
* @param {string} apiPathMode - 'items' or 'system'
* @returns {string} Endpoint path (e.g., 'items/posts' or 'users')
*/
function buildEndpoint(collection, apiPathMode) {
if (apiPathMode === 'system' && SYSTEM_ENDPOINTS[collection]) {
return SYSTEM_ENDPOINTS[collection];
}
return `items/${collection}`;
}
/**
* Build HTTP headers for API requests
* @param {string} token - Auth token (optional)
* @returns {Object} Headers object
*/
function buildHeaders(token) {
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
/**
* Check if a collection is a Directus system collection
* @param {string} collection - Collection name
* @returns {boolean} True if it's a system collection
*/
function isSystemCollection(collection) {
return collection && collection.startsWith('directus_');
}
/**
* Auto-detect the appropriate API path mode for a collection
* @param {string} collection - Collection name
* @returns {string} 'system' or 'items'
*/
function detectApiPathMode(collection) {
return isSystemCollection(collection) ? 'system' : 'items';
}
/**
* Build full URL for an API request
* @param {Object} backendConfig - Backend configuration
* @param {string} collection - Collection name
* @param {string} apiPathMode - 'items' or 'system'
* @param {string} recordId - Optional record ID for specific record
* @returns {string|null} Full URL or null if invalid
*/
function buildUrl(backendConfig, collection, apiPathMode, recordId = null) {
const baseUrl = backendConfig?.url;
if (!baseUrl || !collection) {
return null;
}
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
const endpoint = buildEndpoint(collection, apiPathMode);
let url = `${cleanBaseUrl}/${endpoint}`;
if (recordId) {
url += `/${recordId}`;
}
return url;
}
/**
* Normalize a value for API submission
* Handles date conversion to ISO 8601 format
* @param {*} value - The value to normalize
* @param {Object} fieldSchema - Field schema information
* @returns {*} Normalized value
*/
function normalizeValue(value, fieldSchema) {
if (value === null || value === undefined) {
return value;
}
// Handle date/datetime fields - convert to ISO 8601
if (
fieldSchema &&
(fieldSchema.type === 'dateTime' ||
fieldSchema.type === 'date' ||
fieldSchema.type === 'timestamp' ||
fieldSchema.type === 'time')
) {
// If already an ISO string, return as-is
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return value;
}
// Try to parse as date
try {
const date = new Date(value);
if (!isNaN(date.getTime())) {
return date.toISOString();
}
} catch (e) {
console.warn('[BYOB Utils] Failed to convert date value:', value);
}
}
return value;
}
/**
* Filter collections based on API path mode
* @param {Array} collections - All collections from schema
* @param {string} apiPathMode - 'items' or 'system'
* @returns {Array} Filtered collections
*/
function filterCollectionsByMode(collections, apiPathMode) {
if (!apiPathMode || apiPathMode === 'items') {
// Items mode: exclude system tables
return collections.filter((c) => !isSystemCollection(c.name));
} else {
// System mode: only system tables
return collections.filter((c) => isSystemCollection(c.name));
}
}
/**
* Check if a field should be shown in the property editor
* Filters out presentation elements, hidden fields, and readonly meta fields
* @param {Object} field - Field schema
* @returns {boolean} True if field should be shown
*/
function shouldShowField(field) {
if (!field || !field.name) return false;
// Skip presentation interfaces (dividers, notices, etc.)
if (field.meta?.interface && field.meta.interface.startsWith('presentation-')) {
return false;
}
// Skip explicitly hidden fields
if (field.meta?.hidden === true) {
return false;
}
return true;
}
/**
* Get enhanced field type for property editor
* Maps Directus field types to Noodl port types with additional metadata
* @param {Object} field - Field schema
* @returns {Object} Port type definition { type, options, placeholder }
*/
function getEnhancedFieldType(field) {
const result = {
type: 'string',
options: null,
placeholder: null
};
// Check for enum/select fields
if (field.meta?.interface === 'select-dropdown' || field.meta?.interface === 'select-dropdown-m2o') {
const choices = field.meta?.options?.choices;
if (choices && Array.isArray(choices)) {
result.type = {
name: 'enum',
enums: choices.map((choice) => ({
label: choice.text || choice.value,
value: choice.value
})),
allowEditOnly: false
};
return result;
}
}
// Map basic field types
if (field.type === 'integer' || field.type === 'bigInteger' || field.type === 'float' || field.type === 'decimal') {
result.type = 'number';
} else if (field.type === 'boolean') {
result.type = 'boolean';
} else if (field.type === 'json' || field.type === 'array') {
result.type = 'object';
} else if (field.type === 'dateTime' || field.type === 'timestamp') {
result.type = 'string';
result.placeholder = 'YYYY-MM-DDTHH:mm:ss.sssZ';
} else if (field.type === 'date') {
result.type = 'string';
result.placeholder = 'YYYY-MM-DD';
} else if (field.type === 'time') {
result.type = 'string';
result.placeholder = 'HH:mm:ss';
} else if (field.type === 'uuid') {
result.type = 'string';
result.placeholder = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
} else {
result.type = 'string';
}
return result;
}
module.exports = {
SYSTEM_ENDPOINTS,
resolveBackend,
buildEndpoint,
buildHeaders,
buildUrl,
isSystemCollection,
detectApiPathMode,
normalizeValue,
filterCollectionsByMode,
shouldShowField,
getEnhancedFieldType
};