@hailer/mcp 1.3.21 → 1.3.32
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.
- package/.claude/skills/create-and-publish-app/SKILL.md +44 -56
- package/.claude/skills/hailer-app-builder/SKILL.md +414 -970
- package/.claude/skills/hailer-app-primitives/SKILL.md +742 -0
- package/.claude/skills/hailer-apps-pictures/SKILL.md +191 -87
- package/.claude/skills/hailer-design-patterns/SKILL.md +317 -0
- package/.claude/skills/hailer-design-system/SKILL.md +202 -149
- package/.claude/skills/hailer-docs-search/SKILL.md +75 -0
- package/.claude/skills/hailer-workflow-archetypes/SKILL.md +301 -0
- package/.claude/skills/insight-join-patterns/SKILL.md +313 -0
- package/.claude/skills/publish-hailer-app/SKILL.md +211 -0
- package/.claude/skills/sdk-activity-patterns/SKILL.md +257 -105
- package/.claude/skills/sdk-function-fields/SKILL.md +253 -492
- package/.claude/skills/sdk-insight-calculations/SKILL.md +364 -0
- package/.claude/skills/sdk-insight-queries/SKILL.md +192 -397
- package/.claude/skills/sdk-ws-config-skill/SKILL.md +265 -720
- package/.claude/skills/tool-response-verification/SKILL.md +143 -0
- package/CLAUDE.md +45 -19
- package/dist/bot/bot.d.ts.map +1 -1
- package/dist/bot/bot.js +21 -1
- package/dist/bot/bot.js.map +1 -1
- package/dist/bot/services/helper-prompt.d.ts +1 -1
- package/dist/bot/services/helper-prompt.d.ts.map +1 -1
- package/dist/bot/services/helper-prompt.js +4 -6
- package/dist/bot/services/helper-prompt.js.map +1 -1
- package/dist/bot/services/message-formatter.d.ts.map +1 -1
- package/dist/bot/services/message-formatter.js +2 -1
- package/dist/bot/services/message-formatter.js.map +1 -1
- package/dist/bot/services/system-prompt.d.ts.map +1 -1
- package/dist/bot/services/system-prompt.js +21 -34
- package/dist/bot/services/system-prompt.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -1
- package/dist/lib/object-id.d.ts +9 -0
- package/dist/lib/object-id.d.ts.map +1 -0
- package/dist/lib/object-id.js +15 -0
- package/dist/lib/object-id.js.map +1 -0
- package/dist/mcp/hailer-rpc.d.ts +3 -0
- package/dist/mcp/hailer-rpc.d.ts.map +1 -1
- package/dist/mcp/hailer-rpc.js +7 -0
- package/dist/mcp/hailer-rpc.js.map +1 -1
- package/dist/mcp/tool-profiles.d.ts +1 -0
- package/dist/mcp/tool-profiles.d.ts.map +1 -1
- package/dist/mcp/tool-profiles.js +21 -4
- package/dist/mcp/tool-profiles.js.map +1 -1
- package/dist/mcp/tools/aliases.d.ts.map +1 -1
- package/dist/mcp/tools/aliases.js +14 -6
- package/dist/mcp/tools/aliases.js.map +1 -1
- package/dist/mcp/tools/app-core.d.ts.map +1 -1
- package/dist/mcp/tools/app-core.js +6 -3
- package/dist/mcp/tools/app-core.js.map +1 -1
- package/dist/mcp/tools/app-marketplace.d.ts.map +1 -1
- package/dist/mcp/tools/app-marketplace.js +7 -2
- package/dist/mcp/tools/app-marketplace.js.map +1 -1
- package/dist/mcp/tools/docs.d.ts +20 -0
- package/dist/mcp/tools/docs.d.ts.map +1 -0
- package/dist/mcp/tools/docs.js +87 -0
- package/dist/mcp/tools/docs.js.map +1 -0
- package/dist/mcp/tools/function-fields.d.ts +17 -0
- package/dist/mcp/tools/function-fields.d.ts.map +1 -0
- package/dist/mcp/tools/function-fields.js +717 -0
- package/dist/mcp/tools/function-fields.js.map +1 -0
- package/dist/mcp/tools/index.d.ts.map +1 -1
- package/dist/mcp/tools/index.js +4 -0
- package/dist/mcp/tools/index.js.map +1 -1
- package/dist/mcp/tools/workflow.d.ts +0 -1
- package/dist/mcp/tools/workflow.d.ts.map +1 -1
- package/dist/mcp/tools/workflow.js +1 -190
- package/dist/mcp/tools/workflow.js.map +1 -1
- package/dist/public-chat/graduate.d.ts +5 -4
- package/dist/public-chat/graduate.d.ts.map +1 -1
- package/dist/public-chat/graduate.js +226 -76
- package/dist/public-chat/graduate.js.map +1 -1
- package/dist/public-chat/handler.d.ts.map +1 -1
- package/dist/public-chat/handler.js +10 -2
- package/dist/public-chat/handler.js.map +1 -1
- package/dist/public-chat/index.d.ts.map +1 -1
- package/dist/public-chat/index.js +12 -1
- package/dist/public-chat/index.js.map +1 -1
- package/dist/public-chat/session-store.d.ts +22 -1
- package/dist/public-chat/session-store.d.ts.map +1 -1
- package/dist/public-chat/session-store.js +56 -2
- package/dist/public-chat/session-store.js.map +1 -1
- package/dist/public-chat/studio-prewarm.js +1 -1
- package/dist/public-chat/studio-prewarm.js.map +1 -1
- package/dist/public-chat/system-prompt.d.ts +1 -1
- package/dist/public-chat/system-prompt.d.ts.map +1 -1
- package/dist/public-chat/system-prompt.js +161 -83
- package/dist/public-chat/system-prompt.js.map +1 -1
- package/package.json +1 -1
- package/.claude/skills/hailer-project-protocol/SKILL.md +0 -398
|
@@ -1,60 +1,155 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hailer-app-builder
|
|
3
|
-
description:
|
|
4
|
-
version:
|
|
3
|
+
description: Core patterns for building Hailer apps with @hailer/app-sdk — load when building any app, reading activity data, writing field values, handling SDK versions, implementing real-time updates, or debugging common app errors
|
|
4
|
+
version: 3.12.0
|
|
5
5
|
triggers:
|
|
6
6
|
- build app
|
|
7
7
|
- hailer app
|
|
8
8
|
- app sdk
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
# Hailer App Builder
|
|
11
|
+
# Hailer App Builder
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Core patterns and templates for building Hailer apps with `@hailer/app-sdk`. For UI components and icons, load `hailer-design-system`. For scaffolding, publishing, and deploy, load `create-and-publish-app` or `publish-hailer-app`. For canonical UI primitives (ConfirmDialog, SearchableSelect, DatePicker, hailerRest), load `hailer-app-primitives`.
|
|
14
14
|
|
|
15
15
|
<build-philosophy>
|
|
16
16
|
## How to Build Fast: Correct Data First, Theme Does the Looks
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
1. **Spend your effort on the data layer.** Read real field/phase/workflow IDs from `workspace/enums.ts` + `fields.ts` (never guess, never use labels as keys). A dashboard showing the *wrong* numbers beautifully is a failure; correct numbers in plain components is a success.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- Layout: `Box`, `Flex`, `Grid`, `SimpleGrid`, `Stack`, `Container`
|
|
24
|
-
- Data: `Stat`/`StatLabel`/`StatNumber`/`StatHelpText` (stat cards), `Table`/`Thead`/`Tbody`/`Tr`/`Td`, `Card`/`CardHeader`/`CardBody`
|
|
20
|
+
2. **Do NOT hand-design UI.** The scaffold wires `<ChakraProvider theme={hailerTheme}>` — every stock Chakra component is already on Hailer brand. Compose from plain Chakra primitives:
|
|
21
|
+
- Layout: `Box`, `Flex`, `Grid`, `SimpleGrid`, `Stack`
|
|
22
|
+
- Data: `Stat`/`StatLabel`/`StatNumber`, `Table`/`Thead`/`Tbody`/`Tr`/`Td`, `Card`/`CardBody`
|
|
25
23
|
- Text/state: `Heading`, `Text`, `Badge`, `Tag`, `Spinner`, `Skeleton`, `Alert`
|
|
26
|
-
- Import icons like `import { HailerSearch } from './hailer/theme/icons/HailerSearch'`
|
|
27
24
|
|
|
28
|
-
|
|
25
|
+
3. **Always handle three states** — loading (`Spinner`/`Skeleton`), empty (a `Text`, never a crash), and outside-Hailer (the app must render standalone).
|
|
29
26
|
|
|
30
|
-
|
|
27
|
+
4. **VERIFY data exists BEFORE building against it.** Check activity counts per phase for every workflow+phase you plan to fetch. After publishing, state what data the app should show so the user can confirm it matches.
|
|
28
|
+
</build-philosophy>
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
<version-check>
|
|
31
|
+
## SDK Version Check (FIRST STEP)
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
</build-philosophy>
|
|
33
|
+
**Before writing any code, check `package.json` for the `@hailer/app-sdk` version.** The 1.x and 2.x APIs differ in parameter names and return types.
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
## CRITICAL: Scaffolding and Data Sources
|
|
35
|
+
### Breaking Changes: v1 → v2
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
| Method | v1 (1.0.3) | v2 (2.0.0+) |
|
|
38
|
+
|--------|-----------|-----------------|
|
|
39
|
+
| `activity.create()` | `(processId, items[], opts?)` | `(workflowId, items[], opts?)` |
|
|
40
|
+
| `user.list()` return | `{[key: string]: User}` object map | `User[]` array |
|
|
41
|
+
| `app.config.update()` | `(appId, config)` | `(config, {appId?})` |
|
|
42
|
+
| `product.isProductInstalled()` | `(id)` | `(productId, workspaceId)` |
|
|
43
|
+
| `product.install()` | `(id)` | `(productId, workspaceId)` |
|
|
44
|
+
| `product.list()` return | `Product[]` | `{products[], totalCount}` |
|
|
45
|
+
| `permission.map()` | zero-arg `→ Promise<void>` | `({workspaceId?}?) → Promise<PermissionMap>` |
|
|
46
|
+
|
|
47
|
+
### v2 Naming Inconsistency
|
|
48
|
+
|
|
49
|
+
v2 renamed `processId` → `workflowId` in some methods but not others:
|
|
50
|
+
- `activity.create(workflowId, ...)` — renamed
|
|
51
|
+
- `activity.list(processId, ...)` — NOT renamed
|
|
52
|
+
- `ui.activity.create(processId, ...)` — NOT renamed
|
|
53
|
+
|
|
54
|
+
### User Type (v1 vs v2)
|
|
55
|
+
|
|
56
|
+
- v1: `{ _id, firstname, lastname, email, lastSeen, defaultProfilepic, status?, managedUser?, removed? }` — rich shape
|
|
57
|
+
- v2: `{ _id, firstname, lastname }` — minimal; profile metadata stripped
|
|
58
|
+
|
|
59
|
+
### `Workflow.phases` is a map in ALL SDK versions
|
|
60
|
+
|
|
61
|
+
`workflow.phases` is `{[phaseId: string]: WorkflowPhase}` in both v1 and v2. Calling `.find()` on it silently returns `undefined` every time.
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
// WRONG — .find() silently returns undefined on a map
|
|
65
|
+
const phase = workflow.phases.find(p => p._id === phaseId);
|
|
66
|
+
|
|
67
|
+
// CORRECT — direct key lookup
|
|
68
|
+
const phase = workflow.phases?.[phaseId];
|
|
44
69
|
```
|
|
45
|
-
The name is POSITIONAL (not `--name`), the template must be valid (`react-ts`/`react`/`vanilla-ts`/`svelte-ts`/…, NOT `minimal`), and the dir must be fresh — that combination runs with zero prompts. A bare `npx @hailer/create-app`, a wrong `--name` flag, an invalid template, or piping input all drop into an interactive prompt that hangs the agent. `prep-app` then clears the scaffold's placeholder appId + fake author and fixes its `StoreSet` tsc error, so you skip those papercuts entirely. Scaffold fresh, never copy an existing app, never hand-create the structure. (Apps are script-only — no MCP scaffold tool exists.)
|
|
46
70
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
### v2-Only Modules
|
|
72
|
+
|
|
73
|
+
Don't import in 1.x apps: `payment`, `admin`, `metrics`, `workflow.install()`, `ui.workflow.function.edit()`.
|
|
74
|
+
|
|
75
|
+
### v2 Renamed Types
|
|
76
|
+
|
|
77
|
+
`HailerAppConfig` → `Config`, `HailerSettings` → `Settings`, `HailerSignal` → `Signal`, `HailerApiInfo` → `ApiInfo`
|
|
78
|
+
|
|
79
|
+
### v2 Signal Changes
|
|
80
|
+
|
|
81
|
+
`subscribedSignals` constructor option removed — v2 streams ALL signals. Drop the option; filter by `signal.name` in the callback.
|
|
82
|
+
|
|
83
|
+
**v2 2.4.2 (runtime-verified):** activity signals use **dotted names** (`activity.update`, `activity.create`, `activity.delete`). Bare `'activity'` fires **0×**. Use prefix-match: `signal.name.startsWith('activity')`. Signal payload: `{ activityIds: string[], workflowId: string, phaseId: string }`.
|
|
84
|
+
</version-check>
|
|
85
|
+
|
|
86
|
+
<api-quick-index>
|
|
87
|
+
## API Quick Index
|
|
88
|
+
|
|
89
|
+
| Task | Method | Notes |
|
|
90
|
+
|------|--------|-------|
|
|
91
|
+
| List activities | `api.activity.list(wfId, phaseId, { limit, skip, filters, sortBy, sortOrder, includeUsers?, includeTeams?, includeHistory? }?)` | `search` does NOT exist in `ActivityListOptions` — only in `ActivityKanbanListOptions` |
|
|
92
|
+
| Get one activity | `hailer.activity.get(activityId)` | Single arg in both v1 and v2 |
|
|
93
|
+
| Create activities | `hailer.activity.create(wfId, items[], opts?)` | opts optional |
|
|
94
|
+
| Update activities | `hailer.activity.update(items[], opts)` | Plain values, not {type,value} |
|
|
95
|
+
| Delete activities | `hailer.activity.remove(activityIds[])` | Returns count |
|
|
96
|
+
| Move to phase | `hailer.activity.update([{_id, phaseId}], {})` | No `.move()` method |
|
|
97
|
+
| Kanban view | `hailer.activity.kanban.list(wfId, opts?)` | Grouped by phase |
|
|
98
|
+
| Open activity sidebar | `hailer.ui.activity.open(id, opts?)` | |
|
|
99
|
+
| Open create form | `hailer.ui.activity.create(wfId, opts?)` | v2.4.2+: **promise never settles on cancel** — see gotcha below |
|
|
100
|
+
| Upload file | `hailer.file.upload(file, name, opts)` (SDK ≥2.4.x) / `hailer.ui.files.uploadFile(file, name, opts)` (legacy, deprecated in 2.4.x) | Returns fileId |
|
|
101
|
+
| Show toast | `hailer.ui.snackbar.open(text, btn, ms?)` | Button is display-only, no action callback |
|
|
102
|
+
| Get current user | `hailer.user.current()` | |
|
|
103
|
+
|
|
104
|
+
> **Canonical read/write shape:**
|
|
105
|
+
>
|
|
106
|
+
> | Context | Shape |
|
|
107
|
+
> |---------|-------|
|
|
108
|
+
> | READ: REST/MCP `v3.activity.list` | `{ type: "text", value: "foo" }` |
|
|
109
|
+
> | READ: app-sdk v2 | `"foo"` (raw value — no wrapper) |
|
|
110
|
+
> | READ: app-sdk v1 | `{ value: "foo" }` |
|
|
111
|
+
> | WRITE: app-sdk (create/update), MCP | `"foo"` (plain value — never wrap) |
|
|
112
|
+
>
|
|
113
|
+
> **The `{type,value}` object is READ-only.** Passing it to app-sdk write calls sends wrong data silently. Passing it to REST/MCP write calls is rejected with code 191.
|
|
114
|
+
</api-quick-index>
|
|
115
|
+
|
|
116
|
+
<rest-api-from-app>
|
|
117
|
+
## Calling the REST API from an App (v2 only)
|
|
118
|
+
|
|
119
|
+
`hailer.http.fetch` reaches the full authenticated `api.hailer.com` REST surface as the current user (v2 only; v1 apps lack `http.*`). See `hailer-app-primitives` → `hailerRest` for the canonical typed wrapper.
|
|
51
120
|
|
|
52
121
|
```typescript
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
122
|
+
const res = await hailer.http.fetch('https://api.hailer.com/api/v2/some.endpoint', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json' },
|
|
125
|
+
body: JSON.stringify(payload),
|
|
126
|
+
});
|
|
127
|
+
// body is a RAW STRING — must JSON.parse manually
|
|
57
128
|
```
|
|
129
|
+
|
|
130
|
+
- **URL must start with `https://api.hailer.com`** (`monolith.hailer.com` is private-DNS only — fails at DNS)
|
|
131
|
+
- **Web:** allowlist is global — works out-of-the-box
|
|
132
|
+
- **iOS:** `allowedUrls` is per-app, default empty → 400 "URL not allowed"
|
|
133
|
+
- **`http.upload` is web-only** — absent on the iOS bridge
|
|
134
|
+
|
|
135
|
+
When `hailer.request('x')` returns "Cannot find api endpoint", try `hailer.http.fetch` before concluding "can't":
|
|
136
|
+
- **code 191** = handler reached, arg validation failed → endpoint IS reachable; fix the args
|
|
137
|
+
- **code 7** = not exposed on the HTTP route → WS-only or truly blocked
|
|
138
|
+
- **`messenger.*` is legacy no-version namespace** — use `/api/messenger.send` NOT `/api/v3/messenger.send`
|
|
139
|
+
</rest-api-from-app>
|
|
140
|
+
|
|
141
|
+
<critical-rules>
|
|
142
|
+
## Scaffolding
|
|
143
|
+
|
|
144
|
+
**When creating an app by copying a sibling app**, rewrite `index.html` `<title>` (and manifest `name`) immediately — stale titles cause wrong-app false positives during browser verification.
|
|
145
|
+
|
|
146
|
+
**To create a new app:** `npx @hailer/create-app <name> --template react-ts` then `npx hailer-mcp prep-app <name>`. See `create-and-publish-app` skill for the full non-interactive form and gotchas.
|
|
147
|
+
|
|
148
|
+
Use `mcp__hailer__manage_app` (`action:'create'`) only when you need a Hailer entry pointing to an already-deployed URL (no local files).
|
|
149
|
+
|
|
150
|
+
**For project data (workflows, fields, phases):** Read `workspace/` TypeScript files directly. Do NOT use MCP tools for data structure queries.
|
|
151
|
+
|
|
152
|
+
**Template portability — no insights:** A workflow used as a portable template must not depend on insights. Insights are not key-portable across workspaces. Template apps do client-side aggregation and ship an empty `workspace/insights.ts`.
|
|
58
153
|
</critical-rules>
|
|
59
154
|
|
|
60
155
|
<sdk-setup>
|
|
@@ -74,14 +169,11 @@ import { useHailer } from '@hailer/app-sdk';
|
|
|
74
169
|
function App() {
|
|
75
170
|
const { inside, hailer } = useHailer();
|
|
76
171
|
|
|
77
|
-
// CORRECT dependency array
|
|
78
172
|
useEffect(() => {
|
|
79
173
|
// fetch data
|
|
80
174
|
}, [inside]); // Use [inside] NOT [hailer]
|
|
81
175
|
|
|
82
|
-
|
|
83
|
-
if (!inside) return <Text>Open this app inside Hailer</Text>;
|
|
84
|
-
|
|
176
|
+
if (!inside) return <Text>Open this app inside Hailer</Text>; // After hooks
|
|
85
177
|
return <Box>...</Box>;
|
|
86
178
|
}
|
|
87
179
|
```
|
|
@@ -90,36 +182,14 @@ function App() {
|
|
|
90
182
|
<usehailer-fix>
|
|
91
183
|
## CRITICAL: Replace Scaffold's useHailer Hook
|
|
92
184
|
|
|
93
|
-
**The scaffold generates a buggy useHailer hook
|
|
94
|
-
|
|
95
|
-
### Why the Scaffold's Hook is Broken
|
|
185
|
+
**The scaffold generates a buggy useHailer hook** — each component gets its own `useState`, so child pages see `inside: false` even when `App.tsx` sees `inside: true`.
|
|
96
186
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
```typescript
|
|
100
|
-
// ❌ BUGGY - each component gets its own state
|
|
101
|
-
function useHailer() {
|
|
102
|
-
const [inside, setInside] = useState(false); // Each component gets separate copy!
|
|
103
|
-
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
hailer.init({ config: () => setInside(true) }); // Only updates THIS component
|
|
106
|
-
}, []);
|
|
107
|
-
|
|
108
|
-
return { inside, hailer };
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
**Result:** App.tsx sees `inside: true`, but child pages (Dashboard, Settings) still see `inside: false`.
|
|
113
|
-
|
|
114
|
-
### The Fix: Shared State with useSyncExternalStore
|
|
115
|
-
|
|
116
|
-
Replace `src/hailer/use-hailer.ts` with:
|
|
187
|
+
After scaffolding, ALWAYS replace `src/hailer/use-hailer.ts` with this shared-state implementation:
|
|
117
188
|
|
|
118
189
|
```typescript
|
|
119
190
|
import { useSyncExternalStore } from 'react';
|
|
120
191
|
import HailerApi from '@hailer/app-sdk';
|
|
121
192
|
|
|
122
|
-
// Types
|
|
123
193
|
interface HailerState {
|
|
124
194
|
inside: boolean;
|
|
125
195
|
hailer: ReturnType<typeof HailerApi> | null;
|
|
@@ -138,7 +208,6 @@ declare global {
|
|
|
138
208
|
}
|
|
139
209
|
}
|
|
140
210
|
|
|
141
|
-
// Initialize store once on window
|
|
142
211
|
function getStore() {
|
|
143
212
|
if (!window.__hailerStore) {
|
|
144
213
|
window.__hailerStore = {
|
|
@@ -148,26 +217,21 @@ function getStore() {
|
|
|
148
217
|
this.listeners.add(listener);
|
|
149
218
|
return () => this.listeners.delete(listener);
|
|
150
219
|
},
|
|
151
|
-
getSnapshot() {
|
|
152
|
-
return this.state;
|
|
153
|
-
},
|
|
220
|
+
getSnapshot() { return this.state; },
|
|
154
221
|
setState(newState) {
|
|
155
222
|
this.state = { ...this.state, ...newState };
|
|
156
223
|
this.listeners.forEach((l) => l());
|
|
157
224
|
},
|
|
158
225
|
};
|
|
159
226
|
|
|
160
|
-
// Initialize SDK once
|
|
161
227
|
const api = HailerApi({
|
|
162
|
-
config: (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
});
|
|
167
|
-
},
|
|
168
|
-
error: (err) => {
|
|
169
|
-
console.error('Hailer SDK error:', err);
|
|
228
|
+
config: (cfg) => {
|
|
229
|
+
// v1: cfg.fields is a nested object { fieldKey: { value: ... } }
|
|
230
|
+
// v2: cfg IS the flat Record<string, string> directly — no .fields nesting
|
|
231
|
+
const flat = cfg?.fields ?? cfg ?? null;
|
|
232
|
+
window.__hailerStore!.setState({ inside: true, config: flat });
|
|
170
233
|
},
|
|
234
|
+
error: (err) => console.error('Hailer SDK error:', err),
|
|
171
235
|
});
|
|
172
236
|
window.__hailerStore.setState({ hailer: api });
|
|
173
237
|
}
|
|
@@ -180,1069 +244,449 @@ export default function useHailer() {
|
|
|
180
244
|
store.subscribe.bind(store),
|
|
181
245
|
store.getSnapshot.bind(store)
|
|
182
246
|
);
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
inside: state.inside,
|
|
186
|
-
hailer: state.hailer!,
|
|
187
|
-
config: state.config,
|
|
188
|
-
};
|
|
247
|
+
return { inside: state.inside, hailer: state.hailer!, config: state.config };
|
|
189
248
|
}
|
|
190
249
|
```
|
|
191
250
|
|
|
192
|
-
|
|
251
|
+
**Why:** Single store on `window` → all components share one source of truth. `useSyncExternalStore` is React 18's official pattern for external state.
|
|
193
252
|
|
|
194
|
-
|
|
195
|
-
2. **SDK initialized once** - No duplicate callbacks
|
|
196
|
-
3. **useSyncExternalStore** - React 18's official pattern for external state
|
|
197
|
-
4. **All components update together** - When `inside` changes, every subscriber re-renders
|
|
253
|
+
### Scaffold tsc bugs — fix up front
|
|
198
254
|
|
|
199
|
-
|
|
255
|
+
Freshly scaffolded apps carry tsc errors in `src/hailer/use-app.ts` — at minimum the `StoreSet` `replace?: boolean` → `replace?: false` fix (zustand 5). Apply immediately. `npx hailer-mcp prep-app <app-dir>` automates this fix.
|
|
200
256
|
|
|
201
|
-
|
|
257
|
+
A `vite-env.d.ts` containing an `import` becomes a module — ambient declarations stop being global; wrap them in `declare global { … }` to restore globals.
|
|
202
258
|
</usehailer-fix>
|
|
203
259
|
|
|
204
|
-
<
|
|
205
|
-
##
|
|
260
|
+
<onready-overfetch>
|
|
261
|
+
## `onReady` / `useEffect([inside])` — Only Fetch What You Need
|
|
206
262
|
|
|
207
|
-
|
|
208
|
-
// List activities from workflow phase
|
|
209
|
-
const activities = await hailer.activity.list(workflowId, phaseId, options?);
|
|
263
|
+
**Calling `hailer.workflow.list()`, `hailer.user.list()`, or `hailer.permission.map()` in `onReady` causes parent-frame spam and CORS errors when the app does not actually need those calls.**
|
|
210
264
|
|
|
211
|
-
|
|
212
|
-
const activity = await hailer.activity.get(activityId);
|
|
265
|
+
These calls fire translation-scope loads for each workflow, producing repeated `Error when listening to scope load: undefined` and `Failed to load translations for scope: misc` errors — re-triggered on every incoming signal.
|
|
213
266
|
|
|
214
|
-
|
|
215
|
-
const created = await hailer.activity.create(workflowId, activities[], options?);
|
|
216
|
-
|
|
217
|
-
// Update activities (returns count)
|
|
218
|
-
const count = await hailer.activity.update(activities[], options);
|
|
219
|
-
|
|
220
|
-
// Remove activities (returns count)
|
|
221
|
-
const count = await hailer.activity.remove(activityIds[]);
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
### ActivityListOptions
|
|
267
|
+
A standalone or chat app that does not use workflow data should call only what it needs:
|
|
225
268
|
|
|
226
269
|
```typescript
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
includeUsers?: boolean;
|
|
233
|
-
includeTeams?: boolean;
|
|
234
|
-
includeHistory?: boolean;
|
|
235
|
-
filters?: any;
|
|
236
|
-
}
|
|
270
|
+
// Correct for a standalone/chat app
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!inside) return;
|
|
273
|
+
hailer.user.current().then(u => setCurrentUser(u));
|
|
274
|
+
}, [inside]);
|
|
237
275
|
```
|
|
276
|
+
</onready-overfetch>
|
|
238
277
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
```typescript
|
|
242
|
-
interface ActivityCreateOptions {
|
|
243
|
-
teamId?: string; // Assign to team
|
|
244
|
-
fileIds?: string[]; // Attach files (use ui.files.uploadFile first)
|
|
245
|
-
followerIds?: string[]; // Add followers
|
|
246
|
-
location?: {
|
|
247
|
-
label?: string;
|
|
248
|
-
type: 'area' | 'point' | 'polyline';
|
|
249
|
-
data: [{ lat: number; lng: number }];
|
|
250
|
-
};
|
|
251
|
-
discussionId?: string; // Link to discussion
|
|
252
|
-
phaseId?: string; // Initial phase (defaults to first)
|
|
253
|
-
returnDocument?: boolean; // Return full activity after create
|
|
254
|
-
ignoreRequired?: boolean; // Skip required field validation
|
|
255
|
-
}
|
|
256
|
-
```
|
|
278
|
+
<sdk-api>
|
|
279
|
+
## Field Keys — Default Build Pattern
|
|
257
280
|
|
|
258
|
-
|
|
281
|
+
**All new app builds reference workflows, phases, and fields by stable key → runtime hex via a `buildXxxSchema(workflows)` resolver.** Never hardcode hex IDs in app source. Activity IDs are the only exception.
|
|
259
282
|
|
|
260
283
|
```typescript
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
currentPhase: string;
|
|
266
|
-
fields?: Record<string, { value: unknown }>;
|
|
267
|
-
files?: string[];
|
|
268
|
-
followers?: string[];
|
|
269
|
-
created?: number;
|
|
270
|
-
updated?: number;
|
|
271
|
-
updatedBy?: string;
|
|
272
|
-
priority?: number;
|
|
273
|
-
location?: {
|
|
274
|
-
type: 'point' | 'area' | 'polyline';
|
|
275
|
-
label: string | null;
|
|
276
|
-
data: Array<{ lat: number; lng: number }>;
|
|
277
|
-
};
|
|
278
|
-
}
|
|
284
|
+
// src/constants/sales.ts — stable slugs, portable across tenants
|
|
285
|
+
export const WORKFLOW_KEYS = { DEALS: 'sales-deals' } as const;
|
|
286
|
+
export const PHASE_KEYS = { OPEN: 'open', WON: 'won', LOST: 'lost' } as const;
|
|
287
|
+
export const FIELD_KEYS = { VALUE: 'dealValue', OWNER: 'owner' } as const;
|
|
279
288
|
```
|
|
280
289
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
**CRITICAL:** The SDK does NOT have a `hailer.activity.move()` method. To move activities between phases, use `hailer.activity.update()` with the `phaseId` parameter.
|
|
284
|
-
|
|
285
|
-
```typescript
|
|
286
|
-
// CORRECT - Use activity.update() with phaseId
|
|
287
|
-
await hailer.activity.update([
|
|
288
|
-
{
|
|
289
|
-
_id: activityId,
|
|
290
|
-
phaseId: newPhaseId, // Move to different phase
|
|
291
|
-
},
|
|
292
|
-
], {});
|
|
293
|
-
|
|
294
|
-
// ❌ WRONG - activity.move() DOES NOT EXIST
|
|
295
|
-
// await hailer.activity.move(activityId, newPhaseId); // This method doesn't exist in the SDK!
|
|
296
|
-
```
|
|
290
|
+
**Missing-precondition rule:** Before building, verify: `grep -c '^\s*key:' workspace/*/fields.ts`. A workspace with zero keys means `auto_set_keys` never ran. Do not build a static hex table dressed as a resolver — escalate: "Run `mcp__hailer__auto_set_keys({ workflowId })` for each workflow first."
|
|
297
291
|
|
|
298
|
-
**
|
|
299
|
-
```typescript
|
|
300
|
-
const activityIds = ['id1', 'id2', 'id3'];
|
|
301
|
-
const targetPhaseId = 'phaseId789';
|
|
302
|
-
|
|
303
|
-
await hailer.activity.update(
|
|
304
|
-
activityIds.map(id => ({
|
|
305
|
-
_id: id,
|
|
306
|
-
phaseId: targetPhaseId,
|
|
307
|
-
})),
|
|
308
|
-
{}
|
|
309
|
-
);
|
|
310
|
-
```
|
|
292
|
+
**Passing a workflow key directly to `hailer.activity.list()` returns code 127** ("Invalid process key or missing workspace id") — always pass resolved hex.
|
|
311
293
|
|
|
312
|
-
|
|
294
|
+
> **Resolver primitive-capture gotcha.** `FIELD_IDS` objects start as empty strings and are filled in at app boot. Any primitive string value copied at module-evaluation time stays `''` forever. Use object references (`FIELD_IDS.mu`) inside JSX/hooks, never copy a primitive leaf at module level.
|
|
313
295
|
|
|
314
|
-
|
|
296
|
+
## Activity API
|
|
315
297
|
|
|
316
|
-
**Solution:** Query all phases in parallel:
|
|
317
298
|
```typescript
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
)
|
|
324
|
-
);
|
|
325
|
-
const allActivities = phaseResults.flat();
|
|
299
|
+
const activities = await hailer.activity.list(workflowId, phaseId);
|
|
300
|
+
const activity = await hailer.activity.get(activityId); // single arg, both v1 and v2
|
|
301
|
+
const created = await hailer.activity.create(workflowId, activities[], options?);
|
|
302
|
+
const count = await hailer.activity.update(activities[], options);
|
|
303
|
+
const count = await hailer.activity.remove(activityIds[]);
|
|
326
304
|
```
|
|
327
305
|
|
|
328
|
-
###
|
|
329
|
-
|
|
330
|
-
Don't blindly fetch all phases — fetch only what the user needs to see (e.g., active only for dashboards, all except archived for kanban). For field value formats and phase selection patterns, see the **sdk-activity-patterns** skill.
|
|
306
|
+
### activity.list() — Pagination
|
|
331
307
|
|
|
332
|
-
|
|
308
|
+
**Production limit cap (SUSPECT #1 FOR EMPTY DATA ON LOAD):** `hailer.activity.list()` silently returns `[]` on production when `limit` is too large — **no error thrown**, even with full admin permissions. `limit: 100` works, `limit: 1000` returns empty. Use `pageSize ≤ 200`.
|
|
333
309
|
|
|
334
310
|
```typescript
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
### KanbanListOptions
|
|
348
|
-
|
|
349
|
-
```typescript
|
|
350
|
-
interface ActivityKanbanListOptions {
|
|
351
|
-
filter: {
|
|
352
|
-
user?: { uid: string; field: string };
|
|
353
|
-
account?: string;
|
|
354
|
-
team?: string;
|
|
355
|
-
dates?: { field: string; start: number; end: number };
|
|
356
|
-
};
|
|
357
|
-
search?: string;
|
|
358
|
-
limit?: number;
|
|
359
|
-
skip?: number;
|
|
360
|
-
phase?: string;
|
|
361
|
-
includeUsers?: boolean;
|
|
362
|
-
includeTeams?: boolean;
|
|
311
|
+
async function listAll(hailer: any, workflowId: string, phaseId: string, opts = {}, pageSize = 200) {
|
|
312
|
+
const all: any[] = [];
|
|
313
|
+
let skip = 0;
|
|
314
|
+
while (true) {
|
|
315
|
+
const page = await hailer.activity.list(workflowId, phaseId, { ...opts, limit: pageSize, skip });
|
|
316
|
+
all.push(...page);
|
|
317
|
+
if (page.length < pageSize) break;
|
|
318
|
+
skip += pageSize;
|
|
319
|
+
if (skip > 50000) break; // safety cap
|
|
320
|
+
}
|
|
321
|
+
return all;
|
|
363
322
|
}
|
|
364
323
|
```
|
|
365
324
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
### Activity UI
|
|
369
|
-
|
|
370
|
-
```typescript
|
|
371
|
-
// Open activity in sidebar
|
|
372
|
-
await hailer.ui.activity.open(activityId, options?);
|
|
373
|
-
|
|
374
|
-
type ActivityTabTypes = 'detail' | 'discussion' | 'files' | 'location' | 'linkedFrom' | 'options';
|
|
375
|
-
|
|
376
|
-
// Open with specific tab
|
|
377
|
-
await hailer.ui.activity.open(activityId, { tab: 'files' });
|
|
378
|
-
|
|
379
|
-
// Open create form (returns created activity or null if cancelled)
|
|
380
|
-
const result = await hailer.ui.activity.create(workflowId, {
|
|
381
|
-
name?: string,
|
|
382
|
-
fields?: { [fieldId]: value },
|
|
383
|
-
location?: { type: 'point', data: [{ lat, lng }] }
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// Bulk edit multiple activities
|
|
387
|
-
await hailer.ui.activity.editMultiple(activityIds[]);
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
**Note:** `hailer.openSidebar()` does NOT exist - use `hailer.ui.activity.open()`.
|
|
325
|
+
### activity.list() Gotchas
|
|
391
326
|
|
|
392
|
-
|
|
327
|
+
- **Valid phaseId required:** Empty string `''` returns nothing.
|
|
328
|
+
- **Don't assume phase names** — check `phases.ts` for exact strings (e.g. `"Uudet"` not `"New"`).
|
|
329
|
+
- **Large `limit` silently returns `[]` on prod** — Keep `pageSize ≤ 200`.
|
|
330
|
+
- **`returnFlat: true` is REST/MCP-only** — do NOT pass it to app-sdk. In v2 app-sdk, field values come back as raw values already; passing `returnFlat` silently has no effect.
|
|
331
|
+
- **`activity.currentPhase` is unreliable from list/kanban paths.** Fix: stamp the queried phaseId: `all.push({ ...a, currentPhase: a.currentPhase || phaseId })`. When present, the value may be hex OR key — include entries for both in lookup maps.
|
|
393
332
|
|
|
333
|
+
Multi-phase fetch:
|
|
394
334
|
```typescript
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
{ isPublic?: boolean }
|
|
335
|
+
const phaseResults = await Promise.all(
|
|
336
|
+
[PHASES.ACTIVE, PHASES.COMPLETED].map(phaseId =>
|
|
337
|
+
hailer.activity.list(workflowId, phaseId).catch(() => [])
|
|
338
|
+
)
|
|
400
339
|
);
|
|
401
|
-
|
|
402
|
-
// Then attach to activity on create:
|
|
403
|
-
await hailer.activity.create(workflowId, [{ name: 'Doc' }], { fileIds: [fileId] });
|
|
340
|
+
const all = phaseResults.flat();
|
|
404
341
|
```
|
|
405
342
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
```typescript
|
|
409
|
-
// Show notification
|
|
410
|
-
await hailer.ui.snackbar.open(text, buttonLabel, duration?);
|
|
411
|
-
|
|
412
|
-
// Examples
|
|
413
|
-
hailer.ui.snackbar.open('Saved!', 'OK');
|
|
414
|
-
hailer.ui.snackbar.open('Deleted', 'Undo', 5000);
|
|
415
|
-
```
|
|
343
|
+
**Phase fan-out across `phasesOrder` for archived rows.** When listing all rows including archived phases, iterate `workflowConfig.phasesOrder` rather than assuming one phase covers everything.
|
|
416
344
|
|
|
417
|
-
###
|
|
345
|
+
### Phase Transitions
|
|
418
346
|
|
|
347
|
+
No `hailer.activity.move()` — use update with phaseId:
|
|
419
348
|
```typescript
|
|
420
|
-
await hailer.
|
|
421
|
-
await hailer.ui.insight.edit(insightId); // Open edit dialog
|
|
422
|
-
await hailer.ui.insight.delete(insightId); // Open delete confirmation
|
|
423
|
-
await hailer.ui.insight.permission(insightId); // Open permissions dialog
|
|
349
|
+
await hailer.activity.update([{ _id: activityId, phaseId: newPhaseId }], {});
|
|
424
350
|
```
|
|
425
351
|
|
|
426
|
-
|
|
352
|
+
### hailer.activity.create() — phaseId is Optional
|
|
427
353
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
354
|
+
`phaseId` in the options argument is optional. All these forms work:
|
|
355
|
+
- `hailer.activity.create(wfId, [{name, fields, teamId}])` — options omitted
|
|
356
|
+
- `hailer.activity.create(wfId, [{name, fields, teamId}], {})` — empty options
|
|
357
|
+
- `hailer.activity.create(wfId, [{name, fields, teamId}], { phaseId: 'X' })` — phase-targeted
|
|
431
358
|
|
|
432
|
-
|
|
433
|
-
const insights = await hailer.insight.list();
|
|
359
|
+
### hailer.ui.activity.create() vs hailer.activity.create()
|
|
434
360
|
|
|
435
|
-
|
|
436
|
-
const updated = await hailer.insight.update(insightId, partialUpdate);
|
|
437
|
-
```
|
|
361
|
+
`hailer.ui.activity.create(processId)` opens the native Hailer "new activity" sidebar.
|
|
438
362
|
|
|
439
|
-
**
|
|
440
|
-
```typescript
|
|
441
|
-
interface InsightData {
|
|
442
|
-
columns: string[];
|
|
443
|
-
rows: any[][];
|
|
444
|
-
}
|
|
363
|
+
**Critical gotcha — cancel hangs the promise forever (v2.x ≥2.4.2).** If the user dismisses the form without saving, the awaited promise NEVER settles. Do NOT `await` it directly. Treat as fire-and-forget, or react to activity-created signals.
|
|
445
364
|
|
|
446
|
-
|
|
447
|
-
_id: string;
|
|
448
|
-
name: string;
|
|
449
|
-
// ... other insight properties
|
|
450
|
-
}
|
|
451
|
-
```
|
|
365
|
+
### Activity Object Field Names — v1 vs v2
|
|
452
366
|
|
|
453
|
-
|
|
367
|
+
| Property | v1.x | v2.x (2.4.2 runtime) |
|
|
368
|
+
|----------|------|------|
|
|
369
|
+
| Field value read | `activity.fields?.[id]?.value` | `activity.fields?.[id]` (raw — `.value` wrapper gone) |
|
|
370
|
+
| Team ID | (not on object) | `activity.teamId` (flat string) |
|
|
454
371
|
|
|
455
|
-
|
|
456
|
-
// List all workflows
|
|
457
|
-
const workflows = await hailer.workflow.list();
|
|
372
|
+
Runtime-confirmed shape for `activity.get()` on @hailer/app-sdk **2.4.2**: `{_id, name, created, updated, sequence, priority, files, fields, followerIds, workflowId, phaseId, teamId}`.
|
|
458
373
|
|
|
459
|
-
|
|
460
|
-
const workflow = await hailer.workflow.get(workflowId);
|
|
461
|
-
```
|
|
374
|
+
**Silent failure pattern — v1→v2 migration:** Code that reads `activity.fields?.[id]?.value` compiles without errors in v2 but silently returns `undefined` everywhere.
|
|
462
375
|
|
|
463
|
-
|
|
376
|
+
### Editable Function Fields
|
|
464
377
|
|
|
465
|
-
|
|
466
|
-
// Get current logged-in user
|
|
467
|
-
const me = await hailer.user.current();
|
|
468
|
-
// Returns: { _id, email, firstname, lastname, ... }
|
|
378
|
+
A function field with `editable: true` is a valid write target via `hailer.activity.update()` — pass a plain scalar value. A stored value wins over the computed default.
|
|
469
379
|
|
|
470
|
-
|
|
471
|
-
const user = await hailer.user.get(userId);
|
|
380
|
+
### hailer.insight.data() Option Types
|
|
472
381
|
|
|
473
|
-
|
|
474
|
-
const users = await hailer.user.list();
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
### Getting User Info for Personalized Greeting
|
|
478
|
-
|
|
479
|
-
**The config callback does NOT include user info:**
|
|
480
|
-
```typescript
|
|
481
|
-
// ❌ WRONG - config only has { fields: {} }
|
|
482
|
-
const { config } = useHailer();
|
|
483
|
-
const userName = config?.userName; // undefined!
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
**Use hailer.user.current() instead:**
|
|
487
|
-
```typescript
|
|
488
|
-
const { inside, hailer } = useHailer();
|
|
489
|
-
const [userName, setUserName] = useState<string>('');
|
|
490
|
-
|
|
491
|
-
useEffect(() => {
|
|
492
|
-
if (!inside) return;
|
|
493
|
-
|
|
494
|
-
hailer.user.current().then(user => {
|
|
495
|
-
setUserName(user.firstname || 'there');
|
|
496
|
-
});
|
|
497
|
-
}, [inside]);
|
|
498
|
-
|
|
499
|
-
return <Heading>Hello, {userName}!</Heading>;
|
|
500
|
-
```
|
|
501
|
-
|
|
502
|
-
## Workspace API
|
|
503
|
-
|
|
504
|
-
```typescript
|
|
505
|
-
// Get current workspace
|
|
506
|
-
const workspace = await hailer.workspace.current();
|
|
507
|
-
|
|
508
|
-
// List workspaces (personal apps only)
|
|
509
|
-
const workspaces = await hailer.workspace.list();
|
|
510
|
-
```
|
|
382
|
+
Pass `{ update: true }` to force a fresh fetch. Passing `{}` (no `update` key) has been observed to hang silently in production.
|
|
511
383
|
</sdk-api>
|
|
512
384
|
|
|
513
|
-
<field-resolver>
|
|
514
|
-
## Field Resolver Pattern
|
|
515
|
-
|
|
516
|
-
Every scaffolded app includes `src/hailer/field-resolver.ts`. It resolves human-readable field keys to hex IDs at runtime.
|
|
517
|
-
|
|
518
|
-
### Functions
|
|
519
|
-
|
|
520
|
-
**`createFieldResolver(workflowFields)`** — low-level. Takes a workflow's `fields` map, returns a resolver function.
|
|
521
|
-
|
|
522
|
-
**`useFieldResolver(workflows, workflowId)`** — hook-friendly wrapper. Takes `useApp().app.workflows` and a workflow ID.
|
|
523
|
-
|
|
524
|
-
### How It Works
|
|
525
|
-
|
|
526
|
-
- Pass a field **key** (e.g., `'matchDate'`) → resolves to hex ID at runtime
|
|
527
|
-
- Pass a field **hex ID** (e.g., `'691ffdf84217e9e8434e5697'`) → returns as-is
|
|
528
|
-
- Workflows without keys → passthrough, hex IDs still work
|
|
529
|
-
|
|
530
|
-
### Usage
|
|
531
|
-
|
|
532
|
-
```typescript
|
|
533
|
-
import { useFieldResolver } from './hailer/field-resolver';
|
|
534
|
-
import { useApp } from './hailer/use-app';
|
|
535
|
-
|
|
536
|
-
const WORKFLOW_ID = '691ffdf84217e9e8434e56a5';
|
|
537
|
-
const FIELD_KEYS = {
|
|
538
|
-
MATCH_DATE: 'matchDate',
|
|
539
|
-
HOME_TEAM: 'homeTeam',
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
function MyComponent() {
|
|
543
|
-
const { app } = useApp();
|
|
544
|
-
const f = useFieldResolver(app.workflows, WORKFLOW_ID);
|
|
545
|
-
|
|
546
|
-
// f() resolves keys to IDs — works with either
|
|
547
|
-
const date = activity.fields?.[f(FIELD_KEYS.MATCH_DATE)];
|
|
548
|
-
const team = activity.fields?.[f(FIELD_KEYS.HOME_TEAM)];
|
|
549
|
-
}
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
### Key Points
|
|
553
|
-
|
|
554
|
-
- Define field keys as constants (human-readable, portable across workspaces)
|
|
555
|
-
- The resolver handles both keys and hex IDs — safe to pass either
|
|
556
|
-
- Wait for workflow data before fetching activities — add the workflow to `useEffect` deps
|
|
557
|
-
- `useApp().app.workflows` is loaded on init by the scaffold's `onReady` handler
|
|
558
|
-
</field-resolver>
|
|
559
|
-
|
|
560
385
|
<field-patterns>
|
|
561
386
|
## Extracting Field Values
|
|
562
387
|
|
|
563
388
|
```typescript
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
389
|
+
// v1 — .value wrapper
|
|
390
|
+
const amount = activity.fields?.[FIELD_ID]?.value as number;
|
|
391
|
+
|
|
392
|
+
// v2 — raw value directly
|
|
393
|
+
const amount = activity.fields?.[FIELD_ID] as number;
|
|
568
394
|
|
|
569
|
-
// Safe extraction helper
|
|
395
|
+
// Safe extraction helper (v1 shape)
|
|
570
396
|
function getFieldValue<T>(activity: Activity, fieldId: string, defaultValue: T): T {
|
|
571
397
|
return (activity.fields?.[fieldId]?.value as T) ?? defaultValue;
|
|
572
398
|
}
|
|
573
399
|
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
const date = getFieldValue(activity, 'fieldId789', null);
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
## Field Types
|
|
581
|
-
|
|
582
|
-
```typescript
|
|
583
|
-
// Text field
|
|
584
|
-
const text = activity.fields?.['fieldId']?.value as string;
|
|
585
|
-
|
|
586
|
-
// Number field
|
|
587
|
-
const num = activity.fields?.['fieldId']?.value as number;
|
|
588
|
-
|
|
589
|
-
// Date field (timestamp)
|
|
590
|
-
const date = activity.fields?.['fieldId']?.value as number;
|
|
591
|
-
const formatted = new Date(date).toLocaleDateString();
|
|
400
|
+
// ActivityLink — READ returns {_id, name} object, WRITE takes plain ID string
|
|
401
|
+
interface ActivityLinkValue { _id: string; name: string; }
|
|
402
|
+
const linked = activity.fields?.['fieldId']?.value as ActivityLinkValue;
|
|
592
403
|
|
|
593
|
-
//
|
|
594
|
-
|
|
404
|
+
// Writing an ActivityLink field — plain ID only
|
|
405
|
+
await hailer.activity.update([{
|
|
406
|
+
_id: activityId,
|
|
407
|
+
fields: { [LINK_FIELD_ID]: someActivityId }, // string, not {_id, name}
|
|
408
|
+
}], {});
|
|
595
409
|
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
_id
|
|
600
|
-
|
|
410
|
+
// Defensive readLink helpers — activity.list returns activitylink as {_id,name} OR [{_id,name}]
|
|
411
|
+
function readLinkId(v: unknown): string | undefined {
|
|
412
|
+
if (Array.isArray(v)) return (v[0] as any)?._id;
|
|
413
|
+
if (v && typeof v === 'object') return (v as any)._id;
|
|
414
|
+
if (typeof v === 'string') return v;
|
|
415
|
+
return undefined;
|
|
601
416
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
417
|
+
function readLinkName(v: unknown): string | undefined {
|
|
418
|
+
if (Array.isArray(v)) return (v[0] as any)?.name;
|
|
419
|
+
if (v && typeof v === 'object') return (v as any).name;
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
// Match linked activities by id, not name — names break silently on rename
|
|
605
423
|
|
|
606
424
|
// User field
|
|
607
|
-
interface UserValue {
|
|
608
|
-
_id: string;
|
|
609
|
-
firstname: string;
|
|
610
|
-
lastname: string;
|
|
611
|
-
}
|
|
425
|
+
interface UserValue { _id: string; firstname: string; lastname: string; }
|
|
612
426
|
const user = activity.fields?.['fieldId']?.value as UserValue;
|
|
613
427
|
const userName = user ? `${user.firstname} ${user.lastname}` : 'Unknown';
|
|
428
|
+
|
|
429
|
+
// Date (timestamp)
|
|
430
|
+
const formatted = new Date(activity.fields?.['fieldId']?.value as number).toLocaleDateString();
|
|
614
431
|
```
|
|
615
432
|
|
|
616
|
-
|
|
433
|
+
### React error #31 — range field rendered raw in JSX
|
|
617
434
|
|
|
618
|
-
|
|
435
|
+
**React error #31 = a `timerange` / `daterange` / `datetimerange` field value rendered directly in JSX.**
|
|
619
436
|
|
|
620
437
|
```typescript
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
if (!match) return null;
|
|
629
|
-
|
|
630
|
-
const [, day, month, year, hours = '0', minutes = '0'] = match;
|
|
631
|
-
return new Date(
|
|
632
|
-
parseInt(year),
|
|
633
|
-
parseInt(month) - 1, // JS months are 0-indexed
|
|
634
|
-
parseInt(day),
|
|
635
|
-
parseInt(hours),
|
|
636
|
-
parseInt(minutes)
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Sorting Finnish dates
|
|
641
|
-
items.sort((a, b) => {
|
|
642
|
-
const dateA = parseFinnishDate(a.dateField);
|
|
643
|
-
const dateB = parseFinnishDate(b.dateField);
|
|
644
|
-
if (!dateA || !dateB) return 0;
|
|
645
|
-
return dateA.getTime() - dateB.getTime();
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
// Formatting back to Finnish
|
|
649
|
-
function formatFinnishDate(date: Date): string {
|
|
650
|
-
return date.toLocaleDateString('fi-FI'); // "3.2.2026"
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function formatFinnishDateTime(date: Date): string {
|
|
654
|
-
return date.toLocaleString('fi-FI', {
|
|
655
|
-
day: 'numeric',
|
|
656
|
-
month: 'numeric',
|
|
657
|
-
year: 'numeric',
|
|
658
|
-
hour: '2-digit',
|
|
659
|
-
minute: '2-digit',
|
|
660
|
-
}); // "3.2.2026 klo 10.00"
|
|
438
|
+
function formatTimeRange(v: unknown): string {
|
|
439
|
+
if (!v) return '';
|
|
440
|
+
if (typeof v === 'object' && v !== null && 'start' in v && 'end' in v) {
|
|
441
|
+
const r = v as { start: number; end: number };
|
|
442
|
+
return `${new Date(r.start).toLocaleTimeString('fi-FI', { timeZone: 'Europe/Helsinki', hour: '2-digit', minute: '2-digit' })} – ${new Date(r.end).toLocaleTimeString('fi-FI', { timeZone: 'Europe/Helsinki', hour: '2-digit', minute: '2-digit' })}`;
|
|
443
|
+
}
|
|
444
|
+
return String(v);
|
|
661
445
|
}
|
|
662
446
|
```
|
|
663
447
|
|
|
664
|
-
|
|
665
|
-
- Insight data returns formatted dates (not timestamps)
|
|
666
|
-
- Text fields containing dates
|
|
667
|
-
- Displaying dates to Finnish users
|
|
668
|
-
</field-patterns>
|
|
448
|
+
## Write Field Formats (App-SDK)
|
|
669
449
|
|
|
670
|
-
|
|
671
|
-
## Activity Table
|
|
450
|
+
Pass plain values to `hailer.activity.create` / `hailer.activity.update` — no `{type, value}` or `{value}` wrappers.
|
|
672
451
|
|
|
673
452
|
```typescript
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
columns: { fieldId: string; label: string }[];
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function ActivityTable({ activities, loading, columns }: Props) {
|
|
689
|
-
if (loading) return <Spinner />;
|
|
690
|
-
if (activities.length === 0) return <Text>No data</Text>;
|
|
453
|
+
// activity.create() format
|
|
454
|
+
await hailer.activity.create(WORKFLOW_ID, [
|
|
455
|
+
{
|
|
456
|
+
name: 'Activity name',
|
|
457
|
+
fields: {
|
|
458
|
+
[FIELD_ID]: 'string value',
|
|
459
|
+
[NUMBER_FIELD]: 42,
|
|
460
|
+
[ACTIVITYLINK_FIELD]: 'linkedActId', // plain string ID — not {_id, name}
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
]);
|
|
691
464
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
{activities.map(activity => (
|
|
704
|
-
<Tr key={activity._id}>
|
|
705
|
-
<Td>{activity.name}</Td>
|
|
706
|
-
{columns.map(col => (
|
|
707
|
-
<Td key={col.fieldId}>
|
|
708
|
-
{String(activity.fields?.[col.fieldId]?.value ?? '-')}
|
|
709
|
-
</Td>
|
|
710
|
-
))}
|
|
711
|
-
</Tr>
|
|
712
|
-
))}
|
|
713
|
-
</Tbody>
|
|
714
|
-
</Table>
|
|
715
|
-
);
|
|
716
|
-
}
|
|
465
|
+
// activity.update() format (null clears a field)
|
|
466
|
+
await hailer.activity.update([
|
|
467
|
+
{
|
|
468
|
+
_id: 'activityId',
|
|
469
|
+
fields: {
|
|
470
|
+
[FIELD_ID]: 'new value',
|
|
471
|
+
[FIELD_TO_CLEAR]: null,
|
|
472
|
+
},
|
|
473
|
+
phaseId: 'newPhaseId', // optional - move phase
|
|
474
|
+
},
|
|
475
|
+
], {});
|
|
717
476
|
```
|
|
718
477
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
interface Props {
|
|
725
|
-
activity: Activity;
|
|
726
|
-
fieldId: string;
|
|
727
|
-
fieldLabel: string;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function ActivityCard({ activity, fieldId, fieldLabel }: Props) {
|
|
731
|
-
const bg = useColorModeValue('white', 'gray.700');
|
|
732
|
-
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
733
|
-
|
|
734
|
-
return (
|
|
735
|
-
<Box
|
|
736
|
-
p={4}
|
|
737
|
-
bg={bg}
|
|
738
|
-
borderRadius="md"
|
|
739
|
-
border="1px"
|
|
740
|
-
borderColor={borderColor}
|
|
741
|
-
>
|
|
742
|
-
<VStack align="start" spacing={2}>
|
|
743
|
-
<Heading size="sm">{activity.name}</Heading>
|
|
744
|
-
<Text fontSize="sm" color="gray.500">
|
|
745
|
-
{fieldLabel}: {String(activity.fields?.[fieldId]?.value ?? '-')}
|
|
746
|
-
</Text>
|
|
747
|
-
</VStack>
|
|
748
|
-
</Box>
|
|
749
|
-
);
|
|
750
|
-
}
|
|
751
|
-
```
|
|
478
|
+
**App-specific create() pitfalls:**
|
|
479
|
+
- Passing single object instead of array — must always be an array
|
|
480
|
+
- Passing `undefined` as options → `ValidationError "debug is not allowed"` — omit or pass `{}`
|
|
481
|
+
- "Missing teams" error (code 127) → workflow needs `enablePreselectedTeam: true` + `preselectedTeam: { account: workspaceOwnerAccountId, team: teamId }`
|
|
482
|
+
</field-patterns>
|
|
752
483
|
|
|
753
|
-
|
|
484
|
+
<app-template>
|
|
485
|
+
## Full App Skeleton
|
|
754
486
|
|
|
755
487
|
```typescript
|
|
756
|
-
|
|
488
|
+
const WORKFLOW_ID = 'from_workflows.ts';
|
|
489
|
+
const PHASE_ID = 'from_phases.ts';
|
|
490
|
+
const FIELDS = { STATUS: 'fieldId' } as const;
|
|
757
491
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
492
|
+
function App() {
|
|
493
|
+
const { inside, hailer } = useHailer();
|
|
494
|
+
const [activities, setActivities] = useState<Activity[]>([]);
|
|
495
|
+
const [loading, setLoading] = useState(true);
|
|
763
496
|
|
|
764
|
-
|
|
765
|
-
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
if (!inside) return;
|
|
499
|
+
hailer.activity.list(WORKFLOW_ID, PHASE_ID).then(setActivities).finally(() => setLoading(false));
|
|
500
|
+
}, [inside]); // [inside] NOT [hailer]
|
|
766
501
|
|
|
502
|
+
if (!inside) return <Text>Open inside Hailer</Text>;
|
|
503
|
+
if (loading) return <Spinner />;
|
|
767
504
|
return (
|
|
768
|
-
<Box p={4} bg={
|
|
769
|
-
<
|
|
770
|
-
<StatLabel>{label}</StatLabel>
|
|
771
|
-
<StatNumber>{value}</StatNumber>
|
|
772
|
-
{helpText && <StatHelpText>{helpText}</StatHelpText>}
|
|
773
|
-
</Stat>
|
|
505
|
+
<Box p={4} bg={useColorModeValue('gray.50', 'gray.800')} minH="100vh">
|
|
506
|
+
<Table variant="simple" size="sm">...</Table>
|
|
774
507
|
</Box>
|
|
775
508
|
);
|
|
776
509
|
}
|
|
777
510
|
```
|
|
778
|
-
</component-templates>
|
|
779
511
|
|
|
780
|
-
|
|
781
|
-
## Hailer Theme Colors
|
|
512
|
+
## App Version Display (REQUIRED)
|
|
782
513
|
|
|
783
|
-
|
|
784
|
-
// Dark mode support - ALWAYS use useColorModeValue
|
|
785
|
-
const bg = useColorModeValue('white', 'gray.700');
|
|
786
|
-
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
787
|
-
const textColor = useColorModeValue('gray.800', 'white');
|
|
788
|
-
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
|
789
|
-
|
|
790
|
-
// Valid color tokens (DO NOT invent tokens)
|
|
791
|
-
// gray.50, gray.100, ..., gray.900
|
|
792
|
-
// white, black
|
|
793
|
-
// red.500, green.500, blue.500, yellow.500, purple.500
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
## Light/Dark Mode Safety Rules
|
|
514
|
+
Every Hailer app must show its version number visually.
|
|
797
515
|
|
|
798
|
-
**DON'T use these patterns:**
|
|
799
516
|
```typescript
|
|
800
|
-
//
|
|
801
|
-
|
|
517
|
+
// vite.config.ts — inject version at build time
|
|
518
|
+
import { readFileSync } from 'node:fs';
|
|
519
|
+
const manifest = JSON.parse(readFileSync('./public/manifest.json', 'utf-8'));
|
|
802
520
|
|
|
803
|
-
|
|
804
|
-
|
|
521
|
+
export default defineConfig({
|
|
522
|
+
define: { __APP_VERSION__: JSON.stringify(manifest.version) },
|
|
523
|
+
});
|
|
805
524
|
|
|
806
|
-
//
|
|
807
|
-
<
|
|
525
|
+
// In App.tsx
|
|
526
|
+
<Text fontSize="xs" color="gray.400" position="fixed" bottom={1} right={2}>
|
|
527
|
+
v{APP_VERSION}
|
|
528
|
+
</Text>
|
|
808
529
|
```
|
|
530
|
+
</app-template>
|
|
809
531
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
// ✅ CORRECT - explicit colors that exist in Chakra
|
|
813
|
-
color="blue.600"
|
|
814
|
-
|
|
815
|
-
// ✅ CORRECT - adapts to color mode
|
|
816
|
-
<Text color={useColorModeValue('gray.800', 'white')}>Adapts</Text>
|
|
532
|
+
<reactive-data>
|
|
533
|
+
## Reactive Data
|
|
817
534
|
|
|
818
|
-
|
|
819
|
-
const badgeBg = useColorModeValue('blue.100', 'blue.700');
|
|
820
|
-
const badgeColor = useColorModeValue('blue.800', 'blue.100');
|
|
821
|
-
<Badge bg={badgeBg} color={badgeColor}>Status</Badge>
|
|
822
|
-
```
|
|
535
|
+
After any create/update/delete/phase change, re-fetch data shown in the UI:
|
|
823
536
|
|
|
824
|
-
**Semantic tokens (define once, use everywhere):**
|
|
825
537
|
```typescript
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
appBg: { _light: 'gray.50', _dark: 'gray.800' },
|
|
830
|
-
cardBg: { _light: 'white', _dark: 'gray.700' },
|
|
831
|
-
textPrimary: { _light: 'gray.800', _dark: 'white' },
|
|
832
|
-
textMuted: { _light: 'gray.500', _dark: 'gray.400' },
|
|
833
|
-
}
|
|
834
|
-
}
|
|
538
|
+
// Pattern 1: Refetch after mutation
|
|
539
|
+
await hailer.activity.create(WORKFLOW_ID, [{ name, fields }], {});
|
|
540
|
+
await loadActivities();
|
|
835
541
|
|
|
836
|
-
//
|
|
837
|
-
|
|
542
|
+
// Pattern 2: Refresh key (cross-component)
|
|
543
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
544
|
+
<CreateForm onSuccess={() => setRefreshKey(k => k + 1)} />
|
|
838
545
|
```
|
|
839
546
|
|
|
840
|
-
|
|
547
|
+
### Tabs — Lazy Mount
|
|
841
548
|
|
|
842
|
-
|
|
549
|
+
**Chakra `<Tabs>` mounts all panels eagerly by default.** Without `isLazy`, every `<TabPanel>` fires effects in parallel even if the user never clicks them.
|
|
843
550
|
|
|
844
|
-
```
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
551
|
+
```tsx
|
|
552
|
+
<Tabs isLazy>
|
|
553
|
+
<TabPanels>
|
|
554
|
+
<TabPanel><HeavyDataTab /></TabPanel>
|
|
555
|
+
<TabPanel><AnotherTab /></TabPanel>
|
|
556
|
+
</TabPanels>
|
|
557
|
+
</Tabs>
|
|
558
|
+
```
|
|
559
|
+
</reactive-data>
|
|
850
560
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
bg: `${baseColor}.100`,
|
|
854
|
-
color: `${baseColor}.800`,
|
|
855
|
-
borderColor: `${baseColor}.200`,
|
|
856
|
-
};
|
|
857
|
-
} else {
|
|
858
|
-
return {
|
|
859
|
-
bg: `${baseColor}.700`,
|
|
860
|
-
color: `${baseColor}.100`,
|
|
861
|
-
borderColor: `${baseColor}.600`,
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
}
|
|
561
|
+
<real-time>
|
|
562
|
+
## Real-Time Updates via Signals
|
|
865
563
|
|
|
866
|
-
|
|
867
|
-
function PhaseBadge({ phase, color }: { phase: string; color: string }) {
|
|
868
|
-
const colors = usePhaseColors(color);
|
|
564
|
+
v1: `HailerSignal { sig, meta }` + `subscribedSignals` option. v2: `Signal { name, data }`, `subscribedSignals` removed — v2 streams all signals.
|
|
869
565
|
|
|
870
|
-
|
|
871
|
-
<Badge bg={colors.bg} color={colors.color}>
|
|
872
|
-
{phase}
|
|
873
|
-
</Badge>
|
|
874
|
-
);
|
|
875
|
-
}
|
|
566
|
+
**v2 2.4.2:** dotted names (`activity.update`, `.create`, `.delete`). Bare `'activity'` fires 0×. Use prefix-match. Payload: `{ activityIds[], workflowId, phaseId }`.
|
|
876
567
|
|
|
877
|
-
|
|
878
|
-
function EventBar({ title, phaseColor }: { title: string; phaseColor: string }) {
|
|
879
|
-
const colors = usePhaseColors(phaseColor);
|
|
568
|
+
**When to use:** Dashboards → signals. Forms → refetch after mutation.
|
|
880
569
|
|
|
881
|
-
|
|
882
|
-
<Box
|
|
883
|
-
px={2}
|
|
884
|
-
py={1}
|
|
885
|
-
bg={colors.bg}
|
|
886
|
-
color={colors.color}
|
|
887
|
-
borderLeft="3px solid"
|
|
888
|
-
borderLeftColor={colors.borderColor}
|
|
889
|
-
borderRadius="sm"
|
|
890
|
-
>
|
|
891
|
-
{title}
|
|
892
|
-
</Box>
|
|
893
|
-
);
|
|
894
|
-
}
|
|
895
|
-
```
|
|
570
|
+
### Debounced Reload (v2)
|
|
896
571
|
|
|
897
|
-
**Color mapping from Hailer phases:**
|
|
898
572
|
```typescript
|
|
899
|
-
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
'
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
573
|
+
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
574
|
+
const inFlightRef = useRef(false);
|
|
575
|
+
signals: (signal: any) => {
|
|
576
|
+
if (!signal.name.startsWith('activity')) return;
|
|
577
|
+
const workflowId = signal.data?.process ?? signal.data?.processId ?? signal.data?.workflowId;
|
|
578
|
+
if (workflowId !== MY_WORKFLOW_ID) return;
|
|
579
|
+
if (inFlightRef.current) return;
|
|
580
|
+
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
|
581
|
+
refreshTimerRef.current = setTimeout(async () => {
|
|
582
|
+
inFlightRef.current = true;
|
|
583
|
+
try { await reload(); } finally { inFlightRef.current = false; }
|
|
584
|
+
}, 800);
|
|
585
|
+
}
|
|
909
586
|
```
|
|
910
587
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
## Workflow Permission Errors
|
|
915
|
-
|
|
916
|
-
When SDK calls fail with permission/not-allowed errors, check **workflow configuration in Hailer** first.
|
|
917
|
-
|
|
918
|
-
**Common symptoms:**
|
|
919
|
-
- Phase transitions not working
|
|
920
|
-
- "Not allowed" errors on operations that should work
|
|
921
|
-
|
|
922
|
-
**Cause:** Features like phase transitions must be **enabled in workflow configuration** in Hailer. The SDK can't do operations that aren't configured.
|
|
923
|
-
|
|
924
|
-
**Debug steps:**
|
|
925
|
-
1. Check Hailer UI: Workflow Settings → Phase settings
|
|
926
|
-
2. Verify phase transitions are configured (`possibleNextPhase`)
|
|
927
|
-
3. Verify user has permission to the workflow/phase
|
|
928
|
-
4. Check if the feature (move, archive, etc.) is enabled for that phase
|
|
929
|
-
|
|
930
|
-
**Example:** Phase move fails because `possibleNextPhase` doesn't include the target phase.
|
|
931
|
-
</troubleshooting>
|
|
588
|
+
**Replay storm guard:** skip signals arriving within ~3s of mount (`if (Date.now() - mountTimeRef.current < 3000) return`).
|
|
589
|
+
**Skeleton gate:** `{loading && rows.length === 0 ? <Skeleton /> : <DataTable />}` — don't blank on background refresh.
|
|
590
|
+
</real-time>
|
|
932
591
|
|
|
933
592
|
<build-fixes>
|
|
934
593
|
## Common Build Errors
|
|
935
594
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
595
|
+
| Error | Fix |
|
|
596
|
+
|-------|-----|
|
|
597
|
+
| `Cannot find module '@hailer/app-sdk'` | `import useHailer from './hailer/use-hailer'` |
|
|
598
|
+
| `has no exported member 'useHailer'` | Use default import (not named) |
|
|
599
|
+
| `fields possibly undefined` | `activity.fields?.[fieldId]?.value` |
|
|
600
|
+
| Infinite re-render | `useEffect(..., [inside])` not `[hailer]` |
|
|
601
|
+
| Hooks order error | All hooks before any early returns |
|
|
602
|
+
| Hook in map/conditional | Move hook to component top level |
|
|
603
|
+
| `activity.move is not a function` | Use `activity.update([{_id, phaseId}], {})` |
|
|
940
604
|
|
|
941
|
-
|
|
942
|
-
import useHailer from './hailer/use-hailer';
|
|
943
|
-
```
|
|
605
|
+
### Routing
|
|
944
606
|
|
|
945
|
-
|
|
946
|
-
```typescript
|
|
947
|
-
// WRONG - named import
|
|
948
|
-
import { useHailer } from './hailer/use-hailer';
|
|
949
|
-
|
|
950
|
-
// CORRECT - default import
|
|
951
|
-
import useHailer from './hailer/use-hailer';
|
|
952
|
-
```
|
|
953
|
-
|
|
954
|
-
### fields possibly undefined
|
|
955
|
-
```typescript
|
|
956
|
-
// WRONG
|
|
957
|
-
const value = activity.fields[fieldId].value;
|
|
958
|
-
|
|
959
|
-
// CORRECT
|
|
960
|
-
const value = activity.fields?.[fieldId]?.value;
|
|
961
|
-
```
|
|
962
|
-
|
|
963
|
-
### Infinite re-render loop
|
|
964
|
-
```typescript
|
|
965
|
-
// WRONG - hailer changes every render
|
|
966
|
-
useEffect(() => { ... }, [hailer]);
|
|
967
|
-
|
|
968
|
-
// CORRECT - inside is stable
|
|
969
|
-
useEffect(() => { ... }, [inside]);
|
|
970
|
-
```
|
|
971
|
-
|
|
972
|
-
### Hooks order error
|
|
973
|
-
```typescript
|
|
974
|
-
// WRONG - early return before hook
|
|
975
|
-
if (!inside) return <Text>Error</Text>;
|
|
976
|
-
const [data, setData] = useState([]); // Error!
|
|
977
|
-
|
|
978
|
-
// CORRECT - hooks first, then early return
|
|
979
|
-
const [data, setData] = useState([]);
|
|
980
|
-
if (!inside) return <Text>Error</Text>;
|
|
981
|
-
```
|
|
982
|
-
|
|
983
|
-
### Hooks inside map() or conditionals
|
|
984
|
-
```typescript
|
|
985
|
-
// WRONG - hook in map
|
|
986
|
-
{items.map((item) => (
|
|
987
|
-
<Tr _hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}>
|
|
988
|
-
))}
|
|
989
|
-
|
|
990
|
-
// CORRECT - hook at top level
|
|
991
|
-
const hoverBg = useColorModeValue('gray.50', 'gray.600');
|
|
992
|
-
{items.map((item) => (
|
|
993
|
-
<Tr _hover={{ bg: hoverBg }}>
|
|
994
|
-
))}
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
### hailer.activity.move is not a function
|
|
998
|
-
|
|
999
|
-
`activity.move()` does not exist. Use `activity.update([{ _id, phaseId }], {})` — see Phase Transitions in sdk-api.
|
|
1000
|
-
|
|
1001
|
-
### Routing: HashRouter vs BrowserRouter
|
|
1002
|
-
|
|
1003
|
-
**Most Hailer apps don't need routing at all.** Use state-based page switching:
|
|
607
|
+
Most Hailer apps don't need routing — use state-based switching:
|
|
1004
608
|
```typescript
|
|
1005
609
|
const [page, setPage] = useState('home');
|
|
1006
610
|
{page === 'home' && <HomePage />}
|
|
1007
|
-
{page === 'settings' && <SettingsPage />}
|
|
1008
611
|
```
|
|
1009
612
|
|
|
1010
|
-
**
|
|
1011
|
-
```typescript
|
|
1012
|
-
// ✅ USE HashRouter - works in iframe, no server config
|
|
1013
|
-
import { createHashRouter, RouterProvider } from 'react-router-dom';
|
|
1014
|
-
|
|
1015
|
-
const router = createHashRouter([
|
|
1016
|
-
{ path: '/', element: <HomePage /> },
|
|
1017
|
-
{ path: '/settings', element: <SettingsPage /> },
|
|
1018
|
-
]);
|
|
613
|
+
**Multi-page apps: use HashRouter, not BrowserRouter.** The static CDN at `apps.hailer.com/<ws>/<appId>/` serves only `index.html`.
|
|
1019
614
|
|
|
1020
|
-
|
|
1021
|
-
```
|
|
615
|
+
### localStorage — scope per workspace+app
|
|
1022
616
|
|
|
617
|
+
`apps.hailer.com` is the shared origin for all deployed apps. Namespace every persisted key:
|
|
1023
618
|
```typescript
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// because iframe URL contains /index.html path
|
|
619
|
+
const storageKey = (key: string) => `${workspaceId}.${appId}.${key}`;
|
|
620
|
+
localStorage.setItem(storageKey('selectedStockId'), id);
|
|
1027
621
|
```
|
|
1028
622
|
|
|
1029
|
-
|
|
1030
|
-
- Public apps with shareable URLs
|
|
1031
|
-
- Complex multi-section apps (like hailer-admin)
|
|
1032
|
-
- Apps where back button navigation matters
|
|
1033
|
-
</build-fixes>
|
|
623
|
+
### `isAdmin` from `useApp` starts as `undefined`
|
|
1034
624
|
|
|
1035
|
-
<
|
|
1036
|
-
## SDK Create/Update Formats
|
|
625
|
+
Guard `if (isAdmin === undefined) return <LoadingState/>` before any branch that gates on `isAdmin` — treating it as `false` before `permission.map()` completes causes admins to hit terminal branches before the flag flips.
|
|
1037
626
|
|
|
1038
|
-
###
|
|
627
|
+
### `v3.app.public` always returns 403
|
|
1039
628
|
|
|
1040
|
-
|
|
629
|
+
Endpoint is unimplemented in hailer-api. Workaround: settings workflow + public insight.
|
|
1041
630
|
|
|
1042
|
-
|
|
631
|
+
### `vite build` does NOT type-check
|
|
1043
632
|
|
|
1044
|
-
|
|
1045
|
-
await hailer.activity.create(WORKFLOW_ID, [
|
|
1046
|
-
{
|
|
1047
|
-
name: 'Activity name',
|
|
1048
|
-
fields: {
|
|
1049
|
-
[FIELD_ID]: 'string or number value', // NOT wrapped in { value: ... }
|
|
1050
|
-
[ACTIVITYLINK_FIELD]: 'linkedActivityId', // Just the ID, not { _id, name }
|
|
1051
|
-
},
|
|
1052
|
-
},
|
|
1053
|
-
], {});
|
|
1054
|
-
```
|
|
633
|
+
`npm run build` uses esbuild (transpile-only) — succeeds even when types are broken. Always run `npx tsc --noEmit` before declaring an app done.
|
|
1055
634
|
|
|
1056
|
-
|
|
1057
|
-
- Passing single object instead of array
|
|
1058
|
-
- Wrapping field values in `{ value: ... }`
|
|
1059
|
-
- Passing `{ _id, name }` for activitylinks instead of just ID
|
|
635
|
+
### Inline date-editor silently does nothing (self-blur trap)
|
|
1060
636
|
|
|
1061
|
-
|
|
637
|
+
Native `<input type="date" autoFocus onBlur={commit}>` self-blurs on programmatic mount — the blur fires synchronously during `autoFocus`, calling the commit handler with an empty value. Fix: use `editorKind:'text'` so the cell falls through to a plain text input.
|
|
1062
638
|
|
|
1063
|
-
|
|
639
|
+
### Wizard / Multi-Step Form — Single Staging Surface Rule
|
|
1064
640
|
|
|
1065
|
-
|
|
1066
|
-
await hailer.activity.update([
|
|
1067
|
-
{
|
|
1068
|
-
_id: 'activityId',
|
|
1069
|
-
name: 'New name', // optional
|
|
1070
|
-
fields: {
|
|
1071
|
-
[FIELD_ID]: newValue,
|
|
1072
|
-
},
|
|
1073
|
-
phaseId: 'newPhaseId', // optional - move to different phase
|
|
1074
|
-
},
|
|
1075
|
-
], {});
|
|
1076
|
-
```
|
|
1077
|
-
</sdk-crud>
|
|
641
|
+
A two-zone model (separate "review zone" + staged panel) causes silent empty-commit bugs — the clerk forgets to click "Add all" and the final create call receives empty rows.
|
|
1078
642
|
|
|
1079
|
-
|
|
1080
|
-
|
|
643
|
+
**Rule: ONE staging surface.** When "Suggest rows" is clicked, push directly into the same `rows` state that the commit action reads from. Make the staged panel inline-editable.
|
|
644
|
+
</build-fixes>
|
|
1081
645
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
App.tsx # Main component (EDIT THIS)
|
|
1085
|
-
main.tsx # Entry point (NEVER EDIT)
|
|
1086
|
-
hailer/
|
|
1087
|
-
use-hailer.ts # SDK hook (generated)
|
|
1088
|
-
types/
|
|
1089
|
-
index.ts # Type definitions (CREATE)
|
|
1090
|
-
utils/
|
|
1091
|
-
fields.ts # Field helpers (CREATE)
|
|
1092
|
-
constants/
|
|
1093
|
-
fields.ts # Field ID constants (CREATE)
|
|
1094
|
-
```
|
|
646
|
+
<eslint-policy>
|
|
647
|
+
## ESLint — Required Before Done
|
|
1095
648
|
|
|
1096
|
-
|
|
649
|
+
Every Hailer app must have working ESLint and pass lint before the app is considered done.
|
|
1097
650
|
|
|
1098
|
-
```
|
|
1099
|
-
|
|
1100
|
-
export const WORKFLOW_ID = 'workflowId123';
|
|
1101
|
-
export const PHASE_ID = 'phaseId456';
|
|
1102
|
-
|
|
1103
|
-
export const FIELDS = {
|
|
1104
|
-
NAME: 'fieldId001',
|
|
1105
|
-
STATUS: 'fieldId002',
|
|
1106
|
-
DATE: 'fieldId003',
|
|
1107
|
-
} as const;
|
|
651
|
+
```bash
|
|
652
|
+
npm run lint # run before reporting done; lint errors block done
|
|
1108
653
|
```
|
|
1109
654
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
<app-manifest>
|
|
1113
|
-
## App Manifest Configuration
|
|
1114
|
-
|
|
1115
|
-
The `manifest.json` file in app root defines configurable settings exposed in Hailer UI.
|
|
1116
|
-
|
|
1117
|
-
### config.fields Structure
|
|
1118
|
-
|
|
655
|
+
If the `lint` script is missing, add it:
|
|
1119
656
|
```json
|
|
1120
|
-
{
|
|
1121
|
-
"name": "My App",
|
|
1122
|
-
"version": "1.0.0",
|
|
1123
|
-
"config": {
|
|
1124
|
-
"fields": {
|
|
1125
|
-
"defaultView": {
|
|
1126
|
-
"type": "string",
|
|
1127
|
-
"label": "Default View",
|
|
1128
|
-
"default": "month"
|
|
1129
|
-
},
|
|
1130
|
-
"slotMinTime": {
|
|
1131
|
-
"type": "string",
|
|
1132
|
-
"label": "Day Start Time",
|
|
1133
|
-
"default": "08:00"
|
|
1134
|
-
},
|
|
1135
|
-
"showWeekends": {
|
|
1136
|
-
"type": "boolean",
|
|
1137
|
-
"label": "Show Weekends",
|
|
1138
|
-
"default": true
|
|
1139
|
-
},
|
|
1140
|
-
"itemsPerPage": {
|
|
1141
|
-
"type": "number",
|
|
1142
|
-
"label": "Items Per Page",
|
|
1143
|
-
"default": 25
|
|
1144
|
-
},
|
|
1145
|
-
"enabledFeatures": {
|
|
1146
|
-
"type": "array",
|
|
1147
|
-
"label": "Enabled Features",
|
|
1148
|
-
"default": ["search", "export"]
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
```
|
|
1154
|
-
|
|
1155
|
-
### Field Types
|
|
1156
|
-
|
|
1157
|
-
| Type | Description | Example Default |
|
|
1158
|
-
|------|-------------|-----------------|
|
|
1159
|
-
| `string` | Text value | `"month"` |
|
|
1160
|
-
| `number` | Numeric value | `25` |
|
|
1161
|
-
| `boolean` | True/false toggle | `true` |
|
|
1162
|
-
| `array` | List of values | `["a", "b"]` |
|
|
1163
|
-
|
|
1164
|
-
### Accessing Config in App
|
|
1165
|
-
|
|
1166
|
-
Config values come through HailerApi callback when app loads inside Hailer:
|
|
1167
|
-
|
|
1168
|
-
```typescript
|
|
1169
|
-
const { inside, hailer, config } = useHailer();
|
|
1170
|
-
|
|
1171
|
-
// Access configured values
|
|
1172
|
-
const defaultView = config?.defaultView ?? 'month';
|
|
1173
|
-
const showWeekends = config?.showWeekends ?? true;
|
|
657
|
+
"scripts": { "lint": "eslint src --ext .ts,.tsx --max-warnings 0" }
|
|
1174
658
|
```
|
|
659
|
+
</eslint-policy>
|
|
1175
660
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
**DO expose in config.fields:**
|
|
1179
|
-
- User-customizable options (default views, display preferences)
|
|
1180
|
-
- Workflow/phase IDs that vary per installation
|
|
1181
|
-
- Feature toggles
|
|
1182
|
-
|
|
1183
|
-
**DON'T expose in config.fields:**
|
|
1184
|
-
- Values hardcoded in app code (wastes config UI space)
|
|
1185
|
-
- Sensitive data (use environment variables)
|
|
1186
|
-
- Internal constants that users shouldn't change
|
|
1187
|
-
|
|
1188
|
-
**RULE:** If a config field is defined but the app ignores it, remove it from manifest.
|
|
1189
|
-
</app-manifest>
|
|
1190
|
-
|
|
1191
|
-
<public-api>
|
|
1192
|
-
## Public API (Apps Outside Hailer)
|
|
661
|
+
<file-handling>
|
|
662
|
+
## File Handling
|
|
1193
663
|
|
|
1194
|
-
|
|
664
|
+
A file-modifier text field (`modifier.file: true`) stores a JSON-stringified array of file IDs — parse it, don't read it as a single ID.
|
|
1195
665
|
|
|
1196
|
-
|
|
1197
|
-
// Public insight data
|
|
1198
|
-
const data = await hailer.public.insight.data(insightKey);
|
|
1199
|
-
const objects = await hailer.public.insight.dataAsObject(insightKey);
|
|
1200
|
-
|
|
1201
|
-
// Public forms
|
|
1202
|
-
const formData = await hailer.public.form.data(formsKey);
|
|
1203
|
-
const result = await hailer.public.form.submit(formsKey, formData);
|
|
666
|
+
`hailer.file.get().data` is a full `data:` URI, not raw base64 — use it directly as an `<img src>`.
|
|
1204
667
|
|
|
1205
|
-
|
|
1206
|
-
|
|
668
|
+
**`fileIds` only bind at `activity.create`.** There is no retroactive attach in app-sdk. Defer activity creation until files are ready. See `hailer-apps-pictures` for the canonical upload pattern.
|
|
669
|
+
</file-handling>
|
|
1207
670
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
const product = await hailer.public.product.get(productId);
|
|
1211
|
-
```
|
|
671
|
+
<ios-compat>
|
|
672
|
+
## iOS Compatibility — Default Target
|
|
1212
673
|
|
|
1213
|
-
|
|
1214
|
-
- Building external-facing apps (not in Hailer iframe)
|
|
1215
|
-
- Public dashboards using insight keys
|
|
1216
|
-
- Public form submissions
|
|
1217
|
-
- No user authentication available
|
|
674
|
+
Build every app so it works on iOS unless a web-only capability is genuinely required.
|
|
1218
675
|
|
|
1219
|
-
|
|
1220
|
-
|
|
676
|
+
| Feature | Web | iOS |
|
|
677
|
+
|---------|-----|-----|
|
|
678
|
+
| `hailer.http.fetch('https://api.hailer.com/...')` | Works out-of-box | Requires `allowedUrls` to include `https://api.hailer.com` — default empty → 400 |
|
|
679
|
+
| `hailer.http.upload` | Available | Absent — use `hailer.file.upload()` instead |
|
|
680
|
+
| `ui.activity.editMultiple` | Available | Not supported |
|
|
681
|
+
| `ui.insight.create/edit/delete/permission` | Available | Not supported |
|
|
1221
682
|
|
|
1222
|
-
|
|
1223
|
-
|
|
683
|
+
When an app must use a web-only capability, state it explicitly: "This app will be web-only because it uses `http.upload`".
|
|
684
|
+
</ios-compat>
|
|
1224
685
|
|
|
1225
|
-
|
|
686
|
+
<vpc-reachability>
|
|
687
|
+
## Apps Cannot Reach the Monolith
|
|
1226
688
|
|
|
1227
|
-
|
|
1228
|
-
node_modules/@hailer/app-sdk/lib/modules/
|
|
1229
|
-
├── activity.d.ts - list, get, create, update, remove
|
|
1230
|
-
├── activity/kanban.d.ts - kanban.list, load, updatePriority
|
|
1231
|
-
├── user.d.ts - current, get, list
|
|
1232
|
-
├── ui.d.ts - snackbar, activity, insight, files
|
|
1233
|
-
├── ui/activity.d.ts - open, create, editMultiple
|
|
1234
|
-
├── ui/snackbar.d.ts - open
|
|
1235
|
-
├── ui/files.d.ts - uploadFile
|
|
1236
|
-
├── insight.d.ts - data, list, update
|
|
1237
|
-
├── process.d.ts - list, get (workflow API uses this)
|
|
1238
|
-
├── workspace.d.ts - current, list, product methods
|
|
1239
|
-
├── permission.d.ts - map
|
|
1240
|
-
├── app.d.ts - config.update, product methods
|
|
1241
|
-
└── public/ - insight, form, product (no auth)
|
|
1242
|
-
```
|
|
689
|
+
Hailer apps run in the end user's browser, outside the microservice VPC. `monolith.hailer.com` is private-DNS only — `fetch('https://monolith.hailer.com/...')` fails at DNS resolution, not CORS.
|
|
1243
690
|
|
|
1244
|
-
|
|
1245
|
-
-
|
|
1246
|
-
- Need method signature details → types are the source of truth
|
|
1247
|
-
- New SDK version → types show what's available
|
|
1248
|
-
</sdk-reference>
|
|
691
|
+
Backend patterns when an app needs server logic: activity-write + phase webhook, or a dedicated internet-facing microservice.
|
|
692
|
+
</vpc-reachability>
|