Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/metadata/src/metadata-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -629,9 +629,29 @@ describe('MetadataManager — IMetadataService Contract', () => {
expect(info).toBeUndefined();
});

it('should emit declarative type-level actions (datasource Test connection)', async () => {
it('should emit no type-level actions for datasource by default', async () => {
// The datasource "Test connection" action was relocated out of the
// open-source registry into the datasource-admin backend plugin, which
// registers it at install time. With that plugin absent (as in this
// unit test), the type emits no actions.
const info = await manager.getTypeInfo('datasource');
expect(info).toBeDefined();
expect('actions' in info!).toBe(false);
});

it('should surface a plugin-registered type-level action', async () => {
const { registerMetadataTypeActions } = await import('@objectstack/spec/kernel');
registerMetadataTypeActions('datasource', [
{
name: 'test_connection',
label: 'Test connection',
type: 'api',
method: 'POST',
target: '/api/v1/datasources/${ctx.recordId}/test',
refreshAfter: false,
} as any,
]);
const info = await manager.getTypeInfo('datasource');
const test = info!.actions?.find((a: any) => a.name === 'test_connection');
expect(test).toMatchObject({ type: 'api', method: 'POST' });
});
Expand Down
16 changes: 15 additions & 1 deletion packages/objectql/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ import { parseFilterAST, isFilterAST } from '@objectstack/spec/data';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
import { type FormView, isAggregatedViewContainer } from '@objectstack/spec/ui';
import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema } from '@objectstack/spec/kernel';
import { DEFAULT_METADATA_TYPE_REGISTRY, getMetadataTypeSchema, getMetadataTypeActions } from '@objectstack/spec/kernel';
import {
extractProtection,
evaluateLockForWrite,
Expand DownExpand Up@@ -950,6 +950,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
?? HAND_CRAFTED_SCHEMAS[singular];
const form = TYPE_TO_FORM[singular];

// Type-level actions: merge the registry's declarative actions
// with any plugin-registered overlay (`registerMetadataTypeActions`).
// This is the single accessor — a host plugin (e.g. the private
// datasource-admin backend) contributes its `test_connection`
// button here, co-located with the route handler it calls, so the
// button only appears when the backend that serves it is installed.
const typeActions = getMetadataTypeActions(singular);

const base = registryByType.get(singular as any);
if (base) {
const isEnvOverridden = writableOverrides.has(singular);
Expand All@@ -963,6 +971,10 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
: 'registry' as const,
schema,
form,
// Override the spread `base.actions` with the merged view
// (declarative + plugin-registered). Omit when empty to
// preserve the prior "no actions key" response shape.
...(typeActions.length ? { actions: typeActions } : {}),
};
}
// Runtime-registered type with no registry entry — synthesise a
Expand All@@ -983,6 +995,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
overrideSource: writableOverrides.has(singular) ? 'env' as const : 'registry' as const,
schema,
form,
// Plugin-registered actions on a type with no registry entry.
...(typeActions.length ? { actions: typeActions } : {}),
};
}).sort((a, b) => {
if (a.domain !== b.domain) return a.domain.localeCompare(b.domain);
Expand Down
26 changes: 7 additions & 19 deletions packages/spec/src/kernel/metadata-plugin.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -627,25 +627,13 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntry[] = [
executionPinned: false,
loadOrder: 5,
domain: 'system',
// First metadata-admin type-level action (GAP 1): probe the live
// connection for an existing datasource. The matching route
// (`POST /api/v1/datasources/:name/test`) is registered by the host's
// datasource-admin backend; when that backend is absent the button is
// still emitted but the call returns "unavailable" rather than 404-ing
// the page. `${ctx.recordId}` resolves to the datasource's name.
actions: [
{
name: 'test_connection',
label: 'Test connection',
icon: 'plug-zap',
type: 'api',
target: '/api/v1/datasources/${ctx.recordId}/test',
method: 'POST',
variant: 'secondary',
refreshAfter: false,
locations: ['record_header', 'list_item'],
},
],
// No declarative type-level action here. The metadata-admin
// "Test connection" button (GAP 1) is contributed at runtime by the
// datasource-admin backend plugin via `registerMetadataTypeActions`,
// co-located with the route handler it calls
// (`POST /api/v1/datasources/:name/test`). That keeps the open-source
// framework from advertising a button whose backend it doesn't ship:
// the button is emitted iff the plugin that serves it is installed.
},
{ type: 'external_catalog', label: 'External Catalog', filePatterns: ['**/*.external-catalog.ts', '**/*.external-catalog.yml', '**/*.external-catalog.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 6, domain: 'system' },
{ type: 'translation', label: 'Translation', filePatterns: ['**/*.translation.ts', '**/*.translation.yml', '**/*.translation.json'], supportsOverlay: true, allowOrgOverride: true, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 90, domain: 'system' },
Expand Down
48 changes: 31 additions & 17 deletions packages/spec/src/kernel/metadata-type-actions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,20 +17,17 @@ const action = (name: string, overrides: Partial<Action> = {}): Action =>

describe('Metadata type-level actions', () => {
describe('declarative actions on the registry entry', () => {
it('ships a Test-connection action on the datasource entry', () => {
// The open-source framework intentionally ships NO declarative type-level
// actions. The datasource "Test connection" button used to live here, but
// it was relocated to the datasource-admin backend plugin (which owns the
// route it calls) so the framework never advertises a button it can't serve.
it('ships no declarative action on the datasource entry', () => {
const ds = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'datasource');
expect(ds?.actions).toBeDefined();
const test = ds!.actions!.find((a) => a.name === 'test_connection');
expect(test).toMatchObject({
type: 'api',
method: 'POST',
target: '/api/v1/datasources/${ctx.recordId}/test',
});
expect(ds?.actions ?? []).toEqual([]);
});

it('surfaces declarative actions through getMetadataTypeActions', () => {
const actions = getMetadataTypeActions('datasource');
expect(actions.map((a) => a.name)).toContain('test_connection');
it('returns [] for datasource until a plugin registers an action', () => {
expect(getMetadataTypeActions('datasource')).toEqual([]);
});

it('returns [] for a type with no actions', () => {
Expand All@@ -44,12 +41,29 @@ describe('Metadata type-level actions', () => {
expect(getMetadataTypeActions('my_custom_type').map((a) => a.name)).toEqual(['do_thing']);
});

it('merges plugin actions on top of declarative ones, declarative first', () => {
registerMetadataTypeActions('datasource', [action('rotate_secret')]);
const names = getMetadataTypeActions('datasource').map((a) => a.name);
expect(names).toContain('test_connection');
expect(names).toContain('rotate_secret');
expect(names.indexOf('test_connection')).toBeLessThan(names.indexOf('rotate_secret'));
it('surfaces a plugin-registered action on a built-in type (datasource)', () => {
// Mirrors what the datasource-admin plugin does at install time.
registerMetadataTypeActions('datasource', [
action('test_connection', {
method: 'POST',
target: '/api/v1/datasources/${ctx.recordId}/test',
}),
]);
const test = getMetadataTypeActions('datasource').find((a) => a.name === 'test_connection');
expect(test).toMatchObject({
type: 'api',
method: 'POST',
target: '/api/v1/datasources/${ctx.recordId}/test',
});
});

it('appends later registrations after earlier ones', () => {
registerMetadataTypeActions('merge_order_type', [action('first_action')]);
registerMetadataTypeActions('merge_order_type', [action('second_action')]);
const names = getMetadataTypeActions('merge_order_type').map((a) => a.name);
expect(names).toContain('first_action');
expect(names).toContain('second_action');
expect(names.indexOf('first_action')).toBeLessThan(names.indexOf('second_action'));
});

it('dedupes by name — a later registration overrides the earlier', () => {
Expand Down
Loading