New data query node for Directus backend integration

This commit is contained in:
Richard Osborne
2025-12-30 11:55:30 +01:00
parent 6fd59e83e6
commit ae7d3b8a8b
52 changed files with 17798 additions and 303 deletions

View File

@@ -0,0 +1,627 @@
/**
* Backend Services Model
*
* Manages backend configurations for the BYOB (Bring Your Own Backend) system.
* Handles CRUD operations, connection testing, and schema introspection.
*
* @module BackendServices
* @since 1.2.0
*/
import { ProjectModel } from '@noodl-models/projectmodel';
import { Model } from '@noodl-utils/model';
import { getPreset } from './presets';
import {
BackendConfig,
BackendConfigSerialized,
BackendServicesEvent,
BackendServicesEvents,
BackendServicesMetadata,
CachedSchema,
ConnectionStatus,
ConnectionTestResult,
CreateBackendRequest,
IBackendServices,
UpdateBackendRequest
} from './types';
/**
* Generate a unique ID for a new backend
*/
function generateId(): string {
return `backend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Serialize a BackendConfig for storage
*/
function serializeBackend(backend: BackendConfig): BackendConfigSerialized {
return {
...backend,
schema: backend.schema
? {
...backend.schema,
fetchedAt: backend.schema.fetchedAt.toISOString()
}
: undefined,
lastSynced: backend.lastSynced?.toISOString(),
createdAt: backend.createdAt.toISOString(),
updatedAt: backend.updatedAt.toISOString()
};
}
/**
* Deserialize a BackendConfig from storage
*/
function deserializeBackend(data: BackendConfigSerialized): BackendConfig {
return {
...data,
schema: data.schema
? {
...data.schema,
fetchedAt: new Date(data.schema.fetchedAt)
}
: undefined,
lastSynced: data.lastSynced ? new Date(data.lastSynced) : undefined,
createdAt: new Date(data.createdAt),
updatedAt: new Date(data.updatedAt)
};
}
/**
* Backend Services singleton
* Manages all backend configurations for the current project
*/
export class BackendServices extends Model<BackendServicesEvent, BackendServicesEvents> implements IBackendServices {
public static instance: BackendServices = new BackendServices();
private _isLoading = false;
private _backends: BackendConfig[] = [];
private _activeBackendId: string | undefined;
// ============================================================================
// Getters
// ============================================================================
get isLoading(): boolean {
return this._isLoading;
}
get backends(): BackendConfig[] {
return this._backends;
}
get activeBackendId(): string | undefined {
return this._activeBackendId;
}
get activeBackend(): BackendConfig | undefined {
if (!this._activeBackendId) return undefined;
return this._backends.find((b) => b.id === this._activeBackendId);
}
// ============================================================================
// Initialization
// ============================================================================
/**
* Initialize and load backends from the current project
*/
async initialize(): Promise<void> {
this._isLoading = true;
try {
const project = ProjectModel.instance;
if (!project) {
console.warn('[BackendServices] No project loaded');
return;
}
const metadata = project.getMetaData('backendServices') as BackendServicesMetadata | undefined;
if (metadata) {
this._backends = metadata.backends.map(deserializeBackend);
this._activeBackendId = metadata.activeBackendId;
} else {
this._backends = [];
this._activeBackendId = undefined;
}
this.notifyListeners(BackendServicesEvent.BackendsChanged);
} finally {
this._isLoading = false;
}
}
/**
* Reset the service (for project switching)
*/
reset(): void {
this._backends = [];
this._activeBackendId = undefined;
this._isLoading = false;
this.notifyListeners(BackendServicesEvent.BackendsChanged);
}
// ============================================================================
// Persistence
// ============================================================================
/**
* Save backends to project metadata
*/
private saveToProject(): void {
const project = ProjectModel.instance;
if (!project) {
console.warn('[BackendServices] Cannot save - no project loaded');
return;
}
const metadata: BackendServicesMetadata = {
backends: this._backends.map(serializeBackend),
activeBackendId: this._activeBackendId
};
project.setMetaData('backendServices', metadata);
project.notifyListeners('backendServicesChanged');
}
// ============================================================================
// CRUD Operations
// ============================================================================
/**
* Get a backend by ID
*/
getBackend(id: string): BackendConfig | undefined {
return this._backends.find((b) => b.id === id);
}
/**
* Create a new backend configuration
*/
async createBackend(request: CreateBackendRequest): Promise<BackendConfig> {
const preset = getPreset(request.type);
const now = new Date();
const backend: BackendConfig = {
id: generateId(),
name: request.name,
type: request.type,
url: request.url.replace(/\/$/, ''), // Remove trailing slash
auth: {
...preset.defaultAuth,
...request.auth
},
endpoints: {
...preset.endpoints,
...request.endpoints
},
responseConfig: {
...preset.responseConfig,
...request.responseConfig
},
status: 'disconnected',
createdAt: now,
updatedAt: now
};
this._backends.push(backend);
// If this is the first backend, make it active
if (this._backends.length === 1) {
this._activeBackendId = backend.id;
this.notifyListeners(BackendServicesEvent.ActiveBackendChanged, backend.id);
}
this.saveToProject();
this.notifyListeners(BackendServicesEvent.BackendsChanged);
return backend;
}
/**
* Update an existing backend configuration
*/
async updateBackend(request: UpdateBackendRequest): Promise<BackendConfig> {
const index = this._backends.findIndex((b) => b.id === request.id);
if (index === -1) {
throw new Error(`Backend not found: ${request.id}`);
}
const existing = this._backends[index];
const updated: BackendConfig = {
...existing,
name: request.name ?? existing.name,
url: request.url ? request.url.replace(/\/$/, '') : existing.url,
auth: request.auth ? { ...existing.auth, ...request.auth } : existing.auth,
endpoints: request.endpoints ? { ...existing.endpoints, ...request.endpoints } : existing.endpoints,
responseConfig: request.responseConfig
? { ...existing.responseConfig, ...request.responseConfig }
: existing.responseConfig,
updatedAt: new Date()
};
this._backends[index] = updated;
this.saveToProject();
this.notifyListeners(BackendServicesEvent.BackendsChanged);
return updated;
}
/**
* Delete a backend configuration
*/
async deleteBackend(id: string): Promise<boolean> {
const index = this._backends.findIndex((b) => b.id === id);
if (index === -1) {
return false;
}
this._backends.splice(index, 1);
// If we deleted the active backend, clear it or pick another
if (this._activeBackendId === id) {
this._activeBackendId = this._backends.length > 0 ? this._backends[0].id : undefined;
this.notifyListeners(BackendServicesEvent.ActiveBackendChanged, this._activeBackendId);
}
this.saveToProject();
this.notifyListeners(BackendServicesEvent.BackendsChanged);
return true;
}
/**
* Set the active backend
*/
setActiveBackend(id: string | undefined): void {
if (id && !this._backends.find((b) => b.id === id)) {
console.warn(`[BackendServices] Backend not found: ${id}`);
return;
}
this._activeBackendId = id;
this.saveToProject();
this.notifyListeners(BackendServicesEvent.ActiveBackendChanged, id);
}
// ============================================================================
// Connection Testing
// ============================================================================
/**
* Test connection to a backend
*/
async testConnection(backendOrConfig: BackendConfig | CreateBackendRequest): Promise<ConnectionTestResult> {
const startTime = Date.now();
// Determine URL and auth
const url = backendOrConfig.url.replace(/\/$/, '');
const auth = backendOrConfig.auth;
const preset = getPreset(backendOrConfig.type);
const endpoints = 'endpoints' in backendOrConfig ? backendOrConfig.endpoints : preset.endpoints;
// Build headers - use adminToken for schema introspection (editor-only)
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Use adminToken for testing connection (schema access requires admin permissions)
const token = auth.adminToken;
if (auth.method === 'bearer' && token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (auth.method === 'api-key' && token) {
const headerName = auth.apiKeyHeader || 'X-API-Key';
headers[headerName] = token;
} else if (auth.method === 'basic' && auth.username && auth.password) {
const encoded = btoa(`${auth.username}:${auth.password}`);
headers['Authorization'] = `Basic ${encoded}`;
}
try {
// Try to fetch schema endpoint as a connectivity test (requires admin token)
const schemaUrl = `${url}${endpoints.schema}`;
const response = await fetch(schemaUrl, {
method: 'GET',
headers
});
const responseTime = Date.now() - startTime;
if (response.ok) {
// Update status if this is an existing backend
if ('id' in backendOrConfig) {
this.updateBackendStatus(backendOrConfig.id, 'connected');
}
return {
success: true,
message: `Connected successfully (${responseTime}ms)`,
responseTime
};
} else {
// Get error details for better error messages
const errorBody = await response.text().catch(() => '');
const message =
response.status === 401 || response.status === 403
? `Authentication failed${errorBody ? `: ${errorBody.slice(0, 100)}` : ''}`
: `HTTP ${response.status}: ${response.statusText}`;
if ('id' in backendOrConfig) {
this.updateBackendStatus(backendOrConfig.id, 'error', message);
}
return {
success: false,
message,
responseTime
};
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
if ('id' in backendOrConfig) {
this.updateBackendStatus(backendOrConfig.id, 'error', message);
}
return {
success: false,
message: `Connection error: ${message}`
};
}
}
/**
* Update the connection status of a backend
*/
private updateBackendStatus(id: string, status: ConnectionStatus, errorMessage?: string): void {
const backend = this._backends.find((b) => b.id === id);
if (!backend) return;
backend.status = status;
backend.lastError = errorMessage;
backend.updatedAt = new Date();
this.saveToProject();
this.notifyListeners(BackendServicesEvent.StatusChanged, id, status);
}
// ============================================================================
// Schema Introspection
// ============================================================================
/**
* Fetch schema from a backend
*/
async fetchSchema(backendId: string): Promise<CachedSchema> {
const backend = this._backends.find((b) => b.id === backendId);
if (!backend) {
throw new Error(`Backend not found: ${backendId}`);
}
// Build headers - use adminToken for schema introspection (editor-only)
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Use adminToken for schema fetching (requires admin/service permissions)
const token = backend.auth.adminToken;
if (backend.auth.method === 'bearer' && token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (backend.auth.method === 'api-key' && token) {
const headerName = backend.auth.apiKeyHeader || 'X-API-Key';
headers[headerName] = token;
}
try {
const schemaUrl = `${backend.url}${backend.endpoints.schema}`;
const response = await fetch(schemaUrl, {
method: 'GET',
headers
});
if (!response.ok) {
throw new Error(`Failed to fetch schema: HTTP ${response.status}`);
}
const data = await response.json();
// Parse schema based on backend type
const schema = this.parseSchemaResponse(backend.type, data);
// Update backend with schema
backend.schema = schema;
backend.lastSynced = new Date();
backend.status = 'connected';
backend.updatedAt = new Date();
this.saveToProject();
this.notifyListeners(BackendServicesEvent.SchemaUpdated, backendId);
return schema;
} catch (error) {
const message = error instanceof Error ? error.message : 'Schema fetch failed';
this.updateBackendStatus(backendId, 'error', message);
throw error;
}
}
/**
* Parse schema response based on backend type
*/
private parseSchemaResponse(type: string, data: unknown): CachedSchema {
const now = new Date();
// Default empty schema
const schema: CachedSchema = {
version: now.getTime().toString(),
fetchedAt: now,
collections: []
};
if (type === 'directus') {
return this.parseDirectusSchema(data, schema);
} else if (type === 'supabase') {
return this.parseSupabaseSchema(data, schema);
} else if (type === 'pocketbase') {
return this.parsePocketbaseSchema(data, schema);
}
// For custom, try to parse as a generic schema format
return this.parseGenericSchema(data, schema);
}
/**
* Parse Directus schema from /fields endpoint
*/
private parseDirectusSchema(data: unknown, schema: CachedSchema): CachedSchema {
// Directus returns: { data: [{ collection, field, type, ... }] }
const fieldsData = (data as { data?: unknown[] })?.data || [];
const collectionMap = new Map<string, (typeof schema.collections)[0]>();
for (const field of fieldsData as Array<{
collection: string;
field: string;
type: string;
schema?: { is_nullable?: boolean; is_primary_key?: boolean; is_unique?: boolean; default_value?: unknown };
meta?: { options?: { choices?: Array<{ value: string }> }; special?: string[] };
}>) {
if (!field.collection || field.collection.startsWith('directus_')) continue;
if (!collectionMap.has(field.collection)) {
collectionMap.set(field.collection, {
name: field.collection,
displayName: field.collection,
fields: [],
primaryKey: 'id'
});
}
const collection = collectionMap.get(field.collection)!;
collection.fields.push({
name: field.field,
displayName: field.field,
type: field.type,
nativeType: field.type,
required: field.schema?.is_nullable === false,
primaryKey: field.schema?.is_primary_key,
unique: field.schema?.is_unique,
defaultValue: field.schema?.default_value,
enumValues: field.meta?.options?.choices?.map((c) => c.value)
});
if (field.schema?.is_primary_key) {
collection.primaryKey = field.field;
}
}
schema.collections = Array.from(collectionMap.values());
return schema;
}
/**
* Parse Supabase schema from OpenAPI spec
*/
private parseSupabaseSchema(data: unknown, schema: CachedSchema): CachedSchema {
// Supabase returns OpenAPI spec with definitions/paths
const openApi = data as {
definitions?: Record<string, { properties?: Record<string, { type?: string; format?: string }> }>;
};
if (openApi.definitions) {
for (const [tableName, tableDef] of Object.entries(openApi.definitions)) {
const fields =
tableDef.properties &&
Object.entries(tableDef.properties).map(([fieldName, fieldDef]) => ({
name: fieldName,
displayName: fieldName,
type: fieldDef.type || 'unknown',
nativeType: fieldDef.format || fieldDef.type || 'unknown',
required: false
}));
schema.collections.push({
name: tableName,
displayName: tableName,
fields: fields || [],
primaryKey: 'id'
});
}
}
return schema;
}
/**
* Parse Pocketbase schema from /api/collections
*/
private parsePocketbaseSchema(data: unknown, schema: CachedSchema): CachedSchema {
// Pocketbase returns: [{ id, name, schema: [{ name, type, required, ... }] }]
const collections = Array.isArray(data) ? data : (data as { items?: unknown[] })?.items || [];
for (const col of collections as Array<{
name: string;
schema?: Array<{ name: string; type: string; required?: boolean; options?: { values?: string[] } }>;
system?: boolean;
}>) {
if (col.system) continue;
schema.collections.push({
name: col.name,
displayName: col.name,
fields:
col.schema?.map((f) => ({
name: f.name,
displayName: f.name,
type: f.type,
nativeType: f.type,
required: f.required || false,
enumValues: f.options?.values
})) || [],
primaryKey: 'id'
});
}
return schema;
}
/**
* Parse generic schema format
*/
private parseGenericSchema(data: unknown, schema: CachedSchema): CachedSchema {
// Try to parse as array of collections or object with collections property
const collections = Array.isArray(data)
? data
: (data as { collections?: unknown[]; tables?: unknown[] })?.collections ||
(data as { tables?: unknown[] })?.tables ||
[];
for (const col of collections as Array<{
name: string;
fields?: Array<{ name: string; type?: string }>;
}>) {
if (!col.name) continue;
schema.collections.push({
name: col.name,
displayName: col.name,
fields:
col.fields?.map((f) => ({
name: f.name,
displayName: f.name,
type: f.type || 'unknown',
nativeType: f.type || 'unknown',
required: false
})) || [],
primaryKey: 'id'
});
}
return schema;
}
}

View File

@@ -0,0 +1,13 @@
/**
* Backend Services Module
*
* BYOB (Bring Your Own Backend) system for connecting to external databases.
* Supports Directus, Supabase, Pocketbase, and custom REST APIs.
*
* @module BackendServices
* @since 1.2.0
*/
export { BackendServices } from './BackendServices';
export * from './types';
export * from './presets';

View File

@@ -0,0 +1,293 @@
/**
* Backend Presets
*
* Pre-configured endpoint patterns for popular BaaS platforms.
* These can be customized by users after selection.
*
* @module BackendServices
* @since 1.2.0
*/
import { BackendAuthConfig, BackendEndpoints, BackendType, ResponseConfig } from './types';
/**
* Preset configuration for a backend type
*/
export interface BackendPreset {
type: BackendType;
displayName: string;
description: string;
icon: string;
docsUrl: string;
defaultAuth: BackendAuthConfig;
endpoints: BackendEndpoints;
responseConfig: ResponseConfig;
/** Placeholder URL to show in the input */
urlPlaceholder: string;
/** Short help text shown inline */
authHelpText: string;
/** Detailed help for admin API key (shown in popup) */
adminKeyHelp: string;
/** Detailed help for public API key (shown in popup) */
publicKeyHelp: string;
}
/**
* Directus preset configuration
* @see https://docs.directus.io/reference/introduction.html
*/
export const directusPreset: BackendPreset = {
type: 'directus',
displayName: 'Directus',
description: 'Open source headless CMS with powerful REST API and admin panel',
icon: 'directus',
docsUrl: 'https://docs.directus.io/reference/introduction.html',
urlPlaceholder: 'https://your-directus-instance.com',
authHelpText: 'Configure tokens from Directus Admin > Settings > Access Tokens',
adminKeyHelp: `**Admin API Key Setup (Directus)**
1. Go to **Settings → Access Tokens** in your Directus admin panel
2. Click **Create Token** and give it a descriptive name (e.g., "Noodl Schema Access")
3. Set the role to **Admin** or a custom role with:
- Read access to \`directus_fields\` (required for schema)
- Read access to \`directus_collections\` (required for table list)
4. Copy the generated token
**Note:** This token is only used in the editor for fetching your database structure. It will NOT be published to your deployed app.`,
publicKeyHelp: `**Public API Key Setup (Directus)**
1. Go to **Settings → Access Tokens** in your Directus admin panel
2. Create a new token with the **Public** role (or a custom limited role)
3. Configure which collections this role can access:
- Go to **Settings → Roles & Permissions → Public**
- Enable **read** permissions only on collections you want public
- Example: Allow reading "products" but not "users"
4. Copy the generated token
**⚠️ Warning:** This token WILL be visible in your deployed app. Only grant access to data that should be publicly readable.`,
defaultAuth: {
method: 'bearer',
adminToken: '',
publicToken: ''
},
endpoints: {
list: '/items/{table}',
get: '/items/{table}/{id}',
create: '/items/{table}',
update: '/items/{table}/{id}',
delete: '/items/{table}/{id}',
schema: '/fields'
},
responseConfig: {
dataPath: 'data',
totalCountPath: 'meta.total_count',
paginationType: 'offset',
offsetParam: 'offset',
limitParam: 'limit'
}
};
/**
* Supabase preset configuration
* @see https://supabase.com/docs/guides/api
*/
export const supabasePreset: BackendPreset = {
type: 'supabase',
displayName: 'Supabase',
description: 'Open source Firebase alternative with PostgreSQL database',
icon: 'supabase',
docsUrl: 'https://supabase.com/docs/guides/api',
urlPlaceholder: 'https://your-project.supabase.co',
authHelpText: 'Find your keys in Supabase Dashboard > Settings > API',
adminKeyHelp: `**Admin API Key Setup (Supabase)**
1. Go to your Supabase project dashboard
2. Navigate to **Settings → API**
3. Under "Project API Keys", find the **service_role** key
4. Click "Reveal" and copy the key
**Important:** The service_role key bypasses Row Level Security (RLS). This gives full database access which is needed for schema introspection.
**Note:** This key is only used in the editor. It will NOT be published to your deployed app.`,
publicKeyHelp: `**Public API Key Setup (Supabase)**
1. Go to your Supabase project dashboard
2. Navigate to **Settings → API**
3. Under "Project API Keys", find the **anon** (public) key
4. Copy this key
**Security with RLS:**
The anon key works with Row Level Security (RLS) policies. Make sure to:
- Enable RLS on tables that should have restricted access
- Create policies for what anonymous users can read
- Example policy: Allow reading products where \`is_published = true\`
**⚠️ Warning:** This key WILL be visible in your deployed app. Use RLS policies to control data access.`,
defaultAuth: {
method: 'api-key',
apiKeyHeader: 'apikey',
adminToken: '',
publicToken: ''
},
endpoints: {
list: '/rest/v1/{table}',
get: '/rest/v1/{table}?id=eq.{id}',
create: '/rest/v1/{table}',
update: '/rest/v1/{table}?id=eq.{id}',
delete: '/rest/v1/{table}?id=eq.{id}',
schema: '/rest/v1/' // Returns OpenAPI spec
},
responseConfig: {
dataPath: '', // Supabase returns array directly
totalCountPath: '', // Needs special header: Prefer: count=exact
paginationType: 'offset',
offsetParam: 'offset',
limitParam: 'limit'
}
};
/**
* Pocketbase preset configuration
* @see https://pocketbase.io/docs/api-records/
*/
export const pocketbasePreset: BackendPreset = {
type: 'pocketbase',
displayName: 'Pocketbase',
description: 'Lightweight backend in a single binary with SQLite',
icon: 'pocketbase',
docsUrl: 'https://pocketbase.io/docs/api-records/',
urlPlaceholder: 'http://localhost:8090',
authHelpText: 'Use admin credentials for schema access',
adminKeyHelp: `**Admin API Key Setup (Pocketbase)**
1. Start your Pocketbase instance and go to the admin UI (typically /_/)
2. Navigate to **Settings → Admins**
3. You can either:
- Use your admin email/password for authentication, OR
- Create an admin API token if your version supports it
**Alternative - Using Admin Auth:**
For Pocketbase, you may need to use admin email + password authentication instead of a static token. The editor will handle this appropriately.
**Note:** Admin access is only used in the editor for fetching collections and their schemas. It will NOT be published.`,
publicKeyHelp: `**Public API Key Setup (Pocketbase)**
Pocketbase uses **API Rules** instead of API keys for public access:
1. Go to your Pocketbase admin UI
2. Navigate to **Collections** and select a collection
3. Click **API Rules** tab
4. Configure rules for anonymous access:
- **List rule:** Leave empty for public read, or add conditions
- **View rule:** Leave empty for public single-item read
- Example: \`is_published = true\` to only show published items
**No Public Token Needed:**
If you've configured API rules to allow anonymous access, you can leave this field empty. Pocketbase will allow access based on your rules.
**⚠️ Note:** API rules determine what unauthenticated users can access in your deployed app.`,
defaultAuth: {
method: 'bearer',
adminToken: '',
publicToken: ''
},
endpoints: {
list: '/api/collections/{table}/records',
get: '/api/collections/{table}/records/{id}',
create: '/api/collections/{table}/records',
update: '/api/collections/{table}/records/{id}',
delete: '/api/collections/{table}/records/{id}',
schema: '/api/collections'
},
responseConfig: {
dataPath: 'items',
totalCountPath: 'totalItems',
paginationType: 'page',
offsetParam: 'page',
limitParam: 'perPage'
}
};
/**
* Custom REST API preset (starting point for manual configuration)
*/
export const customPreset: BackendPreset = {
type: 'custom',
displayName: 'Custom REST API',
description: 'Configure any REST API with custom endpoints',
icon: 'api',
docsUrl: '',
urlPlaceholder: 'https://api.example.com',
authHelpText: 'Configure authentication based on your API requirements',
adminKeyHelp: `**Admin API Key Setup (Custom REST)**
For schema introspection, you need an API key or token that has permission to:
- Read your API's schema endpoint
- Access metadata about tables/collections and their fields
This will depend on your specific API implementation. Common patterns:
- Bearer token with admin scope
- API key with read-all permissions
- Service account credentials
**Note:** This key is only used in the editor for development. It will NOT be published to your deployed app.`,
publicKeyHelp: `**Public API Key Setup (Custom REST)**
If your API supports unauthenticated or limited-access requests, configure:
- A public API key with restricted permissions
- A read-only token for specific resources
- Or leave empty if your API handles public access differently
**⚠️ Warning:** If you provide a public key, it WILL be included in your deployed app and visible to end users. Only use keys that are safe to expose publicly.`,
defaultAuth: {
method: 'bearer',
adminToken: '',
publicToken: ''
},
endpoints: {
list: '/{table}',
get: '/{table}/{id}',
create: '/{table}',
update: '/{table}/{id}',
delete: '/{table}/{id}',
schema: '/schema'
},
responseConfig: {
dataPath: 'data',
totalCountPath: 'total',
paginationType: 'offset',
offsetParam: 'offset',
limitParam: 'limit'
}
};
/**
* All available presets
*/
export const backendPresets: Record<BackendType, BackendPreset> = {
directus: directusPreset,
supabase: supabasePreset,
pocketbase: pocketbasePreset,
custom: customPreset
};
/**
* Get a preset by type
*/
export function getPreset(type: BackendType): BackendPreset {
return backendPresets[type] || customPreset;
}
/**
* Get all presets as an array (useful for UI)
*/
export function getAllPresets(): BackendPreset[] {
return Object.values(backendPresets);
}
/**
* Get presets excluding custom (for preset selection UI)
*/
export function getPresetOptions(): BackendPreset[] {
return [directusPreset, supabasePreset, pocketbasePreset];
}

View File

@@ -0,0 +1,313 @@
/**
* Backend Services Types
*
* Type definitions for the BYOB (Bring Your Own Backend) system.
* Supports Directus, Supabase, Pocketbase, and custom REST APIs.
*
* @module BackendServices
* @since 1.2.0
*/
import { IModel } from '@noodl-utils/model';
// ============================================================================
// Backend Configuration Types
// ============================================================================
/**
* Supported backend types with preset configurations
*/
export type BackendType = 'directus' | 'supabase' | 'pocketbase' | 'custom';
/**
* Authentication methods supported by backends
*/
export type AuthMethod = 'none' | 'bearer' | 'api-key' | 'basic';
/**
* Connection status of a backend
*/
export type ConnectionStatus = 'connected' | 'disconnected' | 'error' | 'checking';
/**
* Pagination styles supported by different backends
*/
export type PaginationType = 'offset' | 'cursor' | 'page';
/**
* Authentication configuration for a backend
*/
export interface BackendAuthConfig {
/** Authentication method */
method: AuthMethod;
/**
* Admin token for schema introspection (editor-only).
* This token is NOT published to the deployed app.
* Should have permissions to read schema/fields.
*/
adminToken?: string;
/**
* Public token for unauthenticated user access.
* This token WILL be published and visible in the deployed app.
* Should only have limited permissions for public data access.
*/
publicToken?: string;
/** Custom header name for API key authentication (e.g., "X-API-Key", "apikey") */
apiKeyHeader?: string;
/** Username for basic auth */
username?: string;
/** Password for basic auth */
password?: string;
}
/**
* Endpoint configuration for CRUD operations
* Supports {table} and {id} placeholders
*/
export interface BackendEndpoints {
/** List records: GET /items/{table} */
list: string;
/** Get single record: GET /items/{table}/{id} */
get: string;
/** Create record: POST /items/{table} */
create: string;
/** Update record: PATCH /items/{table}/{id} */
update: string;
/** Delete record: DELETE /items/{table}/{id} */
delete: string;
/** Schema introspection endpoint */
schema: string;
}
/**
* Response parsing configuration
*/
export interface ResponseConfig {
/** Path to data in response (e.g., "data" for Directus, "items" for custom) */
dataPath?: string;
/** Path to total count in response */
totalCountPath?: string;
/** Pagination style */
paginationType: PaginationType;
/** Field name for pagination offset/skip */
offsetParam?: string;
/** Field name for pagination limit */
limitParam?: string;
}
/**
* Schema field definition
*/
export interface SchemaField {
name: string;
displayName?: string;
type: string;
nativeType: string;
required: boolean;
unique?: boolean;
primaryKey?: boolean;
defaultValue?: unknown;
enumValues?: string[];
relationTarget?: string;
relationType?: 'one-to-one' | 'one-to-many' | 'many-to-one' | 'many-to-many';
}
/**
* Schema collection/table definition
*/
export interface SchemaCollection {
name: string;
displayName?: string;
description?: string;
fields: SchemaField[];
primaryKey: string;
isSystem?: boolean;
}
/**
* Cached schema from backend introspection
*/
export interface CachedSchema {
version: string;
fetchedAt: Date;
collections: SchemaCollection[];
}
/**
* Full backend configuration
*/
export interface BackendConfig {
/** Unique identifier */
id: string;
/** User-friendly name */
name: string;
/** Backend type (preset or custom) */
type: BackendType;
/** Base URL of the backend */
url: string;
/** Authentication configuration */
auth: BackendAuthConfig;
/** Endpoint patterns */
endpoints: BackendEndpoints;
/** Response parsing configuration */
responseConfig: ResponseConfig;
/** Cached schema from introspection */
schema?: CachedSchema;
/** Connection status */
status: ConnectionStatus;
/** Last successful schema sync */
lastSynced?: Date;
/** Last error message */
lastError?: string;
/** When this backend was created */
createdAt: Date;
/** When this backend was last updated */
updatedAt: Date;
}
/**
* Serialized backend config for storage in project metadata
*/
export interface BackendConfigSerialized {
id: string;
name: string;
type: BackendType;
url: string;
auth: BackendAuthConfig;
endpoints: BackendEndpoints;
responseConfig: ResponseConfig;
schema?: {
version: string;
fetchedAt: string;
collections: SchemaCollection[];
};
status: ConnectionStatus;
lastSynced?: string;
lastError?: string;
createdAt: string;
updatedAt: string;
}
/**
* Project metadata for backend services
*/
export interface BackendServicesMetadata {
/** List of configured backends */
backends: BackendConfigSerialized[];
/** ID of the active backend (used by data nodes by default) */
activeBackendId?: string;
}
// ============================================================================
// Request/Response Types
// ============================================================================
/**
* Request to create a new backend
*/
export interface CreateBackendRequest {
name: string;
type: BackendType;
url: string;
auth: BackendAuthConfig;
endpoints?: Partial<BackendEndpoints>;
responseConfig?: Partial<ResponseConfig>;
}
/**
* Request to update an existing backend
*/
export interface UpdateBackendRequest {
id: string;
name?: string;
url?: string;
auth?: Partial<BackendAuthConfig>;
endpoints?: Partial<BackendEndpoints>;
responseConfig?: Partial<ResponseConfig>;
}
/**
* Result of a connection test
*/
export interface ConnectionTestResult {
success: boolean;
message: string;
responseTime?: number;
serverVersion?: string;
}
// ============================================================================
// Event Types
// ============================================================================
/**
* Events emitted by BackendServices
*/
export enum BackendServicesEvent {
/** A backend was added, updated, or deleted */
BackendsChanged = 'BackendsChanged',
/** The active backend changed */
ActiveBackendChanged = 'ActiveBackendChanged',
/** Schema was fetched for a backend */
SchemaUpdated = 'SchemaUpdated',
/** Connection status changed for a backend */
StatusChanged = 'StatusChanged'
}
/**
* Event handler signatures
*/
export type BackendServicesEvents = {
[BackendServicesEvent.BackendsChanged]: () => void;
[BackendServicesEvent.ActiveBackendChanged]: (backendId: string | undefined) => void;
[BackendServicesEvent.SchemaUpdated]: (backendId: string) => void;
[BackendServicesEvent.StatusChanged]: (backendId: string, status: ConnectionStatus) => void;
};
// ============================================================================
// Service Interface
// ============================================================================
/**
* Backend Services interface
*/
export interface IBackendServices extends IModel<BackendServicesEvent, BackendServicesEvents> {
/** Whether the service is currently loading */
readonly isLoading: boolean;
/** All configured backends */
readonly backends: BackendConfig[];
/** The currently active backend (if any) */
readonly activeBackend: BackendConfig | undefined;
/** ID of the active backend */
readonly activeBackendId: string | undefined;
/** Initialize and load backends from project */
initialize(): Promise<void>;
/** Get a backend by ID */
getBackend(id: string): BackendConfig | undefined;
/** Create a new backend */
createBackend(request: CreateBackendRequest): Promise<BackendConfig>;
/** Update an existing backend */
updateBackend(request: UpdateBackendRequest): Promise<BackendConfig>;
/** Delete a backend */
deleteBackend(id: string): Promise<boolean>;
/** Set the active backend */
setActiveBackend(id: string | undefined): void;
/** Test connection to a backend */
testConnection(backendOrConfig: BackendConfig | CreateBackendRequest): Promise<ConnectionTestResult>;
/** Fetch schema from a backend */
fetchSchema(backendId: string): Promise<CachedSchema>;
/** Reset the service (for project switching) */
reset(): void;
}

View File

@@ -8,6 +8,7 @@ import { IconName } from '@noodl-core-ui/components/common/Icon';
import config from '../../shared/config/config';
import { ComponentDiffDocumentProvider } from './views/documents/ComponentDiffDocument';
import { EditorDocumentProvider } from './views/documents/EditorDocument';
import { BackendServicesPanel } from './views/panels/BackendServicesPanel/BackendServicesPanel';
import { CloudFunctionsPanel } from './views/panels/CloudFunctionsPanel/CloudFunctionsPanel';
import { CloudServicePanel } from './views/panels/CloudServicePanel/CloudServicePanel';
import { ComponentPortsComponent } from './views/panels/componentports';
@@ -101,10 +102,19 @@ export function installSidePanel({ isLesson }: SetupEditorOptions) {
panel: CloudFunctionsPanel
});
SidebarModel.instance.register({
id: 'backend-services',
name: 'Backend Services',
isDisabled: isLesson === true,
order: 8,
icon: IconName.RestApi,
panel: BackendServicesPanel
});
SidebarModel.instance.register({
id: 'settings',
name: 'Project settings',
order: 8,
order: 9,
icon: IconName.Setting,
panel: ProjectSettingsPanel
});

View File

@@ -0,0 +1,151 @@
/**
* Add Backend Dialog Styles
*
* @module BackendServicesPanel
*/
.PresetGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.PresetCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
text-align: center;
&:hover {
background-color: var(--theme-color-bg-2);
border-color: var(--theme-color-border-highlight);
}
&.Selected {
border-color: var(--theme-color-primary);
background-color: var(--theme-color-primary-bg);
}
}
.PresetIcon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--theme-color-bg-1);
border-radius: 8px;
font-size: 16px;
font-weight: 600;
color: var(--theme-color-fg-highlight);
margin-bottom: 4px;
}
.TestResult {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid;
}
// Field note with icon
.FieldNote {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 4px 0;
}
.LockIcon,
.WarningIcon {
font-size: 12px;
line-height: 1;
}
// Help Popup Overlay
.HelpPopupOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
// Help Popup Content
.HelpPopup {
background-color: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: 8px;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.HelpPopupHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-3);
}
.HelpPopupContent {
padding: 16px;
overflow-y: auto;
line-height: 1.6;
p {
margin: 0 0 12px 0;
color: var(--theme-color-fg-default);
font-size: 13px;
&:last-child {
margin-bottom: 0;
}
&:empty {
margin-bottom: 8px;
}
}
strong {
color: var(--theme-color-fg-highlight);
font-weight: 600;
}
}
.HelpHeading {
font-size: 14px !important;
margin-top: 16px !important;
margin-bottom: 8px !important;
&:first-child {
margin-top: 0 !important;
}
}
.InlineCode {
background-color: var(--theme-color-bg-1);
color: var(--theme-color-primary);
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
}

View File

@@ -0,0 +1,341 @@
/**
* Add Backend Dialog
*
* Modal dialog for adding new backend configurations.
* Supports preset selection (Directus, Supabase, Pocketbase) and custom REST APIs.
*
* @module BackendServicesPanel
* @since 1.2.0
*/
import React, { useState, useCallback } from 'react';
import { BackendServices, BackendType, CreateBackendRequest } from '@noodl-models/BackendServices';
import { getPreset, getPresetOptions, BackendPreset } from '@noodl-models/BackendServices/presets';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Modal } from '@noodl-core-ui/components/layout/Modal';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { ToastLayer } from '../../../ToastLayer/ToastLayer';
import css from './AddBackendDialog.module.scss';
export interface AddBackendDialogProps {
isVisible: boolean;
onClose: () => void;
onCreated: () => void;
}
interface HelpPopupProps {
content: string;
onClose: () => void;
}
function HelpPopup({ content, onClose }: HelpPopupProps) {
return (
<div className={css.HelpPopupOverlay} onClick={onClose}>
<div className={css.HelpPopup} onClick={(e) => e.stopPropagation()}>
<div className={css.HelpPopupHeader}>
<Text textType={TextType.DefaultContrast}>Setup Instructions</Text>
<IconButton icon={IconName.Close} size={IconSize.Small} onClick={onClose} />
</div>
<div className={css.HelpPopupContent}>
{content.split('\n').map((line, i) => {
// Handle bold text with **text**
const parts = line.split(/(\*\*[^*]+\*\*)/g);
return (
<p key={i} className={line.startsWith('**') ? css.HelpHeading : undefined}>
{parts.map((part, j) => {
if (part.startsWith('**') && part.endsWith('**')) {
return <strong key={j}>{part.slice(2, -2)}</strong>;
}
// Handle inline code with `text`
const codeParts = part.split(/(`[^`]+`)/g);
return codeParts.map((codePart, k) => {
if (codePart.startsWith('`') && codePart.endsWith('`')) {
return (
<code key={`${j}-${k}`} className={css.InlineCode}>
{codePart.slice(1, -1)}
</code>
);
}
return codePart;
});
})}
</p>
);
})}
</div>
</div>
</div>
);
}
export function AddBackendDialog({ isVisible, onClose, onCreated }: AddBackendDialogProps) {
const [selectedType, setSelectedType] = useState<BackendType>('directus');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [adminToken, setAdminToken] = useState('');
const [publicToken, setPublicToken] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [showAdminHelp, setShowAdminHelp] = useState(false);
const [showPublicHelp, setShowPublicHelp] = useState(false);
const presetOptions = getPresetOptions();
const selectedPreset = getPreset(selectedType);
// Reset form when dialog opens
React.useEffect(() => {
if (isVisible) {
setSelectedType('directus');
setName('');
setUrl('');
setAdminToken('');
setPublicToken('');
setTestResult(null);
setShowAdminHelp(false);
setShowPublicHelp(false);
}
}, [isVisible]);
const handleSelectPreset = useCallback((preset: BackendPreset) => {
setSelectedType(preset.type);
setTestResult(null);
}, []);
const handleTestConnection = useCallback(async () => {
if (!url) {
setTestResult({ success: false, message: 'Please enter a URL' });
return;
}
if (!adminToken) {
setTestResult({ success: false, message: 'Please enter an Admin API Key to test connection' });
return;
}
setIsLoading(true);
setTestResult(null);
try {
const request: CreateBackendRequest = {
name: name || 'Test',
type: selectedType,
url,
auth: {
method: selectedPreset.defaultAuth.method,
adminToken,
publicToken,
apiKeyHeader: selectedPreset.defaultAuth.apiKeyHeader
}
};
const result = await BackendServices.instance.testConnection(request);
setTestResult(result);
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed'
});
} finally {
setIsLoading(false);
}
}, [url, name, selectedType, selectedPreset, adminToken, publicToken]);
const handleCreate = useCallback(async () => {
if (!name || !url) {
ToastLayer.showError('Please fill in all required fields');
return;
}
if (!adminToken) {
ToastLayer.showError('Admin API Key is required for schema introspection');
return;
}
setIsLoading(true);
try {
const request: CreateBackendRequest = {
name,
type: selectedType,
url,
auth: {
method: selectedPreset.defaultAuth.method,
adminToken,
publicToken,
apiKeyHeader: selectedPreset.defaultAuth.apiKeyHeader
}
};
await BackendServices.instance.createBackend(request);
ToastLayer.showSuccess(`Backend "${name}" created successfully`);
onCreated();
} catch (error) {
ToastLayer.showError(error instanceof Error ? error.message : 'Failed to create backend');
} finally {
setIsLoading(false);
}
}, [name, url, selectedType, selectedPreset, adminToken, publicToken, onCreated]);
const isFormValid = name.trim().length > 0 && url.trim().length > 0 && adminToken.trim().length > 0;
return (
<Modal isVisible={isVisible} onClose={onClose} title="Add Backend">
<VStack hasSpacing>
{/* Preset Selection */}
<Box hasBottomSpacing>
<Text textType={TextType.Shy} hasBottomSpacing>
Select a backend type
</Text>
<div className={css.PresetGrid}>
{presetOptions.map((preset) => (
<button
key={preset.type}
type="button"
className={`${css.PresetCard} ${selectedType === preset.type ? css.Selected : ''}`}
onClick={() => handleSelectPreset(preset)}
>
<div className={css.PresetIcon}>{preset.displayName.charAt(0)}</div>
<Text textType={TextType.DefaultContrast}>{preset.displayName}</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{preset.description.substring(0, 50)}...
</Text>
</button>
))}
<button
type="button"
className={`${css.PresetCard} ${selectedType === 'custom' ? css.Selected : ''}`}
onClick={() => handleSelectPreset(getPreset('custom'))}
>
<div className={css.PresetIcon}>?</div>
<Text textType={TextType.DefaultContrast}>Custom REST</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
Configure any REST API
</Text>
</button>
</div>
</Box>
{/* Name */}
<Box hasBottomSpacing>
<TextInput
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={`My ${selectedPreset.displayName} Backend`}
hasBottomSpacing
/>
</Box>
{/* URL */}
<Box hasBottomSpacing>
<TextInput
label="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={selectedPreset.urlPlaceholder}
hasBottomSpacing
/>
</Box>
{/* Admin API Key (Required) */}
<Box hasBottomSpacing>
<HStack UNSAFE_style={{ alignItems: 'center', marginBottom: '4px' }}>
<Text textType={TextType.DefaultContrast} style={{ flex: 1 }}>
Admin API Key (Required)
</Text>
<IconButton
icon={IconName.Question}
size={IconSize.Small}
onClick={() => setShowAdminHelp(true)}
testId="admin-key-help"
/>
</HStack>
<TextInput
value={adminToken}
onChange={(e) => setAdminToken(e.target.value)}
type="password"
placeholder="Enter your admin/service API key"
/>
<div className={css.FieldNote}>
<span className={css.LockIcon}>🔒</span>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
Editor only not published to deployed app
</Text>
</div>
</Box>
{/* Public API Key (Optional) */}
<Box hasBottomSpacing>
<HStack UNSAFE_style={{ alignItems: 'center', marginBottom: '4px' }}>
<Text textType={TextType.DefaultContrast} style={{ flex: 1 }}>
Public API Key (Optional)
</Text>
<IconButton
icon={IconName.Question}
size={IconSize.Small}
onClick={() => setShowPublicHelp(true)}
testId="public-key-help"
/>
</HStack>
<TextInput
value={publicToken}
onChange={(e) => setPublicToken(e.target.value)}
type="password"
placeholder="Enter your public/anon API key"
/>
<div className={css.FieldNote}>
<span className={css.WarningIcon}></span>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
Will be visible in deployed app scope for public access only
</Text>
</div>
</Box>
{/* Test Result */}
{testResult && (
<Box hasBottomSpacing>
<div
className={css.TestResult}
style={{
backgroundColor: testResult.success ? 'var(--theme-color-success-bg)' : 'var(--theme-color-danger-bg)',
borderColor: testResult.success ? 'var(--theme-color-success)' : 'var(--theme-color-danger)'
}}
>
<Text
textType={TextType.DefaultContrast}
style={{
color: testResult.success ? 'var(--theme-color-success)' : 'var(--theme-color-danger)'
}}
>
{testResult.success ? '✓ ' : '✗ '}
{testResult.message}
</Text>
</div>
</Box>
)}
{/* Actions */}
<HStack hasSpacing UNSAFE_style={{ justifyContent: 'flex-end' }}>
<PrimaryButton label="Cancel" variant={PrimaryButtonVariant.Muted} onClick={onClose} isDisabled={isLoading} />
<PrimaryButton
label="Test Connection"
variant={PrimaryButtonVariant.Muted}
onClick={handleTestConnection}
isDisabled={isLoading || !url || !adminToken}
/>
<PrimaryButton label="Create Backend" onClick={handleCreate} isDisabled={isLoading || !isFormValid} />
</HStack>
</VStack>
{/* Help Popups */}
{showAdminHelp && <HelpPopup content={selectedPreset.adminKeyHelp} onClose={() => setShowAdminHelp(false)} />}
{showPublicHelp && <HelpPopup content={selectedPreset.publicKeyHelp} onClose={() => setShowPublicHelp(false)} />}
</Modal>
);
}

View File

@@ -0,0 +1,59 @@
.Root {
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
transition: border-color 0.2s ease;
&:hover {
border-color: var(--theme-color-border-hover);
}
&.Active {
border-color: var(--theme-color-primary);
background-color: var(--theme-color-bg-2);
}
}
.Header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.TypeIcon {
width: 32px;
height: 32px;
border-radius: 6px;
background-color: var(--theme-color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ActiveBadge {
background-color: var(--theme-color-primary);
color: var(--theme-color-fg-on-primary);
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.Status {
margin-bottom: 8px;
padding-top: 8px;
border-top: 1px solid var(--theme-color-border-default);
}
.SchemaInfo {
margin-bottom: 8px;
}
.Actions {
padding-top: 8px;
border-top: 1px solid var(--theme-color-border-default);
}

View File

@@ -0,0 +1,146 @@
/**
* Backend Card Component
*
* Displays a single backend configuration with status and actions.
*
* @module BackendServicesPanel
* @since 1.2.0
*/
import React from 'react';
import { BackendConfig, ConnectionStatus } from '@noodl-models/BackendServices';
import { getPreset } from '@noodl-models/BackendServices/presets';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './BackendCard.module.scss';
export interface BackendCardProps {
backend: BackendConfig;
isActive: boolean;
onSetActive: () => void;
onDelete: () => void;
onTestConnection: () => void;
onFetchSchema: () => void;
}
/**
* Get status icon and color based on connection status
*/
function getStatusDisplay(status: ConnectionStatus): { icon: IconName; color: string; text: string } {
switch (status) {
case 'connected':
return { icon: IconName.Check, color: 'var(--theme-color-success)', text: 'Connected' };
case 'disconnected':
return { icon: IconName.CircleOpen, color: 'var(--theme-color-fg-default-shy)', text: 'Disconnected' };
case 'error':
return { icon: IconName.WarningTriangle, color: 'var(--theme-color-danger)', text: 'Error' };
case 'checking':
return { icon: IconName.Refresh, color: 'var(--theme-color-primary)', text: 'Checking...' };
default:
return { icon: IconName.CircleOpen, color: 'var(--theme-color-fg-default-shy)', text: 'Unknown' };
}
}
export function BackendCard({
backend,
isActive,
onSetActive,
onDelete,
onTestConnection,
onFetchSchema
}: BackendCardProps) {
const preset = getPreset(backend.type);
const statusDisplay = getStatusDisplay(backend.status);
return (
<div className={`${css.Root} ${isActive ? css.Active : ''}`} data-test={`backend-card-${backend.id}`}>
{/* Header */}
<div className={css.Header}>
<HStack hasSpacing>
<div className={css.TypeIcon}>
<Text textType={TextType.Proud}>{preset.displayName.charAt(0).toUpperCase()}</Text>
</div>
<VStack>
<Text textType={TextType.DefaultContrast}>{backend.name}</Text>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{preset.displayName} {backend.url}
</Text>
</VStack>
</HStack>
{isActive && (
<div className={css.ActiveBadge}>
<Text textType={TextType.Shy} style={{ fontSize: '10px' }}>
ACTIVE
</Text>
</div>
)}
</div>
{/* Status */}
<div className={css.Status}>
<HStack hasSpacing>
<Icon icon={statusDisplay.icon} size={IconSize.Tiny} UNSAFE_style={{ color: statusDisplay.color }} />
<Text textType={TextType.Shy}>{statusDisplay.text}</Text>
{backend.lastSynced && (
<Text textType={TextType.Shy} style={{ fontSize: '10px' }}>
Last sync: {new Date(backend.lastSynced).toLocaleTimeString()}
</Text>
)}
</HStack>
{backend.lastError && (
<Text textType={TextType.Shy} style={{ fontSize: '11px', color: 'var(--theme-color-danger)' }}>
{backend.lastError}
</Text>
)}
</div>
{/* Schema Info */}
{backend.schema && backend.schema.collections.length > 0 && (
<div className={css.SchemaInfo}>
<Text textType={TextType.Shy} style={{ fontSize: '11px' }}>
{backend.schema.collections.length} collection{backend.schema.collections.length !== 1 ? 's' : ''}
</Text>
</div>
)}
{/* Actions */}
<div className={css.Actions}>
<HStack hasSpacing>
{!isActive && (
<PrimaryButton
label="Set Active"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onSetActive}
/>
)}
<PrimaryButton
label="Test"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onTestConnection}
/>
<PrimaryButton
label="Sync Schema"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.Muted}
onClick={onFetchSchema}
/>
<IconButton
icon={IconName.Trash}
size={IconSize.Small}
onClick={onDelete}
testId={`delete-backend-${backend.id}`}
/>
</HStack>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
/**
* Backend Services Panel
*
* Main panel for managing backend database connections (BYOB).
* Similar in structure to CloudServicePanel.
*
* @module BackendServicesPanel
* @since 1.2.0
*/
import { useEventListener } from '@noodl-hooks/useEventListener';
import React, { useCallback, useEffect, useState } from 'react';
import { BackendServices, BackendServicesEvent } from '@noodl-models/BackendServices';
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton } from '@noodl-core-ui/components/inputs/IconButton';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Container } from '@noodl-core-ui/components/layout/Container';
import { VStack } from '@noodl-core-ui/components/layout/Stack';
import { useConfirmationDialog } from '@noodl-core-ui/components/popups/ConfirmationDialog/ConfirmationDialog.hooks';
import { BasePanel } from '@noodl-core-ui/components/sidebar/BasePanel';
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import { AddBackendDialog } from './AddBackendDialog/AddBackendDialog';
import { BackendCard } from './BackendCard/BackendCard';
export function BackendServicesPanel() {
const [backends, setBackends] = useState(BackendServices.instance.backends);
const [activeBackendId, setActiveBackendId] = useState<string | null>(BackendServices.instance.activeBackendId);
const [isLoading, setIsLoading] = useState(false);
const [isAddDialogVisible, setIsAddDialogVisible] = useState(false);
const [hasActivity, setHasActivity] = useState(false);
// Initialize backends on mount
useEffect(() => {
setIsLoading(true);
BackendServices.instance.initialize().finally(() => {
setBackends(BackendServices.instance.backends);
setActiveBackendId(BackendServices.instance.activeBackendId);
setIsLoading(false);
});
}, []);
// Listen for backend changes
useEventListener(BackendServices.instance, BackendServicesEvent.BackendsChanged, () => {
setBackends([...BackendServices.instance.backends]);
});
useEventListener(BackendServices.instance, BackendServicesEvent.ActiveBackendChanged, (id: string | null) => {
setActiveBackendId(id);
});
// Delete confirmation dialog
const [DeleteDialog, confirmDelete] = useConfirmationDialog({
title: 'Delete Backend',
message: 'Are you sure you want to delete this backend configuration? This cannot be undone.',
isDangerousAction: true
});
// Handle delete backend
const handleDeleteBackend = useCallback(
async (id: string) => {
try {
await confirmDelete();
// If we get here, user confirmed
setHasActivity(true);
try {
await BackendServices.instance.deleteBackend(id);
} finally {
setHasActivity(false);
}
} catch {
// User cancelled - do nothing
}
},
[confirmDelete]
);
// Handle set active backend
const handleSetActive = useCallback((id: string) => {
BackendServices.instance.setActiveBackend(id);
}, []);
// Handle test connection
const handleTestConnection = useCallback(async (id: string) => {
setHasActivity(true);
try {
const backend = BackendServices.instance.getBackend(id);
if (backend) {
await BackendServices.instance.testConnection(backend);
setBackends([...BackendServices.instance.backends]);
}
} finally {
setHasActivity(false);
}
}, []);
// Handle fetch schema
const handleFetchSchema = useCallback(async (id: string) => {
setHasActivity(true);
try {
await BackendServices.instance.fetchSchema(id);
setBackends([...BackendServices.instance.backends]);
} finally {
setHasActivity(false);
}
}, []);
return (
<BasePanel title="Backend Services" hasActivityBlocker={hasActivity} hasContentScroll>
<DeleteDialog />
{isLoading ? (
<Container hasLeftSpacing hasTopSpacing>
<ActivityIndicator />
</Container>
) : (
<>
<Section
title="Available Backends"
variant={SectionVariant.Panel}
actions={
<IconButton
icon={IconName.Plus}
size={IconSize.Small}
onClick={() => setIsAddDialogVisible(true)}
testId="add-backend-button"
/>
}
>
{backends.length > 0 ? (
<VStack>
{backends.map((backend) => (
<BackendCard
key={backend.id}
backend={backend}
isActive={backend.id === activeBackendId}
onSetActive={() => handleSetActive(backend.id)}
onDelete={() => handleDeleteBackend(backend.id)}
onTestConnection={() => handleTestConnection(backend.id)}
onFetchSchema={() => handleFetchSchema(backend.id)}
/>
))}
</VStack>
) : (
<Container hasLeftSpacing hasTopSpacing hasBottomSpacing>
<Text>No backends configured</Text>
<Box hasTopSpacing>
<Text textType={TextType.Shy}>
Click the + button to add a Directus, Supabase, or custom REST backend.
</Text>
</Box>
</Container>
)}
</Section>
</>
)}
<AddBackendDialog
isVisible={isAddDialogVisible}
onClose={() => setIsAddDialogVisible(false)}
onCreated={() => {
setIsAddDialogVisible(false);
setBackends([...BackendServices.instance.backends]);
}}
/>
</BasePanel>
);
}

View File

@@ -0,0 +1,99 @@
/**
* BYOB Filter Type
*
* Property editor type for the BYOB Visual Filter Builder.
* Renders a button in the property panel that opens a modal with the full builder.
*/
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { FilterBuilderButton } from '../components/ByobFilterBuilder';
import { fromDirectusFilter } from '../components/ByobFilterBuilder/converter';
import { FilterGroup, SchemaCollection } from '../components/ByobFilterBuilder/types';
import { TypeView } from '../TypeView';
import { getEditType } from '../utils';
export class ByobFilterType extends TypeView {
private root: Root | null = null;
static fromPort(args: TSFixme): ByobFilterType {
const view = new ByobFilterType();
const p = args.port;
const parent = args.parent;
view.port = p;
view.displayName = p.displayName ? p.displayName : p.name;
view.name = p.name;
view.type = getEditType(p);
view.default = p.default;
view.group = p.group;
view.value = parent.model.getParameter(p.name);
view.parent = parent;
view.isConnected = parent.model.isPortConnected(p.name, 'target');
view.isDefault = parent.model.parameters[p.name] === undefined;
return view;
}
render() {
const div = document.createElement('div');
// Parse value from JSON string - extracted so it can be called on render and re-render
const parseValue = (): FilterGroup | null => {
if (this.value) {
try {
const parsed = JSON.parse(this.value as string);
// Check if it's already our visual builder format (has 'conditions' array)
if (parsed.conditions) {
return parsed as FilterGroup;
} else {
// It's a legacy Directus format, convert it
return fromDirectusFilter(parsed);
}
} catch (e) {
console.warn('[ByobFilterType] Failed to parse existing filter:', e);
}
}
return null;
};
// Render the button component
const renderButton = (filterValue: FilterGroup | null) => {
// Get schema from port type (populated by node's updatePorts)
const schema: SchemaCollection | null = (this.type as TSFixme)?.schema || null;
const props = {
value: filterValue,
schema: schema,
onChange: (filter: FilterGroup) => {
// Store the full visual builder format (with conditions array, IDs, etc.)
// The runtime will convert to Directus format at fetch time
const jsonString = JSON.stringify(filter);
console.log('[ByobFilterType] Saving filter:', jsonString);
const undoArgs = { undo: true, label: 'filter changed', oldValue: this.value };
this.value = jsonString;
this.parent.model.setParameter(this.name, jsonString, undoArgs);
this.isDefault = false;
// Re-render the button with the new value so UI updates immediately
renderButton(filter);
}
};
if (!this.root) {
this.root = createRoot(div);
}
this.root.render(React.createElement(FilterBuilderButton, props));
};
renderButton(parseValue());
this.el = $(div);
return this.el;
}
}

View File

@@ -10,6 +10,7 @@ import { getEditType } from '../utils';
import { AlignToolsType } from './AlignTools/AlignToolsType';
import { BasicType } from './BasicType';
import { BooleanType } from './BooleanType';
import { ByobFilterType } from './ByobFilterType';
import { ColorType } from './ColorPicker/ColorType';
import { ComponentType } from './ComponentType';
import { CurveType } from './CurveEditor/CurveType';
@@ -344,6 +345,10 @@ export class Ports extends View {
return NodeLibrary.nameForPortType(type) === 'query-sorting';
}
function isOfByobFilterType() {
return NodeLibrary.nameForPortType(type) === 'byob-filter';
}
// Is of pages type
function isOfPagesType() {
return NodeLibrary.nameForPortType(type) === 'pages';
@@ -379,6 +384,7 @@ export class Ports extends View {
else if (isOfCurveType()) return CurveType;
else if (isOfQueryFilterType()) return QueryFilterType;
else if (isOfQuerySortingType()) return QuerySortingType;
else if (isOfByobFilterType()) return ByobFilterType;
else if (isOfPagesType()) return PagesType;
else if (isOfPropListType()) return PropListType;
}

View File

@@ -0,0 +1,350 @@
/**
* BYOB Filter Builder Styles
*
* Uses design tokens from theme variables
*/
.ByobFilterBuilder {
display: flex;
flex-direction: column;
background-color: var(--theme-color-bg-2);
border-radius: 4px;
overflow: hidden;
}
.ByobFilterBuilderHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: var(--theme-color-bg-3);
border-bottom: 1px solid var(--theme-color-border-default);
}
.ByobFilterBuilderActions {
display: flex;
gap: 4px;
}
.ByobFilterBuilderContent {
padding: 8px;
}
// Filter Group Styles
.FilterGroup {
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
overflow: hidden;
&Root {
border: none;
background: transparent;
}
// Nested groups get progressively lighter borders
&[data-depth='1'] {
background-color: var(--theme-color-bg-2);
}
&[data-depth='2'] {
background-color: var(--theme-color-bg-1);
}
}
.FilterGroupHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background-color: var(--theme-color-bg-4);
border-bottom: 1px solid var(--theme-color-border-default);
}
.FilterGroupHeaderLeft {
display: flex;
align-items: center;
gap: 8px;
}
.FilterGroupDragHandle {
cursor: grab;
padding: 2px 4px;
border-radius: 2px;
opacity: 0.5;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background-color: var(--theme-color-bg-2);
}
&:active {
cursor: grabbing;
}
}
.FilterGroupDragging {
opacity: 0.5;
border-style: dashed !important;
}
.FilterGroupDragOver {
border-color: var(--theme-color-primary) !important;
box-shadow: 0 0 0 2px var(--theme-color-primary-25);
}
.FilterGroupDropIndicator {
background-color: var(--theme-color-primary-10);
color: var(--theme-color-primary);
padding: 8px 12px;
text-align: center;
font-size: 12px;
font-weight: 500;
border-bottom: 1px solid var(--theme-color-primary-25);
}
.FilterGroupCombinator {
background-color: var(--theme-color-primary);
color: var(--theme-color-fg-on-primary);
border: none;
padding: 4px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
&:hover {
background-color: var(--theme-color-primary-highlight);
}
}
.FilterGroupDelete {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
.FilterGroupConditions {
padding: 8px;
}
.FilterGroupCombinatorLabel {
text-align: center;
font-size: 10px;
font-weight: 600;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 0;
}
.FilterGroupEmpty {
padding: 16px;
text-align: center;
color: var(--theme-color-fg-default-shy);
font-size: 12px;
font-style: italic;
}
.FilterGroupActions {
display: flex;
gap: 8px;
}
// Filter Condition Styles
.FilterCondition {
background-color: var(--theme-color-bg-2);
border-radius: 4px;
padding: 4px;
border: 1px solid transparent;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.FilterConditionDragging {
opacity: 0.5;
border-style: dashed !important;
border-color: var(--theme-color-border-default) !important;
}
.FilterConditionDragHandle {
cursor: grab;
padding: 2px 4px;
border-radius: 2px;
opacity: 0.4;
flex-shrink: 0;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background-color: var(--theme-color-bg-3);
}
&:active {
cursor: grabbing;
}
}
.FilterConditionFields {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.FilterConditionField {
flex: 1;
min-width: 120px;
max-width: 200px;
}
.FilterConditionOperator {
flex: 1;
min-width: 100px;
max-width: 180px;
}
.FilterConditionValue {
flex: 2;
min-width: 100px;
}
.FilterConditionValueSmall {
flex: 1;
min-width: 60px;
max-width: 120px;
}
.FilterConditionBetweenLabel {
color: var(--theme-color-fg-default-shy);
font-size: 12px;
padding: 0 4px;
}
.FilterConditionDelete {
opacity: 0.5;
flex-shrink: 0;
&:hover {
opacity: 1;
}
}
// Connect toggle button - switches between static and connected mode
.FilterConditionConnectToggle {
flex-shrink: 0;
opacity: 0.6;
transition: opacity 0.15s ease, color 0.15s ease;
&:hover {
opacity: 1;
}
}
// Port indicator for connected mode
.FilterConditionPortIndicator {
display: flex;
align-items: center;
gap: 6px;
flex: 2;
min-width: 100px;
padding: 6px 10px;
background-color: var(--theme-color-primary-10);
border: 1px solid var(--theme-color-primary-25);
border-radius: 4px;
color: var(--theme-color-primary);
}
.FilterConditionPortDot {
color: var(--theme-color-primary);
}
.FilterConditionPortName {
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 11px;
font-weight: 500;
}
// JSON Preview Styles
.ByobFilterBuilderJson {
border-top: 1px solid var(--theme-color-border-default);
background-color: var(--theme-color-bg-1);
}
.ByobFilterBuilderJsonHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 12px;
background-color: var(--theme-color-bg-2);
border-bottom: 1px solid var(--theme-color-border-default);
}
.ByobFilterBuilderJsonActions {
display: flex;
gap: 4px;
}
.ByobFilterBuilderJsonPreview {
margin: 0;
padding: 12px;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 11px;
color: var(--theme-color-fg-default);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.ByobFilterBuilderJsonEditor {
width: 100%;
min-height: 150px;
padding: 12px;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 11px;
color: var(--theme-color-fg-default);
background-color: var(--theme-color-bg-1);
border: none;
resize: vertical;
outline: none;
&:focus {
outline: 1px solid var(--theme-color-primary);
}
}
.ByobFilterBuilderJsonError {
color: var(--theme-color-danger);
font-size: 12px;
}
// Filter Builder Button (compact property panel view)
.FilterBuilderButton {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--theme-color-bg-3);
border: 1px solid var(--theme-color-border-default);
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background-color: var(--theme-color-bg-2);
border-color: var(--theme-color-border-highlight);
}
}
.FilterBuilderButtonContent {
display: flex;
align-items: center;
gap: 8px;
}

View File

@@ -0,0 +1,288 @@
/**
* BYOB Filter Builder
*
* Main component for building Directus-compatible filters visually.
* Includes JSON preview and the ability to edit JSON directly.
*/
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import { IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './ByobFilterBuilder.module.scss';
import { DragProvider } from './DragContext';
import { FilterGroup } from './FilterGroup';
import { ByobFilterBuilderProps, createEmptyFilterGroup, FilterGroup as FilterGroupType, SchemaField } from './types';
/**
* Ref interface for imperative access to ByobFilterBuilder
* Used by FilterBuilderModal to save pending JSON edits before closing
*/
export interface ByobFilterBuilderRef {
/**
* If JSON edit mode is active, saves the current JSON text.
* Returns the saved filter if successful, or null if not in edit mode or invalid.
*/
saveJsonIfEditing: () => FilterGroupType | null;
}
/**
* Convert filter to JSON string for display/editing (internal format)
* This preserves IDs and structure so edits can be saved correctly
*/
function filterToJsonString(filter: FilterGroupType | null, pretty = true): string {
if (!filter) return '';
return JSON.stringify(filter, null, pretty ? 2 : 0);
}
/**
* Parse JSON string to filter model (internal format)
* Validates the structure has the required fields
*/
function jsonStringToFilter(json: string): FilterGroupType | null {
try {
const parsed = JSON.parse(json);
// Validate it has the required structure
if (parsed && typeof parsed === 'object' && 'id' in parsed && 'type' in parsed && 'conditions' in parsed) {
if ((parsed.type === 'and' || parsed.type === 'or') && Array.isArray(parsed.conditions)) {
// Recursively validate conditions
if (validateFilterGroup(parsed)) {
return parsed as FilterGroupType;
}
}
}
return null;
} catch {
return null;
}
}
/**
* Validate a filter group structure recursively
*/
function validateFilterGroup(group: unknown): boolean {
if (!group || typeof group !== 'object') return false;
const g = group as Record<string, unknown>;
if (!g.id || !g.type || !Array.isArray(g.conditions)) return false;
if (g.type !== 'and' && g.type !== 'or') return false;
// Validate each condition
for (const item of g.conditions) {
if (!item || typeof item !== 'object') return false;
const i = item as Record<string, unknown>;
if (!i.id) return false;
// Is it a group?
if ('conditions' in i) {
if (!validateFilterGroup(i)) return false;
} else {
// It's a condition - must have field and operator
if (!('field' in i) || !('operator' in i)) return false;
}
}
return true;
}
export const ByobFilterBuilder = forwardRef<ByobFilterBuilderRef, ByobFilterBuilderProps>(function ByobFilterBuilder(
{ value, schema, onChange },
ref
) {
// Initialize filter state
const [filter, setFilter] = useState<FilterGroupType>(() => {
return value || createEmptyFilterGroup();
});
// Show/hide JSON preview
const [showJson, setShowJson] = useState(false);
// JSON editing mode
const [jsonEditMode, setJsonEditMode] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
// Expose imperative methods via ref
useImperativeHandle(
ref,
() => ({
saveJsonIfEditing: (): FilterGroupType | null => {
if (jsonEditMode) {
try {
const parsed = jsonStringToFilter(jsonText);
if (parsed) {
setFilter(parsed);
onChange(parsed);
setJsonEditMode(false);
setJsonError(null);
return parsed;
}
} catch {
// Invalid JSON - return null
}
}
return null;
}
}),
[jsonEditMode, jsonText, onChange]
);
// Sync external value changes
useEffect(() => {
if (value) {
setFilter(value);
}
}, [value]);
// Update JSON text when filter changes
useEffect(() => {
if (!jsonEditMode) {
setJsonText(filterToJsonString(filter));
}
}, [filter, jsonEditMode]);
// Handle filter changes from visual builder
const handleFilterChange = useCallback(
(newFilter: FilterGroupType) => {
setFilter(newFilter);
onChange(newFilter);
},
[onChange]
);
// Toggle JSON preview
const handleToggleJson = useCallback(() => {
setShowJson((prev) => !prev);
setJsonEditMode(false);
setJsonError(null);
}, []);
// Enter JSON edit mode
const handleEditJson = useCallback(() => {
setJsonEditMode(true);
setJsonText(filterToJsonString(filter));
setJsonError(null);
}, [filter]);
// Save JSON edits
const handleSaveJson = useCallback(() => {
try {
const parsed = jsonStringToFilter(jsonText);
if (parsed) {
setFilter(parsed);
onChange(parsed);
setJsonEditMode(false);
setJsonError(null);
} else {
setJsonError('Invalid filter JSON structure');
}
} catch (e) {
setJsonError('Invalid JSON syntax');
}
}, [jsonText, onChange]);
// Cancel JSON edits
const handleCancelJson = useCallback(() => {
setJsonEditMode(false);
setJsonText(filterToJsonString(filter));
setJsonError(null);
}, [filter]);
// Copy JSON to clipboard
const handleCopyJson = useCallback(() => {
const json = filterToJsonString(filter);
navigator.clipboard.writeText(json).catch(console.error);
}, [filter]);
// Get fields from schema
const fields: SchemaField[] = schema?.fields || [];
return (
<div className={css.ByobFilterBuilder}>
{/* Header */}
<div className={css.ByobFilterBuilderHeader}>
<Text textType={TextType.DefaultContrast}>Filter Conditions</Text>
<div className={css.ByobFilterBuilderActions}>
<IconButton
icon={IconName.Code}
size={IconSize.Small}
variant={showJson ? IconButtonVariant.Default : IconButtonVariant.Transparent}
onClick={handleToggleJson}
/>
</div>
</div>
{/* Visual Filter Builder - Wrapped in DragProvider for drag & drop */}
<DragProvider rootFilter={filter} onFilterChange={handleFilterChange}>
<div className={css.ByobFilterBuilderContent}>
<FilterGroup group={filter} fields={fields} onChange={handleFilterChange} isRoot={true} />
</div>
</DragProvider>
{/* JSON Preview Panel */}
{showJson && (
<div className={css.ByobFilterBuilderJson}>
<div className={css.ByobFilterBuilderJsonHeader}>
<Text textType={TextType.Shy}>Filter JSON (editable)</Text>
<div className={css.ByobFilterBuilderJsonActions}>
{!jsonEditMode ? (
<>
<IconButton
icon={IconName.Copy}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={handleCopyJson}
/>
<IconButton
icon={IconName.Pencil}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={handleEditJson}
/>
</>
) : (
<>
<IconButton
icon={IconName.Check}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={handleSaveJson}
/>
<IconButton
icon={IconName.Close}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={handleCancelJson}
/>
</>
)}
</div>
</div>
{jsonError && (
<Box hasXSpacing={2} hasYSpacing={1}>
<span className={css.ByobFilterBuilderJsonError}>{jsonError}</span>
</Box>
)}
{jsonEditMode ? (
<textarea
className={css.ByobFilterBuilderJsonEditor}
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
spellCheck={false}
/>
) : (
<pre className={css.ByobFilterBuilderJsonPreview}>{jsonText || '(empty filter)'}</pre>
)}
</div>
)}
</div>
);
});
// Export for module
export default ByobFilterBuilder;

View File

@@ -0,0 +1,167 @@
/**
* Drag Context for Filter Builder
*
* Provides drag & drop state management for moving groups between parents.
*/
import React, { createContext, useCallback, useContext, useState } from 'react';
import { FilterGroup as FilterGroupType, FilterItem, isFilterGroup } from './types';
export interface DragState {
/** The item currently being dragged */
draggedItem: FilterItem | null;
/** The ID of the group that contains the dragged item */
sourceGroupId: string | null;
}
export interface DragContextValue {
dragState: DragState;
setDraggedItem: (item: FilterItem | null, sourceGroupId: string | null) => void;
moveItem: (targetGroupId: string) => void;
/** Root filter for finding items */
rootFilter: FilterGroupType | null;
setRootFilter: (filter: FilterGroupType | null) => void;
onFilterChange: ((filter: FilterGroupType) => void) | null;
}
const DragContext = createContext<DragContextValue>({
dragState: { draggedItem: null, sourceGroupId: null },
setDraggedItem: () => {},
moveItem: () => {},
rootFilter: null,
setRootFilter: () => {},
onFilterChange: null
});
export function useDragContext() {
return useContext(DragContext);
}
interface DragProviderProps {
children: React.ReactNode;
rootFilter: FilterGroupType | null;
onFilterChange: (filter: FilterGroupType) => void;
}
export function DragProvider({ children, rootFilter, onFilterChange }: DragProviderProps) {
const [dragState, setDragState] = useState<DragState>({
draggedItem: null,
sourceGroupId: null
});
const setDraggedItem = useCallback((item: FilterItem | null, sourceGroupId: string | null) => {
setDragState({ draggedItem: item, sourceGroupId });
}, []);
/**
* Find a group by ID in the filter tree
*/
const findGroupById = useCallback((id: string, group: FilterGroupType | null): FilterGroupType | null => {
if (!group) return null;
if (group.id === id) return group;
for (const item of group.conditions) {
if (isFilterGroup(item)) {
const found = findGroupById(id, item);
if (found) return found;
}
}
return null;
}, []);
/**
* Remove an item from a group by ID (returns new filter)
*/
const removeItemFromTree = useCallback((filter: FilterGroupType, itemId: string): FilterGroupType => {
return {
...filter,
conditions: filter.conditions
.filter((item) => item.id !== itemId)
.map((item) => {
if (isFilterGroup(item)) {
return removeItemFromTree(item, itemId);
}
return item;
})
};
}, []);
/**
* Add an item to a group by ID (returns new filter)
*/
const addItemToGroup = useCallback(
(filter: FilterGroupType, targetGroupId: string, item: FilterItem): FilterGroupType => {
if (filter.id === targetGroupId) {
return {
...filter,
conditions: [...filter.conditions, item]
};
}
return {
...filter,
conditions: filter.conditions.map((cond) => {
if (isFilterGroup(cond)) {
return addItemToGroup(cond, targetGroupId, item);
}
return cond;
})
};
},
[]
);
/**
* Check if targetId is a descendant of sourceId (prevent circular nesting)
*/
const isDescendant = useCallback((sourceGroup: FilterGroupType, targetId: string): boolean => {
for (const item of sourceGroup.conditions) {
if (item.id === targetId) return true;
if (isFilterGroup(item) && isDescendant(item, targetId)) return true;
}
return false;
}, []);
/**
* Move the dragged item to a target group
*/
const moveItem = useCallback(
(targetGroupId: string) => {
if (!dragState.draggedItem || !rootFilter || !onFilterChange) return;
// Can't drop on itself
if (dragState.draggedItem.id === targetGroupId) return;
// Can't drop on the same parent
if (dragState.sourceGroupId === targetGroupId) return;
// If dragging a group, can't drop into a descendant (circular nesting)
if (isFilterGroup(dragState.draggedItem)) {
if (isDescendant(dragState.draggedItem, targetGroupId)) {
console.warn('[DragContext] Cannot drop a group into its own descendant');
return;
}
}
// Perform the move
let newFilter = removeItemFromTree(rootFilter, dragState.draggedItem.id);
newFilter = addItemToGroup(newFilter, targetGroupId, dragState.draggedItem);
onFilterChange(newFilter);
setDragState({ draggedItem: null, sourceGroupId: null });
},
[dragState, rootFilter, onFilterChange, removeItemFromTree, addItemToGroup, isDescendant]
);
const contextValue: DragContextValue = {
dragState,
setDraggedItem,
moveItem,
rootFilter,
setRootFilter: () => {}, // Not used directly
onFilterChange
};
return <DragContext.Provider value={contextValue}>{children}</DragContext.Provider>;
}

View File

@@ -0,0 +1,72 @@
/**
* Filter Builder Button
*
* Compact button for the property panel that shows a summary
* and opens the full Filter Builder in a modal.
*/
import React, { useCallback, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
import css from './ByobFilterBuilder.module.scss';
import { FilterBuilderModal } from './FilterBuilderModal';
import { FilterGroup, getFilterSummary, SchemaCollection } from './types';
export interface FilterBuilderButtonProps {
value: FilterGroup | null;
schema: SchemaCollection | null;
onChange: (filter: FilterGroup) => void;
}
export function FilterBuilderButton({ value, schema, onChange }: FilterBuilderButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
const summary = getFilterSummary(value);
const hasFilter = summary !== 'No filter';
const handleOpenModal = useCallback(() => {
setIsModalOpen(true);
}, []);
const handleCloseModal = useCallback(() => {
setIsModalOpen(false);
}, []);
const handleSave = useCallback(
(filter: FilterGroup) => {
onChange(filter);
},
[onChange]
);
return (
<>
<div className={css.FilterBuilderButton} onClick={handleOpenModal}>
<div className={css.FilterBuilderButtonContent}>
<Icon icon={IconName.Search} size={IconSize.Small} UNSAFE_style={{ opacity: hasFilter ? 1 : 0.5 }} />
<Text textType={hasFilter ? TextType.DefaultContrast : TextType.Shy}>{summary}</Text>
</div>
<IconButton
icon={IconName.Pencil}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={(e) => {
e.stopPropagation();
handleOpenModal();
}}
/>
</div>
<FilterBuilderModal
isVisible={isModalOpen}
value={value}
schema={schema}
onSave={handleSave}
onClose={handleCloseModal}
/>
</>
);
}

View File

@@ -0,0 +1,82 @@
/**
* Filter Builder Modal
*
* Modal dialog wrapper for the Visual Filter Builder.
* Keeps the property panel clean by showing the full builder in a modal.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { PrimaryButton, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Modal } from '@noodl-core-ui/components/layout/Modal';
import { HStack } from '@noodl-core-ui/components/layout/Stack';
import { ByobFilterBuilder, ByobFilterBuilderRef } from './ByobFilterBuilder';
import { createEmptyFilterGroup, FilterGroup, SchemaCollection } from './types';
export interface FilterBuilderModalProps {
isVisible: boolean;
value: FilterGroup | null;
schema: SchemaCollection | null;
onSave: (filter: FilterGroup) => void;
onClose: () => void;
}
export function FilterBuilderModal({ isVisible, value, schema, onSave, onClose }: FilterBuilderModalProps) {
// Ref to access filter builder imperatively (for saving pending JSON edits)
const filterBuilderRef = useRef<ByobFilterBuilderRef>(null);
// Local state for editing - only save when "Save" is clicked
const [localFilter, setLocalFilter] = useState<FilterGroup>(() => {
return value || createEmptyFilterGroup();
});
// Reset local state when modal opens with new value
useEffect(() => {
if (isVisible) {
setLocalFilter(value || createEmptyFilterGroup());
}
}, [isVisible, value]);
const handleFilterChange = useCallback((newFilter: FilterGroup) => {
setLocalFilter(newFilter);
}, []);
const handleSave = useCallback(() => {
// First, save any pending JSON edits from the raw editor
// This returns the updated filter if JSON was being edited, null otherwise
const updatedFromJson = filterBuilderRef.current?.saveJsonIfEditing();
// Use the JSON-edited filter if available, otherwise use localFilter
onSave(updatedFromJson || localFilter);
onClose();
}, [localFilter, onSave, onClose]);
const handleCancel = useCallback(() => {
// Discard changes
onClose();
}, [onClose]);
const footerContent = (
<HStack hasSpacing UNSAFE_style={{ justifyContent: 'flex-end', width: '100%' }}>
<PrimaryButton label="Cancel" variant={PrimaryButtonVariant.Muted} onClick={handleCancel} />
<PrimaryButton label="Save Filter" onClick={handleSave} />
</HStack>
);
return (
<Modal
isVisible={isVisible}
onClose={handleCancel}
title="Edit Filter"
subtitle={schema?.name ? `Collection: ${schema.displayName || schema.name}` : undefined}
footerSlot={footerContent}
hasFooterDivider
UNSAFE_style={{ width: '700px', maxHeight: '80vh' }}
>
<div style={{ minHeight: '300px', maxHeight: '60vh', overflow: 'auto' }}>
<ByobFilterBuilder ref={filterBuilderRef} value={localFilter} schema={schema} onChange={handleFilterChange} />
</div>
</Modal>
);
}

View File

@@ -0,0 +1,241 @@
/**
* FilterCondition Component
*
* A single filter condition row: Field | Operator | Value
* Supports drag & drop to move conditions between groups.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { Select, SelectColorTheme } from '@noodl-core-ui/components/inputs/Select';
import { TextInput } from '@noodl-core-ui/components/inputs/TextInput';
import { Box } from '@noodl-core-ui/components/layout/Box';
import css from './ByobFilterBuilder.module.scss';
import { useDragContext } from './DragContext';
import { getOperatorsForType, operatorNeedsValue, operatorNeedsTwoValues } from './operators';
import { FieldType, FilterCondition as FilterConditionType, FilterOperator, SchemaField } from './types';
export interface FilterConditionProps {
condition: FilterConditionType;
fields: SchemaField[];
onChange: (condition: FilterConditionType) => void;
onDelete: () => void;
/** The ID of the parent group containing this condition */
parentGroupId?: string;
}
export function FilterCondition({ condition, fields, onChange, onDelete, parentGroupId }: FilterConditionProps) {
const { dragState, setDraggedItem } = useDragContext();
const [isDragging, setIsDragging] = useState(false);
// Is this condition being dragged?
const isBeingDragged = dragState.draggedItem?.id === condition.id;
// Drag handlers
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.stopPropagation();
setDraggedItem(condition, parentGroupId || null);
setIsDragging(true);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', condition.id);
},
[condition, parentGroupId, setDraggedItem]
);
const handleDragEnd = useCallback(
(e: React.DragEvent) => {
e.stopPropagation();
setDraggedItem(null, null);
setIsDragging(false);
},
[setDraggedItem]
);
// Get the selected field's type
const selectedField = useMemo(() => {
return fields.find((f) => f.name === condition.field);
}, [fields, condition.field]);
const fieldType: FieldType = selectedField?.type || 'string';
// Get available operators for this field type
const availableOperators = useMemo(() => {
return getOperatorsForType(fieldType);
}, [fieldType]);
// Handle field change
const handleFieldChange = useCallback(
(value: string) => {
const newField = fields.find((f) => f.name === value);
const newType = newField?.type || 'string';
// Reset operator if not valid for new type
const newOperators = getOperatorsForType(newType);
const isOperatorValid = newOperators.some((op) => op.value === condition.operator);
onChange({
...condition,
field: value,
operator: isOperatorValid ? condition.operator : '_eq',
value: '' // Reset value when field changes
});
},
[condition, fields, onChange]
);
// Handle operator change
const handleOperatorChange = useCallback(
(value: string) => {
const newOperator = value as FilterOperator;
const needsValue = operatorNeedsValue(newOperator);
const needsTwoValues = operatorNeedsTwoValues(newOperator);
let newValue = condition.value;
// Reset value based on operator requirements
if (!needsValue) {
newValue = null;
} else if (needsTwoValues && !Array.isArray(condition.value)) {
newValue = ['', ''];
} else if (!needsTwoValues && Array.isArray(condition.value)) {
newValue = '';
}
onChange({
...condition,
operator: newOperator,
value: newValue
});
},
[condition, onChange]
);
// Handle value change
const handleValueChange = useCallback(
(value: string) => {
onChange({
...condition,
value: value
});
},
[condition, onChange]
);
// Handle between value changes
const handleBetweenValueChange = useCallback(
(index: number, value: string) => {
const currentValue = Array.isArray(condition.value) ? condition.value : ['', ''];
const newValue = [...currentValue];
newValue[index] = value;
onChange({
...condition,
value: newValue as [string, string]
});
},
[condition, onChange]
);
const needsValue = operatorNeedsValue(condition.operator);
const needsTwoValues = operatorNeedsTwoValues(condition.operator);
// Field options for dropdown
const fieldOptions = useMemo(() => {
return [
{ label: '(Select field)', value: '' },
...fields.map((f) => ({
label: f.displayName || f.name,
value: f.name
}))
];
}, [fields]);
// Operator options for dropdown
const operatorOptions = useMemo(() => {
return availableOperators.map((op) => ({
label: op.label,
value: op.value
}));
}, [availableOperators]);
// Build class names
const conditionClassName = [css.FilterCondition, isBeingDragged || isDragging ? css.FilterConditionDragging : '']
.filter(Boolean)
.join(' ');
return (
<Box hasXSpacing={1} hasYSpacing={1} UNSAFE_className={conditionClassName}>
<div className={css.FilterConditionFields}>
{/* Drag Handle */}
<div
className={css.FilterConditionDragHandle}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
title="Drag to move into another group"
>
<Icon icon={IconName.Grip} size={IconSize.Small} />
</div>
{/* Field Selector */}
<Select
value={condition.field}
options={fieldOptions}
onChange={handleFieldChange}
colorTheme={SelectColorTheme.Dark}
UNSAFE_className={css.FilterConditionField}
/>
{/* Operator Selector */}
<Select
value={condition.operator}
options={operatorOptions}
onChange={handleOperatorChange}
colorTheme={SelectColorTheme.Dark}
UNSAFE_className={css.FilterConditionOperator}
/>
{/* Value Input - Single value */}
{needsValue && !needsTwoValues && (
<TextInput
value={String(condition.value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
placeholder="Value"
UNSAFE_className={css.FilterConditionValue}
/>
)}
{/* Between: Two value inputs */}
{needsTwoValues && (
<>
<TextInput
value={String(Array.isArray(condition.value) ? condition.value[0] : '')}
onChange={(e) => handleBetweenValueChange(0, e.target.value)}
placeholder="From"
UNSAFE_className={css.FilterConditionValueSmall}
/>
<span className={css.FilterConditionBetweenLabel}>and</span>
<TextInput
value={String(Array.isArray(condition.value) ? condition.value[1] : '')}
onChange={(e) => handleBetweenValueChange(1, e.target.value)}
placeholder="To"
UNSAFE_className={css.FilterConditionValueSmall}
/>
</>
)}
{/* Delete Button */}
<IconButton
icon={IconName.Trash}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={onDelete}
UNSAFE_className={css.FilterConditionDelete}
/>
</div>
</Box>
);
}

View File

@@ -0,0 +1,347 @@
/**
* FilterGroup Component
*
* A recursive AND/OR group container for filter conditions.
* Supports unlimited nesting of groups via drag & drop.
*/
import React, { useCallback, useState } from 'react';
import { Icon, IconName, IconSize } from '@noodl-core-ui/components/common/Icon';
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
import { Box } from '@noodl-core-ui/components/layout/Box';
import { ConfirmationDialog } from '@noodl-core-ui/components/popups/ConfirmationDialog';
import css from './ByobFilterBuilder.module.scss';
import { useDragContext } from './DragContext';
import { FilterCondition } from './FilterCondition';
import {
createEmptyCondition,
FilterCombinator,
FilterCondition as FilterConditionType,
FilterGroup as FilterGroupType,
FilterItem,
generateId,
isFilterGroup,
SchemaField
} from './types';
export interface FilterGroupProps {
group: FilterGroupType;
fields: SchemaField[];
onChange: (group: FilterGroupType) => void;
onDelete?: () => void;
isRoot?: boolean;
depth?: number;
parentGroupId?: string;
}
export function FilterGroup({
group,
fields,
onChange,
onDelete,
isRoot = false,
depth = 0,
parentGroupId
}: FilterGroupProps) {
const { dragState, setDraggedItem, moveItem } = useDragContext();
const [isDragOver, setIsDragOver] = useState(false);
// Delete confirmation state
const [pendingDeleteIndex, setPendingDeleteIndex] = useState<number | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Is this group being dragged?
const isDragging = dragState.draggedItem?.id === group.id;
// Can this group accept a drop?
const canAcceptDrop =
dragState.draggedItem !== null && dragState.draggedItem.id !== group.id && dragState.sourceGroupId !== group.id;
// Toggle between AND/OR
const handleToggleCombinator = useCallback(() => {
const newType: FilterCombinator = group.type === 'and' ? 'or' : 'and';
onChange({
...group,
type: newType
});
}, [group, onChange]);
// Add a new condition
const handleAddCondition = useCallback(() => {
const newCondition = createEmptyCondition();
onChange({
...group,
conditions: [...group.conditions, newCondition]
});
}, [group, onChange]);
// Add a nested group
const handleAddGroup = useCallback(() => {
const newGroup: FilterGroupType = {
id: generateId(),
type: group.type === 'and' ? 'or' : 'and', // Opposite type for variety
conditions: []
};
onChange({
...group,
conditions: [...group.conditions, newGroup]
});
}, [group, onChange]);
// Update a condition
const handleConditionChange = useCallback(
(index: number, updatedItem: FilterItem) => {
const newConditions = [...group.conditions];
newConditions[index] = updatedItem;
onChange({
...group,
conditions: newConditions
});
},
[group, onChange]
);
// Helper to count total items in a group (recursively)
const countGroupItems = useCallback((g: FilterGroupType): number => {
let count = g.conditions.length;
for (const item of g.conditions) {
if (isFilterGroup(item)) {
count += countGroupItems(item);
}
}
return count;
}, []);
// Delete a condition - checks if it's a group with children and shows confirmation
const handleRequestDelete = useCallback(
(index: number) => {
const item = group.conditions[index];
// If it's a group with children, show confirmation
if (isFilterGroup(item) && item.conditions.length > 0) {
setPendingDeleteIndex(index);
setShowDeleteConfirm(true);
} else {
// Delete directly (single condition or empty group)
const newConditions = group.conditions.filter((_, i) => i !== index);
onChange({
...group,
conditions: newConditions
});
}
},
[group, onChange]
);
// Confirm deletion of a group with children
const handleConfirmDelete = useCallback(() => {
if (pendingDeleteIndex !== null) {
const newConditions = group.conditions.filter((_, i) => i !== pendingDeleteIndex);
onChange({
...group,
conditions: newConditions
});
}
setPendingDeleteIndex(null);
setShowDeleteConfirm(false);
}, [group, onChange, pendingDeleteIndex]);
// Cancel deletion
const handleCancelDelete = useCallback(() => {
setPendingDeleteIndex(null);
setShowDeleteConfirm(false);
}, []);
// Get info about pending delete item for the confirmation message
const getPendingDeleteInfo = useCallback(() => {
if (pendingDeleteIndex === null) return { count: 0 };
const item = group.conditions[pendingDeleteIndex];
if (isFilterGroup(item)) {
return { count: countGroupItems(item) };
}
return { count: 0 };
}, [pendingDeleteIndex, group.conditions, countGroupItems]);
// Drag handlers
const handleDragStart = useCallback(
(e: React.DragEvent) => {
e.stopPropagation();
setDraggedItem(group, parentGroupId || null);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', group.id);
},
[group, parentGroupId, setDraggedItem]
);
const handleDragEnd = useCallback(
(e: React.DragEvent) => {
e.stopPropagation();
setDraggedItem(null, null);
setIsDragOver(false);
},
[setDraggedItem]
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (canAcceptDrop) {
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}
},
[canAcceptDrop]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (canAcceptDrop) {
moveItem(group.id);
}
},
[canAcceptDrop, group.id, moveItem]
);
const hasConditions = group.conditions.length > 0;
// Build class names
const groupClassName = [
css.FilterGroup,
isRoot ? css.FilterGroupRoot : '',
isDragging ? css.FilterGroupDragging : '',
isDragOver && canAcceptDrop ? css.FilterGroupDragOver : ''
]
.filter(Boolean)
.join(' ');
const deleteInfo = getPendingDeleteInfo();
return (
<>
<div
className={groupClassName}
data-depth={depth}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Group Header */}
<div className={css.FilterGroupHeader}>
<div className={css.FilterGroupHeaderLeft}>
{/* Drag Handle (only for non-root groups) */}
{!isRoot && (
<div
className={css.FilterGroupDragHandle}
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
title="Drag to move into another group"
>
<Icon icon={IconName.Grip} size={IconSize.Small} />
</div>
)}
<button className={css.FilterGroupCombinator} onClick={handleToggleCombinator} type="button">
{group.type.toUpperCase()}
</button>
</div>
{!isRoot && onDelete && (
<IconButton
icon={IconName.Close}
size={IconSize.Small}
variant={IconButtonVariant.Transparent}
onClick={onDelete}
UNSAFE_className={css.FilterGroupDelete}
/>
)}
</div>
{/* Drop Zone Indicator */}
{isDragOver && canAcceptDrop && (
<div className={css.FilterGroupDropIndicator}>Drop here to nest inside this group</div>
)}
{/* Conditions */}
<div className={css.FilterGroupConditions}>
{group.conditions.map((item, index) => (
<React.Fragment key={item.id}>
{/* Combinator label between conditions */}
{index > 0 && <div className={css.FilterGroupCombinatorLabel}>{group.type.toUpperCase()}</div>}
{isFilterGroup(item) ? (
// Nested group
<FilterGroup
group={item}
fields={fields}
onChange={(updated) => handleConditionChange(index, updated)}
onDelete={() => handleRequestDelete(index)}
depth={depth + 1}
parentGroupId={group.id}
/>
) : (
// Single condition
<FilterCondition
condition={item as FilterConditionType}
fields={fields}
onChange={(updated) => handleConditionChange(index, updated)}
onDelete={() => handleRequestDelete(index)}
parentGroupId={group.id}
/>
)}
</React.Fragment>
))}
{/* Empty state */}
{!hasConditions && (
<div className={css.FilterGroupEmpty}>No conditions. Add a filter rule to get started.</div>
)}
</div>
{/* Action Buttons */}
<Box hasXSpacing={1} hasYSpacing={1}>
<div className={css.FilterGroupActions}>
<PrimaryButton
label="Add Condition"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.MutedOnLowBg}
onClick={handleAddCondition}
/>
<PrimaryButton
label="Add Group"
size={PrimaryButtonSize.Small}
variant={PrimaryButtonVariant.MutedOnLowBg}
onClick={handleAddGroup}
/>
</div>
</Box>
</div>
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
isVisible={showDeleteConfirm}
title="Delete Group?"
message={`This group contains ${deleteInfo.count} item${
deleteInfo.count !== 1 ? 's' : ''
} (conditions and/or nested groups). Deleting this group will also delete all items inside it. This action cannot be undone.`}
confirmButtonLabel="Delete Group"
cancelButtonLabel="Cancel"
isDangerousAction
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
);
}

View File

@@ -0,0 +1,220 @@
/**
* BYOB Filter Converter
*
* Converts between our internal filter model and Directus filter JSON format.
*/
import { FilterCondition, FilterGroup, FilterItem, FilterValue, isFilterGroup } from './types';
/**
* Directus filter object structure
*/
export type DirectusFilter = Record<string, unknown>;
/**
* Convert internal filter model to Directus filter JSON
*
* Input (our model):
* {
* type: 'and',
* conditions: [
* { field: 'status', operator: '_eq', value: 'published' },
* { field: 'author.name', operator: '_contains', value: 'John' }
* ]
* }
*
* Output (Directus format):
* {
* "_and": [
* { "status": { "_eq": "published" } },
* { "author": { "name": { "_contains": "John" } } }
* ]
* }
*/
export function toDirectusFilter(filter: FilterGroup | null): DirectusFilter | null {
if (!filter) return null;
// Skip empty groups
if (filter.conditions.length === 0) return null;
// If only one condition and it's not a group, return it directly (no wrapping _and/_or)
if (filter.conditions.length === 1 && !isFilterGroup(filter.conditions[0])) {
return convertCondition(filter.conditions[0] as FilterCondition);
}
const key = `_${filter.type}` as const; // _and or _or
const conditions = filter.conditions.map((item) => convertItem(item)).filter((c): c is DirectusFilter => c !== null);
if (conditions.length === 0) return null;
if (conditions.length === 1) return conditions[0];
return { [key]: conditions };
}
/**
* Convert a single filter item (condition or group)
*/
function convertItem(item: FilterItem): DirectusFilter | null {
if (isFilterGroup(item)) {
return toDirectusFilter(item);
} else {
return convertCondition(item);
}
}
/**
* Convert a single condition to Directus format
*
* Handles nested fields like "author.name" by building nested objects
*/
function convertCondition(condition: FilterCondition): DirectusFilter | null {
const { field, operator, value } = condition;
// Skip conditions without a field
if (!field) return null;
// Build the operator/value pair
// For null checks, value is not used
const operatorValue =
operator === '_null' || operator === '_nnull' || operator === '_empty' || operator === '_nempty' ? true : value;
// Handle nested fields like "author.name"
const parts = field.split('.');
// Build from inside out: { "_eq": "value" }
let result: DirectusFilter = { [operator]: operatorValue };
// Wrap in nested objects for each field part
for (let i = parts.length - 1; i >= 0; i--) {
result = { [parts[i]]: result };
}
return result;
}
/**
* Parse Directus filter JSON back to our internal model
*
* This allows loading existing filters for editing
*/
export function fromDirectusFilter(filter: DirectusFilter | null): FilterGroup | null {
if (!filter || typeof filter !== 'object') return null;
const keys = Object.keys(filter);
if (keys.length === 0) return null;
// Check if this is a group (_and or _or)
if (keys.length === 1 && (keys[0] === '_and' || keys[0] === '_or')) {
const type = keys[0] === '_and' ? 'and' : 'or';
const conditions = filter[keys[0]] as DirectusFilter[];
if (!Array.isArray(conditions)) return null;
return {
id: generateId(),
type,
conditions: conditions.map((c) => parseFilterItem(c)).filter((c): c is FilterItem => c !== null)
};
}
// Single condition - wrap in an AND group
const condition = parseCondition(filter);
if (!condition) return null;
return {
id: generateId(),
type: 'and',
conditions: [condition]
};
}
/**
* Parse a single filter item from Directus format
*/
function parseFilterItem(item: DirectusFilter): FilterItem | null {
if (!item || typeof item !== 'object') return null;
const keys = Object.keys(item);
if (keys.length === 0) return null;
// Check if this is a nested group
if (keys.length === 1 && (keys[0] === '_and' || keys[0] === '_or')) {
const type = keys[0] === '_and' ? 'and' : 'or';
const conditions = item[keys[0]] as DirectusFilter[];
if (!Array.isArray(conditions)) return null;
return {
id: generateId(),
type,
conditions: conditions.map((c) => parseFilterItem(c)).filter((c): c is FilterItem => c !== null)
};
}
// Otherwise it's a condition
return parseCondition(item);
}
/**
* Parse a condition from Directus format
*
* Input: { "status": { "_eq": "published" } }
* Output: { field: "status", operator: "_eq", value: "published" }
*
* Input: { "author": { "name": { "_contains": "John" } } }
* Output: { field: "author.name", operator: "_contains", value: "John" }
*/
function parseCondition(obj: DirectusFilter, path: string[] = []): FilterCondition | null {
const keys = Object.keys(obj);
if (keys.length !== 1) return null;
const key = keys[0];
const value = obj[key];
// Check if this key is an operator
if (key.startsWith('_') && !['_and', '_or'].includes(key)) {
// This is the operator level
return {
id: generateId(),
field: path.join('.'),
operator: key as FilterCondition['operator'],
value: value as FilterValue
};
}
// Otherwise, this is a field name - recurse deeper
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return parseCondition(value as DirectusFilter, [...path, key]);
}
return null;
}
/**
* Generate unique ID
*/
function generateId(): string {
return Math.random().toString(36).substring(2, 11);
}
/**
* Stringify filter to JSON for display
*/
export function filterToJsonString(filter: FilterGroup | null, pretty = true): string {
const directusFilter = toDirectusFilter(filter);
if (!directusFilter) return '';
return JSON.stringify(directusFilter, null, pretty ? 2 : 0);
}
/**
* Parse JSON string to filter model
*/
export function jsonStringToFilter(json: string): FilterGroup | null {
try {
const parsed = JSON.parse(json);
return fromDirectusFilter(parsed);
} catch {
return null;
}
}

View File

@@ -0,0 +1,51 @@
/**
* BYOB Filter Builder Module
*
* Visual filter builder for Directus-compatible queries
*/
export { ByobFilterBuilder } from './ByobFilterBuilder';
export { DragProvider, useDragContext } from './DragContext';
export { FilterBuilderButton } from './FilterBuilderButton';
export { FilterBuilderModal } from './FilterBuilderModal';
export { FilterGroup } from './FilterGroup';
export { FilterCondition } from './FilterCondition';
// Types
export type {
ByobFilterBuilderProps,
FilterCombinator,
FilterCondition as FilterConditionType,
FilterGroup as FilterGroupType,
FilterItem,
FilterOperator,
FilterValue,
FilterValueSource,
FieldType,
OperatorDefinition,
SchemaCollection,
SchemaField
} from './types';
// Utilities
export {
countFilterItems,
createEmptyCondition,
createEmptyFilterGroup,
generateId,
getFilterSummary,
isFilterGroup
} from './types';
// Converter
export { filterToJsonString, fromDirectusFilter, jsonStringToFilter, toDirectusFilter } from './converter';
// Operators
export {
ALL_OPERATORS,
getOperatorDefinition,
getOperatorLabel,
getOperatorsForType,
operatorNeedsValue,
operatorNeedsTwoValues
} from './operators';

View File

@@ -0,0 +1,185 @@
/**
* BYOB Filter Operators
*
* Defines available operators for each field type with human-readable labels.
*/
import { FieldType, FilterOperator, OperatorDefinition } from './types';
/**
* All available operators with their definitions
*/
export const ALL_OPERATORS: Record<FilterOperator, OperatorDefinition> = {
// Equality
_eq: { value: '_eq', label: 'equals', description: 'Exact match', valueCount: 1 },
_neq: { value: '_neq', label: 'does not equal', description: 'Not equal', valueCount: 1 },
// Comparison
_gt: { value: '_gt', label: 'greater than', description: 'Greater than', valueCount: 1 },
_gte: { value: '_gte', label: 'greater than or equal', description: 'Greater than or equal', valueCount: 1 },
_lt: { value: '_lt', label: 'less than', description: 'Less than', valueCount: 1 },
_lte: { value: '_lte', label: 'less than or equal', description: 'Less than or equal', valueCount: 1 },
// String
_contains: { value: '_contains', label: 'contains', description: 'Contains substring', valueCount: 1 },
_ncontains: { value: '_ncontains', label: 'does not contain', description: 'Does not contain', valueCount: 1 },
_icontains: {
value: '_icontains',
label: 'contains (case-insensitive)',
description: 'Contains (case-insensitive)',
valueCount: 1
},
_starts_with: { value: '_starts_with', label: 'starts with', description: 'Starts with', valueCount: 1 },
_nstarts_with: {
value: '_nstarts_with',
label: 'does not start with',
description: 'Does not start with',
valueCount: 1
},
_istarts_with: {
value: '_istarts_with',
label: 'starts with (case-insensitive)',
description: 'Starts with (case-insensitive)',
valueCount: 1
},
_ends_with: { value: '_ends_with', label: 'ends with', description: 'Ends with', valueCount: 1 },
_nends_with: { value: '_nends_with', label: 'does not end with', description: 'Does not end with', valueCount: 1 },
_iends_with: {
value: '_iends_with',
label: 'ends with (case-insensitive)',
description: 'Ends with (case-insensitive)',
valueCount: 1
},
// Array/In
_in: { value: '_in', label: 'is one of', description: 'Value is in list', valueCount: 1 },
_nin: { value: '_nin', label: 'is not one of', description: 'Value is not in list', valueCount: 1 },
// Null
_null: { value: '_null', label: 'is null', description: 'Is null', valueCount: 0 },
_nnull: { value: '_nnull', label: 'is not null', description: 'Is not null', valueCount: 0 },
// Between
_between: { value: '_between', label: 'is between', description: 'Between two values', valueCount: 2 },
_nbetween: { value: '_nbetween', label: 'is not between', description: 'Not between two values', valueCount: 2 },
// Empty
_empty: { value: '_empty', label: 'is empty', description: 'Is empty string or array', valueCount: 0 },
_nempty: { value: '_nempty', label: 'is not empty', description: 'Is not empty', valueCount: 0 },
// Regex
_regex: { value: '_regex', label: 'matches regex', description: 'Matches regular expression', valueCount: 1 }
};
/**
* Operators available for each field type
*/
const OPERATORS_BY_TYPE: Record<FieldType, FilterOperator[]> = {
// String types - full text operators
string: [
'_eq',
'_neq',
'_contains',
'_ncontains',
'_icontains',
'_starts_with',
'_nstarts_with',
'_istarts_with',
'_ends_with',
'_nends_with',
'_iends_with',
'_in',
'_nin',
'_null',
'_nnull',
'_empty',
'_nempty',
'_regex'
],
text: [
'_eq',
'_neq',
'_contains',
'_ncontains',
'_icontains',
'_starts_with',
'_nstarts_with',
'_ends_with',
'_nends_with',
'_null',
'_nnull',
'_empty',
'_nempty',
'_regex'
],
// Numeric types - comparison operators
integer: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_in', '_nin', '_between', '_nbetween', '_null', '_nnull'],
float: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_in', '_nin', '_between', '_nbetween', '_null', '_nnull'],
decimal: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_in', '_nin', '_between', '_nbetween', '_null', '_nnull'],
// Boolean - simple equality
boolean: ['_eq', '_neq', '_null', '_nnull'],
// Date/Time types - comparison and between
date: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_between', '_nbetween', '_null', '_nnull'],
datetime: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_between', '_nbetween', '_null', '_nnull'],
time: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_between', '_nbetween', '_null', '_nnull'],
timestamp: ['_eq', '_neq', '_gt', '_gte', '_lt', '_lte', '_between', '_nbetween', '_null', '_nnull'],
// UUID - equality and null
uuid: ['_eq', '_neq', '_in', '_nin', '_null', '_nnull'],
// JSON - limited operators
json: ['_null', '_nnull', '_empty', '_nempty'],
// CSV - limited operators
csv: ['_contains', '_ncontains', '_null', '_nnull', '_empty', '_nempty'],
// Hash - equality only
hash: ['_eq', '_neq', '_null', '_nnull'],
// Alias - depends on target, default to string-like
alias: ['_eq', '_neq', '_null', '_nnull'],
// Unknown - basic operators
unknown: ['_eq', '_neq', '_null', '_nnull']
};
/**
* Get available operators for a field type
*/
export function getOperatorsForType(type: FieldType): OperatorDefinition[] {
const operatorKeys = OPERATORS_BY_TYPE[type] || OPERATORS_BY_TYPE.unknown;
return operatorKeys.map((key) => ALL_OPERATORS[key]);
}
/**
* Get operator definition by value
*/
export function getOperatorDefinition(operator: FilterOperator): OperatorDefinition {
return ALL_OPERATORS[operator] || ALL_OPERATORS._eq;
}
/**
* Get a friendly display label for an operator
*/
export function getOperatorLabel(operator: FilterOperator): string {
return ALL_OPERATORS[operator]?.label || operator;
}
/**
* Check if operator requires a value input
*/
export function operatorNeedsValue(operator: FilterOperator): boolean {
const def = ALL_OPERATORS[operator];
return def ? def.valueCount > 0 : true;
}
/**
* Check if operator requires two values (between)
*/
export function operatorNeedsTwoValues(operator: FilterOperator): boolean {
const def = ALL_OPERATORS[operator];
return def ? def.valueCount === 2 : false;
}

View File

@@ -0,0 +1,236 @@
/**
* BYOB Filter Builder Types
*
* Data models for the visual filter builder that generates
* Directus-compatible filter JSON.
*/
export type FilterCombinator = 'and' | 'or';
/**
* A group of conditions combined with AND or OR
*/
export interface FilterGroup {
id: string;
type: FilterCombinator;
conditions: FilterItem[];
}
/**
* A single filter condition
*/
/**
* Filter value can be primitive types, arrays (for _in/_nin), or tuples (for _between)
*/
export type FilterValue = string | number | boolean | null | string[] | number[] | [number, number] | [string, string];
/**
* Value source determines whether the condition value is static (typed in)
* or connected (comes from a node input port)
*/
export type FilterValueSource = 'static' | 'connected';
export interface FilterCondition {
id: string;
field: string; // e.g., "status" or "author.name"
operator: FilterOperator;
value: FilterValue;
/** Whether the value is static (typed in) or connected (from input port) */
valueSource?: FilterValueSource;
/** Port name when valueSource is 'connected' - auto-generated from condition id */
valuePortName?: string;
}
/**
* Either a group or a condition
*/
export type FilterItem = FilterGroup | FilterCondition;
/**
* Type guard to check if item is a group
*/
export function isFilterGroup(item: FilterItem): item is FilterGroup {
return 'type' in item && (item.type === 'and' || item.type === 'or');
}
/**
* Directus filter operators
*/
export type FilterOperator =
// Equality
| '_eq'
| '_neq'
// Comparison
| '_gt'
| '_gte'
| '_lt'
| '_lte'
// String
| '_contains'
| '_ncontains'
| '_icontains' // case insensitive
| '_starts_with'
| '_nstarts_with'
| '_istarts_with'
| '_ends_with'
| '_nends_with'
| '_iends_with'
// Array
| '_in'
| '_nin'
// Null
| '_null'
| '_nnull'
// Between
| '_between'
| '_nbetween'
// Empty (for strings/arrays)
| '_empty'
| '_nempty'
// Regex
| '_regex';
/**
* Field types from schema
*/
export type FieldType =
| 'string'
| 'text'
| 'integer'
| 'float'
| 'decimal'
| 'boolean'
| 'date'
| 'datetime'
| 'time'
| 'timestamp'
| 'uuid'
| 'json'
| 'csv'
| 'hash'
| 'alias'
| 'unknown';
/**
* Operator definition with display info
*/
export interface OperatorDefinition {
value: FilterOperator;
label: string;
description?: string;
valueCount: 0 | 1 | 2; // 0 = no value (null check), 1 = single value, 2 = between
}
/**
* Schema field definition (from BackendServices)
*/
export interface SchemaField {
name: string;
displayName?: string;
type: FieldType;
relatedCollection?: string;
nullable?: boolean;
enumValues?: string[];
}
/**
* Schema collection definition
*/
export interface SchemaCollection {
name: string;
displayName?: string;
fields: SchemaField[];
}
/**
* Props for the main filter builder
*/
export interface ByobFilterBuilderProps {
value: FilterGroup | null;
schema: SchemaCollection | null;
onChange: (filter: FilterGroup) => void;
}
/**
* Create an empty filter group
*/
export function createEmptyFilterGroup(): FilterGroup {
return {
id: generateId(),
type: 'and',
conditions: []
};
}
/**
* Create an empty filter condition
*/
export function createEmptyCondition(): FilterCondition {
return {
id: generateId(),
field: '',
operator: '_eq',
value: ''
};
}
/**
* Generate a unique ID
*/
export function generateId(): string {
return Math.random().toString(36).substring(2, 11);
}
/**
* Count total conditions and groups in a filter
*/
export function countFilterItems(group: FilterGroup | null): { conditions: number; groups: number } {
if (!group) {
return { conditions: 0, groups: 0 };
}
let conditions = 0;
let groups = 0;
function countRecursive(item: FilterItem) {
if (isFilterGroup(item)) {
groups++;
item.conditions.forEach(countRecursive);
} else {
// Only count conditions that have a field selected
if (item.field) {
conditions++;
}
}
}
// Count the root group
group.conditions.forEach(countRecursive);
return { conditions, groups };
}
/**
* Generate a human-readable summary of the filter
*/
export function getFilterSummary(group: FilterGroup | null): string {
if (!group) {
return 'No filter';
}
const { conditions, groups } = countFilterItems(group);
if (conditions === 0 && groups === 0) {
return 'No filter';
}
const parts: string[] = [];
if (conditions > 0) {
parts.push(`${conditions} condition${conditions !== 1 ? 's' : ''}`);
}
if (groups > 0) {
parts.push(`${groups} group${groups !== 1 ? 's' : ''}`);
}
return parts.join(', ');
}

View File

@@ -562,6 +562,10 @@ function generateNodeLibrary(nodeRegister) {
{
name: 'External Data',
items: ['net.noodl.HTTP', 'REST2']
},
{
name: 'BYOB Data',
items: ['noodl.byob.QueryData']
}
]
},

View File

@@ -0,0 +1,835 @@
/**
* BYOB Query Data Node
*
* A universal data node for querying records from any BYOB (Bring Your Own Backend) service.
* Works with Directus, Supabase, Appwrite, custom REST APIs, and more.
* Integrates with the Backend Services system for schema-aware dropdowns
* and supports the Visual Filter Builder.
*
* @module noodl-runtime
* @since 2.0.0
*/
const NoodlRuntime = require('../../../../noodl-runtime');
console.log('[BYOB Query Data] 📦 Module loaded');
var QueryDataNode = {
name: 'noodl.byob.QueryData',
displayNodeName: 'Query Data',
docs: 'https://docs.noodl.net/nodes/data/byob/query-data',
category: 'Data',
color: 'data',
searchTags: ['byob', 'query', 'data', 'database', 'records', 'directus', 'supabase', 'api', 'backend', 'rest'],
initialize: function () {
console.log('[BYOB Query Data] 🚀 INITIALIZE called');
this._internal.inputValues = {};
this._internal.loading = false;
this._internal.records = [];
this._internal.totalCount = 0;
this._internal.inspectData = null;
},
getInspectInfo() {
if (!this._internal.inspectData) {
return { type: 'text', value: '[Not executed yet]' };
}
return { type: 'value', value: this._internal.inspectData };
},
// NOTE: Most inputs are defined dynamically in updatePorts() to support schema-driven dropdowns.
// Only keep the fetch signal here as a static input.
inputs: {
fetch: {
type: 'signal',
displayName: 'Fetch',
group: 'Actions',
valueChangedToTrue: function () {
console.log('[BYOB Query Data] ⚡ FETCH SIGNAL RECEIVED');
this.scheduleFetch();
}
}
},
outputs: {
records: {
type: 'array',
displayName: 'Records',
group: 'Results',
getter: function () {
return this._internal.records;
}
},
firstRecord: {
type: 'object',
displayName: 'First Record',
group: 'Results',
getter: function () {
return this._internal.records && this._internal.records.length > 0 ? this._internal.records[0] : null;
}
},
count: {
type: 'number',
displayName: 'Count',
group: 'Results',
getter: function () {
return this._internal.records ? this._internal.records.length : 0;
}
},
totalCount: {
type: 'number',
displayName: 'Total Count',
group: 'Results',
getter: function () {
return this._internal.totalCount;
}
},
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: {
_storeInputValue: function (name, value) {
this._internal.inputValues[name] = value;
},
/**
* Store filter port value (for connected filter conditions)
*/
_storeFilterPortValue: function (name, value) {
if (!this._internal.filterPortValues) {
this._internal.filterPortValues = {};
}
this._internal.filterPortValues[name] = value;
},
/**
* Resolve the backend configuration from metadata
* 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
};
},
scheduleFetch: function () {
console.log('[BYOB Query Data] scheduleFetch called');
if (this._internal.hasScheduledFetch) {
console.log('[BYOB Query Data] Already scheduled, skipping');
return;
}
this._internal.hasScheduledFetch = true;
this.scheduleAfterInputsHaveUpdated(this.doFetch.bind(this));
},
buildUrl: function (backendConfig) {
const baseUrl = backendConfig?.url || '';
const collection = this._internal.collection || '';
if (!baseUrl || !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 query parameters
const params = new URLSearchParams();
// Fields
const fields = this._internal.fields || '*';
if (fields && fields !== '*') {
params.append('fields', fields);
}
// Filter - resolve connected values and convert to Directus format
const resolvedFilter = this.resolveFilterWithConnectedValues();
if (resolvedFilter) {
try {
const filterJson = JSON.stringify(resolvedFilter);
console.log('[BYOB Query Data] Resolved filter:', filterJson);
params.append('filter', filterJson);
} catch (e) {
console.warn('[BYOB Query Data] Error serializing filter:', e);
}
}
// Sort
const sortField = this._internal.sortField;
const sortOrder = this._internal.sortOrder || 'asc';
if (sortField) {
const sortValue = sortOrder === 'desc' ? `-${sortField}` : sortField;
params.append('sort', sortValue);
}
// Pagination
const limit = this._internal.limit;
if (limit !== undefined && limit !== null && limit > 0) {
params.append('limit', String(limit));
}
const offset = this._internal.offset;
if (offset !== undefined && offset !== null && offset > 0) {
params.append('offset', String(offset));
}
// Request total count for pagination
params.append('meta', 'total_count');
const queryString = params.toString();
if (queryString) {
url += '?' + queryString;
}
return url;
},
buildHeaders: function (backendConfig) {
const headers = {
'Content-Type': 'application/json'
};
const authToken = backendConfig?.token;
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
},
doFetch: function () {
console.log('[BYOB Query Data] doFetch executing');
this._internal.hasScheduledFetch = false;
// Resolve the backend configuration
const backendConfig = this.resolveBackend();
if (!backendConfig) {
console.log('[BYOB Query Data] 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 url = this.buildUrl(backendConfig);
const headers = this.buildHeaders(backendConfig);
console.log('[BYOB Query Data] Request:', {
url,
backendType: backendConfig.type,
headers: Object.keys(headers)
});
// Validate inputs
if (!url) {
console.log('[BYOB Query Data] Missing URL or collection');
this._internal.error = { message: 'Backend URL and Collection are required' };
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
return;
}
// Set loading state
this._internal.loading = true;
this.flagOutputDirty('loading');
// Store for inspect
this._internal.lastRequestUrl = url;
// Perform fetch
fetch(url, {
method: 'GET',
headers: headers
})
.then((response) => {
if (!response.ok) {
return response
.json()
.then((errorBody) => {
throw {
status: response.status,
statusText: response.statusText,
body: errorBody
};
})
.catch((parseError) => {
// If JSON parsing fails, throw basic error
if (parseError.status) throw parseError;
throw {
status: response.status,
statusText: response.statusText,
body: null
};
});
}
return response.json();
})
.then((data) => {
console.log('[BYOB Query Data] Response received:', {
dataLength: data.data ? data.data.length : 0,
meta: data.meta
});
// Directus response format: { data: [...], meta: { total_count: ... } }
this._internal.records = data.data || [];
this._internal.totalCount = data.meta?.total_count || this._internal.records.length;
this._internal.error = null;
this._internal.loading = false;
// Update inspect data
this._internal.inspectData = {
url: this._internal.lastRequestUrl,
collection: this._internal.collection,
recordCount: this._internal.records.length,
totalCount: this._internal.totalCount,
records: this._internal.records.slice(0, 5) // Show first 5 for preview
};
// Flag all outputs dirty
this.flagOutputDirty('records');
this.flagOutputDirty('firstRecord');
this.flagOutputDirty('count');
this.flagOutputDirty('totalCount');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('success');
})
.catch((error) => {
console.error('[BYOB Query Data] Error:', error);
this._internal.loading = false;
this._internal.records = [];
this._internal.totalCount = 0;
// 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'
};
}
// Update inspect data
this._internal.inspectData = {
url: this._internal.lastRequestUrl,
collection: this._internal.collection,
error: this._internal.error
};
this.flagOutputDirty('records');
this.flagOutputDirty('firstRecord');
this.flagOutputDirty('count');
this.flagOutputDirty('totalCount');
this.flagOutputDirty('loading');
this.flagOutputDirty('error');
this.sendSignalOnOutput('failure');
});
},
registerInputIfNeeded: function (name) {
if (this.hasInput(name)) return;
// Map of dynamic input names to their setters
const dynamicInputSetters = {
backendId: (value) => {
this._internal.backendId = value;
},
collection: (value) => {
this._internal.collection = value;
},
filter: (value) => {
this._internal.filter = value;
},
sortField: (value) => {
this._internal.sortField = value;
},
sortOrder: (value) => {
this._internal.sortOrder = value;
},
limit: (value) => {
this._internal.limit = value;
},
offset: (value) => {
this._internal.offset = value;
},
fields: (value) => {
this._internal.fields = value;
}
};
// Register standard dynamic inputs
if (dynamicInputSetters[name]) {
return this.registerInput(name, {
set: dynamicInputSetters[name]
});
}
// Register dynamic filter port inputs (filter_<field>_<id>)
if (name.startsWith('filter_')) {
return this.registerInput(name, {
set: this._storeFilterPortValue.bind(this, name)
});
}
},
/**
* Resolve connected filter values and build final filter JSON
* Replaces placeholder values in the filter structure with actual port values
*/
resolveFilterWithConnectedValues: function () {
const filterJson = this._internal.filter;
if (!filterJson || !filterJson.trim()) return null;
try {
const parsed = JSON.parse(filterJson);
// If this is a visual filter builder format, resolve connected values
if (parsed.conditions) {
const resolved = this._resolveFilterGroupValues(parsed);
// Convert to Directus filter format
return this._toDirectusFilter(resolved);
}
// If it's already a Directus filter, return as-is
return parsed;
} catch (e) {
console.warn('[BYOB Query Data] Error parsing filter:', e);
return null;
}
},
/**
* Recursively resolve connected values in a filter group
*/
_resolveFilterGroupValues: function (group) {
if (!group || !group.conditions) return group;
const resolvedConditions = group.conditions.map((item) => {
if (item.type === 'and' || item.type === 'or') {
// Recurse into nested groups
return this._resolveFilterGroupValues(item);
} else {
// This is a condition - resolve connected value if needed
if (item.valueSource === 'connected' && item.valuePortName) {
const portValue = this._internal.filterPortValues?.[item.valuePortName];
return {
...item,
value: portValue !== undefined ? portValue : item.value
};
}
return item;
}
});
return {
...group,
conditions: resolvedConditions
};
},
/**
* Convert visual filter builder format to Directus filter format
*/
_toDirectusFilter: function (group) {
if (!group || !group.conditions || group.conditions.length === 0) {
return null;
}
const combinator = group.type === 'or' ? '_or' : '_and';
const filterItems = [];
for (const item of group.conditions) {
if (item.type === 'and' || item.type === 'or') {
// Nested group
const nestedFilter = this._toDirectusFilter(item);
if (nestedFilter) {
filterItems.push(nestedFilter);
}
} else {
// Condition - convert to Directus format
if (item.field && item.operator) {
const condition = {};
condition[item.field] = {};
condition[item.field][item.operator] = item.value;
filterItems.push(condition);
}
}
}
if (filterItems.length === 0) return null;
if (filterItems.length === 1) return filterItems[0];
const result = {};
result[combinator] = filterItems;
return result;
}
}
};
/**
* Helper to find all connected filter conditions in a filter group
* Returns array of { portName, field, operator } for each connected condition
*/
function findConnectedConditions(filterGroup, connected = []) {
if (!filterGroup || !filterGroup.conditions) return connected;
for (const item of filterGroup.conditions) {
if (item.type === 'and' || item.type === 'or') {
// Recurse into nested groups
findConnectedConditions(item, connected);
} else if (item.valueSource === 'connected' && item.valuePortName) {
// This is a connected condition
connected.push({
portName: item.valuePortName,
field: item.field,
operator: item.operator,
conditionId: item.id
});
}
}
return connected;
}
/**
* Parse filter JSON string and extract connected conditions
*/
function parseFilterForConnectedPorts(filterJson) {
if (!filterJson || !filterJson.trim()) return [];
try {
const parsed = JSON.parse(filterJson);
// Check if this is a visual filter builder format (has 'conditions' array)
if (parsed.conditions) {
return findConnectedConditions(parsed);
}
return [];
} catch (e) {
return [];
}
}
/**
* Update dynamic ports based on node configuration
* This will be extended to support schema-driven collection/field dropdowns
*/
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 collections = selectedBackend?.schema?.collections || [];
// Collection dropdown (populated from schema)
const collectionEnums = [{ label: '(Select collection)', value: '' }];
collections.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: 'Query'
});
// Sort field dropdown (populated from selected collection's fields)
const selectedCollection = collections.find((c) => c.name === parameters.collection);
// Filter port - uses Visual Filter Builder when schema is available
ports.push({
name: 'filter',
displayName: 'Filter',
type: {
name: 'byob-filter',
// Pass schema fields to the filter builder for field dropdowns
schema: selectedCollection
? {
collection: selectedCollection.name,
fields: selectedCollection.fields || []
}
: null
},
plug: 'input',
group: 'Query'
});
const fields = selectedCollection?.fields || [];
const sortFieldEnums = [{ label: '(None)', value: '' }];
fields.forEach((f) => {
sortFieldEnums.push({ label: f.displayName || f.name, value: f.name });
});
ports.push({
name: 'sortField',
displayName: 'Sort Field',
type: {
name: 'enum',
enums: sortFieldEnums
},
plug: 'input',
group: 'Query'
});
ports.push({
name: 'sortOrder',
displayName: 'Sort Order',
type: {
name: 'enum',
enums: [
{ label: 'Ascending', value: 'asc' },
{ label: 'Descending', value: 'desc' }
]
},
default: 'asc',
plug: 'input',
group: 'Query'
});
ports.push({
name: 'limit',
displayName: 'Limit',
type: 'number',
default: 100,
plug: 'input',
group: 'Pagination'
});
ports.push({
name: 'offset',
displayName: 'Offset',
type: 'number',
default: 0,
plug: 'input',
group: 'Pagination'
});
ports.push({
name: 'fields',
displayName: 'Fields',
type: 'string',
default: '*',
plug: 'input',
group: 'Query'
});
// Parse filter to find connected conditions and add dynamic ports
const connectedConditions = parseFilterForConnectedPorts(parameters.filter);
if (connectedConditions.length > 0) {
connectedConditions.forEach((condition) => {
// Find the field info for better display name
const fieldInfo = fields.find((f) => f.name === condition.field);
const displayName = fieldInfo?.displayName || condition.field || condition.portName;
ports.push({
name: condition.portName,
displayName: `Filter: ${displayName}`,
type: '*', // Accept any type
plug: 'input',
group: 'Filter Values'
});
});
}
// NOTE: 'fetch' signal is defined in static inputs (with valueChangedToTrue handler)
// DO NOT add it here again or it will appear twice in the connection popup
// Outputs
ports.push({
name: 'records',
displayName: 'Records',
type: 'array',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'firstRecord',
displayName: 'First Record',
type: 'object',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'count',
displayName: 'Count',
type: 'number',
plug: 'output',
group: 'Results'
});
ports.push({
name: 'totalCount',
displayName: 'Total Count',
type: 'number',
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: QueryDataNode,
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 () {
// Update ports when backendId or collection changes
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
// Listen for backend services changes (schema updates, backend added/removed)
graphModel.on('metadataChanged.backendServices', function () {
updatePorts(node.id, node.parameters, context.editorConnection, graphModel);
});
}
graphModel.on('editorImportComplete', () => {
graphModel.on('nodeAdded.noodl.byob.QueryData', function (node) {
_managePortsForNode(node);
});
for (const node of graphModel.getNodesWithType('noodl.byob.QueryData')) {
_managePortsForNode(node);
}
});
}
};

View File

@@ -63,6 +63,9 @@ export default function registerNodes(noodlRuntime) {
// HTTP node - temporarily here for debugging (normally in noodl-runtime)
require('@noodl/runtime/src/nodes/std-library/data/httpnode'),
// BYOB (Bring Your Own Backend) data nodes
require('@noodl/runtime/src/nodes/std-library/data/byob-query-data'),
//require('./nodes/std-library/variables/number'), // moved to runtime
//require('./nodes/std-library/variables/string'),
//require('./nodes/std-library/variables/boolean'),