pgkiln 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/SECURITY.md +5 -0
  2. package/db/migrations/121_ai_agents.sql +94 -0
  3. package/db/migrations/122_team_development.sql +186 -0
  4. package/db/migrations/123_workflow_outcomes.sql +407 -0
  5. package/db/migrations/124_component_details.sql +51 -0
  6. package/docs/apex-feature-parity.md +12 -10
  7. package/docs/guide/01-installation.md +7 -3
  8. package/docs/guide/03-builder.md +121 -7
  9. package/docs/guide/04-pages-and-regions.md +71 -21
  10. package/docs/guide/05-items.md +7 -1
  11. package/docs/guide/06-processing.md +123 -8
  12. package/docs/guide/07-dynamic-actions.md +34 -0
  13. package/docs/guide/09-reference.md +2 -1
  14. package/docs/guide/11-from-apex.md +2 -1
  15. package/docs/guide/12-development.md +44 -8
  16. package/docs/guide/20-ai-agents.md +4 -2
  17. package/examples/hr/README.md +2 -0
  18. package/examples/hr/hr_64_ai_agent.sql +97 -0
  19. package/examples/hr/hr_65_ask_reports.sql +21 -0
  20. package/examples/hr/hr_66_team_development.sql +58 -0
  21. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  22. package/examples/hr/hr_68_component_details.sql +96 -0
  23. package/package.json +1 -1
  24. package/public/app.css +40 -0
  25. package/public/app.js +134 -20
  26. package/public/builder.css +83 -1
  27. package/public/builder.js +47 -0
  28. package/src/ai/chat.ts +18 -18
  29. package/src/ai/service.ts +35 -11
  30. package/src/ai/types.ts +7 -2
  31. package/src/app.ts +6 -0
  32. package/src/builder/advisor.ts +6 -3
  33. package/src/builder/ai-agents.ts +372 -0
  34. package/src/builder/ai-builder.ts +3 -3
  35. package/src/builder/ai.ts +13 -9
  36. package/src/builder/assistant.ts +22 -7
  37. package/src/builder/components.ts +38 -9
  38. package/src/builder/designer.ts +8 -3
  39. package/src/builder/feedback.ts +6 -1
  40. package/src/builder/locks.ts +7 -1
  41. package/src/builder/region-help.ts +2 -2
  42. package/src/builder/routes.ts +4 -1
  43. package/src/builder/shared.ts +1 -1
  44. package/src/builder/team.ts +991 -0
  45. package/src/builder/ui.ts +2 -1
  46. package/src/builder/workflow-migrate.ts +210 -0
  47. package/src/builder/workflows.ts +29 -6
  48. package/src/builder/workspace-home.ts +2 -0
  49. package/src/builder/workspace-transfer.ts +1 -1
  50. package/src/cli/replace.ts +7 -1
  51. package/src/i18n/ar.ts +17 -0
  52. package/src/i18n/cs.ts +17 -0
  53. package/src/i18n/da.ts +17 -0
  54. package/src/i18n/de.ts +17 -0
  55. package/src/i18n/el.ts +17 -0
  56. package/src/i18n/es.ts +17 -0
  57. package/src/i18n/fi.ts +17 -0
  58. package/src/i18n/fr.ts +17 -0
  59. package/src/i18n/he.ts +17 -0
  60. package/src/i18n/it.ts +17 -0
  61. package/src/i18n/ja.ts +17 -0
  62. package/src/i18n/ko.ts +17 -0
  63. package/src/i18n/nb.ts +17 -0
  64. package/src/i18n/pl.ts +17 -0
  65. package/src/i18n/pt.ts +17 -0
  66. package/src/i18n/ru.ts +17 -0
  67. package/src/i18n/sv.ts +17 -0
  68. package/src/i18n/tr.ts +17 -0
  69. package/src/i18n/uk.ts +17 -0
  70. package/src/i18n/zh.ts +17 -0
  71. package/src/i18n.ts +34 -0
  72. package/src/instance.ts +2 -0
  73. package/src/metadata.ts +9 -3
  74. package/src/runtime/ai-agents.ts +365 -0
  75. package/src/runtime/ai-filter.ts +385 -107
  76. package/src/runtime/assistant.ts +64 -11
  77. package/src/runtime/context.ts +2 -0
  78. package/src/runtime/ir-phrases.ts +812 -0
  79. package/src/runtime/ir-plan.ts +449 -0
  80. package/src/runtime/items.ts +1 -1
  81. package/src/runtime/regions.ts +6 -2
  82. package/src/runtime/render.ts +1 -1
  83. package/src/runtime/report.ts +1 -1
  84. package/src/runtime/tasks.ts +63 -12
  85. package/src/task-events.ts +232 -0
  86. package/src/team-samples.ts +74 -0
  87. package/src/team.ts +342 -0
  88. package/src/workflow.ts +135 -18
  89. package/src/workspace-export.ts +16 -1
package/src/builder/ai.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
- import { DEFAULT_CLAUDE_MODEL, envKeyName, generate, keySource, usageToday, type AiService } from '../ai/service.ts';
2
+ import { DEFAULT_CLAUDE_MODEL, envKeyName, GEMINI_BASE_URL, generate, keySource, providerLabel, usageToday, type AiService } from '../ai/service.ts';
3
+ import type { ProviderName } from '../ai/types.ts';
4
+ import { toolCallTable } from './ai-agents.ts';
3
5
  import { AiError } from '../ai/types.ts';
4
6
  import { owner } from '../db.ts';
5
7
  import { html, raw } from '../html.ts';
@@ -16,7 +18,7 @@ import { back, BASE, csrf, developer, flash, input, region, select, send, shell,
16
18
  // AI usage (developers): what an application may use and what it used.
17
19
  // API keys are encrypted (src/secrets.ts) and never sent back to the browser.
18
20
 
19
- const PROVIDERS: [string, string][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI']];
21
+ const PROVIDERS: [string, string][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI'], ['gemini', 'Gemini (Google, OpenAI-compatible endpoint)'], ['openai_compatible', 'OpenAI-compatible (Ollama, vLLM, Azure OpenAI, a gateway)']];
20
22
  const EFFORTS: [string, string][] = [['', 'Model default'], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
21
23
  const TEST_RESULT = '__AITEST';
22
24
 
@@ -33,18 +35,18 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
33
35
  <fieldset class="prop-group"><legend>Service</legend><div class="form-grid">
34
36
  ${isNew ? input('name', 'Name', '', { required: true, placeholder: 'e.g. CLAUDE', help: 'Upper case letters, digits and _. Processes refer to the service by this name.' }) : ''}
35
37
  ${input('description', 'Description', s.description)}
36
- ${select('provider', 'Provider', s.provider ?? 'anthropic', PROVIDERS, 'Prompts, and the item values they contain, are sent to this provider.')}
38
+ ${select('provider', 'Provider', s.provider ?? 'anthropic', PROVIDERS, 'Prompts, and the item values they contain, are sent to this provider. Gemini and OpenAI-compatible endpoints speak OpenAI\'s Chat Completions.')}
37
39
  ${input('model', 'Model', s.model ?? (isNew ? DEFAULT_CLAUDE_MODEL : ''), { required: true, help: `Exactly as the provider names it, e.g. ${DEFAULT_CLAUDE_MODEL} (Claude) or the OpenAI model your account uses. pgkiln never changes it.` })}
38
40
  ${select('effort', 'Effort (Claude)', isNew ? 'medium' : s.effort ?? '', EFFORTS, 'How deeply Claude thinks (thinking is always on for current Claude models): lower is faster and cheaper; high or more for hard tasks. Model default: medium for Claude Opus 5.5.')}
39
41
  ${input('max_tokens', 'Maximum output tokens', s.max_tokens ?? 4000, { type: 'number', help: 'Per request (1 – 128000); a process may ask for fewer.' })}
40
42
  ${input('timeout_s', 'Time limit (seconds)', s.timeout_s ?? 120, { type: 'number', help: '5 – 600. Pages wait for the answer.' })}
41
- ${input('base_url', 'Base URL', s.base_url, { placeholder: 'empty: the provider\'s own API', help: 'A gateway or proxy that speaks the provider\'s API. Administrators only.' })}
43
+ ${input('base_url', 'Base URL', s.base_url, { placeholder: 'empty: the provider\'s own API', help: `A gateway or proxy that speaks the provider's API. Administrators only. Gemini: empty is ${GEMINI_BASE_URL}. OpenAI-compatible: required, e.g. http://localhost:11434/v1 (Ollama), https://<resource>.openai.azure.com/openai/v1/ (Azure OpenAI).` })}
42
44
  </div>
43
45
  ${check('refusal_fallback', 'Refusal fallbacks (Claude)', s.refusal_fallback !== false, 'When Claude declines a request for safety reasons, Anthropic re-runs it on its recommended fallback model. The usage log shows the model that answered.')}
44
46
  ${check('enabled', 'Enabled', s.enabled !== false)}
45
47
  </fieldset>
46
48
  <fieldset class="prop-group"><legend>API key</legend>
47
- ${input('api_key', 'API key', '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as 'anthropic') ?? 'anthropic')} is used (when set).` })}
49
+ ${input('api_key', 'API key', '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as ProviderName) ?? 'anthropic')} is used (when set)${s.provider === 'openai_compatible' ? '; an endpoint without keys (Ollama) needs none' : ''}.` })}
48
50
  ${source === 'stored' ? check('remove_key', 'Remove the stored key (use the server default)', false) : ''}
49
51
  ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: API keys can't be stored until it is set (the server's environment variables still work).</div>`}
50
52
  </fieldset>
@@ -54,7 +56,7 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
54
56
 
55
57
  /** The form's values, checked; throws with a message. */
56
58
  function formValues(b: Record<string, string | undefined>) {
57
- const provider = b.provider === 'openai' ? 'openai' : 'anthropic';
59
+ const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
58
60
  const model = (b.model ?? '').trim();
59
61
  if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('The model is a name like claude-opus-5-5 (letters, digits and . _ : / @ -).');
60
62
  const maxTokens = Number(b.max_tokens || 4000);
@@ -63,6 +65,7 @@ function formValues(b: Record<string, string | undefined>) {
63
65
  if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('Time limit: 5 to 600 seconds.');
64
66
  const baseUrl = (b.base_url ?? '').trim() || null;
65
67
  if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('The base URL starts with http:// or https://.');
68
+ if (!baseUrl && provider === 'openai_compatible') throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
66
69
  const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
67
70
  return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
68
71
  }
@@ -104,7 +107,7 @@ export async function aiRoutes(app: FastifyInstance) {
104
107
  <thead><tr><th>Service</th><th>Provider</th><th>Model</th><th>API key</th><th>Applications</th><th>Status</th></tr></thead>
105
108
  <tbody>${services.length ? services.map((x) => html`<tr>
106
109
  <td data-label="Service"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
107
- <td data-label="Provider">${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'}</td>
110
+ <td data-label="Provider">${providerLabel(x.provider)}</td>
108
111
  <td data-label="Model">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
109
112
  <td data-label="API key">${KEY_TEXT[keySource(x)]}</td>
110
113
  <td data-label="Applications">${x.apps ?? html`<span class="muted">none</span>`}</td>
@@ -271,12 +274,13 @@ export async function aiRoutes(app: FastifyInstance) {
271
274
  <div class="title-row"><h1 class="ai-name">AI usage · ${a.name}</h1></div>
272
275
  <p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai">AI services</a>` : ''}), today's use against their limits, and the recent requests.</p>
273
276
  ${region('Services', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Service</th><th>Model</th><th class="num">Requests today</th><th class="num">Tokens today</th><th>Status</th></tr></thead>
274
- <tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="Service">${x.name}</td><td data-label="Model">${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'} ${x.model}</td>
277
+ <tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="Service">${x.name}</td><td data-label="Model">${providerLabel(x.provider)} ${x.model}</td>
275
278
  <td class="num" data-label="Requests today">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
276
279
  <td class="num" data-label="Tokens today">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
277
280
  <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
278
281
  : html`<tr><td colspan="5" class="empty">This application may not use any AI service yet.</td></tr>`}</tbody></table></div>`)}
279
- ${region('Recent requests', usageTable(recent, false))}`;
282
+ ${region('Recent requests', usageTable(recent, false))}
283
+ ${await toolCallTable(a.id)}`;
280
284
  return send(reply, s, shell(s, `AI usage · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['AI usage']], main));
281
285
  });
282
286
  }
@@ -1,9 +1,11 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
+ import { providerLabel } from '../ai/service.ts';
2
3
  import { owner } from '../db.ts';
3
4
  import { html, raw, type Raw } from '../html.ts';
4
5
  import { aiFilterProblems } from '../runtime/ai-filter.ts';
5
6
  import { assistantProblems, type AssistantConfig } from '../runtime/assistant.ts';
6
7
  import type { Session } from '../session.ts';
8
+ import { agentOptions } from './ai-agents.ts';
7
9
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
8
10
 
9
11
  // Page designer → an AI assistant region → Settings (service, prompts,
@@ -23,7 +25,7 @@ async function serviceOptions(appId: number, current: unknown) {
23
25
  const cur = typeof current === 'string' ? current.toUpperCase() : '';
24
26
  return {
25
27
  allowed,
26
- options: html`${allowed.map((x) => opt(x.name, `${x.name} (${x.provider === 'anthropic' ? 'Claude' : 'OpenAI'}, ${x.model})`, cur))}${cur && !allowed.some((x) => x.name === cur) ? opt(cur, `${cur} (not available to this application)`, cur) : ''}`,
28
+ options: html`${allowed.map((x) => opt(x.name, `${x.name} (${providerLabel(x.provider)}, ${x.model})`, cur))}${cur && !allowed.some((x) => x.name === cur) ? opt(cur, `${cur} (not available to this application)`, cur) : ''}`,
27
29
  };
28
30
  }
29
31
 
@@ -36,14 +38,19 @@ export async function assistantSettingsForm(pageId: number, appId: number, r: {
36
38
  const id = (n: string) => `ra_${r.id}_${n}`;
37
39
  const { allowed, options } = await serviceOptions(appId, cfg.service);
38
40
  const problems = assistantProblems(cfg);
41
+ const agents = await agentOptions(appId);
42
+ const agent = typeof cfg.agent === 'string' ? cfg.agent.toUpperCase() : '';
39
43
  return html`<h3 class="u-mt15">AI assistant settings</h3>
40
44
  <p class="muted u-mt0">A chat with an AI service. What users type, the context rows and the tools' results go to the service's provider (Claude or OpenAI). Context queries and SQL tools run as the application's database role; SQL tools are rolled back unless they may change data.</p>
41
45
  ${allowed.length ? '' : NO_SERVICE}
42
46
  ${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
43
47
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/assistant" class="component-form">${csrf(s)}
44
48
  <fieldset class="prop-group"><legend>Assistant</legend><div class="form-grid">
49
+ <div class="field"><label class="label" for="${id('agent')}">AI agent</label>
50
+ <select id="${id('agent')}" name="agent">${opt('', '- none: the service, prompt and tools below -', agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
51
+ <small class="help">An agent of <a href="${BASE}/apps/${appId}/ai-agents">Shared Components → AI Agents</a> sets the service, system prompt, reasoning effort, tools and steps; saving with an agent removes the region's own service, system prompt, tools and tool rounds.</small></div>
45
52
  <div class="field"><label class="label" for="${id('service')}">AI service</label>
46
- <select id="${id('service')}" name="service" required>${opt('', '- choose -', cfg.service ?? '')}${options}</select></div>
53
+ <select id="${id('service')}" name="service">${opt('', '- choose -', cfg.service ?? '')}${options}</select></div>
47
54
  <div class="field"><label class="label" for="${id('max_rounds')}">Tool rounds per question</label>
48
55
  <input id="${id('max_rounds')}" name="max_rounds" type="number" min="1" max="10" value="${cfg.max_rounds ?? ''}" placeholder="5"></div>
49
56
  <div class="field"><label class="label" for="${id('max_turns')}">Questions per conversation</label>
@@ -95,6 +102,10 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
95
102
  };
96
103
  out.service = (b.service ?? '').trim().toUpperCase();
97
104
  if (!out.service) delete out.service;
105
+ // (121) an agent sets the service, system prompt, tools and steps itself
106
+ const agent = (b.agent ?? '').trim().toUpperCase();
107
+ if (agent) out.agent = agent;
108
+ else delete out.agent;
98
109
  text('system', 20_000);
99
110
  text('welcome', 2000);
100
111
  text('placeholder', 200);
@@ -116,6 +127,10 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
116
127
  errors.push(`${k === 'tools' ? 'Tools' : 'Context queries'}: not valid JSON (kept as it was).`);
117
128
  }
118
129
  }
130
+ if (out.agent) {
131
+ for (const k of ['service', 'system', 'tools', 'max_rounds']) delete out[k];
132
+ if (!(await agentOptions(appId)).includes(out.agent)) errors.push(`There is no AI agent ${out.agent}.`);
133
+ }
119
134
  errors.push(...assistantProblems(out), ...(await appProblems(appId, out as AssistantConfig)));
120
135
  return { config: out, errors };
121
136
  }
@@ -124,14 +139,13 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
124
139
  export async function aiFilterSettingsForm(pageId: number, appId: number, r: { id: number; config: Config }, s: Session): Promise<Raw> {
125
140
  const cfg = (r.config?.ai_filter ?? {}) as Config;
126
141
  const id = (n: string) => `raf_${r.id}_${n}`;
127
- const { allowed, options } = await serviceOptions(appId, cfg.service);
142
+ const { options } = await serviceOptions(appId, cfg.service);
128
143
  return html`<h3 class="u-mt15">Ask in your own words (AI)</h3>
129
- <p class="muted u-mt0">Users type a question; an AI service turns it into the report's filters, search and sort (only columns the report shows, only its operators). The question and the column names go to the service's provider.</p>
130
- ${allowed.length || cfg.service ? '' : NO_SERVICE}
144
+ <p class="muted u-mt0">Users type a question; it becomes the report's own settings (filters, highlights, sort, control break, totals, group by, pivot, chart, computed columns; only columns the report shows, only its operators), shown as chips they can edit, remove or undo. With an AI service the question and the column names go to the service's provider; without one (<i>built-in phrases</i>) pgkiln reads common phrases itself, in English, Dutch and German.</p>
131
145
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/ai-filter" class="component-form">${csrf(s)}
132
146
  <div class="form-grid">
133
147
  <div class="field"><label class="label" for="${id('service')}">AI service</label>
134
- <select id="${id('service')}" name="service">${opt('', '- off -', cfg.service ?? '')}${options}</select></div>
148
+ <select id="${id('service')}" name="service">${opt('', '- off -', r.config?.ai_filter ? '-' : '')}${opt('-', 'Built-in phrases (no AI)', r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
135
149
  <div class="field" data-wide><label class="label" for="${id('placeholder')}">Placeholder</label>
136
150
  <input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
137
151
  </div>
@@ -171,8 +185,9 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
171
185
  const service = (b.service ?? '').trim().toUpperCase();
172
186
  if (!service) delete config.ai_filter;
173
187
  else {
188
+ // "-": the built-in phrases, no AI service (sprint 112)
174
189
  const placeholder = (b.placeholder ?? '').trim().slice(0, 200);
175
- config.ai_filter = { service, ...(placeholder ? { placeholder } : {}), ...(b.public === 'true' ? { public: true } : {}) };
190
+ config.ai_filter = { ...(service === '-' ? {} : { service }), ...(placeholder ? { placeholder } : {}), ...(b.public === 'true' ? { public: true } : {}) };
176
191
  }
177
192
  const errors = aiFilterProblems(config.ai_filter);
178
193
  if (errors.length) flash(s, errors.join(' '), 'error');
@@ -11,6 +11,7 @@ import { scheduleProblem } from '../automations.ts';
11
11
  import { ICONS, isIcon } from '../icons.ts';
12
12
  import { templateProblem } from '../runtime/document.ts';
13
13
  import { stepProblems } from '../workflow.ts';
14
+ import { eventProblems, notificationProblems, outcomeProblems } from '../task-events.ts';
14
15
  import { workflowBeforeSave } from './workflows.ts';
15
16
  import { handlerProblems } from '../runtime/rest.ts';
16
17
  import { TEMPLATE_COMPONENT_SPEC } from './template-spec.ts';
@@ -89,6 +90,14 @@ const conditionFields = (group: string): Field[] => [
89
90
  const menuBeforeSave = async (v: Record<string, unknown>) => {
90
91
  if (v.menu === '{}' || v.menu === '[]') v.menu = null;
91
92
  if (typeof v.menu === 'string' && !Array.isArray(JSON.parse(v.menu))) throw new Error('Menu entries: a JSON array, e.g. [{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE"}]');
93
+ // (124) the database checks every entry too (meta.button_menu_ok); this names the one that's wrong
94
+ if (typeof v.menu === 'string')
95
+ for (const e of JSON.parse(v.menu) as Record<string, unknown>[]) {
96
+ const kinds = ['page', 'request', 'action'].filter((k) => e && typeof e === 'object' && k in e);
97
+ if (kinds.length !== 1) throw new Error(`Menu entry "${String(e?.label ?? '')}": exactly one of "page", "request" or "action".`);
98
+ if ('action' in e && !/^[A-Z][A-Z0-9_]{0,59}$/.test(String(e.action)))
99
+ throw new Error(`Menu entry "${String(e.label ?? '')}": "action" is a name in capitals (A-Z, 0-9, _), e.g. EXPORT.`);
100
+ }
92
101
  };
93
102
  const CLASS_LIST = /^[a-z][a-z0-9_-]{0,39}( [a-z][a-z0-9_-]{0,39}){0,4}$/;
94
103
 
@@ -175,7 +184,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
175
184
  { name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
176
185
  { name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
177
186
  { name: 'menu', label: 'Menu entries (action menu, JSON)', kind: 'json', wide: true, group: 'Behaviour',
178
- help: '[{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE", "confirm": "Archive it?", "authz": "ADMIN", "icon": "inbox"}] — a link to a page, or a submit with that request (processes and branches see it as the button pressed). At most 20.' },
187
+ help: '[{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE", "confirm": "Archive it?", "authz": "ADMIN", "icon": "inbox"}, {"label": "Export", "action": "EXPORT"}] — a link to a page, a submit with that request (processes and branches see it as the button pressed), or an action: the dynamic actions of event menu_entry with that name (or the menu button\'s name) as trigger. At most 20.' },
179
188
  { name: 'hot', common: true, label: 'Primary (hot) button', kind: 'bool', group: 'Appearance' },
180
189
  { name: 'template_options', label: 'Template options', kind: 'options', choices: BUTTON_OPTIONS, group: 'Appearance',
181
190
  help: 'CSS classes from a fixed list, added to the button (APEX: Template Options).' },
@@ -206,18 +215,18 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
206
215
  : null,
207
216
  fields: [
208
217
  { name: 'name', label: 'Name', kind: 'text', group: 'When' },
209
- { name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom'], group: 'When',
210
- help: 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
211
- { name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom). Empty for load and before_submit.' },
218
+ { name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom', 'menu_entry'], group: 'When',
219
+ help: 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
220
+ { name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom), menu entry action names or menu button names (menu_entry: any of its entries; a client-side condition tests the entry\'s name). Empty for load and before_submit.' },
212
221
  { name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'in_list', 'is_null', 'is_not_null'], group: 'When' },
213
222
  { name: 'condition_value', label: 'Condition value', kind: 'text', group: 'When' },
214
- { name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share'], group: 'Action',
215
- help: 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied.' },
223
+ { name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share', 'wait_popup', 'remove_wait_popup'], group: 'Action',
224
+ help: 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied. wait_popup: a modal "please wait" overlay (Message as its text) until remove_wait_popup or the next page, e.g. on before_submit around a long process, or wait_popup, execute_sql, remove_wait_popup around an Ajax call.' },
216
225
  { name: 'affected_items', label: 'Affected items', kind: 'upper', group: 'Action' },
217
226
  { name: 'affected_region_id', label: 'Affected region', kind: 'region', group: 'Action' },
218
227
  { name: 'code', label: 'SQL', kind: 'code', wide: true, group: 'Action', help: 'set_value: a SELECT whose columns set the affected items · execute_sql: any SQL; returned columns named like items set them · ai_generate: the name of an ai_generate process on this page (its output items are updated; items to submit default to the items its prompts use) · execute_javascript: the name of a function registered with pgkiln.actions.register(name, fn) in a static file the page loads.' },
219
228
  { name: 'items_to_submit', label: 'Items to submit', kind: 'upper', group: 'Action' },
220
- { name: 'message', label: 'Message (alert, show_success, show_error)', kind: 'text', group: 'Action' },
229
+ { name: 'message', label: 'Message (alert, confirm, show_success, show_error, wait_popup)', kind: 'text', group: 'Action' },
221
230
  { name: 'config', label: 'Plug-in attributes (JSON)', kind: 'json', group: 'Action', help: 'plugin: {"attributes": {"NAME": "value or &ITEM."}} (Shared Components → Plug-ins lists each plug-in\'s attributes).' },
222
231
  { name: 'css_classes', label: 'CSS classes (add_class, remove_class)', kind: 'text', group: 'Action', help: 'Up to five class names, e.g. is-highlight is-muted (lower case letters, digits, - and _).' },
223
232
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Security' },
@@ -614,7 +623,18 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
614
623
  if (v.expiration && !v.due_in) return 'When overdue: set "Due in" first (a task without a due date is never overdue).';
615
624
  if (v.expiration === 'renew' && v.max_renewals !== null && v.max_renewals !== undefined && v.max_renewals !== '' && (Number(v.max_renewals) < 1 || Number(v.max_renewals) > 100)) return 'Maximum renewals: 1 to 100.';
616
625
  if (v.expiration === 'escalate' && !String(v.escalate_role ?? '').trim()) return 'Escalation role: the role that takes the task over.';
617
- return null;
626
+ // (123) custom outcomes, events and notifications (parseFields gives the JSON as text)
627
+ const json = (x: unknown) => {
628
+ const parsed = typeof x === 'string' ? (x.trim() ? JSON.parse(x) : null) : x;
629
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && !Object.keys(parsed).length ? null : parsed;
630
+ };
631
+ const problems = [...outcomeProblems(json(v.outcomes)), ...eventProblems(json(v.events)), ...notificationProblems(json(v.notifications))];
632
+ return problems.length ? problems.join(' ') : null;
633
+ },
634
+ // (123) an empty JSON field is "none" (null), not {}
635
+ beforeSave: async (v) => {
636
+ for (const k of ['outcomes', 'events', 'notifications']) if (v[k] === '{}' || v[k] === '[]' || v[k] === '') v[k] = null;
637
+ if (v.fail_recovery === '') v.fail_recovery = null;
618
638
  },
619
639
  fields: [
620
640
  { name: 'name', label: 'Name', kind: 'upper', group: 'Identification', help: "Application SQL creates tasks with meta.create_task('NAME', :P1_ID, params, owners)." },
@@ -632,7 +652,16 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
632
652
  { name: 'details_page', label: 'Details page', kind: 'page', group: 'Details', help: 'The subject links here, setting the item below to the task\'s record key.' },
633
653
  { name: 'details_item', label: 'Details item', kind: 'upper', group: 'Details', help: 'e.g. P7_ID' },
634
654
  { name: 'action_code', label: 'On completion (SQL)', kind: 'code', wide: true, group: 'Action',
635
- help: 'Runs as the application\'s role when the task is approved, rejected or completed, in the same transaction (an error undoes the decision). Binds: :TASK_ID, :DETAIL_PK, :OUTCOME (APPROVED, REJECTED, COMPLETED), :COMMENT, :APPROVER, :INITIATOR and the task parameters, e.g. select expenses.decide(:DETAIL_PK::int, :OUTCOME, :COMMENT)' },
655
+ help: 'Runs as the application\'s role when the task is approved, rejected or completed, in the same transaction (an error undoes the decision). Binds: :TASK_ID, :DETAIL_PK, :OUTCOME (APPROVED, REJECTED, COMPLETED, or a custom outcome such as YES), :COMMENT, :APPROVER, :INITIATOR and the task parameters, e.g. select expenses.decide(:DETAIL_PK::int, :OUTCOME, :COMMENT)' },
656
+ // (123) custom outcomes, events and notifications
657
+ { name: 'outcomes', label: 'Outcomes (JSON)', kind: 'json', wide: true, group: 'Outcomes',
658
+ help: 'Custom outcomes instead of Approve / Reject (or Complete): [{"name": "YES", "label": "Yes", "colour": "success"}, {"name": "NO", "label": "No", "colour": "danger"}, {"name": "UNDECIDED", "label": "Undecided", "colour": "warning"}] · colours: default, primary, success, danger, warning. The task list shows one button each; :OUTCOME is the name (YES); a workflow\'s task step names them in "next" ({"yes": "PAY", "no": "END"}) and a switch case in "outcome". Empty: the type\'s own outcomes.' },
659
+ { name: 'events', label: 'Events (JSON)', kind: 'json', wide: true, group: 'Events',
660
+ help: '[{"on": "complete", "type": "sql", "code": "select expenses.log(:TASK_ID::bigint, :OUTCOME)"}, {"on": "expire", "type": "mail", "to": "select email from staff where role = \'manager\'", "template": "TASK_EXPIRED"}, {"on": "fail", "type": "workflow", "workflow": "FIX_REQUEST", "vars": {"REASON": "&ERROR."}}] · on: complete, expire, fail (the completion SQL failed) · type: sql (as the application\'s role), mail (meta.send_mail_template with a "template", or "subject" and "body"; "to" is a SELECT returning addresses, or addresses), workflow (meta.start_workflow with the task\'s record key and parameters, plus "vars"). Binds and #NAME# placeholders: the task parameters, :TASK_ID, :DETAIL_PK, :SUBJECT, :OUTCOME, :APPROVER, :INITIATOR, :EVENT; for fail also :ERROR and :COMMENT. Complete and expire run right after the commit, in a transaction of their own; fail runs in the request.' },
661
+ { name: 'fail_recovery', label: 'When the completion SQL fails', kind: 'select', options: ['', 'reopen', 'complete'], group: 'Events',
662
+ help: 'reopen (the default): the decision is undone and the task stays open; the user sees the error. complete: the task is completed with the outcome, without the SQL\'s effect. The "fail" events run either way.' },
663
+ { name: 'notifications', label: 'Notifications (JSON)', kind: 'json', wide: true, group: 'Events',
664
+ help: 'E-mail templates (Shared Components → E-mail templates) sent by pgkiln itself: {"create": "TASK_CREATED", "complete": "TASK_DONE", "expire": "TASK_EXPIRED"} · create: to the potential owners (named users and the application\'s users with an owner role), complete: to the initiator, expire: to both; at the e-mail address of their account. Placeholders: #SUBJECT#, #TASK_ID#, #DETAIL_PK#, #OUTCOME#, #APPROVER#, #INITIATOR#, #EVENT# and the task parameters. The application needs a mail service.' },
636
665
  ],
637
666
  },
638
667
  template_component: TEMPLATE_COMPONENT_SPEC,
@@ -1,3 +1,4 @@
1
+ import { pageIssuesNote } from './team.ts';
1
2
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
3
  import { owner } from '../db.ts';
3
4
  import { html, raw, type Raw } from '../html.ts';
@@ -163,13 +164,17 @@ export async function designerRoutes(app: FastifyInstance) {
163
164
  <div class="pd-dialog-buttons"><button type="button" class="btn" data-dialog-close>Cancel</button><button class="btn btn-primary">Copy</button></div>
164
165
  </form>
165
166
  </dialog>`;
166
- const events: [string, string][] = [['change', 'Change'], ['click', 'Click'], ['load', 'Page load']];
167
+ const events: [string, string][] = [['change', 'Change'], ['click', 'Click'], ['load', 'Page load'], ['menu_entry', 'Menu entry chosen']];
168
+ const daLeaf = (d: (typeof rows.dynamic_action)[number]) => leaf('dynamic_action', d, icon('bolt'), d.name, `${d.trigger_element ?? ''} → ${d.action}`);
169
+ // (sprint 115) the other events (focus, custom, before_submit, …) in one folder, when there are any
170
+ const others = rows.dynamic_action.filter((d) => !events.some(([ev]) => ev === d.event));
167
171
  const dynamicActions = html`${createBar([['dynamic_action', 'Dynamic action']])}
168
172
  <ul class="pd-tree" aria-label="Dynamic actions">
169
173
  ${events.map(([ev, label]) => {
170
174
  const list = rows.dynamic_action.filter((d) => d.event === ev);
171
- return folder(`Events: ${label}`, list.length ? list.map((d) => leaf('dynamic_action', d, icon('bolt'), d.name, `${d.trigger_element ?? ''} → ${d.action}`)) : none('None'), list.length > 0);
175
+ return folder(`Events: ${label}`, list.length ? list.map(daLeaf) : none('None'), list.length > 0);
172
176
  })}
177
+ ${others.length ? folder('Events: Other', others.map((d) => leaf('dynamic_action', d, icon('bolt'), d.name, `${d.event}: ${d.trigger_element ?? ''} → ${d.action}`)), true) : ''}
173
178
  </ul>`;
174
179
  // after processing: the branches in sequence, then (when none applies) the pressed button's target page
175
180
  const afterBranches = rows.branch.filter((x) => x.point !== 'before_header');
@@ -303,7 +308,7 @@ export async function designerRoutes(app: FastifyInstance) {
303
308
  const blocking = runOnly ?? locks.find((l) => l.locked_by !== s.username);
304
309
  const mine = locks.find((l) => l.locked_by === s.username);
305
310
  const commentCount = (await owner.one('select count(*)::int as n from meta.dev_comment where app_id = $1 and page_no = $2', [p.app_id, p.page_no])).n;
306
- const center = html`${blocking ? html`<div class="alert alert-error pd-lock" role="status">${icon('key')} ${lockText(blocking)} Your changes will be refused.</div>` : ''}
311
+ const center = html`${blocking ? html`<div class="alert alert-error pd-lock" role="status">${icon('key')} ${lockText(blocking)} Your changes will be refused.</div>` : ''}${await pageIssuesNote(p.app_id, p.page_no)}
307
312
  <div class="pd-tabs pd-tabs-center" data-tabs="pd-center" aria-label="Page">
308
313
  ${tab('pd-c-layout', 'Layout', layout, true)}
309
314
  ${tab('pd-c-search', 'Page search', pageSearch)}
@@ -3,6 +3,8 @@ import { owner } from '../db.ts';
3
3
  import { html, raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { appOr404 } from './forms.ts';
6
+ import { issuesFrom } from '../team.ts';
7
+ import { toIssueButton } from './team.ts';
6
8
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
7
9
 
8
10
  // Application → Feedback (APEX: Team Development → Feedback, migration 084):
@@ -28,6 +30,8 @@ export async function feedbackRoutes(app: FastifyInstance) {
28
30
  [a.id, status, kind])).rows;
29
31
  const counts = (await owner.query(`select status, count(*)::int as n from meta.feedback where app_id = $1 group by 1`, [a.id])).rows;
30
32
  const count = (st: string) => counts.find((c) => c.status === st)?.n ?? 0;
33
+ // (sprint 113) feedback turned into a Team Development issue links to it
34
+ const issues = await issuesFrom('feedback', rows.map((f) => Number(f.id)));
31
35
  const body = html`
32
36
  ${region('Feedback', html`
33
37
  <form method="post" action="${BASE}/apps/${a.id}/feedback/settings" class="u-mb1">${csrf(s)}
@@ -39,10 +43,11 @@ export async function feedbackRoutes(app: FastifyInstance) {
39
43
  ${select('status', 'Status', status, [['', 'All'], ...STATUSES])}
40
44
  ${select('kind', 'Kind', kind, [['', 'All'], ...KINDS])}
41
45
  <button class="btn btn-sm">${icon('filter')} Show</button></form>
42
- ${rows.length ? html`<ul class="feedback-admin">${rows.map((f) => html`<li class="feedback-item">
46
+ ${rows.length ? html`<ul class="feedback-admin">${rows.map((f) => html`<li class="feedback-item" id="feedback-${f.id}">
43
47
  <div class="feedback-meta"><span class="tag">${KINDS.find(([v]) => v === f.kind)?.[1]}</span>
44
48
  <b>${f.username}</b> <span class="muted">${when(f.created_at)}${f.page_no ? html` · <a href="/a/${a.alias}/${f.page_no}" target="_blank" rel="noopener">page ${f.page_no}</a>` : ''}${f.rating ? ` · ${'★'.repeat(f.rating)}` : ''}${f.user_agent ? ` · ${f.user_agent}` : ''}</span></div>
45
49
  <p class="feedback-body">${f.body}</p>
50
+ <p class="feedback-issue">${toIssueButton(s, 'feedback', Number(f.id), issues.get(Number(f.id)))}</p>
46
51
  <form method="post" action="${BASE}/apps/${a.id}/feedback/${f.id}">${csrf(s)}
47
52
  <div class="form-grid">
48
53
  ${select('status', 'Status', f.status, STATUSES)}
@@ -4,6 +4,7 @@ import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { clientIp, logActivity, type Session } from '../session.ts';
6
6
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
7
+ import { issuesFrom } from '../team.ts';
7
8
 
8
9
  // Page locks, the application lock and developer comments (APEX: page locks,
9
10
  // application lock in 26.1, developer comments). A developer locks a page
@@ -96,6 +97,8 @@ export async function lockPanel(s: Session, appId: number, pageNo: number, opts:
96
97
  owner.query('select id, author, body, created_at::text from meta.dev_comment where app_id = $1 and page_no = $2 order by created_at desc, id desc limit 200', [appId, pageNo]),
97
98
  isAdmin(s.username),
98
99
  ]);
100
+ // (sprint 113) a comment turned into a Team Development issue links to it
101
+ const issues = await issuesFrom('comment', comments.rows.map((c) => Number(c.id)));
99
102
  const own = locks.find((l) => l.page_no === pageNo);
100
103
  const appLock = pageNo ? locks.find((l) => l.page_no === 0) : undefined;
101
104
  const what = pageNo ? `page ${pageNo}` : 'the application';
@@ -126,7 +129,10 @@ export async function lockPanel(s: Session, appId: number, pageNo: number, opts:
126
129
  <div class="dev-comment-head"><b>${c.author}</b> <span class="muted">${String(c.created_at).slice(0, 16)}</span>
127
130
  ${c.author === s.username || admin
128
131
  ? html`<form method="post" action="${BASE}/apps/${appId}/comments/${c.id}/delete" class="u-inline">${csrf(s)}<button class="link-button" data-confirm="Delete this comment?">Delete</button></form>`
129
- : ''}</div>
132
+ : ''}
133
+ ${issues.get(Number(c.id))
134
+ ? html`<a class="small" href="/builder/team/issues/${issues.get(Number(c.id))}">${icon('kanban')} Issue #${issues.get(Number(c.id))}</a>`
135
+ : html`<form method="post" action="/builder/team/from-comment" class="u-inline">${csrf(s)}<input type="hidden" name="comment" value="${c.id}"><button class="link-button">Make an issue</button></form>`}</div>
130
136
  <p class="dev-comment-body">${c.body}</p></li>`)}</ul>`
131
137
  : html`<p class="muted">No comments yet.</p>`}
132
138
  </div>`;
@@ -22,8 +22,8 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
22
22
  [['report', 'chart', 'cards', 'dynamic', 'tree', 'template_component'], '{"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it)'],
23
23
  [['data_reporter'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"order_v","columns":[{"name":"total","label":"Total","format":"FML999G990D00"}]}],"sharing":true,"share_authz":"SCHEME","page_size":25} (the Data Reporter settings below edit it)'],
24
24
  [['form_designer'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"orders","key":"id","columns":[{"name":"customer","label":"Customer"},{"name":"status","lov":"LOV:ORDER_STATUS"}],"insert":true,"update":true,"delete":false}],"design_authz":"SCHEME","page_size":25} (the Form Designer region\'s Settings; users that pass design_authz design forms)'],
25
- [['ai_assistant'], '{"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20} (the AI assistant settings below edit it)'],
26
- [['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
25
+ [['ai_assistant'], '{"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20}; or {"agent":"SALES_AGENT","welcome":"…"} for an AI agent of Shared Components (the AI assistant settings below edit it)'],
26
+ [['report'], '{"ai_filter":{}} adds an "Ask in your own words" box: the question becomes the report\'s settings, shown as chips (built-in phrases; {"service":"NAME"} uses an AI service)'],
27
27
  [['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
28
28
  [['form'], '{"lost_update":false} saves without checking that the row is unchanged since it was shown'],
29
29
  [['login'], '(no attributes) the sign-in form of the application\'s login page (Application settings → Login page): providers, user name and password, "Keep me signed in", "Forgot password?"'],
@@ -2,6 +2,7 @@ import { advise } from './advisor.ts';
2
2
  import { appLocks, isAdmin, lockPanel, lockText } from './locks.ts';
3
3
  import { parseRoleList, validRoleName } from '../dbauth.ts';
4
4
  import { DEFAULT_HEADER, headerProxiesConfigured } from '../headerauth.ts';
5
+ import { appIssues } from './team.ts';
5
6
  import type { FastifyInstance } from 'fastify';
6
7
  import { owner } from '../db.ts';
7
8
  import { html, raw } from '../html.ts';
@@ -259,6 +260,7 @@ export async function builderRoutes(app: FastifyInstance) {
259
260
  const locks = new Map((await appLocks(a.id)).map((l) => [l.page_no, l]));
260
261
  const commentsPerPage = new Map((await owner.query('select page_no, count(*)::int as n from meta.dev_comment where app_id = $1 group by 1', [a.id])).rows.map((r) => [r.page_no, r.n]));
261
262
  const appLock = locks.get(0);
263
+ const issues = await appIssues(a.id);
262
264
 
263
265
  const main = html`
264
266
  ${appHeader(a, 'pages')}
@@ -282,7 +284,7 @@ export async function builderRoutes(app: FastifyInstance) {
282
284
  : html`<div class="table-wrap"><table class="report">
283
285
  <thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
284
286
  <tbody id="pages-rows">${shownPages.map((p) => html`<tr>
285
- <td class="num">${p.page_no}</td><td><a href="${designerUrl(a.id, p.page_no)}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
287
+ <td class="num">${p.page_no}</td><td><a href="${designerUrl(a.id, p.page_no)}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}${issues.tag(p.page_no)}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
286
288
  <td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
287
289
  <td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
288
290
  <td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
@@ -323,6 +325,7 @@ export async function builderRoutes(app: FastifyInstance) {
323
325
  <li><a href="${BASE}/apps/${a.id}/backups">${icon('history')} Backups</a></li>
324
326
  <li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('database')} Supporting objects</a></li>
325
327
  <li><a href="#app-lock">${icon('lock')} Lock the application</a></li>
328
+ ${issues.task}
326
329
  <li><a href="${BASE}/apps/${a.id}/settings?tab=definition">${icon('trash')} Delete the application</a></li>
327
330
  </ul></aside>
328
331
  </div>`;
@@ -71,7 +71,7 @@ export function sharedGroups(appId: number): { key: string; title: string; icon:
71
71
  { key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions')] },
72
72
  { key: 'workflows', title: 'Workflows and Automations', icon: 'activity', items: [t('workflow_definition', 'Workflows'), t('task_definition', 'Task Definitions'), t('automation', 'Automations')] },
73
73
  { key: 'globalization', title: 'Globalization', icon: 'globe', items: [page('globalization', 'Translations and Text Messages'), t('dynamic_translation', 'Dynamic Translations'), settings('globalization', 'Globalization Attributes')] },
74
- { key: 'ai', title: 'Generative AI', icon: 'lightbulb', items: [page('ai', 'AI Services')] },
74
+ { key: 'ai', title: 'Generative AI', icon: 'lightbulb', items: [page('ai', 'AI Services'), page('ai-agents', 'AI Agents')] },
75
75
  ];
76
76
  }
77
77