mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
New data query node for Directus backend integration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -562,6 +562,10 @@ function generateNodeLibrary(nodeRegister) {
|
||||
{
|
||||
name: 'External Data',
|
||||
items: ['net.noodl.HTTP', 'REST2']
|
||||
},
|
||||
{
|
||||
name: 'BYOB Data',
|
||||
items: ['noodl.byob.QueryData']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user