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
3 changes: 3 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import * as objects from './src/objects/index.js';
import { ShowcaseExternalDatasource } from './src/datasources/showcase-external.datasource.js';
import { ExternalCustomer, ExternalOrder } from './src/objects/external/index.js';
import { setupShowcaseExternalDatasource } from './src/datasources/external-fixture.js';
import { registerRecalcEndpoint } from './src/server/recalc-endpoint.js';
import { TaskViews, ProjectViews, InquiryViews } from './src/views/index.js';
import { ShowcaseApp } from './src/apps/index.js';
import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js';
Expand DownExpand Up@@ -192,4 +193,6 @@ export default defineStack({
*/
export const onEnable = async (ctx: unknown): Promise<void> => {
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);
// Mount the custom REST endpoint behind the `showcase_recalc_estimate` api action.
registerRecalcEndpoint(ctx as Parameters<typeof registerRecalcEndpoint>[0]);
};
1 change: 1 addition & 0 deletions examples/app-showcase/src/actions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,6 +97,7 @@ export const RecalcEstimateAction = defineAction({
objectName: task,
type: 'api',
target: '/api/v1/showcase/recalc',
successMessage: 'Estimate recalculated.',
locations: ['record_more', 'record_section'],
refreshAfter: true,
});
Expand Down
84 changes: 84 additions & 0 deletions examples/app-showcase/src/server/recalc-endpoint.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Custom REST endpoint backing the `showcase_recalc_estimate` **api** action.
*
* The showcase exercises every `ActionType`; `type: 'api'` means "POST to a
* custom endpoint". That endpoint has to exist, or the button 404s on click —
* a soft failure the build can't catch (an `api` action only needs a target
* *string*; the URL's reachability isn't statically verifiable). So we mount a
* real route here.
*
* Wiring: there is no declarative endpoint surface in a bundle, so we register
* imperatively against the `http.server` service. We do it on `kernel:ready`
* (the service is reliably available then, and Hono's route matcher is only
* frozen later on `kernel:listening`, so the route lands in time).
*
* Contract: the objectui ActionRunner POSTs the record as the JSON body for a
* string-target api action, so the body carries `id` + the record fields.
* We recompute `estimate_hours` from the schedule window (working at 8h/day)
* and persist it; `refreshAfter: true` on the action repaints the new value.
*/

interface RecalcHostContext {
ql: { update: (object: string, data: Record<string, unknown>, options?: unknown) => Promise<unknown> };
logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void; error?: (...a: unknown[]) => void };
hook?: (event: string, handler: () => Promise<void> | void) => void;
getService?: <T = unknown>(name: string) => Promise<T>;
}

/** Inclusive day-count between two dates × 8h; falls back to one day. */
function estimateFromWindow(start: unknown, end: unknown): number {
const s = typeof start === 'string' || start instanceof Date ? new Date(start as string) : undefined;
const e = typeof end === 'string' || end instanceof Date ? new Date(end as string) : undefined;
if (s && e && !Number.isNaN(s.getTime()) && !Number.isNaN(e.getTime()) && e >= s) {
const days = Math.round((e.getTime() - s.getTime()) / 86_400_000) + 1;
return Math.max(1, days) * 8;
}
return 8;
}

export function registerRecalcEndpoint(ctx: RecalcHostContext): void {
const mount = async (): Promise<void> => {
let server: { post?: (path: string, handler: (req: unknown, res: unknown) => unknown) => void } | undefined;
try {
server = await ctx.getService?.('http.server');
} catch {
server = undefined;
}
if (!server || typeof server.post !== 'function') {
ctx.logger?.warn?.('[showcase] http.server unavailable — POST /api/v1/showcase/recalc not mounted');
return;
}

server.post('/api/v1/showcase/recalc', async (req: unknown, res: unknown) => {
const r = res as {
status: (code: number) => void;
json: (body: unknown) => void;
};
try {
const body = ((req as { body?: Record<string, unknown> })?.body) ?? {};
const id = (body.id ?? body.recordId) as string | undefined;
if (!id) {
r.status(400);
r.json({ success: false, error: 'recordId required' });
return;
}
const estimate_hours = estimateFromWindow(body.start_date, body.end_date);
await ctx.ql.update('showcase_task', { id, estimate_hours }, { where: { id } });
r.json({ success: true, data: { id, estimate_hours } });
} catch (err) {
r.status(500);
r.json({ success: false, error: err instanceof Error ? err.message : String(err) });
}
});

ctx.logger?.info?.('[showcase] mounted POST /api/v1/showcase/recalc');
};

if (typeof ctx.hook === 'function') {
ctx.hook('kernel:ready', mount);
} else {
void mount();
}
}
76 changes: 76 additions & 0 deletions examples/app-showcase/test/recalc-endpoint.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { registerRecalcEndpoint } from '../src/server/recalc-endpoint.js';

/**
* Unit coverage for the custom REST endpoint behind the `showcase_recalc_estimate`
* **api** action (#2169 sibling: an api action whose endpoint was never mounted
* 404s on click). Drives the registration + handler with fakes — no live server.
*/
function harness() {
let kernelReady: (() => Promise<void> | void) | undefined;
let routeHandler: ((req: unknown, res: unknown) => unknown) | undefined;
const updates: Array<{ object: string; data: Record<string, unknown> }> = [];
const ctx = {
ql: {
update: async (object: string, data: Record<string, unknown>) => {
updates.push({ object, data });
return { id: data.id };
},
},
logger: { info() {}, warn() {}, error() {} },
hook: (event: string, handler: () => Promise<void> | void) => {
if (event === 'kernel:ready') kernelReady = handler;
},
getService: async (name: string) =>
name === 'http.server'
? { post: (_p: string, h: (req: unknown, res: unknown) => unknown) => { routeHandler = h; } }
: undefined,
};
registerRecalcEndpoint(ctx as never);
return {
boot: async () => { await kernelReady?.(); },
call: async (body: unknown) => {
let status = 200;
let json: unknown;
await routeHandler?.({ body }, { status: (c: number) => { status = c; }, json: (b: unknown) => { json = b; } });
return { status, json };
},
updates,
hasRoute: () => routeHandler !== undefined,
};
}

describe('showcase recalc endpoint', () => {
it('registers the route on kernel:ready', async () => {
const h = harness();
expect(h.hasRoute()).toBe(false);
await h.boot();
expect(h.hasRoute()).toBe(true);
});

it('recomputes estimate from the schedule window (days × 8h) and persists it', async () => {
const h = harness();
await h.boot();
const res = await h.call({ id: 't1', start_date: '2026-06-16', end_date: '2026-07-02' });
expect(res.status).toBe(200);
expect(res.json).toEqual({ success: true, data: { id: 't1', estimate_hours: 136 } });
expect(h.updates).toEqual([{ object: 'showcase_task', data: { id: 't1', estimate_hours: 136 } }]);
});

it('falls back to 8h when the window is missing', async () => {
const h = harness();
await h.boot();
const res = await h.call({ id: 't2' });
expect(res.json).toEqual({ success: true, data: { id: 't2', estimate_hours: 8 } });
});

it('rejects a request without a record id', async () => {
const h = harness();
await h.boot();
const res = await h.call({});
expect(res.status).toBe(400);
expect(h.updates).toHaveLength(0);
});
});