mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 07:12:54 +01:00
initial ux ui improvements and revised dashboard
This commit is contained in:
@@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
291
packages/noodl-runtime/src/nodes/std-library/data/byob-utils.js
Normal file
291
packages/noodl-runtime/src/nodes/std-library/data/byob-utils.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user