/**
* NocoBase Page Tools - Unified Version
*
* Clean, consistent API for NocoBase page and block management.
* All tools use the correct NocoBase API format.
*/
import { z } from 'zod';
import { NocoBaseClient } from './client.js';
import * as Templates from './templates.js';
// =============================================================================
// CONSTANTS & HELPERS
// =============================================================================
const APP_VERSION = '2.0.0-beta.12';
function generateUid(): string {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 6);
}
function generateName(): string {
return generateUid().substring(0, 11);
}
/**
* Add required NocoBase schema properties to a node and all children
*/
function addSchemaProps(node: any, name?: string): any {
if (!node || typeof node !== 'object') return node;
const result: any = { ...node };
if (!result._isJSONSchemaObject) result._isJSONSchemaObject = true;
if (!result.version) result.version = '2.0';
if (!result['x-uid']) result['x-uid'] = generateUid();
if (!result['x-app-version']) result['x-app-version'] = APP_VERSION;
if (name && !result.name) result.name = name;
if (result.properties) {
const newProps: any = {};
for (const [key, value] of Object.entries(result.properties)) {
newProps[key] = addSchemaProps(value, key);
}
result.properties = newProps;
}
return result;
}
/**
* Insert schema using correct NocoBase API format
*/
async function insertSchema(client: NocoBaseClient, targetUid: string, schema: any): Promise<void> {
const processedSchema = addSchemaProps(schema);
await client.post(
`/uiSchemas:insertAdjacent/${targetUid}?position=beforeEnd`,
{ schema: processedSchema, wrap: null }
);
}
/**
* Wrap a block in Grid.Row > Grid.Col structure
*/
function wrapInGrid(blockSchema: any): any {
const rowName = generateName();
const colName = generateName();
const blockName = blockSchema.name || generateName();
if (!blockSchema.name) blockSchema.name = blockName;
return {
type: 'void',
'x-component': 'Grid.Row',
'x-uid': generateUid(),
name: rowName,
properties: {
[colName]: {
type: 'void',
'x-component': 'Grid.Col',
'x-uid': generateUid(),
name: colName,
properties: {
[blockName]: blockSchema
}
}
}
};
}
// =============================================================================
// SCHEMAS
// =============================================================================
export const ListRoutesSchema = z.object({
type: z.enum(['desktop', 'mobile']).optional().default('desktop'),
limit: z.number().optional().default(100),
});
export const CreatePageSchema = z.object({
title: z.string().min(1).describe('Page title'),
icon: z.string().optional().default('FileOutlined'),
parentId: z.string().optional().describe('Parent group ID'),
type: z.enum(['desktop', 'mobile']).optional().default('desktop'),
});
export const DeletePageSchema = z.object({
routeId: z.string().describe('Route ID to delete'),
type: z.enum(['desktop', 'mobile']).optional().default('desktop'),
});
export const CreateGroupSchema = z.object({
title: z.string().min(1).describe('Group title'),
icon: z.string().optional().default('FolderOutlined'),
parentId: z.string().optional().describe('Parent group ID'),
type: z.enum(['desktop', 'mobile']).optional().default('desktop'),
});
export const CreateLinkSchema = z.object({
title: z.string().min(1).describe('Link title'),
url: z.string().describe('Target URL'),
icon: z.string().optional().default('LinkOutlined'),
parentId: z.string().optional().describe('Parent group ID'),
type: z.enum(['desktop', 'mobile']).optional().default('desktop'),
});
export const InspectSchemaInputSchema = z.object({
uid: z.string().describe('Schema UID to inspect'),
depth: z.number().optional().default(3),
});
export const AddBlockSchema = z.object({
gridUid: z.string().describe('Grid UID of the page'),
blockType: z.enum(['table', 'kanban', 'calendar', 'form', 'details', 'markdown', 'iframe']).describe('Block type'),
collection: z.string().optional().describe('Collection name (required for data blocks)'),
options: z.any().optional().describe('Block-specific options'),
});
export const AddColumnsSchema = z.object({
tableUid: z.string().describe('UID of the Table component'),
collection: z.string().describe('Collection name'),
fields: z.array(z.string()).describe('Field names to add as columns'),
});
export const AddFormFieldsSchema = z.object({
formGridUid: z.string().describe('UID of the form Grid component'),
collection: z.string().describe('Collection name'),
fields: z.array(z.string()).describe('Field names to add'),
});
export const PopupAddBlockSchema = z.object({
popupGridUid: z.string().describe('Grid UID inside the popup'),
blockType: z.enum(['details', 'form', 'related']).describe('Block type'),
collection: z.string().describe('Collection name'),
options: z.any().optional(),
});
export const PopupAddTabSchema = z.object({
popupUid: z.string().describe('Popup UID'),
tabTitle: z.string().describe('Tab title'),
collection: z.string().describe('Collection name'),
});
export const RawInsertSchema = z.object({
schema: z.any().describe('Raw schema to insert'),
});
export const UpdateNodeSchema = z.object({
uid: z.string().describe('Schema node UID'),
updates: z.any().describe('Properties to update'),
});
export const RemoveBlockSchema = z.object({
blockUid: z.string().describe('UID of the block to remove'),
});
// =============================================================================
// ROUTE/PAGE MANAGEMENT
// =============================================================================
export async function listRoutes(client: NocoBaseClient, input: any) {
const validated = ListRoutesSchema.parse(input);
const response = await client.listRoutes(validated.type, {
pageSize: validated.limit,
sort: ['sort', 'createdAt'],
});
const routes = response.data?.data || [];
return {
total: routes.length,
routes: routes.map((r: any) => ({
id: r.id,
title: r.title,
icon: r.icon,
type: r.type,
schemaUid: r.schemaUid,
parentId: r.parentId,
}))
};
}
export async function createPage(client: NocoBaseClient, input: any) {
const validated = CreatePageSchema.parse(input);
// Create empty page schema with full NocoBase structure
const pageUid = generateUid();
const gridUid = generateUid();
const gridName = generateName();
const pageSchema = {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Page',
'x-component-props': {},
'x-uid': pageUid,
'x-async': true,
'x-index': 1,
'x-app-version': APP_VERSION,
name: pageUid,
properties: {
[gridName]: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'page:addBlock',
'x-uid': gridUid,
'x-index': 1,
'x-app-version': APP_VERSION,
name: gridName,
properties: {}
}
}
};
const schemaResponse = await client.post('/uiSchemas:insert', pageSchema);
const schemaUid = schemaResponse.data?.data?.['x-uid'] || pageUid;
const routeData: any = {
type: 'page',
title: validated.title,
icon: validated.icon,
schemaUid,
};
if (validated.parentId) {
routeData.parentId = validated.parentId;
}
const routeResponse = await client.createRoute(validated.type, routeData);
return {
success: true,
routeId: routeResponse.data?.data?.id,
schemaUid,
gridUid,
message: `Page "${validated.title}" created successfully`
};
}
export async function deletePage(client: NocoBaseClient, input: any) {
const validated = DeletePageSchema.parse(input);
await client.deleteRoute(validated.type, validated.routeId);
return {
success: true,
routeId: validated.routeId,
message: `Page deleted successfully`
};
}
export async function createGroup(client: NocoBaseClient, input: any) {
const validated = CreateGroupSchema.parse(input);
const routeData: any = {
type: 'group',
title: validated.title,
icon: validated.icon,
};
if (validated.parentId) {
routeData.parentId = validated.parentId;
}
const response = await client.createRoute(validated.type, routeData);
return {
success: true,
routeId: response.data?.data?.id,
title: validated.title,
message: `Group "${validated.title}" created successfully`
};
}
export async function createLink(client: NocoBaseClient, input: any) {
const validated = CreateLinkSchema.parse(input);
const routeData: any = {
type: 'link',
title: validated.title,
icon: validated.icon,
options: { url: validated.url },
};
if (validated.parentId) {
routeData.parentId = validated.parentId;
}
const response = await client.createRoute(validated.type, routeData);
return {
success: true,
routeId: response.data?.data?.id,
title: validated.title,
url: validated.url,
message: `Link "${validated.title}" created successfully`
};
}
// =============================================================================
// SCHEMA INSPECTION
// =============================================================================
export async function inspectSchema(client: NocoBaseClient, input: any) {
const validated = InspectSchemaInputSchema.parse(input);
const response = await client.getSchema(validated.uid, true);
const schema = response.data?.data;
if (!schema) {
return { error: 'Schema not found' };
}
function simplify(node: any, depth: number): any {
if (!node || typeof node !== 'object' || depth <= 0) return node;
const result: any = {
'x-uid': node['x-uid'],
'x-component': node['x-component'],
};
if (node.name) result.name = node.name;
if (node.title) result.title = node.title;
if (node['x-collection-field']) result.field = node['x-collection-field'];
if (node['x-decorator']) result.decorator = node['x-decorator'];
if (node['x-initializer']) result.initializer = node['x-initializer'];
if (node.properties && depth > 1) {
result.properties = {};
for (const [key, value] of Object.entries(node.properties)) {
result.properties[key] = simplify(value, depth - 1);
}
}
return result;
}
return simplify(schema, validated.depth);
}
export async function getRawSchema(client: NocoBaseClient, input: { uid: string }) {
const response = await client.getSchema(input.uid, true);
return response.data?.data;
}
// =============================================================================
// BLOCK MANAGEMENT
// =============================================================================
export async function addBlock(client: NocoBaseClient, input: any) {
const validated = AddBlockSchema.parse(input);
const options = validated.options || {};
let blockSchema: any;
const cardUid = generateUid();
const innerUid = generateUid();
const gridUid = generateUid();
switch (validated.blockType) {
case 'table':
blockSchema = {
type: 'void',
'x-acl-action': `${validated.collection}:list`,
'x-decorator': 'TableBlockProvider',
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
collection: validated.collection,
dataSource: 'main',
action: 'list',
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-uid': cardUid,
name: generateName(),
properties: {
actions: {
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': { style: { marginBottom: 'var(--nb-spacing)' } },
'x-uid': generateUid(),
name: 'actions',
properties: {}
},
table: {
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: { type: 'checkbox' }
},
'x-uid': innerUid,
name: 'table',
properties: {}
}
}
};
break;
case 'form':
const isCreate = options.action === 'create';
blockSchema = {
type: 'void',
'x-acl-action': `${validated.collection}:${isCreate ? 'create' : 'update'}`,
'x-decorator': 'FormBlockProvider',
'x-use-decorator-props': isCreate ? 'useCreateFormBlockDecoratorProps' : 'useEditFormBlockDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: validated.collection,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': isCreate ? 'blockSettings:createForm' : 'blockSettings:editForm',
'x-component': 'CardItem',
'x-uid': cardUid,
name: generateName(),
properties: {
form: {
type: 'void',
'x-component': 'FormV2',
'x-use-component-props': isCreate ? 'useCreateFormBlockProps' : 'useEditFormBlockProps',
'x-uid': innerUid,
name: 'form',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'form:configureFields',
'x-uid': gridUid,
name: 'grid',
properties: {}
},
actions: {
type: 'void',
'x-initializer': isCreate ? 'createForm:configureActions' : 'editForm:configureActions',
'x-component': 'ActionBar',
'x-component-props': { layout: 'one-column' },
'x-uid': generateUid(),
name: 'actions',
properties: {}
}
}
}
}
};
break;
case 'details':
blockSchema = {
type: 'void',
'x-acl-action': `${validated.collection}:get`,
'x-decorator': 'DetailsBlockProvider',
'x-use-decorator-props': 'useDetailsDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: validated.collection,
readPretty: true,
action: 'get',
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:details',
'x-component': 'CardItem',
'x-uid': cardUid,
name: generateName(),
properties: {
details: {
type: 'void',
'x-component': 'Details',
'x-read-pretty': true,
'x-use-component-props': 'useDetailsProps',
'x-uid': innerUid,
name: 'details',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'details:configureFields',
'x-uid': gridUid,
name: 'grid',
properties: {}
}
}
}
}
};
break;
case 'kanban':
const groupField = options.groupField || 'status';
blockSchema = Templates.createCRMKanbanTemplate(validated.collection!, { groupField });
blockSchema = blockSchema.properties?.page?.properties?.grid?.properties?.[Object.keys(blockSchema.properties?.page?.properties?.grid?.properties || {})[0]] || blockSchema;
break;
case 'calendar':
blockSchema = Templates.createCRMCalendarTemplate(validated.collection!, {
startDateField: options.startDateField || 'start_date',
endDateField: options.endDateField || 'end_date',
titleField: options.titleField || 'title'
});
blockSchema = blockSchema.properties?.page?.properties?.grid?.properties?.[Object.keys(blockSchema.properties?.page?.properties?.grid?.properties || {})[0]] || blockSchema;
break;
case 'markdown':
blockSchema = {
type: 'void',
'x-component': 'Markdown.Void',
'x-component-props': { content: options.content || '# Markdown Content' },
'x-settings': 'blockSettings:markdown',
'x-toolbar': 'BlockSchemaToolbar',
'x-uid': cardUid,
name: generateName(),
};
break;
case 'iframe':
blockSchema = {
type: 'void',
'x-component': 'Iframe',
'x-component-props': {
url: options.url || '',
height: options.height || '400px'
},
'x-settings': 'blockSettings:iframe',
'x-toolbar': 'BlockSchemaToolbar',
'x-uid': cardUid,
name: generateName(),
};
break;
default:
throw new Error(`Block type ${validated.blockType} not supported`);
}
const wrappedBlock = wrapInGrid(blockSchema);
await insertSchema(client, validated.gridUid, wrappedBlock);
return {
success: true,
gridUid: validated.gridUid,
blockUid: cardUid,
innerUid,
formGridUid: gridUid,
blockType: validated.blockType,
message: `Added ${validated.blockType} block. Use formGridUid to add fields.`
};
}
export async function removeBlock(client: NocoBaseClient, input: any) {
const validated = RemoveBlockSchema.parse(input);
await client.post('/uiSchemas:remove', { uid: validated.blockUid });
return {
success: true,
blockUid: validated.blockUid,
message: 'Block removed successfully'
};
}
// =============================================================================
// TABLE COLUMNS
// =============================================================================
export async function addColumns(client: NocoBaseClient, input: any) {
const validated = AddColumnsSchema.parse(input);
const addedColumns: string[] = [];
const errors: string[] = [];
for (const fieldName of validated.fields) {
const columnSchema = {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-toolbar': 'TableColumnSchemaToolbar',
'x-settings': 'fieldSettings:TableColumn',
'x-component': 'TableV2.Column',
'x-uid': generateUid(),
name: generateName(),
properties: {
[fieldName]: {
type: 'string',
name: fieldName,
'x-collection-field': `${validated.collection}.${fieldName}`,
'x-component': 'CollectionField',
'x-component-props': {},
'x-read-pretty': true,
'x-decorator': null,
'x-decorator-props': { labelStyle: { display: 'none' } },
'x-uid': generateUid()
}
}
};
try {
await client.post(
`/uiSchemas:insertAdjacent/${validated.tableUid}?position=beforeEnd`,
{ schema: addSchemaProps(columnSchema), wrap: null }
);
addedColumns.push(fieldName);
} catch (e: any) {
errors.push(`${fieldName}: ${e.message}`);
}
}
return {
success: addedColumns.length > 0,
tableUid: validated.tableUid,
addedColumns,
errors: errors.length > 0 ? errors : undefined,
message: `Added ${addedColumns.length} columns`
};
}
// =============================================================================
// FORM FIELDS
// =============================================================================
export async function addFormFields(client: NocoBaseClient, input: any) {
const validated = AddFormFieldsSchema.parse(input);
// Verify grid exists
const schemaRes = await client.getSchema(validated.formGridUid, false);
if (!schemaRes.data?.data) {
return {
success: false,
error: 'Grid schema not found',
formGridUid: validated.formGridUid
};
}
const addedFields: string[] = [];
const errors: string[] = [];
for (const fieldName of validated.fields) {
const rowName = generateName();
const colName = generateName();
const schema = {
type: 'void',
'x-component': 'Grid.Row',
'x-uid': generateUid(),
name: rowName,
properties: {
[colName]: {
type: 'void',
'x-component': 'Grid.Col',
'x-uid': generateUid(),
name: colName,
properties: {
[fieldName]: {
type: 'string',
name: fieldName,
'x-toolbar': 'FormItemSchemaToolbar',
'x-settings': 'fieldSettings:FormItem',
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
'x-collection-field': `${validated.collection}.${fieldName}`,
'x-component-props': {},
'x-uid': generateUid()
}
}
}
}
};
try {
await client.post(
`/uiSchemas:insertAdjacent/${validated.formGridUid}?position=beforeEnd`,
{ schema: addSchemaProps(schema), wrap: null }
);
addedFields.push(fieldName);
} catch (e: any) {
const errMsg = e.response?.data?.errors?.[0]?.message || e.message;
errors.push(`${fieldName}: ${errMsg}`);
}
}
return {
success: addedFields.length > 0,
formGridUid: validated.formGridUid,
addedFields,
errors: errors.length > 0 ? errors : undefined,
message: addedFields.length > 0
? `Added ${addedFields.length} fields: ${addedFields.join(', ')}`
: `Failed: ${errors.join('; ')}`
};
}
// =============================================================================
// POPUP MANAGEMENT
// =============================================================================
export async function popupAddBlock(client: NocoBaseClient, input: any) {
const validated = PopupAddBlockSchema.parse(input);
const options = validated.options || {};
const cardUid = generateUid();
const innerUid = generateUid();
const gridUid = generateUid();
let blockSchema: any;
switch (validated.blockType) {
case 'details':
blockSchema = {
type: 'void',
'x-acl-action': `${validated.collection}:get`,
'x-decorator': 'DetailsBlockProvider',
'x-use-decorator-props': 'useDetailsDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: validated.collection,
readPretty: true,
action: 'get',
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:details',
'x-component': 'CardItem',
'x-uid': cardUid,
name: generateName(),
properties: {
details: {
type: 'void',
'x-component': 'Details',
'x-read-pretty': true,
'x-use-component-props': 'useDetailsProps',
'x-uid': innerUid,
name: 'details',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'details:configureFields',
'x-uid': gridUid,
name: 'grid',
properties: {}
}
}
}
}
};
break;
case 'form':
const isCreate = options.action === 'create';
blockSchema = {
type: 'void',
'x-acl-action': `${validated.collection}:update`,
'x-decorator': 'FormBlockProvider',
'x-use-decorator-props': isCreate ? 'useCreateFormBlockDecoratorProps' : 'useEditFormBlockDecoratorProps',
'x-decorator-props': {
dataSource: 'main',
collection: validated.collection,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': isCreate ? 'blockSettings:createForm' : 'blockSettings:editForm',
'x-component': 'CardItem',
'x-uid': cardUid,
name: generateName(),
properties: {
form: {
type: 'void',
'x-component': 'FormV2',
'x-use-component-props': isCreate ? 'useCreateFormBlockProps' : 'useEditFormBlockProps',
'x-uid': innerUid,
name: 'form',
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'form:configureFields',
'x-uid': gridUid,
name: 'grid',
properties: {}
},
actions: {
type: 'void',
'x-initializer': isCreate ? 'createForm:configureActions' : 'editForm:configureActions',
'x-component': 'ActionBar',
'x-component-props': { layout: 'one-column' },
'x-uid': generateUid(),
name: 'actions',
properties: {}
}
}
}
}
};
break;
case 'related':
blockSchema = Templates.createRelatedRecordsBlock(
validated.collection,
options.associationField || validated.collection
);
break;
default:
throw new Error(`Block type ${validated.blockType} not supported in popup`);
}
const wrappedBlock = wrapInGrid(blockSchema);
try {
await insertSchema(client, validated.popupGridUid, wrappedBlock);
} catch (e: any) {
const errMsg = e.response?.data?.errors?.[0]?.message || e.message;
return {
success: false,
error: errMsg,
message: `Failed to add block: ${errMsg}`
};
}
return {
success: true,
popupGridUid: validated.popupGridUid,
blockUid: cardUid,
formGridUid: gridUid,
blockType: validated.blockType,
message: `Added ${validated.blockType} block. Use formGridUid "${gridUid}" to add fields.`
};
}
export async function popupAddTab(client: NocoBaseClient, input: any) {
const validated = PopupAddTabSchema.parse(input);
const popupSchema = await client.getSchema(validated.popupUid, true);
const popup = popupSchema.data?.data;
if (!popup?.properties) {
throw new Error('Invalid popup schema');
}
// Find Tabs component
let tabsUid: string | undefined;
for (const prop of Object.values(popup.properties) as any[]) {
if (prop['x-component'] === 'Tabs') {
tabsUid = prop['x-uid'];
break;
}
}
if (!tabsUid) {
throw new Error('Could not find Tabs component in popup');
}
const { tabSchema, tabUid, gridUid } = Templates.createPopupTab(validated.tabTitle, validated.collection);
await client.post(
`/uiSchemas:insertAdjacent/${tabsUid}?position=beforeEnd`,
{ schema: addSchemaProps(tabSchema), wrap: null }
);
return {
success: true,
tabsUid,
newTabUid: tabUid,
gridUid,
message: `Tab "${validated.tabTitle}" added. Use gridUid to add blocks.`
};
}
// =============================================================================
// RAW SCHEMA OPERATIONS
// =============================================================================
export async function rawInsert(client: NocoBaseClient, input: any) {
const validated = RawInsertSchema.parse(input);
const schema = validated.schema;
const parentUid = schema['x-uid'];
if (parentUid && schema.properties) {
const results: string[] = [];
const errors: string[] = [];
for (const [key, childSchema] of Object.entries(schema.properties)) {
try {
await client.post(
`/uiSchemas:insertAdjacent/${parentUid}?position=beforeEnd`,
{ schema: addSchemaProps(childSchema as any, key), wrap: null }
);
results.push(key);
} catch (e: any) {
const errMsg = e.response?.data?.errors?.[0]?.message || e.message;
errors.push(`${key}: ${errMsg}`);
}
}
return {
success: results.length > 0,
method: 'insertAdjacent',
inserted: results,
errors: errors.length > 0 ? errors : undefined,
message: `Inserted ${results.length}/${Object.keys(schema.properties).length} children`
};
}
// Direct insert for new schema
try {
const response = await client.post('/uiSchemas:insert', addSchemaProps(schema));
return {
success: true,
method: 'insert',
data: response.data
};
} catch (e: any) {
return {
success: false,
error: e.response?.data || e.message
};
}
}
export async function updateNode(client: NocoBaseClient, input: any) {
const validated = UpdateNodeSchema.parse(input);
await client.post('/uiSchemas:patch', {
'x-uid': validated.uid,
...validated.updates
});
return {
success: true,
uid: validated.uid,
updated: Object.keys(validated.updates),
message: 'Schema node updated successfully'
};
}